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