工程實作 18 min read

我讓 AI 把遊戲部署上自己的網域:難的不是 deploy,是怎麼確認真的上線

我讓 Claude Code 一條龍把一個 React 遊戲(戰旗 J7 Reborn)部署上 Firebase Hosting、再接自己的網域 j7.yanchen.app,全程只下一句「上架到這個網域」。關鍵不是 `firebase deploy` 那 30 秒,是「怎麼判斷真的上線了」:Deploy complete!、Console 顯示「已連結」、單次 curl 回 200,三個都不算數。這篇把 build → deploy → 接 custom domain → DNS → 等 SSL → round-trip 驗證整條寫透,重點放在那個卡了幾小時的坑:custom domain 顯示「已連結」卻一直 404 —— 以及為什麼那其實不是設定錯,是邊緣節點內容還沒收斂,該等不該動。附 openssl 驗 SAN、curl 連打、SPA rewrite 的實際做法。

我讓 AI 把遊戲部署上自己的網域:難的不是 deploy,是怎麼確認真的上線
本文目錄 · 10
TL;DR
我讓 Claude Code 一條龍把一個 React 遊戲部署上 Firebase Hosting,再接上自己的網域 j7.yanchen.app,全程我只下一句「上架到這個網域」。
關鍵不是 firebase deploy 那行指令——那只要 30 秒;難的是「怎麼判斷真的上線了」。部署印 Deploy complete!、Console 顯示「已連結」、單次 curl 回 200,三個都不算數
這篇把整條流程(build → deploy → 接 custom domain → 設 DNS → 等 SSL → round-trip 驗證)寫下來,重點放在那個讓我卡了幾小時的坑:custom domain 顯示「已連結」卻一直 404,以及為什麼那其實不是設定錯。

把一個寫好的前端專案丟上線,聽起來是已經被解決一百遍的事。npm run buildfirebase deploy,完工。但真的接上自己的網域、要對外人說「你打這個網址就能玩」的時候,中間有一段沒人會在教學裡寫清楚的灰色地帶——指令全綠了,網站卻時好時壞。

這篇是我用 Claude Code 把一個 4×3 棋盤對戰遊戲(戰旗 J7 Reborn)部署到 j7.yanchen.app 的完整紀錄。流程本身不難,但我想把「怎麼確認真的上線」這件事講透,因為那才是會讓你半夜還在 F5 的部分。

📌 目錄

⚡ 為什麼是 Firebase Hosting

先回答最務實的問題:一個純前端的 Vite/React build 產物,要放哪。

我的考量很單純——這是個靜態站(所有邏輯跑在瀏覽器,沒有後端 server),免費額度夠、CDN 內建、自動配 SSL、接 custom domain 不收錢。Firebase Hosting 這幾項都滿足,而且我這個專案本來就用了 Firebase(之後要存玩家進度),同一個專案順手就部署,不用多開帳號。

需求Firebase HostingGitHub PagesVercel
靜態站託管
自動 SSL
custom domain 免費
全球 CDN△(Fastly)
同專案接 Firestore/Auth
SPA rewrite 設定✓(一行 config)△(要 404 hack)
對我這個會用到 Firestore 的專案,Firebase 是最省事的選擇。如果你的站純靜態、又不想碰 Google 生態,GitHub Pages 或 Vercel 一樣能達標——這篇的 round-trip 驗證邏輯三個平台通用。

🔧 部署流程:四個指令的事

先說好消息:把東西推上去,真的只是四個指令。

前置依賴很少:

依賴最低版本驗證
Node20+node --version
Firebase CLI13+firebase --version
Firebase 帳號firebase projects:list
我這台的實際版本:Firebase CLI 15.9.1、Node v22.12.0

整段部署指令塞在一起,給 Claude(或你自己)一次複製:

# 1. 確認登入 + 專案綁定
firebase projects:list
cat .firebaserc          # 看 default project 是哪個

# 2. build 出靜態產物
npm run build # 我的 build script 是 tsc -b && vite build

# 3. 部署 hosting
firebase deploy --only hosting

# 4. 驗證預設網址(下一節細講)
curl -sS -o /dev/null -w "%{http_code}" https://<project>.web.app

firebase deploy --only hosting 跑完,真實輸出長這樣:

=== Deploying to 'battleflag-79afd'...

i deploying hosting
i hosting[battleflag-79afd]: beginning deploy...
i hosting[battleflag-79afd]: found 8 files in dist
i hosting: uploading new files [0/3] (0%)
i hosting: upload complete
✔ hosting[battleflag-79afd]: file upload complete
i hosting[battleflag-79afd]: version finalized
✔ hosting[battleflag-79afd]: release complete

✔ Deploy complete!

Hosting URL: https://battleflag-79afd.web.app

看到 Deploy complete!Hosting URL,預設網址就活了。但注意:這是 handshake,不是上線——稍後 round-trip 那節會解釋為什麼這行字不能信。

設定檔 firebase.json 的關鍵就一塊 hosting

{
  "hosting": {
    "public": "dist",
    "ignore": ["firebase.json", "/.*", "/node_modules/**"],
    "rewrites": [{ "source": "**", "destination": "/index.html" }]
  }
}

public: "dist" 指向 Vite build 的輸出目錄。那個 rewrites 是整篇第一個地雷,下一節單獨講。

💣 SPA 的隱藏地雷:深連結 404

如果你的站是單頁應用(SPA,用前端路由),少了 rewrites 這一段,首頁正常、深連結全爆。

原因是這樣:React Router(或任何前端 router)的路由是瀏覽器端跑的。使用者直接打 https://你的站/battle/c1-1,請求會先到 Firebase 的伺服器找 /battle/c1-1 這個檔案——但你的 dist/ 裡只有一個 index.html,沒有這個路徑的實體檔案,伺服器就回 404。前端 router 根本沒機會接手。

rewrites 那行的意思是:任何路徑都回 /index.html,讓瀏覽器載入 app 後,再由前端 router 解析網址、渲染對的畫面。

"rewrites": [{ "source": "**", "destination": "/index.html" }]

source: "**" 是萬用比對。漏掉它,你會在「首頁能開、分享給朋友的深連結打不開」這種詭異狀況上耗掉一個下午——而且首頁正常會讓你以為部署成功了。

🌐 接 custom domain:DNS 與 SSL

預設的 xxx.web.app 能動之後,接自己的網域分三步:

  • Firebase Console 加網域:Hosting → 新增自訂網域 → 輸入 j7.yanchen.app

  • 設 DNS 紀錄:Firebase 會給你一組要加的紀錄(通常是 A record 指向兩個 Firebase IP,或子網域用 CNAME)。到你的 DNS 供應商加上去。子網域我用 CNAME ts → pixel-badminton.web.app 這種,指回 Firebase 的 .web.app

  • 等驗證 + SSL:DNS 生效後 Firebase 自動驗證網域所有權,接著自動簽 SSL 憑證。
  • 這三步指令面沒什麼好說的——點一點、貼一貼。難的是等待的判斷:你怎麼知道它好了?

    ⚠️ 如果你的 DNS 在 Cloudflare:CNAME 一定要「DNS only」(灰雲)

    這是我這輪實際踩到、卡掉一截時間的雷,單獨拉出來講。Cloudflare 每一筆紀錄旁邊有個雲朵圖示,分兩種狀態:

    • Proxied(橘雲):流量先經過 Cloudflare 的反向代理(CDN、防護、它自己的憑證)。
    • DNS only(灰雲):Cloudflare 只當純 DNS 解析,請求直接打到 CNAME 指向的目標。
    接 Firebase Hosting 的 CNAME 必須切成 DNS only(灰雲)。 留在橘雲 proxy 會同時擋掉兩件事:(1) Firebase 偵測不到你的 CNAME 真的指向它(因為查到的是 Cloudflare 的 proxy IP,不是 Firebase);(2) Firebase 沒法完成網域所有權驗證 → SSL 永遠簽不下來。症狀就是你 DNS 明明加了,Firebase Console 卻一直停在「需要設定 / 驗證中」不前進。

    設定要點(以子網域 ts.yanchen.app 為例):

    欄位
    TypeCNAME
    Namets(只填子網域名,不含 .yanchen.app
    Targetpixel-badminton.web.app(你的 Firebase 站台)
    Proxy statusDNS only(灰雲) ← 關鍵
    TTLAuto
    存檔後回 Firebase Console 點「驗證」,它就抓得到了。如果你之前已經設成橘雲卡住,把雲點成灰色再重驗即可,不用刪掉重加。

    驗 SSL 憑證真的簽下來、而且涵蓋你的網域,用 openssl 看 SAN(Subject Alternative Name):

    echo | openssl s_client -servername j7.yanchen.app \
      -connect j7.yanchen.app:443 2>/dev/null \
      | openssl x509 -noout -ext subjectAltName

    回傳裡有一長串 DNS:...,只要找得到 DNS:j7.yanchen.app 就代表憑證涵蓋你的網域。

    這裡有個會嚇到人的細節:那串 SAN 裡會有幾十個完全不相干的網域(別人的網站)。那不是出錯,是 Firebase Hosting 用共用憑證(multi-SAN),把很多客戶的網域包在同一張憑證裡。只要清單裡有你的,就對了,不用管其他。

    還有一個更早的中間態:no alternative certificate subject name matches

    在憑證 SAN 命中你的網域之前,會先經過一段「DNS 通了、但憑證還沒簽好」的空窗。這時 curl 你的網域會噴:

    curl -sS -I https://ts.yanchen.app
    # SSL: no alternative certificate subject name matches target host name 'ts.yanchen.app'

    第一次看到很容易誤判成「SSL 壞了」或「設定錯了」。其實這是正常的中間態:你的 CNAME 已經生效(TLS 握手能連上、拿得到一張憑證),只是 Firebase 還沒把你的網域簽進那張 Let's Encrypt 共用憑證的 SAN 清單裡。Firebase 會說「最多 24 小時」,實務上常是 10–60 分鐘。

    怎麼跟「真的壞了」區分:這個錯誤代表 DNS 跟 TLS 那層都通了(不然連憑證都拿不到),純粹在等簽發。對策一樣是等——掛一個背景迴圈每 90 秒 curl 一次,等它從 no-cert-match 變成正常回應,再進到下面講的 404 收斂階段。

    整條時間線是:DNS 生效 → 憑證簽好前(no-cert-match)→ SAN 命中你的網域 → 邊緣節點還沒鋪好(404 flapping)→ 穩定 200。每一段都是「在等」,不是「壞了」。

    🔥 最大的坑:已連結卻一直 404

    這是讓我卡最久的地方,也是整篇最該記住的一段。

    DNS 設好、Console 顯示「已連結」、openssl 也確認 SSL SAN 命中我的網域——按理說該上線了。結果 curl 我的網域:

    curl -sS -o /dev/null -w "%{http_code}" https://j7.yanchen.app
    # 一下 200,一下 404,反覆橫跳

    我一開始的直覺是「DNS 沒生效」或「設定打錯」,差點回去翻 DNS 紀錄。但停下來想:憑證都簽好了(SSL 握手要先驗網域所有權,憑證在 = DNS 早就通了),怎麼可能是 DNS 問題?

    真正的原因是:Firebase 的邊緣節點內容同步還沒收斂。憑證簽發跟「把你的 hosting 內容鋪到全球 CDN 邊緣節點並正確對應到這個網域」是兩件事。前者好了,後者可能還在跑。在收斂完成前,你打過去可能被導到還沒鋪好內容的節點 → 404;下一秒打到鋪好的節點 → 200。它會這樣 flap(橫跳)數小時,最久我看過要將近一天。

    判斷準則很簡單:這種 flapping 不要當成 bug 去修。設定沒錯就別動,等。要區分「沒收斂」跟「真的壞了」,看一個東西——憑證 SAN。SAN 命中你的網域 = 設定正確,剩下的純粹是時間。SAN 都沒有你的網域 = 那才是真的要回去查 DNS / Console。

    我的處理方式是排一個定時喚醒,每 20 分鐘自動 curl 一次,連續多次穩定 200 才算收斂——不守在螢幕前刷。

    ✅ round-trip 驗證:handshake 不算上線

    這是我做任何部署都用的鐵律,借這篇講清楚。

    handshake(握手級訊號)≠ 上線。 這些都只代表「沒在最外層掛掉」:

    • firebase deployDeploy complete!
    • Console 顯示「已連結」
    • systemctl status 綠燈
    • 單次 curl 回 200
    round-trip(來回驗證)才算上線。 從輸入端進、輸出端出、人真的看得到結果:
    驗證項handshake(不夠)round-trip(才算)
    部署Deploy complete!curl 連打 5 次都穩定 200
    HTTPSConsole「已連結」openssl 看到 SAN 命中網域
    內容HTTP 200回傳 HTML 的 </code> 是對的</td></tr><tr><td>可用性</td><td>上面全過</td><td><strong>真開瀏覽器,點一下,畫面正常</strong></td></tr></tbody></table> 我這次的 round-trip 紀錄: <pre data-lang="bash"><code class="language-bash"># 1. 連打 5 次,全 200 才算穩(單次 200 在 flapping 期會騙你) for i in 1 2 3 4 5; do curl -sS -o /dev/null -w "%{http_code}\n" "https://j7.yanchen.app?cb=$(date +%s)-$i" sleep 2 done # try1..5: 全 200 <p># 2. title 對不對(確認是我的站,不是 Firebase 預設頁)<br/>curl -sS "https://j7.yanchen.app" | grep -oiE "<title>[^<]*</title>"<br/># <title>戰旗 J7 Reborn</title></code></pre></p> <p>最後一步——真的開瀏覽器——是不能省的。我用瀏覽器自動化開站、選角、按「開始對戰」,看到棋盤、角色、手牌都正常渲染,才在報告裡寫「上線完成」。在這之前任何一個訊號為真,我都只會說「看起來通了」。</p> <p>第二次喊「好了」要比第一次更嚴格,不是更隨便。因為第二次代表第一次驗錯了——該檢討的是驗法,不是再賭一次運氣。這次的 flapping 就是活教材:第一次看到 200 我差點收工,幸好沒有。</p> <h2 id="-把流程交給-ai-的邊界">🤖 把流程交給 AI 的邊界</h2> <p>整條流程我交給 Claude Code 跑,這裡誠實講它幫上忙的地方跟我自己守的線。</p> <p><strong>AI 做得好的</strong>:指令編排(build → deploy → curl → openssl 一條龍)、解析 <code>openssl</code> 那串落落長的 SAN 找出我的網域、flapping 時自動排程定時重驗不用我守著、最後用瀏覽器自動化開站做 round-trip。重複而機械的部分,它比我穩、比我有耐心。</p> <p><strong>我自己守的線</strong>:</p> <ul><li><strong>DNS 紀錄與網域連結是我手動在後台做的</strong>——這類「改了會影響網域解析、出錯難回復」的設定,屬於不可逆的業務層,我不讓 AI 代下手。</li> <li><strong>「上線完成」這句話誰能說</strong>:必須 round-trip 全綠才算。AI 很容易看到 <code>Deploy complete!</code> 就回報成功,這正是我把 round-trip 寫成它的硬性驗收條件的原因——讓它沒看到瀏覽器裡的畫面就不准說「好了」。</li> </ul>把這次經驗沉澱成了一個可重複跑的 SOP(skill),下次任何靜態站上 Firebase,同一套 build → deploy → 接網域 → round-trip 驗證直接套用,連「已連結卻 404」的判斷準則都寫進去了。流程資產化之後,下一個專案的部署就真的只是四個指令的事。 <h2 id="-常見問題">❓ 常見問題</h2> <p><strong>Q:<code>firebase deploy</code> 印了 <code>Deploy complete!</code>,為什麼網站還是打不開?</strong><br/>A:<code>Deploy complete!</code> 只代表檔案上傳到 Firebase 完成(handshake),不代表你打的那個網址已經能正確回應。預設的 <code>web.app</code> 網址通常會很快活;但如果你打的是剛接好的 custom domain,可能還在邊緣節點內容同步中,會 flapping。先 <code>curl</code> 預設 <code>web.app</code> 網址確認部署本身 OK,再分開看 custom domain。</p> <p><strong>Q:custom domain 顯示「已連結」但一直 404,是我設定錯了嗎?</strong><br/>A:先用 <code>openssl</code> 看 SSL 憑證 SAN 有沒有命中你的網域。有命中 = 設定正確,404 是 Firebase 邊緣內容同步還沒收斂,這會 flap 數小時到近一天,不要去動設定,等就好。SAN 沒命中你的網域 = 那才是真的設定/DNS 問題,回去查 DNS 紀錄跟 Console 的網域狀態。</p> <p><strong>Q:SSL 憑證的 SAN 裡有一堆別人的網域,是被駭了嗎?</strong><br/>A:不是。Firebase Hosting 用 multi-SAN 共用憑證,把很多客戶的網域包在同一張憑證裡,這是正常的成本優化做法。你只要確認清單裡有自己的網域 <code>DNS:你的網域</code> 就對了,其他幾十個不相干的網域不用管。</p> <p><strong>Q:我的站首頁能開,但分享出去的深連結(像 <code>/battle/c1-1</code>)打不開?</strong><br/>A:你少了 SPA rewrite。在 <code>firebase.json</code> 的 <code>hosting</code> 加 <code>"rewrites": [{ "source": "**", "destination": "/index.html" }]</code>,讓所有路徑都回 <code>index.html</code>,由前端 router 接手解析。重 deploy 一次即可。</p> <p><strong>Q:怎麼知道部署「真的」成功,而不是看起來成功?</strong><br/>A:做 round-trip,不要只看 handshake。最低標準:<code>curl</code> 連打數次都穩定 200(單次會被 flapping 騙)+ 回傳 HTML 的 <code><title></code> 是你的站 + 真的開瀏覽器點一下確認畫面正常。三者皆過才算上線。</p> <h2 id="-延伸資源">🔗 延伸資源</h2> <ul><li><a href="https://firebase.google.com/docs/hosting/custom-domain" target="_blank" rel="noopener noreferrer">Firebase Hosting 官方文件 — Custom domain</a> — DNS 紀錄與 SSL 自動簽發的官方說明</li> <li><a href="https://firebase.google.com/docs/hosting/full-config#rewrites" target="_blank" rel="noopener noreferrer">Firebase Hosting — 設定行為(rewrites)</a> — SPA rewrite 的完整 config 選項</li> <li><a href="/blog/claude-code-hook-auto-verify-deploy/">Claude Code Hook 怎麼用?git push 後自動驗 GitHub Actions 部署</a> — 同樣是「把部署驗證自動化」的思路,這篇用 hook 驗 CI,可搭配看</li> <li><a href="https://developer.mozilla.org/en-US/docs/Glossary/SPA" target="_blank" rel="noopener noreferrer">MDN — Single-page applications</a> — 為什麼 SPA 需要伺服器端 rewrite 的底層原因</li> </ul></article> <aside class="hidden xl:block"> <div class="sticky top-24 space-y-4"> <div class="border-2 border-black bg-white text-xs font-mono shadow-hard"> <div class="uppercase tracking-widest font-black px-3 py-2 bg-[var(--color-neub-yellow)] border-b-2 border-black flex items-center gap-2"> <span class="w-2 h-2 bg-black inline-block shrink-0" aria-hidden="true"></span> 本文目錄 </div> <ul class="p-1"> <li> <a href="#-目錄" data-toc-link="-目錄" class="toc-link block px-2 py-1.5 opacity-85 hover:opacity-100 hover:bg-[#ffff00] leading-snug transition-all border-l-2 border-transparent hover:border-black"> 📌 目錄 </a> </li><li> <a href="#-為什麼是-firebase-hosting" data-toc-link="-為什麼是-firebase-hosting" class="toc-link block px-2 py-1.5 opacity-85 hover:opacity-100 hover:bg-[#ffff00] leading-snug transition-all border-l-2 border-transparent hover:border-black"> ⚡ 為什麼是 Firebase Hosting </a> </li><li> <a href="#-部署流程四個指令的事" data-toc-link="-部署流程四個指令的事" class="toc-link block px-2 py-1.5 opacity-85 hover:opacity-100 hover:bg-[#ffff00] leading-snug transition-all border-l-2 border-transparent hover:border-black"> 🔧 部署流程:四個指令的事 </a> </li><li> <a href="#-spa-的隱藏地雷深連結-404" data-toc-link="-spa-的隱藏地雷深連結-404" class="toc-link block px-2 py-1.5 opacity-85 hover:opacity-100 hover:bg-[#ffff00] leading-snug transition-all border-l-2 border-transparent hover:border-black"> 💣 SPA 的隱藏地雷:深連結 404 </a> </li><li> <a href="#-接-custom-domaindns-與-ssl" data-toc-link="-接-custom-domaindns-與-ssl" class="toc-link block px-2 py-1.5 opacity-85 hover:opacity-100 hover:bg-[#ffff00] leading-snug transition-all border-l-2 border-transparent hover:border-black"> 🌐 接 custom domain:DNS 與 SSL </a> </li><li> <a href="#-最大的坑已連結卻一直-404" data-toc-link="-最大的坑已連結卻一直-404" class="toc-link block px-2 py-1.5 opacity-85 hover:opacity-100 hover:bg-[#ffff00] leading-snug transition-all border-l-2 border-transparent hover:border-black"> 🔥 最大的坑:已連結卻一直 404 </a> </li><li> <a href="#-round-trip-驗證handshake-不算上線" data-toc-link="-round-trip-驗證handshake-不算上線" class="toc-link block px-2 py-1.5 opacity-85 hover:opacity-100 hover:bg-[#ffff00] leading-snug transition-all border-l-2 border-transparent hover:border-black"> ✅ round-trip 驗證:handshake 不算上線 </a> </li><li> <a href="#-把流程交給-ai-的邊界" data-toc-link="-把流程交給-ai-的邊界" class="toc-link block px-2 py-1.5 opacity-85 hover:opacity-100 hover:bg-[#ffff00] leading-snug transition-all border-l-2 border-transparent hover:border-black"> 🤖 把流程交給 AI 的邊界 </a> </li><li> <a href="#-常見問題" data-toc-link="-常見問題" class="toc-link block px-2 py-1.5 opacity-85 hover:opacity-100 hover:bg-[#ffff00] leading-snug transition-all border-l-2 border-transparent hover:border-black"> ❓ 常見問題 </a> </li><li> <a href="#-延伸資源" data-toc-link="-延伸資源" class="toc-link block px-2 py-1.5 opacity-85 hover:opacity-100 hover:bg-[#ffff00] leading-snug transition-all border-l-2 border-transparent hover:border-black"> 🔗 延伸資源 </a> </li> </ul> </div> <div class="border-2 border-black bg-white p-3 text-center shadow-hard"> <img src="/images/avatar-yc.png" alt="Bob Chen 自畫像" width="56" height="56" loading="lazy" decoding="async" class="w-14 h-14 mx-auto bg-[var(--color-neub-yellow)] border-2 border-black object-cover mb-2"> <div class="font-black text-sm leading-tight">陳彥彤</div> <div class="font-mono text-[10px] uppercase tracking-widest opacity-60 mt-1"> AI Lecturer · Engineer </div> <div class="mt-2 flex justify-center gap-1.5 font-mono"> <a href="mailto:bobchen184@gmail.com" class="px-2 py-1 text-[10px] uppercase tracking-widest border-2 border-black hover:bg-[#ffff00]">email</a> <a href="https://github.com/yanchen184" target="_blank" rel="noopener" class="px-2 py-1 text-[10px] uppercase tracking-widest border-2 border-black hover:bg-[#ffff00]">github</a> </div> </div> </div> </aside> </div> </div> <script> (function () { function init() { const links = document.querySelectorAll('[data-toc-link]'); if (!links.length) return; const map = new Map(); links.forEach((a) => { const id = a.getAttribute('data-toc-link'); const target = document.getElementById(id); if (target) map.set(id, { link: a, target }); }); if (!map.size) return; const setActive = (id) => { map.forEach((v, key) => { v.link.classList.toggle('is-active', key === id); }); }; const io = new IntersectionObserver( (entries) => { // 取目前最靠近頂部的那個 visible heading const visible = entries .filter((e) => e.isIntersecting) .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); if (visible[0]) setActive(visible[0].target.id); }, { rootMargin: '-100px 0px -60% 0px', threshold: [0, 1] }, ); map.forEach((v) => io.observe(v.target)); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })(); </script> <!-- Prism.js syntax highlighting (CDN, 不進 bundle) --> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css"> <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js"></script> <script> /* 程式碼區塊:filename header + copy 按鈕 */ (function () { function init() { const pres = document.querySelectorAll('article.blog-prose pre[data-lang]'); pres.forEach((pre) => { if (pre.dataset.codeEnhanced) return; pre.dataset.codeEnhanced = '1'; const lang = pre.dataset.lang || 'code'; const filename = pre.dataset.filename || ''; const label = filename || lang; // wrap pre in .code-block const wrap = document.createElement('div'); wrap.className = 'code-block'; pre.parentNode.insertBefore(wrap, pre); wrap.appendChild(pre); // header: label + copy btn const header = document.createElement('div'); header.className = 'code-block-header'; const labelEl = document.createElement('span'); labelEl.className = 'code-block-label'; labelEl.textContent = label; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'code-block-copy'; btn.setAttribute('aria-label', '複製程式碼'); btn.textContent = 'copy'; btn.addEventListener('click', async () => { const code = pre.querySelector('code')?.innerText ?? ''; try { await navigator.clipboard.writeText(code); btn.textContent = 'copied ✓'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); } catch { btn.textContent = 'failed'; setTimeout(() => (btn.textContent = 'copy'), 1600); } }); header.appendChild(labelEl); header.appendChild(btn); wrap.insertBefore(header, pre); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })(); </script> <script> /* 幫會橫向 overflow 的 table / pre 加提示,依元素類型用不同文案 */ (function () { const LABELS = { table: { idle: '← 左右滑動看完整表格 →', end: '← 滑回表格開頭' }, pre: { idle: '← 左右滑動看程式碼 →', end: '← 回到程式碼開頭' }, }; function decorate() { const targets = document.querySelectorAll('article.blog-prose table, article.blog-prose pre'); targets.forEach((el) => { if (el.dataset.scrollHinted) return; if (el.scrollWidth <= el.clientWidth + 1) return; const kind = el.tagName === 'TABLE' ? 'table' : 'pre'; const labels = LABELS[kind]; const hint = document.createElement('div'); hint.className = 'scroll-hint'; hint.setAttribute('aria-hidden', 'true'); hint.textContent = labels.idle; // 若 pre 被 .code-block 包起來,把 hint 插在 wrapper 前面;否則插在 el 前 const anchor = el.closest('.code-block') || el; anchor.parentNode.insertBefore(hint, anchor); el.dataset.scrollHinted = '1'; const update = () => { const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 2; hint.classList.toggle('at-end', atEnd); hint.textContent = atEnd ? labels.end : labels.idle; }; update(); el.addEventListener('scroll', update, { passive: true }); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', decorate); } else { decorate(); } window.addEventListener('resize', decorate, { passive: true }); })(); </script> <!-- Author + Share --> <section class="px-6 md:px-10 py-12 border-t-2 border-black bg-white"> <div class="max-w-5xl mx-auto grid gap-6 md:grid-cols-2"> <div> <div class="text-xs uppercase tracking-widest mb-2 opacity-60 font-mono"> author </div> <div class="flex items-start gap-3"> <div class="w-12 h-12 bg-[var(--color-neub-yellow)] border-2 border-black flex items-center justify-center font-black"> 陳 </div> <div> <div class="font-black">陳彥彤</div> <p class="text-sm opacity-70 leading-relaxed"> AI 工程師 · AI 顧問。Java 後端 8 年、AI 工程師 2 年。AI 內訓 · AI 導入顧問 · 前後端與雲端培訓。 </p> </div> </div> </div> <div> <div class="text-xs uppercase tracking-widest mb-2 opacity-60 font-mono"> support </div> <p class="text-sm opacity-80 leading-relaxed"> 覺得文章有用可以到 GitHub 給個 star,或是透過信箱聊聊 AI 內訓、AI 導入顧問或前後端 / 雲端培訓。 </p> <div class="mt-3 flex flex-wrap gap-2 font-mono"> <a href="mailto:bobchen184@gmail.com" class="px-3 py-1.5 text-xs uppercase tracking-widest border-2 border-black hover:bg-[#ffff00]">email</a> <a href="https://github.com/yanchen184" target="_blank" rel="noopener" class="px-3 py-1.5 text-xs uppercase tracking-widest border-2 border-black hover:bg-[#ffff00]">github</a> </div> </div> </div> </section> <!-- 顧問服務 CTA(依文章分類切換服務線) --> <aside class="px-6 md:px-10 py-12 border-t-[3px] border-black bg-[var(--color-neub-yellow)]"> <div class="max-w-5xl mx-auto"> <div class="border-2 border-black bg-white p-6 md:p-8" style="box-shadow: 6px 6px 0 #0a0a0a;"> <div class="text-[11px] uppercase tracking-[0.25em] font-mono font-black opacity-60 mb-3"> kubernetes 企業內訓 </div> <h2 class="text-2xl md:text-3xl font-black tracking-tight leading-tight mb-3"> 這篇教的東西,可以直接搬進你們團隊 </h2> <p class="text-sm md:text-base leading-relaxed text-black/75 max-w-3xl mb-6"> 這系列 K8s 教材就是我企業內訓的現場講義——完全沒碰過 K8s 的工程師,一天課程加實機 lab 就能把服務部上去。如果你的團隊正要導入容器化,把這篇轉給你的主管或 HR,或直接寄信給我。 </p> <div class="flex flex-wrap items-center gap-3"> <a href="mailto:bobchen184@gmail.com?subject=%5BK8s%20%E5%85%A7%E8%A8%93%5D%20%E7%9C%8B%E4%BA%86%E4%BD%A0%E7%9A%84%20K8s%20%E6%96%87%E7%AB%A0%E6%83%B3%E8%81%8A%E8%81%8A&body=%E5%97%A8%20YC%EF%BC%8C%0A%0A%E6%88%91%E7%9C%8B%E4%BA%86%E4%BD%A0%E7%9A%84%E6%96%87%E7%AB%A0%E3%80%8A%E6%88%91%E8%AE%93%20AI%20%E6%8A%8A%E9%81%8A%E6%88%B2%E9%83%A8%E7%BD%B2%E4%B8%8A%E8%87%AA%E5%B7%B1%E7%9A%84%E7%B6%B2%E5%9F%9F%3A%E9%9B%A3%E7%9A%84%E4%B8%8D%E6%98%AF%20deploy%2C%E6%98%AF%E6%80%8E%E9%BA%BC%E7%A2%BA%E8%AA%8D%E7%9C%9F%E7%9A%84%E4%B8%8A%E7%B7%9A%E3%80%8B%EF%BC%8C%E6%83%B3%E8%81%8A%E8%81%8A%EF%BC%9A%0A%E2%80%A2%20%E6%88%91%E5%80%91%E5%9C%98%E9%9A%8A%E7%8B%80%E6%B3%81%EF%BC%9A%0A%E2%80%A2%20%E6%83%B3%E8%A7%A3%E6%B1%BA%E7%9A%84%E5%95%8F%E9%A1%8C%EF%BC%9A%0A%E2%80%A2%20%E5%A4%A7%E6%A6%82%E4%BA%BA%E6%95%B8%20%2F%20%E6%99%82%E7%A8%8B%EF%BC%9A%0A%0A%E8%AC%9D%E8%AC%9D%EF%BC%81" class="px-5 py-2.5 bg-black text-white text-xs md:text-sm uppercase tracking-widest border-2 border-black font-mono font-black hover:bg-[var(--color-neub-yellow)] hover:text-black transition-colors"> 寄信聊聊 → </a> <a href="/services/" class="px-5 py-2.5 bg-white text-xs md:text-sm uppercase tracking-widest border-2 border-black font-mono font-black hover:bg-black hover:text-white transition-colors"> 看顧問服務方案 </a> </div> </div> </div> </aside> <!-- Comments (Giscus / GitHub Discussions) --> <!-- Related Posts --> <section class="px-6 md:px-10 py-14 border-t-2 border-black"> <div class="max-w-5xl mx-auto"> <div class="text-xs uppercase tracking-widest opacity-60 mb-2 font-mono"> related </div> <h2 class="text-2xl md:text-3xl font-black tracking-tight mb-6"> 相關文章 </h2> <div class="grid gap-0 md:grid-cols-3 border-t-2 border-black"> <a href="/blog/firebase-free-web-app-domain/" class="block p-4 border-b-2 border-black hover:bg-[#ffff00] transition-colors md:border-r-2"> <div class="text-xs uppercase tracking-widest opacity-60 mb-2 font-mono"> [AI 工具] · 19min </div> <div class="font-black leading-tight mb-2">Firebase .web.app 免費網域:5 步驟申請,一個帳號能開幾個</div> <div class="text-xs opacity-70 line-clamp-2">Firebase 的 .web.app 是免費內建網域,連 HTTPS 憑證都自動簽好。本文用 5 個步驟教你從零申請,並回答一個帳號到底能開幾個(單一專案最多 36 個 site)。</div> </a><a href="/blog/ai-caught-cloudflare-dns-lying/" class="block p-4 border-b-2 border-black hover:bg-[#ffff00] transition-colors md:border-r-2"> <div class="text-xs uppercase tracking-widest opacity-60 mb-2 font-mono"> [AI 部署] · 13min </div> <div class="font-black leading-tight mb-2">Cloudflare 1.1.1.1 DNS stale cache 騙了我 30 分鐘:AI 多解析器交叉比對抓到(完整時間軸)</div> <div class="text-xs opacity-70 line-clamp-2">切完 NS 後 `dig @1.1.1.1` 整整 30 分鐘都跟我說『還沒生效』,差點回 Porkbun 重設 — AI 多打了 8.8.8.8 / 9.9.9.9 / 208.67.222.222 三家對驗才抓到:三家都回新 NS,只有 1.1.1.1 自己 stale cache。本文是 30 分鐘時間軸還原、Cloudflare cache 為什麼那麼激進、多解析器驗證 SOP。</div> </a><a href="/blog/ai-buy-domain-migration-yanchen-app/" class="block p-4 border-b-2 border-black hover:bg-[#ffff00] transition-colors"> <div class="text-xs uppercase tracking-widest opacity-60 mb-2 font-mono"> [AI 部署] · 22min </div> <div class="font-black leading-tight mb-2">AI 自動買網域 + 整站搬家完整流程:Cloudflare DNS、GitHub Pages、Astro(15 分鐘 8 步驟)</div> <div class="text-xs opacity-70 line-clamp-2">我把舊站 yanchen184.github.io/ai-lecturer-bob 搬到自有網域 yanchen.app,整個流程交給 Claude Code 跑。人類負責的事只有 15 分鐘:刷卡、改 nameserver、按 GSC verify、按 Bing 匯入。AI 負責的 1.5 小時包含:跨來源比價、17 個檔案批次改 URL、跑 CI/CD 自我除錯、寫 SEO 設定、監看 SSL 簽發。本文拆解 8 步驟、AI 該做 vs 你該做對照表、整個流程踩到的 5 個坑(1.1.1.1 stale cache、GSC Change of Address 灰掉的真相⋯⋯),給「想搬家但被 DNS / SSL / redirect / SEO 嚇到」的人一條完整路徑。</div> </a> </div> <div class="mt-6 text-center"> <a href="/blog/" class="inline-block px-4 py-2 text-xs uppercase tracking-widest border-2 border-black hover:bg-[#ffff00] font-mono">← all posts</a> </div> </div> </section> </main> <footer class="bg-black text-white mt-20"> <div class="max-w-7xl mx-auto px-6 md:px-10 py-12 grid gap-8 md:grid-cols-3"> <div> <div class="text-2xl font-black tracking-tight">陳彥彤 YC · AI 顧問</div> <p class="mt-3 text-sm opacity-70 leading-relaxed"> Java 後端 8 年 · AI 工程師 2 年。<br> AI 內訓 · AI 導入顧問 · 前後端與雲端培訓。 </p> </div> <div> <div class="text-xs uppercase tracking-widest opacity-60 mb-3 font-mono">導覽</div> <ul class="space-y-1.5 text-sm"> <li><a href="/" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">首頁</a></li> <li><a href="/#about" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">關於</a></li> <li><a href="/services/" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">顧問服務與內訓</a></li> <li><a href="/blog/" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">部落格</a></li> <li><a href="/rss.xml" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">RSS</a></li> </ul> </div> <div> <div class="text-xs uppercase tracking-widest opacity-60 mb-3 font-mono">聯繫</div> <ul class="space-y-1.5 text-sm"> <li><a href="mailto:bobchen184@gmail.com" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">bobchen184@gmail.com</a></li> <li><a href="https://github.com/yanchen184" target="_blank" rel="noopener" class="hover:bg-[var(--color-neub-yellow)] hover:text-black px-1">GitHub</a></li> </ul> </div> </div> <div class="border-t border-white/10"> <div class="max-w-7xl mx-auto px-6 md:px-10 py-4 text-xs opacity-60 flex flex-wrap justify-between gap-2"> <span>© 2026 陳彥彤 Bob Chen. All rights reserved.</span> <span class="font-mono">build: astro-ssg</span> </div> </div> </footer> <div id="__post_meta" data-slug="ai-deploy-firebase-custom-domain" data-title="我讓 AI 把遊戲部署上自己的網域:難的不是 deploy,是怎麼確認真的上線" hidden></div> <script type="module" src="/_astro/_slug_.astro_astro_type_script_index_0_lang.CtOsOydN.js"></script> <script type="module" src="/_astro/RootLayout.astro_astro_type_script_index_0_lang.DXzXapnn.js"></script> </body> </html>