import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:common_utils/common_utils.dart'; import 'package:logger/logger.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; final _consoleOutput = ConsoleOutput(); final logger = Logger( filter: AppLogFilter(), printer: customPrinter, output: _consoleOutput, ); final customPrinter = PrettyPrinter( lineLength: 0, methodCount: 1, errorMethodCount: 1, colors: true, printEmojis: false, printTime: true, noBoxingByDefault: true, ); /// 设置页使用的日志尾部快照,只返回脱敏后的有限内容,避免大文件阻塞收银主线程。 Future readAppLogPreview({ String? path, int maxBytes = 256 * 1024, int maxLines = 1000, }) async { await logger.init; return _consoleOutput.readPreview( path: path, maxBytes: maxBytes, maxLines: maxLines, ); } /// 列出日志根目录中的文件,设置页据此构建可切换的日志目录树。 Future listAppLogFiles() async { await logger.init; return _consoleOutput.listLogFiles(); } /// 上传当前日志前刷新文件写入队列,保证快照边界包含调用前已经产生的日志。 Future flushCurrentAppLog() async { await logger.init; await _consoleOutput.flushCurrentLog(); } /// 逐行生成脱敏副本,上传工具不会把原始日志文件直接交给网络层。 Future sanitizeAppLogFile({ required File source, required File target, int? maxSourceBytes, }) async { final sourceLength = await source.length(); final limit = maxSourceBytes == null ? sourceLength : maxSourceBytes.clamp(0, sourceLength); final input = await source.open(); IOSink? output; try { output = target.openWrite(mode: FileMode.writeOnly); var position = 0; var pending = ''; const chunkSize = 64 * 1024; while (position < limit) { final length = (limit - position).clamp(0, chunkSize); final bytes = await input.read(length); if (bytes.isEmpty) break; position += bytes.length; pending += utf8.decode(bytes, allowMalformed: true); final lines = pending.split('\n'); pending = lines.removeLast(); for (final line in lines) { output.writeln(sanitizeAppLogContent(line)); } } if (pending.isNotEmpty) output.write(sanitizeAppLogContent(pending)); await output.flush(); } finally { await input.close(); await output?.close(); } } /// 日志预览和上传共用同一套脱敏规则,避免两个入口出现隐私边界漂移。 String sanitizeAppLogContent(String content) => _sanitizePreviewContent(content); class AppLogCatalog { const AppLogCatalog({ required this.rootPath, required this.currentPath, required this.files, }); final String rootPath; final String currentPath; final List files; } class AppLogFile { const AppLogFile({ required this.path, required this.relativePath, required this.name, required this.fileSize, required this.modifiedAt, required this.isCurrent, required this.exists, }); final String path; final String relativePath; final String name; final int fileSize; final DateTime? modifiedAt; final bool isCurrent; final bool exists; } class AppLogPreview { const AppLogPreview({ required this.path, required this.content, required this.fileSize, required this.modifiedAt, required this.lineCount, required this.truncated, }); final String path; final String content; final int fileSize; final DateTime? modifiedAt; final int lineCount; final bool truncated; } class AppLogFilter extends LogFilter { @override bool shouldLog(LogEvent event) { return true; } } class ConsoleOutput extends LogOutput { String? _filePath; IOSink? _sink; Timer? _timer; Future _queue = Future.value(); Future _enqueue(Future Function() action) { _queue = _queue.then((_) => action()).catchError((e, stack) { print("写入日志错误error:|$e|stack:|$stack|"); }); return _queue; } /// 排在既有写入队列末尾刷新当前文件,完成后调用方可取得一致的文件大小快照。 Future flushCurrentLog() => _enqueue(() async => _sink?.flush()); /// 读取当前日志文件尾部,并在返回 UI 前移除终端颜色码和常见敏感字段。 Future readPreview({ String? path, required int maxBytes, required int maxLines, }) async { if (maxBytes <= 0 || maxLines <= 0) { throw ArgumentError('日志预览范围必须大于 0'); } final currentPath = await _resolveCurrentLogPath(); final resolvedPath = path ?? currentPath; await _validatePreviewPath(resolvedPath, currentPath); if (_samePath(resolvedPath, currentPath)) { await _enqueue(() async => _sink?.flush()); } final file = File(resolvedPath); if (!await file.exists()) { return AppLogPreview( path: resolvedPath, content: '', fileSize: 0, modifiedAt: null, lineCount: 0, truncated: false, ); } final stat = await file.stat(); final start = stat.size > maxBytes ? stat.size - maxBytes : 0; final handle = await file.open(); late final List bytes; try { await handle.setPosition(start); bytes = await handle.read(stat.size - start); } finally { await handle.close(); } var content = utf8.decode(bytes, allowMalformed: true); content = _sanitizePreviewContent(content); if (start > 0) content = _removePartialLeadingEntry(content); var lines = const LineSplitter().convert(content); var truncated = start > 0; if (lines.length > maxLines) { lines = lines.sublist(lines.length - maxLines); truncated = true; } return AppLogPreview( path: resolvedPath, content: lines.join('\n').trimRight(), fileSize: stat.size, modifiedAt: stat.modified, lineCount: lines.length, truncated: truncated, ); } /// 只扫描应用日志根目录中的 `.log` 文件,避免设置页变成任意文件浏览器。 Future listLogFiles() async { final currentPath = await _resolveCurrentLogPath(); await _enqueue(() async => _sink?.flush()); final root = Directory(currentPath).parent.parent; final files = []; if (await root.exists()) { await for (final entity in root.list(recursive: true, followLinks: false)) { if (entity is! File || !entity.path.toLowerCase().endsWith('.log')) { continue; } try { final stat = await entity.stat(); files.add( AppLogFile( path: entity.path, relativePath: _relativePath(root.path, entity.path), name: _fileName(entity.path), fileSize: stat.size, modifiedAt: stat.modified, isCurrent: _samePath(entity.path, currentPath), exists: true, ), ); } on FileSystemException { // 扫描期间文件可能被外部清理,跳过失效项即可由下一次刷新恢复。 } } } if (!files.any((file) => file.isCurrent)) { files.add( AppLogFile( path: currentPath, relativePath: _relativePath(root.path, currentPath), name: _fileName(currentPath), fileSize: 0, modifiedAt: null, isCurrent: true, exists: false, ), ); } files.sort((a, b) { if (a.isCurrent != b.isCurrent) return a.isCurrent ? -1 : 1; final timeCompare = (b.modifiedAt ?? DateTime.fromMillisecondsSinceEpoch(0)) .compareTo(a.modifiedAt ?? DateTime.fromMillisecondsSinceEpoch(0)); if (timeCompare != 0) return timeCompare; return b.relativePath.compareTo(a.relativePath); }); return AppLogCatalog( rootPath: root.path, currentPath: currentPath, files: List.unmodifiable(files), ); } Future _resolveCurrentLogPath() async { final path = _filePath ?? await AppFileTool.getLoggerBackupPath(); if (ObjectUtil.isEmpty(path)) { throw const FileSystemException('无法获取日志文件路径'); } return path!; } Future _validatePreviewPath( String path, String currentPath, ) async { if (!path.toLowerCase().endsWith('.log')) { throw const FileSystemException('仅支持预览日志文件'); } final root = Directory(currentPath).parent.parent; final candidate = File(path); final rootPath = await root.exists() ? await root.resolveSymbolicLinks() : root.absolute.path; final candidatePath = await candidate.exists() ? await candidate.resolveSymbolicLinks() : candidate.absolute.path; if (!_isPathInside(rootPath, candidatePath)) { throw const FileSystemException('日志文件不在允许的目录中'); } } @override Future init() async { try { await _enqueue(() async { await _sink?.flush(); await _sink?.close(); _filePath = await AppFileTool.getLoggerBackupPath(); if (ObjectUtil.isNotEmpty(_filePath)) { _sink = File(_filePath!).openWrite( mode: FileMode.writeOnlyAppend, encoding: utf8, ); } else { _sink = null; } }); _timer = Timer.periodic(const Duration(seconds: 60 * 60 * 2), (Timer timer) async { final path = await AppFileTool.getLoggerBackupPath(); if (path == null) return; unawaited(_enqueue(() async { await _sink?.flush(); await _sink?.close(); _filePath = path; _sink = File(_filePath!).openWrite( mode: FileMode.writeOnlyAppend, encoding: utf8, ); })); }); } catch (e, stack) { print("初始化日志错误error:|$e|stack:|$stack|"); } } @override Future destroy() async { _timer?.cancel(); _timer = null; await _enqueue(() async { await _sink?.flush(); await _sink?.close(); _sink = null; }); } /// 开发模式保留现有文件记录范围;生产模式仅持久化达到配置阈值的日志。 @override void output(OutputEvent event) { try { if (AppSysTool.isDev()) { for (final v in event.lines) { print(v); } } unawaited(_enqueue(() async { if (_filePath == null || _sink == null) return; if (AppSysTool.isProd() && event.level.value < Constants.productionFileLogLevel.value) { return; } if (Level.debug == event.level) return; _sink!.writeAll(event.lines, '\n'); _sink!.writeln(); await _sink!.flush(); })); } catch (e, stack) { print("写入日志错误error:|$e|stack:|$stack|"); } } } String _removePartialLeadingEntry(String content) { final entryEnd = content.indexOf('\n\n'); if (entryEnd >= 0) return content.substring(entryEnd + 2); final lineEnd = content.indexOf('\n'); return lineEnd >= 0 ? content.substring(lineEnd + 1) : ''; } bool _samePath(String first, String second) { return File(first).absolute.path == File(second).absolute.path; } bool _isPathInside(String rootPath, String candidatePath) { final root = Directory(rootPath).absolute.path; final candidate = File(candidatePath).absolute.path; return candidate == root || candidate.startsWith('$root${Platform.pathSeparator}'); } String _relativePath(String rootPath, String filePath) { final root = Directory(rootPath).absolute.path.replaceAll('\\', '/'); final file = File(filePath).absolute.path.replaceAll('\\', '/'); final prefix = '$root/'; return file.startsWith(prefix) ? file.substring(prefix.length) : _fileName(file); } String _fileName(String path) { return path.replaceAll('\\', '/').split('/').last; } String _sanitizePreviewContent(String content) { var value = content.replaceAll( RegExp(r'\x1B\[[0-?]*[ -/]*[@-~]'), '', ); value = value.replaceAllMapped( RegExp( r'^.*(?:用户信息|下单参数|存储用户|同步参数|同步设备信息\s+sysInfo|二维码识别.*json|绑定设备信息pa:|添加到.*记录.*json).*$', caseSensitive: false, multiLine: true, ), (_) => '[包含敏感业务数据的日志已隐藏]', ); value = value.replaceAllMapped( RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false), (_) => 'Bearer ***', ); value = value.replaceAllMapped( RegExp( r'((?:"|\b)?(?:token|accessToken|refreshToken|password|passwd|secret|authorization|paymentCode|payCode|authCode|qrCode|serviceCode|shortCode|cardId|cardNo|cardNumber|phone|mobile|idCard|certNo|realName|userName|nickName|address|email|userId|orderId|serviceOrderId)(?:"|\b)?\s*(?:[:=]|\|)\s*)("[^"]*"|[^,\s;|}\]]+)', caseSensitive: false, ), (match) { final source = match.group(2)!; return '${match.group(1)}${source.startsWith('"') ? '"***"' : '***'}'; }, ); // 未带字段名的长编号也可能是手机号、饭卡号或付款码,仅保留定位所需的首尾片段。 return value.replaceAllMapped(RegExp(r'\d{8,}'), (match) { final source = match.group(0)!; return '${source.substring(0, 3)}****${source.substring(source.length - 4)}'; }); }