Browse Source

feat(api-v13): 新增 GET /v3/heartbeat,取代 /v2/heartbeat

v2→v3 的第一个迁移样板。选 heartbeat 是因为只有 dashboard-v6 在用(A 类),
零线上风险,适合把整条链路跑通。

- 单例资源,Route::apiSingleton(...)->only(['show'])。v2 那边 apiResource 注册了
  7 条路由但只有 index 有实现,其余 4 条是幽灵端点,v3 不再重复
- 类名带 v2 血缘(HeartbeatV3Controller),URL 也沿用 v2 资源名只换版本前缀,
  迁移期内两套并存,对不上号很难排查
- 术语澄清:heartbeat 是心跳(liveness),不查任何依赖;真正的健康检查是
  /v2/health-check(逐项探测 OpenSearch/RabbitMQ/DB/Cache),将来单独迁移
- .stop 停机开关返回 503 + Problem Details,用 BusinessException 以免兜底
  处理器抹掉维护文案
- 6 条测试,其中一条专门守着 v2 heartbeat 的裸响应形状没被动过

v2 的 HeartbeatController 一行未动,待 dashboard-v4 下线后删除。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 weeks ago
parent
commit
a79f543f1a

+ 43 - 0
api-v13/app/Http/Controllers/HeartbeatV3Controller.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Exceptions\BusinessException;
+use App\Http\Resources\HeartbeatV3Resource;
+
+class HeartbeatV3Controller extends Controller
+{
+    /**
+     * 心跳
+     *
+     * 取代 `GET /v2/heartbeat`(`HeartbeatController@index`)。v2 那份在
+     * dashboard-v4 下线前原样保留,不要改动。
+     *
+     * **这不是健康检查。** 它不查数据库、不查 OpenSearch / RabbitMQ / Cache,
+     * 只回答一件事:进程还在服务吗、是不是被运维停机了。供前端与负载均衡做
+     * 存活探测(liveness)用,开销接近零,可以高频调用。
+     *
+     * 真正的健康检查是 `GET /v2/health-check`(会逐项探测外部依赖并返回 checks
+     * 明细),将来单独迁为 `/v3/health-check`,与本端点是两件事,不要混用。
+     *
+     * 仓库根目录存在 `.stop` 文件时返回 503(运维停机开关),此时响应体是
+     * RFC 9457 Problem Details 而非正常结构。
+     *
+     * @unauthenticated
+     *
+     * @responseStatus 503 服务已进入停机维护状态
+     */
+    public function show(): HeartbeatV3Resource
+    {
+        if (file_exists(base_path('.stop'))) {
+            // 用 BusinessException 而不是 abort(503):兜底处理器会屏蔽 5xx 的 detail
+            // (防止泄露内部错误),而停机是预期状态,维护文案必须原样送到客户端
+            throw new BusinessException(__('site.maintenance'), 503, 'maintenance');
+        }
+
+        return HeartbeatV3Resource::make([
+            'status' => 'ok',
+            'checked_at' => now(),
+        ]);
+    }
+}

+ 28 - 0
api-v13/app/Http/Resources/HeartbeatV3Resource.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+
+class HeartbeatV3Resource extends V3Resource
+{
+    /**
+     * 心跳结果。
+     *
+     * status 目前只有 'ok' 一个取值——被停机时走 503 的 Problem Details 分支,
+     * 不会到这里。注意这里不含任何依赖项的健康状态,那是 health-check 的职责。
+     *
+     * @param  Request  $request
+     * @return array{
+     *     status: string,
+     *     checked_at: string
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'status' => $this->resource['status'],
+            'checked_at' => $this->resource['checked_at'],
+        ];
+    }
+}

+ 70 - 0
api-v13/tests/Feature/HeartbeatV3Test.php

@@ -0,0 +1,70 @@
+<?php
+
+/**
+ * GET /api/v3/heartbeat —— 心跳(liveness),不是健康检查。
+ * 真正的健康检查是 /v2/health-check,逐项探测外部依赖,将来单独迁移。
+ *
+ * 不用 RefreshDatabase:这个端点不碰数据库,也不需要任何夹具。
+ *
+ * 注意:这里断言的是响应形状,不是契约。项目尚未安装 hotmeteor/spectator,
+ * 所以无法对着生成的 OpenAPI 规格做校验——规格与实现漂移不会被自动拦截。
+ * 装上之后应把这些断言换成 assertValidResponse()。
+ */
+afterEach(function () {
+    @unlink(base_path('.stop'));
+});
+
+it('reports that the process is serving', function () {
+    $response = $this->getJson('/api/v3/heartbeat')->assertOk();
+
+    expect($response->json('data.status'))->toBe('ok');
+    // checked_at 必须是可解析的 ISO 8601 UTC 时间
+    expect(strtotime($response->json('data.checked_at')))->toBeGreaterThan(0);
+    expect($response->json('data.checked_at'))->toEndWith('Z');
+});
+
+it('needs no authentication', function () {
+    // 不带任何 Authorization 头也应当通过
+    $this->getJson('/api/v3/heartbeat')->assertOk();
+});
+
+it('returns 503 with problem details while the stop switch is on', function () {
+    touch(base_path('.stop'));
+
+    $this->getJson('/api/v3/heartbeat')
+        ->assertStatus(503)
+        ->assertJson([
+            'title' => 'Service unavailable.',
+            'status' => 503,
+        ])
+        ->assertJsonStructure(['type', 'title', 'status', 'detail']);
+});
+
+it('localizes the problem detail instead of hardcoding it', function () {
+    touch(base_path('.stop'));
+
+    $detail = $this->getJson('/api/v3/heartbeat')->assertStatus(503)->json('detail');
+
+    // 翻译文件缺失时 __() 会原样吐出键名,这条断言守住这种退化
+    expect($detail)->not->toBe('site.maintenance');
+    expect($detail)->toBe(__('site.maintenance'));
+
+    // 中文翻译必须存在
+    expect(trans('site.maintenance', [], 'zh-Hans'))->toBe('服务已进入停机维护状态');
+});
+
+it('recovers once the stop switch is removed', function () {
+    touch(base_path('.stop'));
+    $this->getJson('/api/v3/heartbeat')->assertStatus(503);
+
+    unlink(base_path('.stop'));
+    $this->getJson('/api/v3/heartbeat')->assertOk();
+});
+
+it('does not touch the frozen v2 heartbeat endpoint', function () {
+    // v2 迁移期内必须原样可用:裸返回 createdAt,不带 data 包装
+    $response = $this->getJson('/api/v2/heartbeat')->assertOk();
+
+    expect($response->json('createdAt'))->not->toBeNull();
+    expect($response->json('data'))->toBeNull();
+});