Jenkins 的 PR 評審中同時執行測試與安全性掃描 ― AI 的 APPROVE 被機械性地反轉的 MineWatch CI

我之前寫過 GitHub Actions 失敗自我修復的 pipeline,以及 讓 Jenkins 透過 GitHub Copilot CLI 自動審查 PR 的機制。兩篇的核心都在於:「如何安全地運行 AI 審查機制」。

不過,只有 AI 審查還不夠。LLM 擅長指出它能讀懂的程式碼問題,但測試是否真的通過、依賴是否有已知漏洞、映像檔是否殘留 CVE,這些都必須實際跑過才知道。

這篇文章作為續篇,整理我在個人開發的 Minecraft 伺服器監控應用程式「MineWatch」中,Jenkins 在每個 PR 會執行哪些檢查,以及如何用這些結果推翻 AI 的判定。MineWatch 的官方網站在這裡。應用程式整體介紹則在這篇文章。

TL;DR

  • 每個 PR 由 Jenkins 執行:建置、單元測試、API 測試、E2E、SAST、密鑰掃描、映像檔漏洞掃描、依賴漏洞檢查、SonarQube。只針對有改到的層。
  • 只要任一驗證失敗,即使 AI 說 APPROVE,也會機械性地改成 VERDICT: REQUEST_CHANGES,不把判定交給 LLM 自由裁量。
  • 本機採兩段式:commit 時先快速清掉格式化、lint、型別檢查、密鑰與 SAST 的差異;push 時再跑完整測試、覆蓋率下限、依賴漏洞檢查。CI 則作為最終防線,深度檢查 PR 帶進來的全部內容。
  • 由於 CI 是在與開發機相同的單台 Mac 上跑,因此把 CI 用的 compose 分離,並設計成不使用主機 port。
  • 自動合併由 GitHub Actions 負責,只允許 reviewed-sha 對應的已審查版本合併。main 的 PR 由人手動合併,不做 Claude 自動修正。
  • 負載測試自動化尚未完成,文末有規劃中的方向。

整體觀

所有驗證都在同一個 Jenkins job 內依序執行。這個 job 固定跑在兼作開發機的單台 Mac 上。原因是 iOS 建置需要 macOS,而且本來就沒有第二台 Mac。

先在本機排除:pre-commit 與 pre-push

在送出 PR 之前,我會先用 pre-commit 在本機完成檢查。它由 .pre-commit-config.yaml 管理,透過 make hooks(其內容就是 pre-commit install)來註冊。也可以用 make check 一次對所有檔案執行。

這些 hook 依執行時機分成兩個層級。

commit 時:只看差異的輕量檢查

hook 對象內容gitleaks整個 repository 是否混入秘密資訊opengrep變更檔案的 SAST(p/ci 規則 + --taint-intrafile)ruffapi/ 的 Python lint 與格式化tyapi/ 的 Python 型別檢查swift-format / swiftlintSwift 格式化與 --strict lintprettier / eslintweb/ admin/ 的格式化與 lint### push 時:把耗時檢查一次跑完

hook 指令目的pytestmake ci-test完整測試覆蓋率下限make coverage-check防止覆蓋率悄悄下降pip-auditmake audit-api依賴的已知漏洞完整測試通常要數十秒以上,如果每次 commit 都跑,很容易把 hook 關掉。因此 commit 時只放輕量檢查,重的部分改到 push 時跑。push 的指令與 Jenkins 完全一致,所以在 CI 之前就能先在本機發現問題。

為了讓一次 pre-commit install 就能同時註冊兩種 hook,我在設定檔開頭指定了要安裝的 hook 類型。各 hook 預設在 commit 時執行,只有那些想在 push 時才跑的項目才加上 stages: [pre-push]。

default_install_hook_types: [pre-commit, pre-push]
default_stages: [pre-commit]

repos:
  - repo: local
    hooks:
      - id: pytest-api
        name: pytest (api)
        entry: make ci-test
        language: system
        files: ^api/
        pass_filenames: false
        stages: [pre-push]

若環境已經執行過 pre-commit install,要註冊 push 用 hook,需要再執行一次 make hooks。

pre-commit 負責「差異、快速」,pre-push 負責「耗時項目整包跑完」,CI 則負責「PR 帶進來的全部、深入檢查」。把 gitleaks、opengrep、型別檢查與測試同時放在本機與 CI,是因為本機 hook 依賴各自環境,而且可以用 --no-verify 繞過。真正有強制力的是 CI。相對地,格式化與 lint 在 CI 不重複跑,而是假設會先在本機處理掉。

由於只檢查差異,沒改到的檔案中的違規仍然會保留。實際把它跑到全檔案時發現,Python、Swift、web、admin 各處都還留有舊違規。本機檢查的目的,是「不要把即將疊上的 commit 弄壞」,不是保證整個 repository 都完全健康。

把 black、isort、flake8 整合成 ruff

Python 的格式化與 lint 原本是 black、isort、flake8 三套,後來參考另一個自製函式庫的配置,改成用 ruff 一套解決。好處是設定集中到 pyproject.toml,而且能消除下面會提到的 flake8 陷阱。

[tool.ruff]
line-length = 100
target-version = "py313"
# migrations/versions 是 Alembic 自動生成物。即使手動修改,重新生成也會被覆蓋,
# 因此整形與 lint 都不套用。
extend-exclude = ["migrations/versions"]

[tool.ruff.lint]
# pyflakes(F) + pycodestyle(E/W) + isort(I)
select = ["E", "F", "W", "I"]
ignore = ["E203"]

移轉後出現了幾個差異:

  • 既有程式碼出現格式化差異:ruff 的 format 與舊版 black 幾乎相容,但並非完全一致,於是有 7 個檔案被重新格式化。
  • 日文長字串被判成 E501:ruff 會把全形字元算作寬度 2。以前 flake8 只按字元數計算時能過的行,在 ruff 下變成超長。字串內容不變,只依格式化結果修正。
  • # ty: ignore[...] 的行即使很長也不會被判 E501:ruff 會把行尾的型別檢查 pragma comment 排除在行長判定之外。這點實際在 review 中引發過問題,後面「AI 的指摘有誤時」會提到。

在覆蓋率上設下限

push 的 hook 也加入了覆蓋率下限檢查。在 pyproject.toml 裡寫下限,當 pytest 搭配 --cov 執行時,只要低於該值就會以非零結束。

[tool.coverage.report]
fail_under = 85

目前覆蓋率是 89%,所以下限設成 85%。如果把下限設得太接近現況,任何小幅度的測試增減都可能讓它失敗,進而讓人想把 hook 關掉。因此我保留約 4 個百分點的緩衝,只防止「悄悄下降」。

我也確認過這個下限真的有效。曾經暫時把下限調到 95% 再執行,結果確實以非零結束。除了產生 Sonar 用的 coverage XML 之外,我另外做了一個只負責判定下限的 make coverage-check。

在本機 hook 中踩到的坑

pre-commit 這邊踩到三個坑,其中一個已經在上面的 ruff 移轉中解掉了。

Opengrep 只能做成 local hook

SAST 使用的是 Opengrep,它是 Semgrep OSS 的 LGPL fork。原因是它能免費使用 Semgrep 已經切出去的 Pro Engine 功能,也就是跨函式的資料流追蹤(--taint-intrafile)。

但官方的 pre-commit hook 沒有持續更新,還是呼叫 pip 版 semgrep 的配置,所以無法使用。因此我改成呼叫本機已安裝 binary 的 local hook。代價是版本不再由 pre-commit 鎖定,必須由各自環境自行統一。

- repo: local
  hooks:
    - id: opengrep
      name: opengrep (SAST)
      entry: opengrep scan --config p/ci --taint-intrafile --error --skip-unknown-extensions
      language: system

flake8 沒有讀到設定檔(已由 ruff 解決)

pre-commit 執行時的工作目錄是 repository root。flake8 只會往上找設定檔,所以 api/.flake8 根本沒被讀到,結果一直套用預設的 79 字元限制而誤判。當時只能用 --config=api/.flake8 明確指定來繞過。

ruff 則會從目標檔案往上尋找 pyproject.toml。因此即使從 root 執行,也會使用 api/pyproject.toml 的設定,不再需要手動指定 --config。

排除設定在分批執行時不穩定

pre-commit 會把檔案拆成批次執行。這時 .semgrepignore 的套用有時不穩定,所以我在 hook 本身的 exclude 也再排除一次。這是為了避免誤判:本地專用頁面刻意包含指向 localhost 的 HTTP 連結。

PR 每次由 Jenkins 執行的驗證

收到 PR webhook 後,Jenkins 會先用 git diff --name-only origin/<base>...HEAD 判斷哪些層級有變動。

prReviewVerification.detectTouchedLayers(
  baseRef: baseRef,
  layers: [
    API  : ['api/'],
    IOS  : ['ios/'],
    WEB  : ['web/'],
    ADMIN: ['admin/'],
    INFRA: ['infra/', 'ci/', 'docker-compose.yml'],
  ],
)

如果 pattern 以 / 結尾,就用前綴比對;否則就做完全相等比對。結果會被寫進 env.TOUCHED_API 之類的 '1' / '0' 環境變數,供各 stage 的 when 使用。如果只有 docs 變更,那實際會跑的只有密鑰掃描與 SAST。

驗證指令執行條件密鑰掃描gitleaks git --log-opts="origin/<base>...HEAD"永遠執行(所有層)SASTopengrep scan --config p/ci --taint-intrafile --error永遠執行(所有層)後端測試make ci-test(pytest)api型別檢查make typecheck(ty)apiAPI 測試make postman-test(Postman/Newman)apiiOS 測試make ios-test(build + Tests / UITests)ios單元測試npm ci && npm test(vitest)web / adminE2EEmake e2e-ci(Playwright)web / admin靜態分析make sonar-*(Quality Gate)有改到的層映像檔漏洞make trivy-scanapi / web / admin依賴漏洞make audit-*(pip-audit / npm audit)api / web / admin密鑰與 SAST 之所以不分層、而是掃描所有檔案,是因為秘密資訊可能混入任何目錄。

即使失敗也不中斷,只記錄結果

每個驗證都會像下面這樣執行,並把結果記到環境變數中。

def result = (sh(script: script, returnStatus: true) == 0) ? 'PASS' : 'FAIL'
env[envVar] = result

因為用了 returnStatus: true,所以即使失敗也不會中斷 build。即便測試失敗,我仍希望 AI review 能照常送出,讓「哪裡出問題」保持可見。沒執行到的層級會記成 SKIP,不會影響判定。最後會統一在 VERDICT 做總結。

各項驗證的補充說明

  • API 測試:用 Postman/Newman 透過真實 HTTP 呼叫所有 endpoint。會一起啟動 DB、API、nginx,連 migration 也一起跑完,最後執行 Newman 並清理資源。
  • iOS:以前只做 make build(只建置),完全沒有執行測試 target。現在改成 make ios-test,明確執行 MineWatchTests 與 MineWatchUITests,避免間歇性漏測。
  • E2E:web/admin 沒有像 iOS 那樣的繞路方式,會經由瀏覽器走真正的 Firebase Auth SDK。因此需要真的 Firebase 金鑰。這些金鑰由 Vault 管理,透過 JCasC 登錄成 Jenkins Credential(Secret file),執行後再用 rm -f 刪除。
  • SonarQube:分成 api、ios、web、admin、infra 五個專案。若 Quality Gate 變紅,會設定讓 scanner 以非零結束(sonar.qualitygate.wait=true),直接視為 FAIL。Community Build 沒有 branch / PR 分析,所以伺服器畫面只會顯示「最近一次掃描狀態」,但 gate 的判定在每次掃描時都會生效,因此不影響合併控制。
  • Trivy:只針對 OS 套件的 CRITICAL / HIGH。應用程式依賴(npm / uv)則由後面的依賴漏洞檢查負責。
  • 依賴漏洞:使用 pip-audit 與 npm audit --audit-level=high。

AI 的 APPROVE 被機械性地反轉

這是本文的核心。

MineWatch 的 review 是由 Copilot CLI 與 Claude Code CLI 各自產生,再透過第三次 Claude Code 呼叫統一成一份。不過,VERDICT: APPROVE 或 VERDICT: REQUEST_CHANGES 並不是交給統合用的 LLM 決定,而是用下列三階段機械化決定。

1. 先根據個別判定與成功/失敗來決定

def computeEffectiveVerdict(Map args = [:]) {
  if (args.copilotFailed && args.claudeFailed) {
    return 'REQUEST_CHANGES'
  }
  if (args.copilotFailed) {
    return args.claudeVerdict
  }
  if (args.claudeFailed) {
    return args.copilotVerdict
  }
  return (args.copilotVerdict == 'REQUEST_CHANGES' || args.claudeVerdict == 'REQUEST_CHANGES')
    ? 'REQUEST_CHANGES' : 'APPROVE'
}

只要其中一個 CLI 失敗,就用另一個的判定繼續。兩個都失敗時就停止。兩個都成功時,只要任一方提出疑慮,就判成停止。這是偏向安全的一套規則。

2. 只向一方撰寫統合 LLM 的輸出

如果統合 LLM 輸出 APPROVE,只要步驟 1 判定為 REQUEST_CHANGES,就會強制改寫。反過來,如果 LLM 本身就說 REQUEST_CHANGES,則一律保留,不會覆蓋。

awk '{ if ($0 == "VERDICT: APPROVE") print "VERDICT: REQUEST_CHANGES"; else print }' review.txt > review.txt.tmp
mv review.txt.tmp review.txt

3. 根據驗證結果再疊一層

最後,只要任一驗證是 FAIL,就把 APPROVE 那一行替換成 REQUEST_CHANGES。

def anyFail = envVarNames.any { (env[it] ?: 'PASS') == 'FAIL' }
if (anyFail) {
  content = content.replaceAll(/(?m)^VERDICT: APPROVE$/, 'VERDICT: REQUEST_CHANGES')
}

這是最後一道防線,避免 Copilot 掛掉時,驗證的 FAIL 被忽略,卻讓 Claude 的 APPROVE 直接通過。未執行(SKIP)的項目不算 FAIL。

實際發生過的例子

在某個 release PR 裡,AI review 的內容本身看起來只有輕微指摘,但最後 VERDICT 仍然是 REQUEST_CHANGES。實際送出的自動驗證欄位如下(節錄):

### 自動驗證
- secret scan: PASS
- opengrep (p/ci, taint-intrafile): PASS
- backend test: PASS
- type check (ty): PASS
- sonar (minewatch-api): PASS
- trivy image scan: FAIL
- dependency audit (pip-audit/npm audit): PASS

即使 AI 從可讀的程式碼層面看不出問題,映像檔中是否還殘留 CVE,仍然要靠掃描才能知道。這就是 AI 明明偏正面,但機器幫忙擋下來的一個例子。

AI 的指摘有誤時

也有相反的情況。前面提到的 ruff 移轉 PR 中,AI review 回傳了 REQUEST_CHANGES,但自動驗證全部 PASS。原因是 reviewer 的指摘大意如下:

ruff format 把包含行尾 # ty: ignore[...] 註解的行格式化成超過 100 字元。因為 select 裡包含 E501,所以 ruff check 應該會擋下來。

被指摘的兩行實測分別是 112 與 121 字元,數字本身沒錯。但 ruff check 卻通過了。為了確認,我做了一個包含兩條同樣長度的行的檔案:一條行尾是 # ty: ignore[...],另一條是一般註解。

x = 1  # ty: ignore[invalid-argument-type] xxxx……(120桁)
y = 1  # plain comment xxxx……(120桁)

ruff check --select E501 的結果是:只有一般註解那行被判 E501,而 # ty: ignore[...] 那行被排除。ruff 的行長判定會把行尾的 pragma comment 略過。

我把這個依據寫進 PR 留言,並請求重新審查。重新審查後變成 APPROVE,最後自動合併。

與其讓機器替你決定判定,不如用同樣的思路:AI 的指摘也要先由機器驗證真偽,再決定是否接受。AI 的指摘往往帶有推測;如果真的有問題就修,若是誤判,就把實測根據回傳,再請它重新審查。

連接到 GitHub Actions

要送出的留言有一份 GitHub Actions 能理解的契約:

  • 內文中的標記(## AI統合レビュー (MineWatch))
  • 最後一行需以行首完全一致的 VERDICT: APPROVE 或 VERDICT: REQUEST_CHANGES 結尾
  • 留言者必須與 repository 變數 JENKINS_BOT_USERNAME 一致(避免別人寫同樣字串就誤觸發)

如果 CLI 本身執行失敗,也會送出 fallback 留言;若沒有 VERDICT,就會退回到 REQUEST_CHANGES。目的是避免在未驗證的情況下直接合併。

自動合併只限制在「已審查的版本」

如果審查期間又有新的 push,可能會出現拿舊版本的 review 去合併新版本的風險。為了避免這件事,我在留言中埋入 <!-- reviewed-sha: ... -->,並在 GitHub Actions 端只有當目前 PR 的 head 與之相符時,才允許自動合併。

gh pr merge "${PR_NUMBER}" \
  --squash \
  --delete-branch \
  --auto \
  --match-head-commit "${REVIEWED_SHA}"

--match-head-commit 的意思是:只有在自動合併排程當下的 head 與審查對象一致時,才允許排程。不過它只能防住排程當下的競態;如果排程成功後又有新的 push,並不保證會自動失效,所以那種情況就交給下一次 Jenkins build 重新審查。

合併的處理方式會依 base branch 分開:

VERDICTbase動作APPROVEmain 以外squash 自動合併APPROVEmain手動合併流程以留言提示REQUEST_CHANGES任意加上 needs-human-review 標籤main 上的 PR 代表 release 合併。它不採用 squash,而是保留 merge commit,並搭配標記與 back-merge 到 develop,由人手動執行。另方面,與第一篇文章中的自我修復 loop 不同,MineWatch 並沒有做 Claude 自動修正。這是為了避免出現「AI review、AI 修正、AI review……」的循環。REQUEST_CHANGES 交由人來修。

踩過的坑

在開發機兼用的 Mac 上,自動合併永遠沒有觸發

Jenkins agent 只有一台 Mac,並且就是開發機。原生的 docker compose 會把 DB 等 port 暴露到主機。若在開發環境已經啟動的情況下跑 CI,DB 會因為下面的錯誤無法啟動:

Bind for 0.0.0.0:55432 failed: port is already allocated

DB 起不來,API 和 nginx 也會連鎖失敗,導致後端測試永遠 FAIL。結果不論 AI 怎麼判,驗證失敗都會把 VERDICT 壓掉,最後變成自動合併永遠不會觸發。

這是安全優先的設計,在驗證環境不穩時,會直接變成「永遠停止」的例子。後來我加了 CI 專用的 compose override:

services:
  db:
    # 不要暴露到主機。在 CI 中,api 容器會透過 db:5432 連線。
    ports: !reset []

  nginx:
    profiles: ["full"]

  mc:
    profiles: ["full"]

重點有三個:

  • compose 的 list 類欄位預設是追加合併,所以不能直接靠覆寫刪掉 port,必須用 !reset。
  • pytest 在 api 容器內執行,透過 compose 網路中的 db:5432 連線到 DB。
  • project 名稱固定為 -p minewatch-ci。預設會用目前資料夾名稱,這會跟開發環境同名,造成 CI 設定不小心重建開發環境的容器。

Postman 測試與 E2E 也都各自使用不同的 project 名稱與不同的 port,因此能在同一台節點共存。

這種分離也影響到本機 pre-push。push 時的 hook 也是用同一個 make ci-test,所以即使在開發環境已經啟動的狀態下 push,也不會發生 port 衝突。

docker compose run 不會自動重建舊映像檔

run 在映像檔已存在時,即使 Dockerfile 或依賴有變,也不會自動重新建置。當我新增依賴套件時,曾經因為一直沿用舊映像檔(缺少新指令)而發生無法重現的失敗。現在都會明確加上 --build。

docker compose run --build --rm api uv run pytest -q

npm audit 在內部 mirror 下會回 400

套件下載經由公司內部的 Nexus mirror,但這個 mirror 不會轉發漏洞 advisory API(會以 Nexus Lifecycle must be configured 回 400)。因此只有在執行 npm audit 時,才直接指定官方 registry。套件來源(.npmrc 的設定)則保持不變。

npm audit --audit-level=high --registry https://registry.npmjs.org

E2E 的 App Check 不能透過後端設定阻擋

Firebase App Check 的 Web SDK 會直接從 client 端向 Google 伺服器送驗證。因此就算把後端設成 APP_CHECK_ENFORCED=false 也擋不住。若設定檔範本中的 placeholder(REPLACE_ME)沒有換掉,E2E 就會因 appCheck/fetch-status-error(403)失敗。

因此,我把 Firebase Console 發出的 debug token 也交由 Vault 管理,執行時才透過 Jenkins Credential 填進設定檔,跑完後再刪除。

30 分鐘就超時了

只要修改 web/admin,npm ci、vitest、E2E(包含 Docker build)、SonarQube(含 Quality Gate 等待)、Trivy、依賴檢查、AI review 產生就會依序執行。實測 30 分鐘時,驗證還跑到一半(依賴檢查階段)就超時,還沒來得及送出 review。後來我把 timeout 拉長到 60 分鐘。驗證越多,這種串行成本就越明顯。

未來課題:自動化負載測試

目前為止的驗證都在看正確性(測試、E2E)、安全性(SAST、密鑰、漏洞掃描)、品質(SonarQube)。對於負載與長時間運行的行為,目前還沒有自動化檢查。

其實我手上只有一支負載測試腳本:它會反覆以與 worker 相同的形式呼叫 APNs(推播)client,確認是否有錯誤,以及 process 的記憶體使用是否持續上升。不過這支腳本目前只在本機手動執行,沒有放進 Makefile,也沒有接到 CI。

我會想做負載/長時間運行檢查,是因為我真的踩過這類問題:

  • 大約一半的推播失敗:worker 會在每個 task 重建 event loop,但 APNs client 內部的 HTTP 連線卻一直沿用建立當下的 loop,因此會出現 Event loop is closed 例外,導致約一半的送出失敗。上面的腳本可以重現。
  • 監控全掛了:Celery worker 泄漏了 Redis connection。當 10 秒一次的監控 task 反覆執行數小時後,file descriptor 上限被耗盡,監控全面失效。

這兩種問題都不是「跑一次」就會看出來的。它們屬於重複執行或長時間運行後才會浮現的 bug。即使單元測試與 E2E 全部 PASS,也仍可能漏掉。

接下來我想往這幾個方向自動化:

  • 長時間運行(soak)測試:重複執行監控 task,觀察錯誤率、記憶體、file descriptor 是否持續上升。把上面 APNs 腳本擴展成這種形式,可能是第一步。
  • API 負載測試:觀察並發請求下的 latency 與錯誤率。
  • SSE 同時連線:觀察大量 client 同時接收伺服器狀態即時推送時的表現。

不過,這些不太可能每個 PR 都跑。現在驗證已經因為增加項目而變長,甚至把 timeout 從 30 分鐘拉到 60 分鐘。成本高的負載測試,比較適合放在非持續執行、而是 release 前才跑的層級。至於方式與工具,目前還沒定案。

總結

  • AI review 看的是「讀程式碼就能知道」的範圍;測試與掃描看的是「實際跑過才知道」的範圍。兩者並行,最終判定交給機器。AI 的指摘也要先用實測確認真偽再接受。
  • 本機採兩段式:commit 時快速處理差異,push 時把耗時項目整包跑完。CI 則深入檢查 PR 的整體。不過本機檢查可以繞過,所以重要項目一定會在 CI 再驗一次。
  • 覆蓋率設定一個比現況略低的下限,防止悄悄退化;而且要故意把下限調高確認它真的會生效。
  • 安全優先的設計在驗證環境不穩時,會變成「永遠停止」。host port、project 名稱、--build 等隔離與可重現性很重要。
  • 驗證項目一增加,串行時間就會變長,timeout 要依實測決定。
  • 自動合併只允許 reviewed-sha 與 --match-head-commit 對上的版本。
  • 負載與長時間運行的檢查 هنوز 還沒自動化。因為我曾踩過這種只有重複執行才會出現的 bug,所以接下來會把它規劃成 release 前執行的層級。

由於這是個人開發環境的配置,如果要直接搬到組織內使用,請先確認 Credential 管理與 AI 整合的公司政策。


JQIT 的工程師中,95% 以上是從無經驗錄用的。
如果可以,也歡迎來我們的企業網站逛逛。

:sparkles:可以從零開始學!一起挑戰吧!:sparkles:

我們也有 note 和 X ↓



原文出處:https://qiita.com/jqit-yukiono/items/8675266928f574677d9e


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝62   💬2  
856
🥈
NewsData
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登