瀏覽代碼

Merge pull request #2493 from visuddhinanda/development

Development
visuddhinanda 1 周之前
父節點
當前提交
ad4c09a5b3

+ 2 - 2
api-test/cases/06_auth.php

@@ -251,12 +251,12 @@ test('Accept-Language: zh-Hans → 中文错误文案 + Content-Language', funct
     $res = auth_wrong_code_in('zh-Hans');
     assert_field_error('code', $res, 'invites 错验证码(zh-Hans)');
     assert_eq('验证码不正确或已过期。', $res['body']['errors']['code'][0] ?? null, 'errors.code');
-    assert_eq('zh-Hans', $res['headers']['Content-Language'][0] ?? null, 'Content-Language');
+    assert_eq('zh-Hans', $res['headers']['content-language'][0] ?? null, 'Content-Language');
 });
 
 test('Accept-Language: zh-TW → 繁体;不支持的语言 → en', function () {
     $hant = auth_wrong_code_in('zh-TW');
     assert_eq('驗證碼不正確或已過期。', $hant['body']['errors']['code'][0] ?? null, 'zh-TW 的 errors.code');
     $fr = auth_wrong_code_in('fr-FR');
-    assert_eq('en', $fr['headers']['Content-Language'][0] ?? null, 'fr-FR 的 Content-Language');
+    assert_eq('en', $fr['headers']['content-language'][0] ?? null, 'fr-FR 的 Content-Language');
 });

+ 8 - 4
api-test/src/bootstrap.php

@@ -70,7 +70,7 @@ function api(string $class, ?string $token = null): object
 }
 
 /**
- * 统一执行一次请求,返回 ['status'=>int, 'body'=>?array, 'headers'=>array]。
+ * 统一执行一次请求,返回 ['status'=>int, 'body'=>?array, 'headers'=>array](头名已转小写)。
  * $fn 必须返回 WithHttpInfo 的三元组 [$content, $status, $headers]。
  * 非 2xx 时生成的客户端抛 ApiException,这里捕获并解出状态码与 body。
  */
@@ -79,7 +79,7 @@ function request(callable $fn): array
     try {
         [$content, $status, $headers] = $fn();
 
-        return ['status' => $status, 'body' => normalize($content), 'headers' => $headers];
+        return ['status' => $status, 'body' => normalize($content), 'headers' => array_change_key_case($headers, CASE_LOWER)];
     } catch (ApiException $e) {
         $status = $e->getCode();
         // 网络层错误(DNS/连接/超时)时,生成客户端把异常包成 ApiException 但 code 不是
@@ -89,7 +89,7 @@ function request(callable $fn): array
         }
         $body = json_decode((string) $e->getResponseBody(), true);
 
-        return ['status' => $status, 'body' => is_array($body) ? $body : null, 'headers' => $e->getResponseHeaders()];
+        return ['status' => $status, 'body' => is_array($body) ? $body : null, 'headers' => array_change_key_case($e->getResponseHeaders() ?? [], CASE_LOWER)];
     }
 }
 
@@ -119,7 +119,11 @@ function raw(string $method, string $path, ?array $query = null, ?array $json =
     }
     $body = json_decode((string) $resp->getBody(), true);
 
-    return ['status' => $resp->getStatusCode(), 'body' => is_array($body) ? $body : null, 'headers' => $resp->getHeaders()];
+    // 头名一律转小写:HTTP 头名不区分大小写,staging 经 envoy 转发后全是小写,
+    // 本地 artisan serve 则是 Content-Language 这种写法,按原样取会两边不一致。
+    $headers = array_change_key_case($resp->getHeaders(), CASE_LOWER);
+
+    return ['status' => $resp->getStatusCode(), 'body' => is_array($body) ? $body : null, 'headers' => $headers];
 }
 
 function normalize(mixed $content): mixed

+ 7 - 1
api-v13/app/Http/Controllers/Library/BookController.php

@@ -108,10 +108,14 @@ class BookController extends Controller
             $channelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
         }
 
+        abort_unless(preg_match('/^\d+-\d+$/', $id), 404);
         [$bookId, $paraId] = explode('-', $id);
         $bookId = (int) $bookId;
         $paraId = (int) $paraId;
 
+        $channel = ChannelApi::getById($channelId);
+        abort_unless($channel, 404);
+
         // 解析选中 chapter 的实际显示区间(起始/终止段落号),再按段落号聚合 HTML,
         // 避免选中父 chapter 时仅显示一个标题。
         $range = $this->resolveDisplayRange($bookId, $paraId);
@@ -131,7 +135,6 @@ class BookController extends Controller
         $book = [];
 
         $book['toc'] = $this->getBookToc($bookId, $paraId, $channelId, 2, 7, [$range['start'], $range['end']]);
-        $channel = ChannelApi::getById($channelId);
         $studio = StudioApi::getById($channel['studio_id']);
         $book['categories'] = $chapter['category'];
         $book['title'] = $chapter['title'];
@@ -180,6 +183,7 @@ class BookController extends Controller
     private function resolveDisplayRange(int $book, int $para): array
     {
         $currBook = $this->bookStart($book, $para);
+        abort_if($currBook === null, 404);
         $bookStart = $currBook->paragraph;
         $bookEnd = $currBook->paragraph + $currBook->chapter_len - 1;
 
@@ -191,6 +195,8 @@ class BookController extends Controller
             ->get();
 
         $curr = $paragraphs->firstWhere('paragraph', $para);
+        // 选中的不是可导航的 chapter 节点(如正文段落 level=100)
+        abort_if($curr === null, 404);
         $endParagraph = $curr->paragraph + $curr->chapter_len - 1;
 
         if ($curr->chapter_strlen > $this->maxChapterStrlen) {

+ 29 - 0
api-v13/tests/Feature/LibraryTipitakaReadTest.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 阅读页 /library/tipitaka/{book}-{para}/read 的目标不存在时返回 404,而不是 500。
+ */
+it('returns 404 when the channel does not exist', function () {
+    $this->get('/library/tipitaka/165-123/read?channel='.Str::uuid())
+        ->assertNotFound();
+});
+
+it('returns 404 when the channel is not a uuid', function () {
+    $this->get('/library/tipitaka/165-123/read?channel=not-a-uuid')
+        ->assertNotFound();
+});
+
+it('returns 404 when the id is malformed', function () {
+    $this->get('/library/tipitaka/abc/read?channel='.makeChannel(makeStudio('me'), 'c'))
+        ->assertNotFound();
+});
+
+it('returns 404 when the paragraph is not a chapter', function () {
+    $this->get('/library/tipitaka/99999-1/read?channel='.makeChannel(makeStudio('me'), 'c'))
+        ->assertNotFound();
+});

+ 16 - 0
dashboard-v6/src/components/article/TypePali.tsx

@@ -28,6 +28,7 @@ import type { TTarget } from "../../types";
 import TocPath from "../tipitaka/TocPath";
 import ParagraphNode from "../tipitaka/ParagraphNode";
 import "./article.css";
+import { articlePath, fullUrl } from "../../utils";
 
 import { useIntl } from "react-intl";
 
@@ -189,6 +190,10 @@ const TypePali = ({
                   key: "library",
                   label: intl.formatMessage({ id: "buttons.open.in.library" }),
                 },
+                {
+                  key: "open_in_tab",
+                  label: intl.formatMessage({ id: "buttons.open.in.tab" }),
+                },
               ],
               onClick: ({ key }) => {
                 if (key === "task") setTaskBuilderModalOpen(true);
@@ -200,6 +205,17 @@ const TypePali = ({
                     "_blank",
                   );
                 }
+                if (key === "open_in_tab" && type && id) {
+                  // 按 props 拼地址而不是复制 location:本组件也会嵌在弹层/模板里
+                  const query = new URLSearchParams();
+                  if (mode) query.set("mode", mode);
+                  if (channelId) query.set("channel", channelId);
+                  const qs = query.toString();
+                  window.open(
+                    fullUrl(articlePath(type, id)) + (qs ? `?${qs}` : ""),
+                    "_blank",
+                  );
+                }
               },
             }}
             placement="bottomRight"

+ 25 - 12
dashboard-v6/src/components/general/NetworkStatus.tsx

@@ -32,6 +32,7 @@ import {
 } from "@ant-design/icons";
 
 import { apiServerHealth } from "../../api/api-health";
+import { nextPollDelay } from "../../utils/poll-backoff";
 import {
   selectNetStatus,
   statusChange,
@@ -43,9 +44,6 @@ const { Text } = Typography;
 
 // ─── 常量 ───────────────────────────────────────────────────────────────────
 
-/** API 轮询间隔(毫秒),默认 60 秒 */
-const POLL_INTERVAL_MS = 60_000;
-
 /** API 请求超时阈值(毫秒) */
 const API_TIMEOUT_MS = 8_000;
 
@@ -283,11 +281,12 @@ export const NetworkStatus: React.FC = () => {
   const netStatus = useSelector(selectNetStatus);
   const [open, setOpen] = useState(false);
   const [refreshing, setRefreshing] = useState(false);
-  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
   // ── 核心检测逻辑 ──────────────────────────────────────────────────────────
 
-  const runCheck = useCallback(async () => {
+  /** 返回 API 是否在线,供轮询决定下一次的间隔 */
+  const runCheck = useCallback(async (): Promise<boolean> => {
     // 标记:检测中
     dispatch(
       statusChange({
@@ -311,7 +310,7 @@ export const NetworkStatus: React.FC = () => {
           lastCheckedAt: new Date().toISOString(),
         })
       );
-      return;
+      return false;
     }
 
     // Step 2:API 健康检测(带超时)
@@ -320,6 +319,7 @@ export const NetworkStatus: React.FC = () => {
     let finalStatus: ENetStatus = "api_error";
 
     try {
+      // FIXME: controller.signal 没有传给 apiServerHealth(),这个超时从来没生效过
       const controller = new AbortController();
       const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
 
@@ -348,6 +348,7 @@ export const NetworkStatus: React.FC = () => {
         lastCheckedAt: new Date().toISOString(),
       })
     );
+    return isApiOnline;
   }, [dispatch, netStatus.lastCheckedAt]);
 
   // ── 手动刷新 ──────────────────────────────────────────────────────────────
@@ -362,11 +363,22 @@ export const NetworkStatus: React.FC = () => {
   // ── 轮询 & 网络事件监听 ───────────────────────────────────────────────────
 
   useEffect(() => {
-    // 首次立即检测
-    runCheck();
+    let disposed = false;
+    let failures = 0;
+
+    // 定时轮询:用 setTimeout 链而不是 setInterval,每次按上一次的结果
+    // 决定下一次的间隔——成功 5 分钟,连续失败逐步退避(见 poll-backoff.ts)
+    const check = async () => {
+      const ok = await runCheck();
+      if (disposed) return;
+      failures = ok ? 0 : failures + 1;
+      // online 事件可能在上一轮未完成时又触发一轮,先清掉旧的,保证只有一条链
+      if (timerRef.current) clearTimeout(timerRef.current);
+      timerRef.current = setTimeout(check, nextPollDelay(failures));
+    };
 
-    // 定时轮询
-    timerRef.current = setInterval(runCheck, POLL_INTERVAL_MS);
+    // 首次立即检测
+    check();
 
     // 监听浏览器网络事件(离线时立即触发)
     const handleOffline = () => {
@@ -380,13 +392,14 @@ export const NetworkStatus: React.FC = () => {
         })
       );
     };
-    const handleOnline = () => runCheck();
+    const handleOnline = () => check();
 
     window.addEventListener("offline", handleOffline);
     window.addEventListener("online", handleOnline);
 
     return () => {
-      if (timerRef.current) clearInterval(timerRef.current);
+      disposed = true;
+      if (timerRef.current) clearTimeout(timerRef.current);
       window.removeEventListener("offline", handleOffline);
       window.removeEventListener("online", handleOnline);
     };

+ 82 - 43
dashboard-v6/src/components/notification/NotificationIcon.tsx

@@ -7,16 +7,24 @@ import { NotificationIcon } from "../../assets/icon";
 import NotificationList from "./NotificationList";
 import { useAppSelector } from "../../hooks";
 import { currentUser, type IUser } from "../../reducers/current-user";
+import { nextPollDelay } from "../../utils/poll-backoff";
+
+/**
+ * 其他标签页(或本页上一轮)这么久之内刚查过,就直接用 localStorage 里的结果。
+ * 要短于最短的轮询间隔(5 分钟 -10% 抖动 = 4.5 分钟),否则本页会一直读到自己上一轮的缓存。
+ */
+const CACHE_MS = 4 * 60_000;
 
 const NotificationIconWidget = () => {
   const [count, setCount] = useState<number>();
   const currUser = useAppSelector(currentUser);
   const [mute, setMute] = useState(false);
 
-  const queryNotification = (user?: IUser) => {
+  /** 返回本轮是否成功,供轮询决定下一次的间隔 */
+  const queryNotification = async (user?: IUser): Promise<boolean> => {
     if (!user) {
       console.debug("未登录 不查询 notification");
-      return;
+      return true;
     }
     const isMute = localStorage.getItem("notification/mute");
     if (isMute && isMute === "true") {
@@ -29,67 +37,98 @@ const NotificationIconWidget = () => {
       "notification/updatedAt"
     );
     if (notificationUpdatedAt) {
-      if (now.getTime() - parseInt(notificationUpdatedAt) < 59000) {
+      if (now.getTime() - parseInt(notificationUpdatedAt) < CACHE_MS) {
         const notificationCount = localStorage.getItem("notification/count");
         if (notificationCount !== null) {
           setCount(parseInt(notificationCount));
           console.debug("has notification count");
-          return;
+          return true;
         }
       }
     }
 
     const url = `/api/v2/notification?view=to&limit=1`;
     console.info("notification api request", url);
-    get<INotificationListResponse>(url).then((json) => {
-      if (json.ok) {
-        console.debug("notification fetch ok ", json.data.unread);
-        localStorage.setItem(
-          "notification/updatedAt",
-          now.getTime().toString()
-        );
-        localStorage.setItem("notification/count", json.data.unread.toString());
-        setCount(json.data.unread);
-        if (json.data.count > 0) {
-          const newMessageTime = json.data.rows[0].created_at;
-          const lastTime = localStorage.getItem("notification/new");
-          if (lastTime === null || lastTime !== newMessageTime) {
-            localStorage.setItem("notification/new", newMessageTime);
+    return get<INotificationListResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          console.debug("notification fetch ok ", json.data.unread);
+          localStorage.setItem(
+            "notification/updatedAt",
+            now.getTime().toString()
+          );
+          localStorage.setItem(
+            "notification/count",
+            json.data.unread.toString()
+          );
+          setCount(json.data.unread);
+          if (json.data.count > 0) {
+            const newMessageTime = json.data.rows[0].created_at;
+            const lastTime = localStorage.getItem("notification/new");
+            if (lastTime === null || lastTime !== newMessageTime) {
+              localStorage.setItem("notification/new", newMessageTime);
 
-            const title = json.data.rows[0].res_type;
-            const content = json.data.rows[0].content;
-            localStorage.setItem(
-              "notification/message",
-              JSON.stringify({ title: title, content: content })
-            );
-            // 发送通知
-            if (!isMute || isMute !== "true") {
-              if (window.Notification && Notification.permission !== "denied") {
-                Notification.requestPermission(function () {
-                  const notification = new Notification(title, {
-                    body: content,
-                    icon: import.meta.env.BASE_URL + "logo192.png",
-                    tag: json.data.rows[0].id,
+              const title = json.data.rows[0].res_type;
+              const content = json.data.rows[0].content;
+              localStorage.setItem(
+                "notification/message",
+                JSON.stringify({ title: title, content: content })
+              );
+              // 发送通知
+              if (!isMute || isMute !== "true") {
+                if (
+                  window.Notification &&
+                  Notification.permission !== "denied"
+                ) {
+                  Notification.requestPermission(function () {
+                    const notification = new Notification(title, {
+                      body: content,
+                      icon: import.meta.env.BASE_URL + "logo192.png",
+                      tag: json.data.rows[0].id,
+                    });
+                    notification.onclick = (event) => {
+                      event.preventDefault(); // 阻止浏览器聚焦于 Notification 的标签页
+                      window.open(json.data.rows[0].url, "_blank");
+                    };
                   });
-                  notification.onclick = (event) => {
-                    event.preventDefault(); // 阻止浏览器聚焦于 Notification 的标签页
-                    window.open(json.data.rows[0].url, "_blank");
-                  };
-                });
+                }
               }
             }
           }
+          return true;
+        } else {
+          console.error(json.message);
+          return false;
         }
-      } else {
-        console.error(json.message);
-      }
-    });
+      })
+      .catch((e: unknown) => {
+        // 502 等非 2xx 时 get() 会抛 HttpError,原来没接住,成了未捕获的 promise
+        console.error(e);
+        return false;
+      });
   };
 
   useEffect(() => {
-    const timer = setInterval(() => queryNotification(currUser), 1000 * 60);
+    if (!currUser) return;
+    let disposed = false;
+    let failures = 0;
+    let timer: ReturnType<typeof setTimeout> | undefined;
+
+    // setTimeout 链:成功 5 分钟一次,连续失败逐步退避(见 poll-backoff.ts)
+    const tick = async () => {
+      const ok = await queryNotification(currUser);
+      if (disposed) return;
+      failures = ok ? 0 : failures + 1;
+      timer = setTimeout(tick, nextPollDelay(failures));
+    };
+
+    // 间隔拉长到 5 分钟后,不能再等第一轮才出角标;
+    // 有 CACHE_MS 的缓存挡着,多开标签页或重新挂载不会重复请求
+    tick();
+
     return () => {
-      clearInterval(timer);
+      disposed = true;
+      clearTimeout(timer);
     };
   }, [currUser]);
 

+ 24 - 0
dashboard-v6/src/utils/poll-backoff.ts

@@ -0,0 +1,24 @@
+// src/utils/poll-backoff.ts
+//
+// 后台轮询(心跳、通知角标)共用的间隔计算。
+//
+// 后端过载或宕机时,轮询失败越快、重发越密,就越压着后端起不来
+// (2026-10-28 线上 502 期间出现过每秒上百次的心跳 + 通知请求)。
+// 所以失败后按指数拉长间隔,成功一次就回到基础间隔。
+
+/** 基础轮询间隔:5 分钟 */
+export const POLL_BASE_MS = 5 * 60_000;
+
+/** 失败退避的上限:30 分钟 */
+export const POLL_MAX_MS = 30 * 60_000;
+
+/**
+ * 连续失败 `failures` 次后,距下一次轮询的等待时间。
+ *
+ * 0 次 → 5 分钟,1 次 → 10 分钟,2 次 → 20 分钟,之后封顶 30 分钟。
+ * 加 ±10% 抖动,避免多个标签页、多个用户在后端恢复的同一刻一起打上来。
+ */
+export const nextPollDelay = (failures: number): number => {
+  const delay = Math.min(POLL_BASE_MS * 2 ** failures, POLL_MAX_MS);
+  return delay * (0.9 + Math.random() * 0.2);
+};