mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 04:35:33 +08:00
feat(log): 新增 DebugLogToolkit 行构建与 JSONL 追加写工具
This commit is contained in:
250
extend/think/log/DebugLogToolkit.php
Normal file
250
extend/think/log/DebugLogToolkit.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
281
tests/Unit/DebugLogToolkitTest.php
Normal file
281
tests/Unit/DebugLogToolkitTest.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use think\facade\App;
|
||||
use think\log\DebugLogToolkit;
|
||||
|
||||
/**
|
||||
* DebugLogToolkit 单元测试(行构建 + JSONL 追加写,不触 DB).
|
||||
*
|
||||
* 依赖 tests/bootstrap.php 引导的容器(facade App 需容器内有 'app' 绑定),
|
||||
* 但不连数据库——appendLine 用例通过 setRuntimePath 把写入目标改到系统临时目录,
|
||||
* 写前清目录、测后清理并还原 runtimePath,不污染项目 runtime 与其它测试。
|
||||
*
|
||||
* REUQEST_UID/REQUEST_UID 常量在 PHPUnit 进程内不可安全 define(不可撤销、
|
||||
* 会污染同进程后续测试文件),故 uid 用例只测 uniqid 兜底路径。
|
||||
*/
|
||||
class DebugLogToolkitTest extends TestCase
|
||||
{
|
||||
/** appendLine/glob 用例的临时目录基路径(系统临时目录下,不碰项目 runtime) */
|
||||
private const TEMP_BASE = 'debug-log-toolkit-test';
|
||||
|
||||
/** @var string 容器原始 runtimePath,tearDown 还原,避免污染同进程其它测试 */
|
||||
private string $originalRuntimePath = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user