447 lines
13 KiB
Dart
447 lines
13 KiB
Dart
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<AppLogPreview> readAppLogPreview({
|
|
String? path,
|
|
int maxBytes = 256 * 1024,
|
|
int maxLines = 1000,
|
|
}) async {
|
|
await logger.init;
|
|
return _consoleOutput.readPreview(
|
|
path: path,
|
|
maxBytes: maxBytes,
|
|
maxLines: maxLines,
|
|
);
|
|
}
|
|
|
|
/// 列出日志根目录中的文件,设置页据此构建可切换的日志目录树。
|
|
Future<AppLogCatalog> listAppLogFiles() async {
|
|
await logger.init;
|
|
return _consoleOutput.listLogFiles();
|
|
}
|
|
|
|
/// 上传当前日志前刷新文件写入队列,保证快照边界包含调用前已经产生的日志。
|
|
Future<void> flushCurrentAppLog() async {
|
|
await logger.init;
|
|
await _consoleOutput.flushCurrentLog();
|
|
}
|
|
|
|
/// 逐行生成脱敏副本,上传工具不会把原始日志文件直接交给网络层。
|
|
Future<void> 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<AppLogFile> 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<void> _queue = Future.value();
|
|
|
|
Future<void> _enqueue(Future<void> Function() action) {
|
|
_queue = _queue.then((_) => action()).catchError((e, stack) {
|
|
print("写入日志错误error:|$e|stack:|$stack|");
|
|
});
|
|
return _queue;
|
|
}
|
|
|
|
/// 排在既有写入队列末尾刷新当前文件,完成后调用方可取得一致的文件大小快照。
|
|
Future<void> flushCurrentLog() => _enqueue(() async => _sink?.flush());
|
|
|
|
/// 读取当前日志文件尾部,并在返回 UI 前移除终端颜色码和常见敏感字段。
|
|
Future<AppLogPreview> 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<int> 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<AppLogCatalog> listLogFiles() async {
|
|
final currentPath = await _resolveCurrentLogPath();
|
|
await _enqueue(() async => _sink?.flush());
|
|
final root = Directory(currentPath).parent.parent;
|
|
final files = <AppLogFile>[];
|
|
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<String> _resolveCurrentLogPath() async {
|
|
final path = _filePath ?? await AppFileTool.getLoggerBackupPath();
|
|
if (ObjectUtil.isEmpty(path)) {
|
|
throw const FileSystemException('无法获取日志文件路径');
|
|
}
|
|
return path!;
|
|
}
|
|
|
|
Future<void> _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<void> 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<void> 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)}';
|
|
});
|
|
}
|