Files
biz_xfj_cloud/lib/common/utils/logger/logger_tool.dart
T
dzc aff7fe38dd feat:统计增加表头提示
feat:商户交易记录、部门交易记录增加分片、进度条导出
feat:增加人脸质量上报
feat:app"通知"权限修改
2026-09-01 12:43:32 +08:00

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)}';
});
}