這份文件寫給想了解野馬營網站的人,不需要任何程式背景。技術名詞第一次出現時都會用日常的話解釋一次。

一分鐘版本

野馬營是一個溫哥華越野跑俱樂部的網站。它同時做三件事:

  • 對外:讓任何人讀跑者寫的比賽心得、看相簿、查賽事行事曆、瀏覽跑者名錄。
  • 對內:讓俱樂部成員自己寫文章、傳照片影片、記錄自己跑完的比賽。
  • 自動化:成員記錄比賽之後,網站自動算出他該得的徽章,並把影片轉成適合網路播放的格式。

整個網站沒有傳統的「主機」。它跑在 Cloudflare 的邊緣網路上 —— 簡單說,就是把網站同時放在全世界幾百個機房,誰來看就由最近的那個機房回應。

全景圖

        訪客                會員               管理員
    (不必登入)          (需邀請)        (俱樂部幹部)
         │                   │                  │
         ▼                   ▼                  ▼
   ┌───────────┐      ┌────────────┐     ┌───────────┐
   │  公開頁面  │      │  會員後台   │     │ 管理後台   │
   │ 文章 相簿  │      │ 寫文章      │     │ 改任何資料 │
   │ 賽事 名錄  │      │ 傳照片影片  │     │ 發邀請     │
   └─────┬─────┘      └──────┬─────┘     └─────┬─────┘
         │                   │                  │
         └───────────┬───────┴──────────────────┘
                     ▼
          ┌─────────────────────┐
          │   網站程式(Worker) │
          │  跑在 Cloudflare 上  │
          └──────────┬──────────┘
                     │
        ┌────────────┼────────────┬─────────────┐
        ▼            ▼            ▼             ▼
     ┌──────┐   ┌────────┐   ┌────────┐   ┌────────┐
     │  D1  │   │   R2   │   │ Stream │   │   AI   │
     │資料庫 │   │檔案倉庫 │   │影片播放 │   │寫作助手 │
     └──────┘   └────────┘   └────────┘   └────────┘
      文字資料    原始照片      轉好的影片    改寫、摘要
      誰寫了什麼   影片檔案      各種畫質

四個外部服務各司其職:

名字

白話解釋

存什麼

D1

資料庫,像一疊可以快速查詢的表格

文章內容、比賽紀錄、誰是誰

R2

檔案倉庫,像一個超大的雲端硬碟

照片原檔、影片原檔

Stream

影片服務

轉好的影片,會依網速自動切畫質

AI

語言模型

不存東西,只幫忙改寫和摘要

訪客看到什麼

不必登入就能看的部分:

  • 首頁 — 精選文章與相簿。
  • 文章 — 跑者寫的比賽心得。如果作者把文章連到自己的一筆比賽紀錄,文章上方會出現那場比賽的徽章。
  • 相簿 — 一次活動的照片集,影片也在裡面,點了可以單獨開一頁分享。
  • 賽事 — 未來賽事的行事曆,可以切換顯示方式,也可以只看「越野世界巡迴賽」之類的系列,或只看「西部百英里的資格賽」。
  • 跑者名錄 — 每位成員一張卡片,上面是他跑過的比賽徽章。可以用徽章篩選,例如「誰跑過 UTMB 100 英里」,而且篩選結果的網址可以直接分享給別人。

會員能做什麼

野馬營沒有公開註冊。 新成員一律由現有管理員發邀請信,邀請連結七天內有效。這是刻意的設計:俱樂部網站的成員名單本來就該由人決定。

會員登入後可以:

  • 寫文章 — 所見即所得的編輯器,打字時會自動存草稿。
  • 匯入文章 — 把寫好的 Markdown / MDX 檔案丟進來,直接變成可編輯的文章(就是這份文件的用法)。
  • 傳照片和影片 — 每人有儲存額度,預設 10 GB。
  • 記錄比賽 — 從賽事目錄挑「哪場比賽、哪個組別、哪一年」。徽章由此自動產生。
  • 寫比賽報告 — 挑一場自己跑完的比賽,文章就跟那筆紀錄綁在一起。同一場比賽只能寫一篇。

比賽資料為什麼要分成四層

這是整個網站最值得理解的一塊。直覺上「比賽」是一件事,但實際上它是四件會用不同速度變化的事,硬湊成一張表會出問題。

  ┌──────────────────────────────────────────────┐
  │  賽事 RaceEvent ── 這場比賽「是什麼」          │
  │  例:Hardrock 100                             │
  │  幾十年不變。名字、國家、屬於哪個系列           │
  └───────┬──────────────────────┬───────────────┘
          │                      │
          │ 有哪些組別可以報        │ 每年辦一次
          ▼                      ▼
  ┌────────────────┐    ┌──────────────────────┐
  │ 組別 Category   │    │ 屆次 Edition          │
  │ 例:UTMB/CCC/   │    │ 例:2026 年那一屆      │
  │    OCC/TDS      │    │ 日期、報名開關         │
  │ 偶爾增減         │    │ 每年換一次            │
  └────────┬───────┘    └──────────┬───────────┘
           │                       │
           └───────────┬───────────┘
                       ▼
          ┌──────────────────────────┐
          │ 紀錄 RaceRecord            │
          │ 「我 2015 年跑完了這個組別」│
          │ 成員自己填,只增不減        │
          └──────────────────────────┘

三個容易被忽略的設計理由:

為什麼叫「組別」不叫「距離」。 白朗峰的報名項目是 UTMB、CCC、OCC、TDS;Sinister 7 有接力組;Barkley 有 Fun Run。這些都不是距離。叫它「距離」會讓人問出「這場比賽的徽章為什麼沒有距離」這種無解的問題。

為什麼屆次可以沒有日期。 有成員想記錄他 2015 年跑的 Hardrock,但沒人查得到那一屆的確切日期,硬編一個會讓公開行事曆顯示錯的日期。所以屆次允許只有年份、沒有日期 —— 行事曆只顯示有日期的,沒日期的就安靜地待在徽章後面撐著那筆紀錄。

為什麼賽事有一個「代號」。 徽章的顏色是把賽事代號丟進一個固定算式算出來的。如果用資料庫自動編的流水號,測試環境和正式環境會編出不同號碼,同一場比賽就會變成兩種顏色。代號是人取的、跨環境一致的,所以顏色永遠一樣。代號一旦定了就不能改 —— 成員的徽章指著它。

徽章怎麼來的

成員填一筆比賽紀錄,徽章就自動出現,不需要任何人核可。有兩種特別的徽章:

  • 六大馬拉松(Six Star) — 跑完波士頓、紐約、芝加哥、倫敦、柏林、東京六場。網站是比對明確的六場清單,而不是「馬拉松系列跑滿六場」—— 跑六次波士頓不算。
  • 資格賽標記 — 有些比賽是西部百英里或 Hardrock 的報名資格賽,行事曆上可以只篩這些。

照片和影片上傳後經歷了什麼

照片和影片走的路完全不同,因為影片必須轉檔。

  照片
  ────
  選檔 ──▶ 瀏覽器先縮圖 ──▶ 直接傳進 R2 ──▶ 產生模糊預覽圖
                                              (載入時先顯示的色塊)

  影片
  ────
  選檔 ──▶ 直接傳進 R2 ──▶ 排進轉檔佇列 ──▶ 轉檔容器取件
                                                  │
                              ┌───────────────────┘
                              ▼
                     轉成多種畫質 ──▶ 存進 Stream ──▶ 相簿可播放
                              │
                              └─▶ 失敗 ──▶ 重試,超過次數就標記失敗並通知

兩個值得一提的地方:

「佇列」其實不是佇列。 網站沒有另外架排隊系統,而是直接用影片那一列資料的狀態欄當佇列:queued 是待辦、running 是有人正在處理、failed 是放棄。這樣少維護一個服務。

為什麼要「租約」而不是信任轉檔程式。 Cloudflare 明說容器不保證跑多久,隨時可能因為主機重啟被中止。所以轉檔程式領件時是「租」一段時間,租約過期還沒完成就視為失敗、可以被別人接手。不然一個中途死掉的工作會永遠卡在「處理中」,而沒有人會知道。

還有一個定期打掃的機制:沒有被任何文章或相簿使用的檔案會被標記,過一段時間後清掉,以免成員的額度被孤兒檔案吃掉。

AI 幫什麼忙

編輯器裡有三個 AI 功能,都跑在 Cloudflare 自己的模型上:

  • 潤飾 — 把選取的段落改寫,改完是並排顯示,原文在左、AI 版在右,成員自己決定要不要換。
  • 擴寫 — 把簡短的幾句話展開成段落。
  • 摘要 — 產生文章摘要。

兩個設計細節:AI 看不到文章裡的圖片和特殊區塊,那些位置會先換成記號,改完再放回去,所以 AI 不會把圖片弄丟。另外每位成員有使用次數上限,而且限制的是「問」而不是「問對」 —— 失敗的請求一樣計數,否則一直送錯誤請求就能繞過限制。

內容其實有兩個來源

這是看原始碼才會發現的事:網站的文章有兩種來路。

  ┌──────────────────────┐        ┌──────────────────────┐
  │  早期文章             │        │  現在的文章           │
  │  寫成檔案放進程式碼裡  │        │  會員在網站上寫       │
  │  要改就要重新部署      │        │  存在 D1 資料庫       │
  └──────────┬───────────┘        └──────────┬───────────┘
             │                                │
             │  一次性搬遷                     │
             └──────────────▶ D1 ◀────────────┘
                              │
                              ▼
                         網站讀這裡

早期的文章是直接寫成檔案跟程式碼放在一起的,改一個字就得重新部署整個網站。現在文章存在資料庫,會員自己就能改。舊文章已經搬進資料庫了,但那套舊機制還留著,因為它同時也負責處理網站的一些固定設定。

網站住在哪裡

  有人把修改合併進主線
            │
            ▼
   ┌────────────────┐
   │ 部署到測試環境   │  先上測試站
   └───────┬────────┘
           ▼
   ┌────────────────┐
   │ 對測試站跑測試   │  用真的瀏覽器把網站點一遍
   └───────┬────────┘
           │ 通過才繼續
           ▼
   ┌────────────────┐
   │ 部署到正式環境   │  需要人按下確認
   └────────────────┘

測試站和正式站跑的是完全相同的程式,差別只在它們連的是不同的資料庫。這一點在設定上很容易搞混,也確實出過事 —— 所以專案文件裡有一整節在講這兩個環境怎麼分辨。

「對測試站跑測試」這一關是刻意擋在正式部署前面的:它用真的瀏覽器操作真的部署,能抓到只在正式環境才會出現的問題,那是在開發者自己電腦上永遠測不出來的。

幾個保護使用者的設計

  • 不把個人資料洩進公開頁面。 文章、相簿、照片都連著「誰上傳的」,而那筆資料裡有電子郵件和登入狀態。所有公開查詢都明確排除這個欄位,而且有測試在盯著公開頁面的原始碼不能出現這些東西。
  • 測試帳號的密碼是公開的,所以有一道鎖。 測試用的預設密碼寫在公開的程式碼裡。網站因此加了一條規則:帶著這個公開密碼去登入任何非本機的網址,會直接報錯停下來。
  • 賽事資料由人審過才進來。 賽事目錄是人工核對過的表格檔,每一列都記著「這是從哪個網址讀來的」和「誰在哪天確認過」。空白就代表沒人確認過。網站不做自動抓取 —— 因為每個賽事網站長得都不一樣,抓錯了會寫進錯的日期,比沒有還糟。

詞彙對照

你會看到的詞

白話

Worker

跑在 Cloudflare 上的網站程式

D1

資料庫,存文字和關聯

R2

檔案倉庫,存照片影片原檔

Stream

影片服務,負責轉檔和播放

遷移(migration)

改資料庫結構的步驟,只執行一次

部署(deploy)

把新版本推上線

邊緣網路

把網站放在全球各地機房,就近回應

前言(frontmatter)

文章檔案最上面那段設定,寫標題和日期


這份文件描述的是 2026 年 9 月的野馬營網站。比賽資料每年都會變,功能也還在長 —— 但上面那四層資料模型和兩條上傳路徑,是這個網站的骨架。