diff --git a/app/Http/Controllers/Api/v1/FreenodeController.php b/app/Http/Controllers/Api/v1/FreenodeController.php index 0f7e52e..c008408 100644 --- a/app/Http/Controllers/Api/v1/FreenodeController.php +++ b/app/Http/Controllers/Api/v1/FreenodeController.php @@ -8,6 +8,7 @@ use App\Services\TomTool\Telegram\Slave as TeleSlave; use Illuminate\Support\Str; use Illuminate\Support\Facades\File; +use Carbon\CarbonImmutable; class FreenodeController extends Controller { @@ -22,7 +23,8 @@ public function filePut(Request $oRequest) $sSubSiteCodeShort = $aData["site_code_short"]; $aRaw = $aData["raw"]; - $sDate = date('Ymd'); + // Subscription links use Beijing's calendar day, independently of app.timezone. + $sDate = CarbonImmutable::now('Asia/Shanghai')->format('Ymd'); $sSuffix = "nodes"; $sRawKey = $sDate . $sSuffix; $sUniqueToken = hash_hmac('sha256', $sRawKey, 'wocaonide'); @@ -47,7 +49,7 @@ public function filePut(Request $oRequest) } $sFileSavePath = $sSubSiteCodeShort."/".$sShortToken; - $sFileSaveName = date("Ymd")."-".$sClient.$sExtension; + $sFileSaveName = $sDate."-".$sClient.$sExtension; $sFilePathFull = $sFileSavePath."/".$sFileSaveName; if (!File::isDirectory($sFileSavePath)) { diff --git a/docs/maintenance/FREENODE.md b/docs/maintenance/FREENODE.md new file mode 100644 index 0000000..100c7c9 --- /dev/null +++ b/docs/maintenance/FREENODE.md @@ -0,0 +1,21 @@ +# 免费节点订阅接收 + +Nodehub只负责接收和托管产物;上游生成、内核校验和站点分发见[master01维护正文](/Users/hellcat/Documents/GemCodes/dd.loc/docs/maintenance/FREENODE.md)。项目身份和访问规则见[GemDocs项目档案](/Users/hellcat/Documents/GemDocs/AI版/10-项目/nodehub.md)。 + +## 订阅日期契约 + +`POST /api/freenode/file/put`由`Api/v1/FreenodeController::filePut()`接收,参数仍为`site_code_short`和各格式`raw`,接口及链接算法不变。 + +接收时通过`CarbonImmutable::now('Asia/Shanghai')`确定一次业务日期,目录令牌和所有格式文件名均使用同一日期。应用默认时区仍为UTC,不改整个应用的日期、任务或日志。与master01按北京时间生成的当天链接保持一致;当天首次正常分发前不保证新日期文件已存在。 + +旧实现用UTC的`date()`,北京时间00:15、06:15两轮会写到前一天目录,直到12:15一轮才产生当天文件。2026-10-21已确认该问题;本次改动仅修复订阅接收日期,不移动历史文件、不复制旧节点伪造未来批次。 + +## 回归验证 + +`php vendor/bin/phpunit tests/Feature/FreenodeDateTest.php --do-not-cache-result`在隔离临时目录运行真实接收控制器,模拟北京时间零点前后以及00:15、06:15、12:15同步时刻,核对三种格式的日期、目录、内容以及全局UTC时区保持不变。Telegram被替换为测试对象,不访问网络或真实公开目录。 + +2026-10-21本机PHP 8.4.1通过5项、35断言。macOS本机使用真实PHP二进制`/usr/local/Cellar/php/8.4.1_1/bin/php`运行,避免Herd包装器导致PHPUnit隔离子进程启动失败。生产部署及验收结果另按实际记录。 + +## 发布与恢复 + +通过Git提交、服务端推送仓库,再将线上工作树更新到明确提交。提交只包括控制器、上述测试和本文;保留已有权限及未跟踪文件。部署前在服务器保存旧提交、控制器原字节/权限、工作区状态和当日订阅哈希;回退只还原本次控制器并记录代码版本,不reset整个有并行改动的工作树,不清除公开订阅目录。数据库、环境配置和系统时间不需要改动。 diff --git a/tests/Feature/FreenodeDateTest.php b/tests/Feature/FreenodeDateTest.php new file mode 100644 index 0000000..369e5e8 --- /dev/null +++ b/tests/Feature/FreenodeDateTest.php @@ -0,0 +1,73 @@ + ['2026-10-21T15:59:59Z', '20261021'], + 'Beijing midnight' => ['2026-10-21T16:00:00Z', '20261022'], + 'midnight scheduled delivery' => ['2026-10-21T16:15:00Z', '20261022'], + 'morning scheduled delivery' => ['2026-10-21T22:15:00Z', '20261022'], + 'noon scheduled delivery' => ['2026-10-22T04:15:00Z', '20261022'], + ]; + } + + #[DataProvider('receivingTimes')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testReceiverWritesAllFormatsUnderBeijingDate(string $instant, string $date): void + { + $previousDirectory = getcwd(); + $previousTimezone = date_default_timezone_get(); + $directory = sys_get_temp_dir().'/nodehub-date-test-'.bin2hex(random_bytes(6)); + mkdir($directory, 0700); + + // Keep the real controller and filesystem, but never send Telegram in a test. + $telegram = Mockery::mock('alias:App\Services\TomTool\Telegram\Slave'); + $telegram->shouldReceive('log')->once()->andReturnSelf(); + $telegram->shouldReceive('enableDetail')->with(false)->once()->andReturnSelf(); + $telegram->shouldReceive('send')->once(); + + try { + config(['app.timezone' => 'UTC', 'app.is_loc' => true]); + date_default_timezone_set('UTC'); + CarbonImmutable::setTestNow(new CarbonImmutable($instant)); + chdir($directory); + $raw = ['v2ray' => 'fixture-uri', 'clash' => 'fixture-clash', 'singbox' => 'fixture-singbox']; + $request = Request::create('/api/freenode/file/put', 'POST', ['site_code_short' => 'cfn', 'raw' => $raw]); + $response = (new FreenodeController())->filePut($request); + + self::assertTrue($response->getData(true)['isDone']); + $token = substr(hash_hmac('sha256', $date.'nodes', 'wocaonide'), 0, 16); + foreach ($raw as $format => $content) { + $extension = $format === 'clash' ? '.yaml' : '.txt'; + self::assertSame($content, file_get_contents($directory.'/cfn/'.$token.'/'.$date.'-'.$format.$extension)); + } + self::assertCount(3, glob($directory.'/cfn/*/*')); + self::assertSame('UTC', date_default_timezone_get()); + self::assertSame('UTC', config('app.timezone')); + } finally { + CarbonImmutable::setTestNow(); + date_default_timezone_set($previousTimezone); + chdir($previousDirectory); + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($directory); + Mockery::close(); + } + } +}