diff --git a/extend/think/log/DebugLogToolkit.php b/extend/think/log/DebugLogToolkit.php new file mode 100644 index 0000000..f44fb7e --- /dev/null +++ b/extend/think/log/DebugLogToolkit.php @@ -0,0 +1,250 @@ +type) + * @param string $content 日志内容(LogRecord->message,调用侧保证已字符串化) + * + * @return array 键值与 FIELDS 完全一致的关联数组 + */ + public static function buildRow(string $level, string $content): array + { + $controllerName = ''; + $actionName = ''; + + if (App::runningInConsole()) { + $appName = 'cli'; + } else { + $appName = self::fetchAppName(); + $controllerName = self::fetchRequestSegment('controller'); + $actionName = self::fetchRequestSegment('action'); + } + + $createTime = time(); + + return [ + 'level' => $level, + 'content' => $content, + 'create_time' => $createTime, + 'create_time_title' => date('Y-m-d H:i:s', $createTime), + 'uid' => self::resolveUid(), + 'app_name' => $appName, + 'controller_name' => $controllerName, + 'action_name' => $actionName, + ]; + } + + /** + * 行数组 → 单物理行 JSON 字符串(不含换行符). + * + * 编码前逐字段做 UTF-8 清洗(非法字节替换为 '?');清洗后 json_encode 仍失败 + * 则整体降级为 '[encoding-error]' 占位行——禁止让 json_encode 返回 false + * 写出空行毒化 JSONL 文件。 + * + * @return string 非空单行 JSON;任何异常路径都返回占位行,永不抛出、永不为空串 + */ + public static function encodeRow(array $row): string + { + try { + $clean = []; + foreach ($row as $key => $value) { + $clean[$key] = is_string($value) + ? mb_convert_encoding($value, 'UTF-8', 'UTF-8') + : $value; + } + + $json = json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + return $json === false || $json === '' ? '[encoding-error]' : $json; + } catch (\Throwable $th) { + return '[encoding-error]'; + } + } + + /** + * 追加写入一行到 JSONL 文件(目标 {runtimePath}log/{Ymd}.jsonl). + * + * - date('Ymd') 每次调用现算,禁止缓存——长驻 CLI 进程跨天滚动文件 + * - 行尾保证单个 "\n"(传入行缺尾换行自动补齐),fwrite 后显式校验写入字节数 + * - 全方法 catch \Throwable 返回 false,绝不抛异常(日志通道写入失败不能中断业务) + * + * @param string $line 单行内容(建议为 encodeRow 输出;空行拒绝写入) + */ + public static function appendLine(string $line): bool + { + try { + if (trim($line) === '') { + return false; + } + + if (substr($line, -1) !== "\n") { + $line .= "\n"; + } + + $dir = App::getRuntimePath() . 'log/'; + if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { + return false; + } + + $file = $dir . date('Ymd') . '.jsonl'; + + $handle = fopen($file, 'a'); + if ($handle === false) { + return false; + } + + if (!flock($handle, LOCK_EX)) { + fclose($handle); + + return false; + } + + $written = fwrite($handle, $line); + if ($written === false || $written !== strlen($line)) { + flock($handle, LOCK_UN); + fclose($handle); + + return false; + } + + flock($handle, LOCK_UN); + fclose($handle); + + return true; + } catch (\Throwable $th) { + return false; + } + } + + /** + * 解析单行 JSONL 为行数组. + * + * @return array|null 正常行返回关联数组;坏 JSON / 非数组 / FIELDS 键不全返回 null + */ + public static function decodeLine(string $line): ?array + { + try { + $decoded = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $th) { + return null; + } + + if (!is_array($decoded)) { + return null; + } + + foreach (self::FIELDS as $field) { + if (!array_key_exists($field, $decoded)) { + return null; + } + } + + return $decoded; + } + + /** + * 判断 CSV 首行是否为旧格式 debug_log CSV 的表头行. + * + * 旧 CSV(DebugMysql.saveByFile 产物,{ymd}.csv)首行为 8 列字段名, + * 导入侧跳过表头不当作数据行入库。 + * + * @param array $fields fgetcsv 读出的首行 + */ + public static function isCsvHeader(array $fields): bool + { + return isset($fields[0]) && $fields[0] === 'level' && count($fields) === 8; + } + + /** + * 解析请求 uid:双常量兼容(public/index.php:11 定义的是历史拼写 REUQEST_UID,保留不改). + * + * @return string REUQEST_UID > REQUEST_UID > uniqid() 兜底 + */ + protected static function resolveUid(): string + { + if (defined('REUQEST_UID')) { + return (string) REUQEST_UID; + } + + if (defined('REQUEST_UID')) { + return (string) REQUEST_UID; + } + + return uniqid(); + } + + /** + * Web 分支取 app_name(独立兜底:app('http') 缺失/异常返回空串). + */ + protected static function fetchAppName(): string + { + try { + $name = app('http')->getName(); + + return is_string($name) && $name !== '' ? $name : ''; + } catch (\Throwable $th) { + return ''; + } + } + + /** + * Web 分支取 request 的 controller/action 段(独立兜底返回空串). + * + * @param string $segment 'controller' 或 'action' + */ + protected static function fetchRequestSegment(string $segment): string + { + try { + $value = request()->{$segment}(); + + return is_string($value) && $value !== '' ? $value : ''; + } catch (\Throwable $th) { + return ''; + } + } +} diff --git a/tests/Unit/DebugLogToolkitTest.php b/tests/Unit/DebugLogToolkitTest.php new file mode 100644 index 0000000..74cbbee --- /dev/null +++ b/tests/Unit/DebugLogToolkitTest.php @@ -0,0 +1,281 @@ +originalRuntimePath = App::getRuntimePath(); + } + + protected function tearDown(): void + { + // 还原 runtimePath(appendLine 用例会改它) + App::setRuntimePath($this->originalRuntimePath); + + // 清理临时目录 + $base = sys_get_temp_dir() . '/' . self::TEMP_BASE; + if (is_dir($base)) { + self::rrmdir($base); + } + } + + /** + * 递归删除目录. + */ + private static function rrmdir(string $dir): void + { + $items = scandir($dir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . DIRECTORY_SEPARATOR . $item; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } + + /** + * 用例 1:buildRow 字段齐全(8 键、键序与 FIELDS 一致)+ CLI 上下文分支. + * + * PHPUnit 进程 PHP_SAPI='cli',App::runningInConsole()=true: + * app_name 必须='cli',且不得触碰 app('http')/request()(DebugMysql L89 反面示范)。 + */ + public function test_buildRow_cli_context_fields_complete(): void + { + $before = time(); + $row = DebugLogToolkit::buildRow('info', 'hello 日志'); + $after = time(); + + // 8 键齐全且键序与契约常量一致 + self::assertSame(DebugLogToolkit::FIELDS, array_keys($row)); + self::assertCount(8, $row); + + // 基础字段 + self::assertSame('info', $row['level']); + self::assertSame('hello 日志', $row['content']); + self::assertGreaterThanOrEqual($before, $row['create_time']); + self::assertLessThanOrEqual($after, $row['create_time']); + self::assertSame(date('Y-m-d H:i:s', $row['create_time']), $row['create_time_title']); + + // CLI 分支:app_name='cli',controller/action 空串 + self::assertSame('cli', $row['app_name']); + self::assertSame('', $row['controller_name']); + self::assertSame('', $row['action_name']); + + // FIELDS 契约自检:首列 level、8 字段(T5 Reader 侧依赖) + self::assertSame('level', DebugLogToolkit::FIELDS[0]); + self::assertCount(8, DebugLogToolkit::FIELDS); + } + + /** + * 用例 2:resolveUid 兜底路径——常量未定义时 uid 为非空字符串(uniqid). + * + * PHPUnit 进程未定义 REUQEST_UID/REQUEST_UID(bootstrap 不 define, + * 常量定义后不可撤销、会跨测试文件污染),只测兜底分支。 + */ + public function test_buildRow_uid_fallback_uniqid(): void + { + $row = DebugLogToolkit::buildRow('info', 'x'); + + self::assertIsString($row['uid']); + self::assertNotSame('', $row['uid']); + } + + /** + * 用例 3:encodeRow 对含换行/引号/中文/制表符的 content 输出单物理行. + */ + public function test_encodeRow_single_physical_line(): void + { + $content = "第一行\nsecond \"line\" with 'quotes'\ttab结尾"; + $json = DebugLogToolkit::encodeRow(DebugLogToolkit::buildRow('debug', $content)); + + // JSONL 铁律:一个逻辑行 = 一个物理行(换行符被转义为 \n 字面量) + self::assertSame(0, substr_count($json, "\n")); + self::assertSame(0, substr_count($json, "\r")); + + // roundtrip:内容无损还原 + $decoded = json_decode($json, true); + self::assertIsArray($decoded); + self::assertSame($content, $decoded['content']); + self::assertSame('debug', $decoded['level']); + } + + /** + * 用例 4:非法 UTF-8("\xB1\x31")不抛异常且返回非空字符串. + * + * 清洗(mb_convert_encoding UTF-8→UTF-8,非法字节替换 '?')后必须可被 + * json_decode 解析——降级占位行 '[encoding-error]' 也满足"非空可解析"。 + */ + public function test_encodeRow_invalid_utf8_never_throws_or_empty(): void + { + $bad = "pre\xB1\x31post"; // \xB1 为非法 UTF-8 起始字节 + + $json = DebugLogToolkit::encodeRow(DebugLogToolkit::buildRow('error', $bad)); + + self::assertIsString($json); + self::assertNotSame('', $json); + + $decoded = json_decode($json, true); + self::assertIsArray($decoded, '非法 UTF-8 行也必须可被 json_decode 解析'); + } + + /** + * 用例 5:decodeLine——encodeRow 输出可解回;坏 JSON/非数组/缺键返回 null. + */ + public function test_decodeLine_roundtrip_and_rejects(): void + { + $line = DebugLogToolkit::encodeRow(DebugLogToolkit::buildRow('info', 'roundtrip 内容')); + + $decoded = DebugLogToolkit::decodeLine($line); + self::assertIsArray($decoded); + self::assertSame(DebugLogToolkit::FIELDS, array_keys($decoded)); + self::assertSame('roundtrip 内容', $decoded['content']); + + // 坏 JSON / 非对象 / 缺键 / 空行 + self::assertNull(DebugLogToolkit::decodeLine('not json at all')); + self::assertNull(DebugLogToolkit::decodeLine('{truncated')); + self::assertNull(DebugLogToolkit::decodeLine('')); + self::assertNull(DebugLogToolkit::decodeLine('[1,2,3]')); // 非关联数组 + self::assertNull(DebugLogToolkit::decodeLine('"string"')); // 标量 + self::assertNull(DebugLogToolkit::decodeLine('{"level":"info"}')); // FIELDS 键不全 + self::assertNull(DebugLogToolkit::decodeLine((string) json_encode([ + 'level' => 'info', + 'content' => 'x', + 'create_time' => 1, + 'create_time_title' => 't', + 'uid' => 'u', + 'app_name' => 'cli', + 'controller_name' => '', + // 缺 action_name + ]))); + } + + /** + * 用例 6:appendLine 在系统临时目录写入后,文件内容逐物理行完整. + * + * runtimePath 改指 sys_get_temp_dir()/debug-log-toolkit-test/, + * 目标文件必须落在 {runtimePath}log/{Ymd}.jsonl;写前清旧目录,tearDown 统一清理。 + */ + public function test_appendLine_writes_complete_lines(): void + { + $base = sys_get_temp_dir() . '/' . self::TEMP_BASE; + + // 写前清理旧目录 + if (is_dir($base)) { + self::rrmdir($base); + } + + // 把容器 runtimePath 改到临时目录(tearDown 还原) + App::setRuntimePath($base . '/'); + + $lines = []; + for ($i = 1; $i <= 3; ++$i) { + $line = DebugLogToolkit::encodeRow(DebugLogToolkit::buildRow('info', "行 {$i} 内容\n带换行")); + $lines[] = $line; + self::assertTrue(DebugLogToolkit::appendLine($line), "第 {$i} 行 appendLine 应成功"); + } + + $file = $base . '/log/' . date('Ymd') . '.jsonl'; + self::assertFileExists($file); + + $content = (string) file_get_contents($file); + $physical = explode("\n", trim($content)); + self::assertCount(3, $physical, '3 行写入应有 3 个物理行'); + + foreach ($lines as $i => $expected) { + self::assertSame($expected, $physical[$i], "物理行 {$i} 与写入内容不一致"); + // 每行可被 decodeLine 解回(JSONL 完整性) + self::assertIsArray(DebugLogToolkit::decodeLine($physical[$i])); + } + + // 追加写语义:再写一行变 4 行 + self::assertTrue(DebugLogToolkit::appendLine($lines[0])); + self::assertCount(4, explode("\n", trim((string) file_get_contents($file)))); + } + + /** + * 用例 7:isCsvHeader——真表头 true,首列/列数不符 false. + */ + public function test_isCsv_header_detection(): void + { + $header = [ + 'level', 'content', 'create_time', 'create_time_title', + 'uid', 'app_name', 'controller_name', 'action_name', + ]; + + self::assertTrue(DebugLogToolkit::isCsvHeader($header)); + self::assertTrue(DebugLogToolkit::isCsvHeader(DebugLogToolkit::FIELDS)); + + self::assertFalse(DebugLogToolkit::isCsvHeader(['level', 'content'])); // 列数不足 + self::assertFalse(DebugLogToolkit::isCsvHeader(array_merge($header, ['extra']))); // 列数超 8 + self::assertFalse(DebugLogToolkit::isCsvHeader(array_merge(['not_level'], array_slice($header, 1)))); // 首列非 level + self::assertFalse(DebugLogToolkit::isCsvHeader([])); // 空行 + } + + /** + * 用例 8:防回归——glob 模式语义不吞 .log 文件. + * + * Reader(T5)用 glob 找 {Ymd}.jsonl 与 *.csv,必须验证: + * - glob 精确名 date('Ymd').'.jsonl' 不匹配 .log 文件 + * - glob '*.csv' 不匹配 .log 文件 + * - 正向对照:真 .jsonl/.csv 能被各自模式匹配(排除"glob 失效导致不匹配"的假绿) + */ + public function test_glob_semantics_not_matching_log_suffix(): void + { + $dir = sys_get_temp_dir() . '/' . self::TEMP_BASE . '/glob'; + if (is_dir($dir)) { + self::rrmdir($dir); + } + mkdir($dir, 0777, true); + + // 造干扰文件 + 正向对照文件 + $logFile = $dir . '/' . date('Ymd') . '.log'; + file_put_contents($logFile, 'legacy log'); + $jsonlFile = $dir . '/' . date('Ymd') . '.jsonl'; + file_put_contents($jsonlFile, '{}'); + $csvFile = $dir . '/sample.csv'; + file_put_contents($csvFile, 'level,content'); + + // glob 精确名(含 .jsonl 后缀)不匹配 .log + $exact = glob($dir . '/' . date('Ymd') . '.jsonl'); + self::assertIsArray($exact); + self::assertSame([$jsonlFile], $exact, '精确名 glob 只命中 .jsonl,不吞同日期 .log'); + self::assertNotContains($logFile, $exact); + + // glob *.csv 不匹配 .log + $csvs = glob($dir . '/*.csv'); + self::assertIsArray($csvs); + self::assertContains($csvFile, $csvs); + self::assertNotContains($logFile, $csvs, '*.csv 不得匹配 .log 文件'); + + // 正向对照:若 jsonl 文件不存在,精确 glob 返回空数组(而非误吞 .log) + unlink($jsonlFile); + $empty = glob($dir . '/' . date('Ymd') . '.jsonl'); + self::assertSame([], $empty); + } +}