From 0fe28a1c6281aa9e82bb4553bd24268836ddd316 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 1 Apr 2026 15:18:07 +0800 Subject: [PATCH] docs: add Android APK build instructions and plan document --- deploy/README.md | 10 + .../2026-04-01-flutter-logging-system.md | 680 ++++++++++++++++++ 2 files changed, 690 insertions(+) create mode 100644 docs/plans/2026-04-01-flutter-logging-system.md diff --git a/deploy/README.md b/deploy/README.md index 9822ce1..39af5f8 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -122,6 +122,16 @@ docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up 在 nginx 增加静态目录映射:`location /releases/ { alias /你的项目绝对路径/deploy/static/releases/; }`,这样 `https://你的域名/releases/xxx.apk` 可直接下载安装包。并在 `deploy/.env.prod` 设置 `SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL=https://你的域名` 与 `SOCIAL_APP_VERSION__RELEASE_PATH_PREFIX=releases`,确保 `check-updates` 返回的 `download_url` 指向该路径。 +## Android APK 打包 + +打包 Android APK 时需指定后端地址: + +```bash +bash deploy/build-android-release.sh --backend-host api.linksy.chat --channel release +``` + +后端地址:`https://api.linksy.chat` + ## 已知约束 - `init-job` 为一次性任务,不长期驻留。 diff --git a/docs/plans/2026-04-01-flutter-logging-system.md b/docs/plans/2026-04-01-flutter-logging-system.md new file mode 100644 index 0000000..b650aa1 --- /dev/null +++ b/docs/plans/2026-04-01-flutter-logging-system.md @@ -0,0 +1,680 @@ +# Flutter 日志系统实现计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 建立前端日志系统,debug 输出到 console,release 输出到本地文件 + +**Architecture:** +- 日志模块放在 `apps/lib/core/logging/` +- 提供 `getLogger(module)` 接口 +- Debug 模式:`debugPrint` 输出到 console +- Release 模式:输出到 `getApplicationDocumentsDirectory()/logs/app.log` + +--- + +## Task 1: 日志模块骨架 + +**Files:** +- Create: `apps/lib/core/logging/logger.dart` +- Create: `apps/lib/core/logging/log_entry.dart` +- Create: `apps/lib/core/logging/log_config.dart` +- Create: `apps/lib/core/logging/log_service.dart` +- Create: `apps/lib/core/logging/log_file_handler.dart` + +**Step 1: Create log_entry.dart** + +```dart +// apps/lib/core/logging/log_entry.dart + +enum LogLevel { debug, info, warning, error } + +class LogEntry { + final DateTime timestamp; + final LogLevel level; + final String message; + final String module; + final String? funcName; + final int? lineNo; + final String? errorType; + final String? stackTrace; + final Map? extra; + + LogEntry({ + required this.timestamp, + required this.level, + required this.message, + required this.module, + this.funcName, + this.lineNo, + this.errorType, + this.stackTrace, + this.extra, + }); + + Map toJson() => { + 'timestamp': timestamp.toIso8601String(), + 'level': level.name, + 'message': message, + 'module': module, + if (funcName != null) 'func_name': funcName, + if (lineNo != null) 'line_no': lineNo, + if (errorType != null) 'error_type': errorType, + if (stackTrace != null) 'stack_trace': stackTrace, + if (extra != null && extra!.isNotEmpty) 'extra': extra, + }; + + String toConsoleString() { + final ts = timestamp.toIso8601String(); + final location = [ + if (funcName != null) funcName, + if (lineNo != null) '@$lineNo', + ].join(''); + final locationStr = location.isNotEmpty ? ' [$location]' : ''; + final errorStr = errorType != null ? ' [$errorType]' : ''; + final extraStr = extra != null && extra!.isNotEmpty ? ' $extra' : ''; + return '$ts ${level.name.toUpperCase().padRight(7)} [$module$locationStr]$errorStr $message$extraStr'; + } + + String toFileString() { + final sb = StringBuffer(); + sb.writeln('[$timestamp] ${level.name.toUpperCase()} [$module]'); + if (funcName != null || lineNo != null) { + sb.write(' at $funcName' ?? ''); + if (lineNo != null) sb.write(':$lineNo'); + sb.writeln(); + } + sb.writeln(' $message'); + if (errorType != null) { + sb.writeln(' Error: $errorType'); + } + if (stackTrace != null) { + sb.writeln(' StackTrace:'); + sb.writeln(stackTrace); + } + if (extra != null && extra!.isNotEmpty) { + sb.writeln(' Extra: $extra'); + } + return sb.toString(); + } +} +``` + +**Step 2: Create log_config.dart** + +```dart +// apps/lib/core/logging/log_config.dart + +enum LogOutput { console, file } + +class LogConfig { + final LogLevel minLevel; + final LogOutput output; + final String logFileName; + final String logDir; + + const LogConfig({ + this.minLevel = LogLevel.debug, + this.output = LogOutput.console, + this.logFileName = 'app.log', + this.logDir = 'logs', + }); + + static LogConfig forDebug() => const LogConfig( + minLevel: LogLevel.debug, + output: LogOutput.console, + ); + + static LogConfig forRelease() => const LogConfig( + minLevel: LogLevel.warning, + output: LogOutput.file, + logFileName: 'app.log', + logDir = 'logs', + ); +} +``` + +**Step 3: Create log_file_handler.dart** + +```dart +// apps/lib/core/logging/log_file_handler.dart + +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; + +class LogFileHandler { + File? _file; + IOSink? _sink; + + Future init(String logDir, String logFileName) async { + final dir = await getApplicationDocumentsDirectory(); + final logPath = '${dir.path}/$logDir'; + await Directory(logPath).create(recursive: true); + _file = File('$logPath/$logFileName'); + _sink = _file!.openWrite(mode: FileMode.append); + } + + void write(String content) { + _sink?.writeln(content); + } + + Future flush() async { + await _sink?.flush(); + } + + Future close() async { + await _sink?.close(); + _sink = null; + _file = null; + } + + Future> readAllLines() async { + if (_file == null || !await _file!.exists()) return []; + return await _file!.readAsLines(); + } + + String? get filePath => _file?.path; +} +``` + +**Step 4: Create log_service.dart** + +```dart +// apps/lib/core/logging/log_service.dart + +import 'package:flutter/foundation.dart'; +import 'log_config.dart'; +import 'log_entry.dart'; +import 'log_file_handler.dart'; + +class LogService { + final LogConfig _config; + LogFileHandler? _fileHandler; + final _buffer = []; + static const _maxBufferSize = 50; + + LogService._({required LogConfig config}) : _config = config; + + static Future create({LogConfig? config}) async { + final isRelease = kReleaseMode; + + final effectiveConfig = config ?? + (isRelease ? LogConfig.forRelease() : LogConfig.forDebug()); + + final service = LogService._(config: effectiveConfig); + + if (effectiveConfig.output == LogOutput.file) { + service._fileHandler = LogFileHandler(); + await service._fileHandler!.init( + effectiveConfig.logDir, + effectiveConfig.logFileName, + ); + } + + return service; + } + + String? get logFilePath => _fileHandler?.filePath; + + void _log(LogEntry entry) { + if (entry.level.index < _config.minLevel.index) return; + + if (_config.output == LogOutput.console) { + debugPrint(entry.toConsoleString()); + if (entry.stackTrace != null) { + debugPrint(entry.stackTrace!); + } + } else { + _buffer.add(entry.toFileString()); + if (_buffer.length >= _maxBufferSize) { + _flushBuffer(); + } + } + } + + void _flushBuffer() { + for (final line in _buffer) { + _fileHandler?.write(line); + } + _buffer.clear(); + _fileHandler?.flush(); + } + + (String?, int?) _extractLocation(StackTrace stackTrace) { + final frames = stackTrace.toString().split('\n'); + for (final frame in frames) { + if (frame.contains('.dart')) { + final match = RegExp(r'#\d+\s+(.+?)\s+\((.+?):(\d+)\)').firstMatch(frame); + if (match != null) { + return (match.group(1), int.tryParse(match.group(3) ?? '')); + } + } + } + return (null, null); + } + + void debug({ + required String message, + required String module, + Map? extra, + StackTrace? stackTrace, + }) { + final trace = stackTrace ?? StackTrace.current; + final (funcName, lineNo) = _extractLocation(trace); + _log(LogEntry( + timestamp: DateTime.now(), + level: LogLevel.debug, + message: message, + module: module, + funcName: funcName, + lineNo: lineNo, + extra: extra, + stackTrace: trace.toString(), + )); + } + + void info({ + required String message, + required String module, + required Map extra, + StackTrace? stackTrace, + }) { + final trace = stackTrace ?? StackTrace.current; + final (funcName, lineNo) = _extractLocation(trace); + _log(LogEntry( + timestamp: DateTime.now(), + level: LogLevel.info, + message: message, + module: module, + funcName: funcName, + lineNo: lineNo, + extra: extra, + stackTrace: trace.toString(), + )); + } + + void warning({ + required String message, + required String module, + required Map extra, + StackTrace? stackTrace, + }) { + final trace = stackTrace ?? StackTrace.current; + final (funcName, lineNo) = _extractLocation(trace); + _log(LogEntry( + timestamp: DateTime.now(), + level: LogLevel.warning, + message: message, + module: module, + funcName: funcName, + lineNo: lineNo, + extra: extra, + stackTrace: trace.toString(), + )); + } + + void error({ + required String message, + required Object error, + required StackTrace stackTrace, + required String module, + Map? extra, + }) { + final (funcName, lineNo) = _extractLocation(stackTrace); + _log(LogEntry( + timestamp: DateTime.now(), + level: LogLevel.error, + message: message, + module: module, + funcName: funcName, + lineNo: lineNo, + errorType: error.runtimeType.toString(), + stackTrace: stackTrace.toString(), + extra: extra, + )); + } + + void flush() { + _flushBuffer(); + _fileHandler?.flush(); + } + + Future> readLogs() async { + return await _fileHandler?.readAllLines() ?? []; + } +} +``` + +**Step 5: Create logger.dart** + +```dart +// apps/lib/core/logging/logger.dart + +import 'log_service.dart'; + +LogService? _globalLogService; + +void setLogService(LogService service) { + _globalLogService = service; +} + +LogService _ensureService() { + return _globalLogService ?? (throw StateError('LogService not initialized')); +} + +class Logger { + final String module; + final LogService _service; + + Logger(this.module, this._service); + + factory Logger.get(String module) { + return Logger(module, _ensureService()); + } + + void debug({ + required String message, + Map? extra, + StackTrace? stackTrace, + }) => _service.debug( + message: message, + module: module, + extra: extra ?? {}, + stackTrace: stackTrace, + ); + + void info({ + required String message, + required Map extra, + StackTrace? stackTrace, + }) => _service.info( + message: message, + module: module, + extra: extra, + stackTrace: stackTrace, + ); + + void warning({ + required String message, + required Map extra, + StackTrace? stackTrace, + }) => _service.warning( + message: message, + module: module, + extra: extra, + stackTrace: stackTrace, + ); + + void error({ + required String message, + required Object error, + required StackTrace stackTrace, + Map? extra, + }) => _service.error( + message: message, + error: error, + stackTrace: stackTrace, + module: module, + extra: extra, + ); +} + +Logger getLogger(String module) => Logger.get(module); +``` + +**Step 6: Commit** + +```bash +git add apps/lib/core/logging/ +git commit -m "feat(logging): add logging module skeleton" +``` + +--- + +## Task 2: 全局错误处理器 + +**Files:** +- Create: `apps/lib/core/logging/error_handler.dart` +- Modify: `apps/lib/main.dart` + +**Step 1: Create error_handler.dart** + +```dart +// apps/lib/core/logging/error_handler.dart + +class AppErrorHandler { + final Logger _logger = getLogger('flutter.error'); + + void register() { + FlutterError.onError = (details) { + _logger.error( + message: 'FlutterError: ${details.exceptionAsString()}', + error: details.exceptionAsString(), + stackTrace: details.stack ?? StackTrace.current, + extra: {'context': 'FlutterError.onError'}, + ); + FlutterError.presentError(details); + }; + } +} +``` + +**Step 2: Modify main.dart** + +```dart +// main.dart + +import 'core/logging/logger.dart'; +import 'core/logging/log_service.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + final logService = await LogService.create(); + Logger.setLogService(logService); + + AppErrorHandler().register(); + + await configureDependencies(); + await Env.init(); + + getLogger('app').info( + message: 'App starting...', + extra: {'version': Env.appVersion, 'platform': Env.platformName}, + ); + + runApp(const LinksyApp()); +} +``` + +**Step 3: Commit** + +```bash +git add apps/lib/core/logging/error_handler.dart apps/lib/main.dart +git commit -m "feat(logging): add global error handler" +``` + +--- + +## Task 3: 添加 path_provider 依赖 + +**Files:** +- Modify: `apps/pubspec.yaml` + +**Step 1: Add dependency** + +```yaml +dependencies: + path_provider: ^2.1.2 +``` + +**Step 2: Commit** + +```bash +git add apps/pubspec.yaml +git commit -m "feat(logging): add path_provider dependency" +``` + +--- + +## Task 4: 迁移 ChatBloc 示例 + +**Files:** +- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart` + +**Step 1: Add logging to ChatBloc** + +```dart +// chat_bloc.dart + +import '../../../../core/logging/logger.dart'; + +class ChatBloc extends Bloc { + final _logger = getLogger('features.chat.bloc'); + + void _onChatSend(ChatBlocSend event) { + _logger.info( + message: 'Sending chat message', + extra: { + 'message_length': event.message.length, + 'attachments_count': event.attachments.length, + }, + ); + + try { + // ... 业务逻辑 + _logger.info( + message: 'Chat message sent successfully', + extra: {'message_id': result.id}, + ); + } catch (e, stackTrace) { + _logger.error( + message: 'Failed to send chat message', + error: e, + stackTrace: stackTrace, + extra: {'message': event.message}, + ); + } + } +} +``` + +**Step 2: Commit** + +```bash +git add apps/lib/features/chat/presentation/bloc/chat_bloc.dart +git commit -m "feat(logging): add logging to ChatBloc" +``` + +--- + +## 日志输出示例 + +### Debug 模式 (console) + +``` +2026-04-01T10:30:00.123 DEBUG [features.chat.bloc@onChatSend:142] Chat message sent successfully {'message_id': 'abc123'} +#0 ChatBloc._onChatSend (package:social_app/features/chat/presentation/bloc/chat_bloc.dart:142) +... + +2026-04-01T10:30:01.456 ERROR [features.chat.bloc@onChatSend:156] Failed to send chat message [SocketException] + #0 ChatBloc._onChatSend (package:social_app/features/chat/presentation/bloc/chat_bloc.dart:156) + ... +``` + +### Release 模式 (file) + +``` +[2026-04-01T10:30:00.123] DEBUG [features.chat.bloc] + at onChatSend:142 + Chat message sent successfully + Extra: {'message_id': 'abc123'} + +[2026-04-01T10:30:01.456] ERROR [features.chat.bloc] + at onChatSend:156 + Failed to send chat message + Error: SocketException + StackTrace: + #0 ChatBloc._onChatSend (package:social_app/features/chat/presentation/bloc/chat_bloc.dart:156) + ... + Extra: {'message': 'Hello'} +``` + +--- + +## API 使用规范 + +```dart +import 'core/logging/logger.dart'; + +final logger = getLogger('module.path'); + +// debug - 可选 extra +logger.debug(message: 'Debug info', extra: {'key': 'value'}); + +// info - 必须有 extra +logger.info(message: 'Operation completed', extra: {'id': '123'}); + +// warning - 必须有 extra +logger.warning(message: 'Deprecated API called', extra: {'api': 'old'}); + +// error - 必须有 error 和 stackTrace +logger.error( + message: 'Operation failed', + error: e, + stackTrace: stackTrace, +); +``` + +--- + +## Task 5: 单元测试 + +**Files:** +- Create: `apps/test/core/logging/log_entry_test.dart` + +**Step 1: Write tests** + +```dart +void main() { + group('LogEntry', () { + test('toJson should serialize correctly', () { + final entry = LogEntry( + timestamp: DateTime(2026, 4, 1, 10, 0, 0), + level: LogLevel.error, + message: 'Test error', + module: 'test.module', + funcName: 'testFunc', + lineNo: 42, + errorType: 'TestError', + stackTrace: 'test stack', + extra: {'key': 'value'}, + ); + + final json = entry.toJson(); + + expect(json['level'], 'error'); + expect(json['error_type'], 'TestError'); + expect(json['extra'], {'key': 'value'}); + }); + }); +} +``` + +**Step 2: Commit** + +```bash +git add apps/test/core/logging/ +git commit -m "test(logging): add logging tests" +``` + +--- + +## 执行方式 + +**两个选择:** + +1. **Subagent-Driven (当前 session)** - 我 dispatch 新的 subagent 逐个执行任务,任务间 review,快速迭代 + +2. **Parallel Session (新 session)** - 在 worktree 中打开新 session,使用 executing-plans,批量执行带 checkpoint + +**选择哪个?**