mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 12:45:32 +08:00
251 lines
7.8 KiB
PHP
251 lines
7.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace think\log;
|
||
|
||
use think\facade\App;
|
||
|
||
/**
|
||
* debug_log 行构建与 JSONL 追加写共享工具(纯静态、无状态).
|
||
*
|
||
* 行格式契约锚点:FIELDS 常量是 T3(异步驱动)/T4(同步驱动修复)/T5(Reader)/T6(导入任务)
|
||
* 四方共享的字段契约,签名与语义变更必须四方同步。
|
||
*
|
||
* 与 think\log\driver\DebugMysql 的关系:
|
||
* - DebugMysql.save() 的行构建逻辑(L89-136)迁移至此共享;
|
||
* - DebugMysql L89 把 app('http') 放在 CLI 判断之前是 bug 示范——
|
||
* 本类 buildRow 必须先判 App::runningInConsole(),CLI 下禁止触碰 http/request。
|
||
*
|
||
* 落盘格式:JSONL(每行一个 JSON 对象,UTF-8),目标
|
||
* {runtimePath}log/{Ymd}.jsonl —— date('Ymd') 每次调用现算(长驻 CLI 进程跨天滚动)。
|
||
*
|
||
* 自动加载:composer.json psr-0 fallback "": "extend/"(与 think\log\driver\DebugMysql 同机制)。
|
||
*/
|
||
class DebugLogToolkit
|
||
{
|
||
/**
|
||
* 行字段契约(顺序即 CSV 表头顺序,与 ul_debug_log 表字段一一对应).
|
||
*
|
||
* T5 Reader 侧将引用此常量校验 JSONL 行完整性;旧 CSV 文件表头 = 同序数组。
|
||
*/
|
||
public const FIELDS = [
|
||
'level',
|
||
'content',
|
||
'create_time',
|
||
'create_time_title',
|
||
'uid',
|
||
'app_name',
|
||
'controller_name',
|
||
'action_name',
|
||
];
|
||
|
||
/**
|
||
* 构建 debug_log 行(8 字段,键序与 FIELDS 一致).
|
||
*
|
||
* CLI 判断顺序铁律:App::runningInConsole() 在前——CLI 下 app_name='cli'、
|
||
* controller/action 空串,禁止触碰 app('http')/request();Web 分支三者各自
|
||
* 独立 try-catch 兜底空串(任一容器依赖缺失不拖垮整行写入)。
|
||
*
|
||
* @param string $level 日志级别(LogRecord->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 '';
|
||
}
|
||
}
|
||
}
|