[{"data":1,"prerenderedAt":5081},["ShallowReactive",2],{"navigation":3,"index":34,"index-blogs":159,"mdc--p12pnv-key":5052,"mdc--x9ibd0-key":5063,"mdc-zaetw1-key":5072},[4],{"title":5,"path":6,"stem":7,"children":8,"page":33},"Articles","\u002Farticles","articles",[9,13,17,21,25,29],{"title":10,"path":11,"stem":12},"用 Daily Snapshot 提升統計查詢速度","\u002Farticles\u002Fdaily-snapshot","articles\u002Fdaily-snapshot",{"title":14,"path":15,"stem":16},"GKE 部署","\u002Farticles\u002Fgke-deployment","articles\u002Fgke-deployment",{"title":18,"path":19,"stem":20},"從 LLM 到 Agent：打通底層邏輯","\u002Farticles\u002Fllm-to-agent","articles\u002Fllm-to-agent",{"title":22,"path":23,"stem":24},"資訊安全實踐","\u002Farticles\u002Fsecurity-best-practices","articles\u002Fsecurity-best-practices",{"title":26,"path":27,"stem":28},"單機架構的性能優化","\u002Farticles\u002Fsingle-machine-performance","articles\u002Fsingle-machine-performance",{"title":30,"path":31,"stem":32},"伺服器渲染 SSR","\u002Farticles\u002Fssr","articles\u002Fssr",false,{"id":35,"title":36,"about":37,"blog":40,"body":43,"description":44,"experience":45,"extension":69,"faq":70,"hero":109,"meta":126,"navigation":127,"path":128,"seo":129,"stem":132,"testimonials":133,"__hash__":158},"index\u002Findex.yml","嗨，我是 Gary\u003Cbr>一位後端工程師",{"title":38,"description":39},"關於我","擁有 6 年建置分散式系統與雲端原生應用程式經驗的後端工程師。\n專精於 Go、Kubernetes，以及設計團隊在生產環境中依賴的可擴展 API。\n",{"title":41,"description":42},"最新文章","最近的一些想法。",null,"我打造穩定、可擴展的後端系統，維護正式環境運行。熟悉分散式系統、微服務架構與 AI 工具。",{"title":46,"items":47},"工作經歷",[48,54,59,64],{"position":49,"date":50,"company":51},"後端工程師 @","2026 - 現在",{"name":52,"color":53},"鑽誠科技","#fdcb6e",{"position":49,"date":55,"company":56},"2024 - 2026",{"name":57,"color":58},"太禾科技","#00DC82",{"position":49,"date":60,"company":61},"2021 - 2024",{"name":62,"color":63},"睿實科技","#FF6363",{"position":49,"date":65,"company":66},"2020 - 2021",{"name":67,"color":68},"華苓科技","#5E6AD2","yml",{"title":71,"description":72,"categories":73},"常見問題","關於我的工作流程與服務的常見問題解答。",[74,86,101],{"title":75,"questions":76},"服務與流程",[77,80,83],{"label":78,"content":79},"你提供哪些服務？","我專精於後端工程與雲端基礎設施。包含設計與建置 REST\u002FgRPC API、分散式系統架構、資料庫 schema 設計與優化、Kubernetes 叢集建置與維運、CI\u002FCD 流程自動化，以及為擴展後端的團隊提供技術顧問服務。\n",{"label":81,"content":82},"你的工程流程是什麼樣的？","我從需求收集與系統設計開始，在寫下第一行程式碼之前先產出架構文件。接著以小而經過充分測試的增量進行迭代——整合測試、壓力測試，以及從第一天起就內建的可觀測性。我與產品和前端團隊密切合作，確保介面乾淨且有版本管理。\n",{"label":84,"content":85},"你接新創公司的案子嗎？","當然。早期新創公司能從務實的架構選擇中受益，這些選擇可以擴展而不需要完全重寫。我幫助團隊快速推進，同時不累積沉重的技術債。\n",{"title":87,"questions":88},"定價與時程",[89,92,95,98],{"label":90,"content":91},"一個專案通常要多少錢？","視範圍而定。單一功能或 API 模組起價約 NT$3 萬～10 萬；完整後端系統（含資料庫設計、部署、CI\u002FCD）通常在 NT$15 萬～50 萬之間。持續顧問或技術審查則以 NT$15,000～30,000 \u002F 天計費。\n",{"label":93,"content":94},"付款條件是什麼？","通常需要 30%～50% 訂金才能啟動專案，其餘於交付時付清。接受銀行轉帳或其他雙方同意的方式。\n",{"label":96,"content":97},"一個典型的專案需要多久？","單一 API 或功能模組約 1–3 週。完整後端系統建置通常需要 1–3 個月，視需求複雜度與溝通頻率而定。\n",{"label":99,"content":100},"你提供顧問保留金或持續支援嗎？","可以。如果需要長期技術諮詢、系統維護或功能迭代，歡迎討論月費合作方式。\n",{"title":38,"questions":102},[103,106],{"label":104,"content":105},"你最享受工作的哪個部分？","看到實做的系統能穩定面對大流量，這件事本身就很有成就感。\n",{"label":107,"content":108},"工作之外你有什麼愛好？","打電動、看技術文章、偶爾研究一些有趣的開源工具，最近在玩 AI 相關的東西。\n",{"images":110},[111,114,117,120,123],{"src":112,"alt":113},"\u002Fhero\u002F111.jpg","Hero 1",{"src":115,"alt":116},"\u002Fhero\u002F222.jpg","Hero 2",{"src":118,"alt":119},"\u002Fhero\u002F333.jpg","Hero 3",{"src":121,"alt":122},"\u002Fhero\u002Fhero-4.jpg","Hero 4",{"src":124,"alt":125},"\u002Fhero\u002Fhero-5.jpg","Hero 5",{},true,"\u002F",{"title":130,"description":131},"Gary Portfolio","歡迎來到我的作品集！我是 Gary，一位專精於分散式系統、雲端基礎設施與高效能 API 的後端工程師。","index",[134,142,150],{"quote":135,"author":136},"Gary 從零開始架構了我們整個資料管道。他對分散式系統的深刻理解，以及針對故障模式進行設計的能力，為我們節省了無數的事故應對時間。吞吐量提升了 5 倍，p99 延遲降低了一半。",{"name":137,"description":138,"avatar":139},"Lucas","友人一號",{"src":140,"srcset":141},"https:\u002F\u002Fapi.dicebear.com\u002F9.x\u002Florelei\u002Fsvg?seed=Lucas","https:\u002F\u002Fapi.dicebear.com\u002F9.x\u002Florelei\u002Fsvg?seed=Lucas 2x",{"quote":143,"author":144},"與 Gary 一起重新設計 API 是一次徹底的改變。他不僅交付了乾淨、文件完整的 REST\u002FgRPC 層，還主動反駁了會產生長期技術債的需求。這正是你在關鍵專案中希望擁有的那種資深工程師。",{"name":145,"description":146,"avatar":147},"Jason","友人二號",{"src":148,"srcset":149},"https:\u002F\u002Fapi.dicebear.com\u002F9.x\u002Florelei\u002Fsvg?seed=Jason","https:\u002F\u002Fapi.dicebear.com\u002F9.x\u002Florelei\u002Fsvg?seed=Jason 2x",{"quote":151,"author":152},"Gary 以零停機時間完成了我們從單體架構到微服務的遷移。他的 Kubernetes 專業知識以及對可觀測性的嚴謹態度，讓整個團隊在整個上線過程中都充滿信心。遷移後第一個月，事故數量降至幾乎為零。",{"name":153,"description":154,"avatar":155},"Johnson","友人三號",{"src":156,"srcset":157},"https:\u002F\u002Fapi.dicebear.com\u002F9.x\u002Florelei\u002Fsvg?seed=Johnson","https:\u002F\u002Fapi.dicebear.com\u002F9.x\u002Florelei\u002Fsvg?seed=Johnson 2x","cyX_j6c3x6DDia7HWMyyD0B1IF7PqK49n5G_6alRdnw",[160,1581,3879],{"id":161,"title":14,"author":162,"body":166,"date":1574,"description":1575,"extension":1576,"externalUrl":43,"image":1577,"meta":1578,"minRead":319,"navigation":127,"path":15,"seo":1579,"stem":16,"__hash__":1580},"blog\u002Farticles\u002Fgke-deployment.md",{"name":163,"avatar":164},"Gary",{"src":165,"alt":163},"\u002Fimages\u002Fselfie.webp",{"type":167,"value":168,"toc":1550},"minimark",[169,173,220,223,226,231,236,330,334,367,371,433,437,441,449,452,454,457,461,479,483,542,546,659,663,744,748,885,887,891,895,937,942,946,957,960,966,968,972,975,981,985,1134,1145,1149,1390,1395,1397,1400,1546],[170,171,172],"h2",{"id":172},"架構",[174,175,176,184,190,196,202,208,214],"ul",{},[177,178,179,183],"li",{},[180,181,182],"strong",{},"gshop-api"," — Node.js\u002FExpress，Port 3001",[177,185,186,189],{},[180,187,188],{},"gshop-dashboard"," — Nuxt.js SSR，Port 3002",[177,191,192,195],{},[180,193,194],{},"gshop-web"," — Nuxt.js SSR，Port 3003",[177,197,198,201],{},[180,199,200],{},"GKE Autopilot Cluster"," — gshop-cluster，asia-east1",[177,203,204,207],{},[180,205,206],{},"Artifact Registry"," — asia-east1-docker.pkg.dev\u002Fgshop-497319\u002Fgshop",[177,209,210,213],{},[180,211,212],{},"Database"," — Supabase（Session Pooler）",[177,215,216,219],{},[180,217,218],{},"Domain"," — garydemo.com（Cloudflare）",[221,222],"hr",{},[170,224,225],{"id":225},"部署流程",[227,228,230],"h3",{"id":229},"首次部署一次性","首次部署（一次性）",[232,233,235],"h4",{"id":234},"_1-建立-kubernetes-secret","1. 建立 Kubernetes Secret",[237,238,243],"pre",{"className":239,"code":240,"language":241,"meta":242,"style":242},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","kubectl create secret generic gshop-api-secret \\\n  --from-literal=DATABASE_URL=\"...\" \\\n  --from-literal=JWT_SECRET=\"...\" \\\n  --from-literal=GCS_BUCKET_NAME=\"...\" \\\n  --from-literal=ANTHROPIC_API_KEY=\"...\"\n","bash","",[244,245,246,272,289,303,317],"code",{"__ignoreMap":242},[247,248,251,255,259,262,265,268],"span",{"class":249,"line":250},"line",1,[247,252,254],{"class":253},"sBMFI","kubectl",[247,256,258],{"class":257},"sfazB"," create",[247,260,261],{"class":257}," secret",[247,263,264],{"class":257}," generic",[247,266,267],{"class":257}," gshop-api-secret",[247,269,271],{"class":270},"sTEyZ"," \\\n",[247,273,275,278,282,285,287],{"class":249,"line":274},2,[247,276,277],{"class":257},"  --from-literal=DATABASE_URL=",[247,279,281],{"class":280},"sMK4o","\"",[247,283,284],{"class":257},"...",[247,286,281],{"class":280},[247,288,271],{"class":270},[247,290,292,295,297,299,301],{"class":249,"line":291},3,[247,293,294],{"class":257},"  --from-literal=JWT_SECRET=",[247,296,281],{"class":280},[247,298,284],{"class":257},[247,300,281],{"class":280},[247,302,271],{"class":270},[247,304,306,309,311,313,315],{"class":249,"line":305},4,[247,307,308],{"class":257},"  --from-literal=GCS_BUCKET_NAME=",[247,310,281],{"class":280},[247,312,284],{"class":257},[247,314,281],{"class":280},[247,316,271],{"class":270},[247,318,320,323,325,327],{"class":249,"line":319},5,[247,321,322],{"class":257},"  --from-literal=ANTHROPIC_API_KEY=",[247,324,281],{"class":280},[247,326,284],{"class":257},[247,328,329],{"class":280},"\"\n",[232,331,333],{"id":332},"_2-建立-cloudflare-origin-certificate-tls-secret","2. 建立 Cloudflare Origin Certificate TLS Secret",[237,335,337],{"className":239,"code":336,"language":241,"meta":242,"style":242},"kubectl create secret tls cloudflare-origin-cert \\\n  --cert=certificate.pem \\\n  --key=private.key\n",[244,338,339,355,362],{"__ignoreMap":242},[247,340,341,343,345,347,350,353],{"class":249,"line":250},[247,342,254],{"class":253},[247,344,258],{"class":257},[247,346,261],{"class":257},[247,348,349],{"class":257}," tls",[247,351,352],{"class":257}," cloudflare-origin-cert",[247,354,271],{"class":270},[247,356,357,360],{"class":249,"line":274},[247,358,359],{"class":257},"  --cert=certificate.pem",[247,361,271],{"class":270},[247,363,364],{"class":249,"line":291},[247,365,366],{"class":257},"  --key=private.key\n",[232,368,370],{"id":369},"_3-套用-k8s-設定","3. 套用 K8s 設定",[237,372,374],{"className":239,"code":373,"language":241,"meta":242,"style":242},"kubectl apply -f k8s\u002Fbackend-config.yaml\nkubectl apply -f k8s\u002Fapi-deployment.yaml\nkubectl apply -f k8s\u002Fdashboard-deployment.yaml\nkubectl apply -f k8s\u002Fweb-deployment.yaml\nkubectl apply -f k8s\u002Fingress.yaml\n",[244,375,376,389,400,411,422],{"__ignoreMap":242},[247,377,378,380,383,386],{"class":249,"line":250},[247,379,254],{"class":253},[247,381,382],{"class":257}," apply",[247,384,385],{"class":257}," -f",[247,387,388],{"class":257}," k8s\u002Fbackend-config.yaml\n",[247,390,391,393,395,397],{"class":249,"line":274},[247,392,254],{"class":253},[247,394,382],{"class":257},[247,396,385],{"class":257},[247,398,399],{"class":257}," k8s\u002Fapi-deployment.yaml\n",[247,401,402,404,406,408],{"class":249,"line":291},[247,403,254],{"class":253},[247,405,382],{"class":257},[247,407,385],{"class":257},[247,409,410],{"class":257}," k8s\u002Fdashboard-deployment.yaml\n",[247,412,413,415,417,419],{"class":249,"line":305},[247,414,254],{"class":253},[247,416,382],{"class":257},[247,418,385],{"class":257},[247,420,421],{"class":257}," k8s\u002Fweb-deployment.yaml\n",[247,423,424,426,428,430],{"class":249,"line":319},[247,425,254],{"class":253},[247,427,382],{"class":257},[247,429,385],{"class":257},[247,431,432],{"class":257}," k8s\u002Fingress.yaml\n",[227,434,436],{"id":435},"日常更新cicd-自動執行","日常更新（CI\u002FCD 自動執行）",[438,439,440],"p",{},"三個 repo 都設有 GitHub Actions，push 到 main 自動完成：",[237,442,447],{"className":443,"code":445,"language":446},[444],"language-text","push main → GitHub Actions → docker build → push Artifact Registry → kubectl rollout restart\n","text",[244,448,445],{"__ignoreMap":242},[438,450,451],{},"不需要手動 build 或部署。",[221,453],{},[170,455,456],{"id":456},"遇到的狀況與解決",[227,458,460],{"id":459},"_1-arm64amd64-平台不符","1. arm64\u002Famd64 平台不符",[174,462,463,469],{},[177,464,465,468],{},[180,466,467],{},"問題","：本地 Apple Silicon Mac build 出 arm64 image，GKE 需要 amd64",[177,470,471,474,475,478],{},[180,472,473],{},"解法","：改用 ",[244,476,477],{},"gcloud builds submit","，Cloud Build 在 amd64 機器上 build",[227,480,482],{"id":481},"_2-imagepullbackoff沒權限拉-image","2. ImagePullBackOff（沒權限拉 image）",[174,484,485,490],{},[177,486,487,489],{},[180,488,467],{},"：GKE Service Account 沒有 Artifact Registry 讀取權限",[177,491,492,494,495],{},[180,493,473],{},"：\n",[237,496,498],{"className":239,"code":497,"language":241,"meta":242,"style":242},"gcloud projects add-iam-policy-binding gshop-497319 \\\n  --member=\"serviceAccount:620172615694-compute@developer.gserviceaccount.com\" \\\n  --role=\"roles\u002Fartifactregistry.reader\"\n",[244,499,500,516,530],{"__ignoreMap":242},[247,501,502,505,508,511,514],{"class":249,"line":250},[247,503,504],{"class":253},"gcloud",[247,506,507],{"class":257}," projects",[247,509,510],{"class":257}," add-iam-policy-binding",[247,512,513],{"class":257}," gshop-497319",[247,515,271],{"class":270},[247,517,518,521,523,526,528],{"class":249,"line":274},[247,519,520],{"class":257},"  --member=",[247,522,281],{"class":280},[247,524,525],{"class":257},"serviceAccount:620172615694-compute@developer.gserviceaccount.com",[247,527,281],{"class":280},[247,529,271],{"class":270},[247,531,532,535,537,540],{"class":249,"line":291},[247,533,534],{"class":257},"  --role=",[247,536,281],{"class":280},[247,538,539],{"class":257},"roles\u002Fartifactregistry.reader",[247,541,329],{"class":280},[227,543,545],{"id":544},"_3-ingress-遲遲拿不到-ipneg-not-ready","3. Ingress 遲遲拿不到 IP（NEG not ready）",[174,547,548,558,597,607],{},[177,549,550,553,554,557],{},[180,551,552],{},"問題 1","：用了 ",[244,555,556],{},"spec.ingressClassName: gce","，GKE 的 controller 不認這個，要用 annotation",[177,559,560,562,563],{},[180,561,473],{},"：改成：\n",[237,564,568],{"className":565,"code":566,"language":567,"meta":242,"style":242},"language-yaml shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","metadata:\n  annotations:\n    kubernetes.io\u002Fingress.class: gce\n","yaml",[244,569,570,579,586],{"__ignoreMap":242},[247,571,572,576],{"class":249,"line":250},[247,573,575],{"class":574},"swJcz","metadata",[247,577,578],{"class":280},":\n",[247,580,581,584],{"class":249,"line":274},[247,582,583],{"class":574},"  annotations",[247,585,578],{"class":280},[247,587,588,591,594],{"class":249,"line":291},[247,589,590],{"class":574},"    kubernetes.io\u002Fingress.class",[247,592,593],{"class":280},":",[247,595,596],{"class":257}," gce\n",[177,598,599,602,603,606],{},[180,600,601],{},"問題 2","：Service 上有舊的 ",[244,604,605],{},"networking.gke.io\u002Ftarget-pool"," annotation 造成衝突",[177,608,609,494,611,658],{},[180,610,473],{},[237,612,614],{"className":239,"code":613,"language":241,"meta":242,"style":242},"kubectl annotate svc gshop-api networking.gke.io\u002Ftarget-pool-\nkubectl annotate svc gshop-dashboard networking.gke.io\u002Ftarget-pool-\nkubectl annotate svc gshop-web networking.gke.io\u002Ftarget-pool-\n",[244,615,616,632,645],{"__ignoreMap":242},[247,617,618,620,623,626,629],{"class":249,"line":250},[247,619,254],{"class":253},[247,621,622],{"class":257}," annotate",[247,624,625],{"class":257}," svc",[247,627,628],{"class":257}," gshop-api",[247,630,631],{"class":257}," networking.gke.io\u002Ftarget-pool-\n",[247,633,634,636,638,640,643],{"class":249,"line":274},[247,635,254],{"class":253},[247,637,622],{"class":257},[247,639,625],{"class":257},[247,641,642],{"class":257}," gshop-dashboard",[247,644,631],{"class":257},[247,646,647,649,651,653,656],{"class":249,"line":291},[247,648,254],{"class":253},[247,650,622],{"class":257},[247,652,625],{"class":257},[247,654,655],{"class":257}," gshop-web",[247,657,631],{"class":257},"\n再刪掉重建 Ingress",[227,660,662],{"id":661},"_4-502-bad-gateway健康檢查失敗","4. 502 Bad Gateway（健康檢查失敗）",[174,664,665,697],{},[177,666,667,669,670,673,674],{},[180,668,467],{},"：GCP Load Balancer 預設用 ",[244,671,672],{},"GET \u002F"," 做健康檢查，但各服務行為不同：\n",[174,675,676,682,691],{},[177,677,678,679,681],{},"gshop-api：",[244,680,128],{}," 沒有 route，回非 200",[177,683,684,685,687,688],{},"gshop-dashboard：",[244,686,128],{}," 302 redirect 到 ",[244,689,690],{},"\u002Flogin",[177,692,693,694,696],{},"gshop-web：",[244,695,128],{}," 正常回 200（不需要處理）",[177,698,699,701,702,705,706,722,723],{},[180,700,473],{},"：建 ",[244,703,704],{},"BackendConfig"," 指定正確的健康檢查路徑：\n",[237,707,709],{"className":565,"code":708,"language":567,"meta":242,"style":242},"# gshop-api → \u002Fhealth\n# gshop-dashboard → \u002Flogin\n",[244,710,711,717],{"__ignoreMap":242},[247,712,713],{"class":249,"line":250},[247,714,716],{"class":715},"sHwdD","# gshop-api → \u002Fhealth\n",[247,718,719],{"class":249,"line":274},[247,720,721],{"class":715},"# gshop-dashboard → \u002Flogin\n","\n並在 Service 加 annotation：\n",[237,724,726],{"className":565,"code":725,"language":567,"meta":242,"style":242},"cloud.google.com\u002Fbackend-config: '{\"default\": \"gshop-api-backend-config\"}'\n",[244,727,728],{"__ignoreMap":242},[247,729,730,733,735,738,741],{"class":249,"line":250},[247,731,732],{"class":574},"cloud.google.com\u002Fbackend-config",[247,734,593],{"class":280},[247,736,737],{"class":280}," '",[247,739,740],{"class":257},"{\"default\": \"gshop-api-backend-config\"}",[247,742,743],{"class":280},"'\n",[227,745,747],{"id":746},"_5-supabase-連線失敗enotfound","5. Supabase 連線失敗（ENOTFOUND）",[174,749,750,759],{},[177,751,752,754,755,758],{},[180,753,467],{},"：Supabase 直連（",[244,756,757],{},"db.xxx.supabase.co:5432","）是 IPv6 only，GKE 是 IPv4 only",[177,760,761,763,764,767,768,774,775,879],{},[180,762,473],{},"：改用 Supabase ",[180,765,766],{},"Session Pooler"," connection string：\n",[237,769,772],{"className":770,"code":771,"language":446},[444],"postgresql:\u002F\u002Fpostgres.vqjzzlutlovqoqshwbza:PASSWORD@aws-1-ap-southeast-1.pooler.supabase.com:5432\u002Fpostgres\n",[244,773,771],{"__ignoreMap":242},"\n更新 K8s secret：\n",[237,776,778],{"className":239,"code":777,"language":241,"meta":242,"style":242},"NEW_URL=\"postgresql:\u002F\u002F...\"\nkubectl patch secret gshop-api-secret -p \"{\\\"data\\\":{\\\"DATABASE_URL\\\":\\\"$(echo -n $NEW_URL | base64)\\\"}}\"\nkubectl rollout restart deployment\u002Fgshop-api\n",[244,779,780,795,866],{"__ignoreMap":242},[247,781,782,785,788,790,793],{"class":249,"line":250},[247,783,784],{"class":270},"NEW_URL",[247,786,787],{"class":280},"=",[247,789,281],{"class":280},[247,791,792],{"class":257},"postgresql:\u002F\u002F...",[247,794,329],{"class":280},[247,796,797,799,802,804,806,809,812,815,818,821,823,826,828,831,833,835,837,840,844,847,850,853,856,859,861,864],{"class":249,"line":274},[247,798,254],{"class":253},[247,800,801],{"class":257}," patch",[247,803,261],{"class":257},[247,805,267],{"class":257},[247,807,808],{"class":257}," -p",[247,810,811],{"class":280}," \"",[247,813,814],{"class":257},"{",[247,816,817],{"class":270},"\\\"",[247,819,820],{"class":257},"data",[247,822,817],{"class":270},[247,824,825],{"class":257},":{",[247,827,817],{"class":270},[247,829,830],{"class":257},"DATABASE_URL",[247,832,817],{"class":270},[247,834,593],{"class":257},[247,836,817],{"class":270},[247,838,839],{"class":280},"$(",[247,841,843],{"class":842},"s2Zo4","echo",[247,845,846],{"class":257}," -n ",[247,848,849],{"class":270},"$NEW_URL",[247,851,852],{"class":280}," |",[247,854,855],{"class":253}," base64",[247,857,858],{"class":280},")",[247,860,817],{"class":270},[247,862,863],{"class":257},"}}",[247,865,329],{"class":280},[247,867,868,870,873,876],{"class":249,"line":291},[247,869,254],{"class":253},[247,871,872],{"class":257}," rollout",[247,874,875],{"class":257}," restart",[247,877,878],{"class":257}," deployment\u002Fgshop-api\n",[880,881,882],"blockquote",{},[438,883,884],{},"本地開發不需要改，Mac 支援 IPv6 直連沒問題",[221,886],{},[170,888,890],{"id":889},"dns-https-設定","DNS & HTTPS 設定",[227,892,894],{"id":893},"cloudflare-a-records","Cloudflare A Records",[896,897,898,911],"table",{},[899,900,901],"thead",{},[902,903,904,908],"tr",{},[905,906,907],"th",{},"子網域",[905,909,910],{},"IP",[912,913,914,923,930],"tbody",{},[902,915,916,920],{},[917,918,919],"td",{},"api.garydemo.com",[917,921,922],{},"34.160.168.110",[902,924,925,928],{},[917,926,927],{},"dashboard.garydemo.com",[917,929,922],{},[902,931,932,935],{},[917,933,934],{},"web.garydemo.com",[917,936,922],{},[880,938,939],{},[438,940,941],{},"三個都指向同一個 Ingress IP，由 Ingress 依 Host header 分流",[227,943,945],{"id":944},"cloudflare-ssltls-模式","Cloudflare SSL\u002FTLS 模式",[174,947,948,954],{},[177,949,950,951],{},"設為 ",[180,952,953],{},"Full (Strict)",[177,955,956],{},"使用 Cloudflare Origin Certificate 存為 K8s TLS Secret",[227,958,959],{"id":959},"流量路徑",[237,961,964],{"className":962,"code":963,"language":446},[444],"瀏覽器 → Cloudflare（Proxy + TLS）→ GCP Load Balancer → Ingress → Pod\n",[244,965,963],{"__ignoreMap":242},[221,967],{},[170,969,971],{"id":970},"cicdgithub-actions","CI\u002FCD（GitHub Actions）",[438,973,974],{},"每個 service 各自有獨立 repo，push 到 main 自動 build + deploy。",[237,976,979],{"className":977,"code":978,"language":446},[444],"push main → GitHub Actions → build image → push Artifact Registry → kubectl rollout restart\n",[244,980,978],{"__ignoreMap":242},[227,982,984],{"id":983},"前置設定一次性","前置設定（一次性）",[237,986,988],{"className":239,"code":987,"language":241,"meta":242,"style":242},"gcloud iam service-accounts create github-actions \\\n  --display-name=\"GitHub Actions\"\n\ngcloud projects add-iam-policy-binding gshop-497319 \\\n  --member=\"serviceAccount:github-actions@gshop-497319.iam.gserviceaccount.com\" \\\n  --role=\"roles\u002Fartifactregistry.writer\"\n\ngcloud projects add-iam-policy-binding gshop-497319 \\\n  --member=\"serviceAccount:github-actions@gshop-497319.iam.gserviceaccount.com\" \\\n  --role=\"roles\u002Fcontainer.developer\"\n\ngcloud iam service-accounts keys create sa-key.json \\\n  --iam-account=github-actions@gshop-497319.iam.gserviceaccount.com\n",[244,989,990,1007,1019,1024,1036,1049,1061,1066,1079,1092,1104,1109,1128],{"__ignoreMap":242},[247,991,992,994,997,1000,1002,1005],{"class":249,"line":250},[247,993,504],{"class":253},[247,995,996],{"class":257}," iam",[247,998,999],{"class":257}," service-accounts",[247,1001,258],{"class":257},[247,1003,1004],{"class":257}," github-actions",[247,1006,271],{"class":270},[247,1008,1009,1012,1014,1017],{"class":249,"line":274},[247,1010,1011],{"class":257},"  --display-name=",[247,1013,281],{"class":280},[247,1015,1016],{"class":257},"GitHub Actions",[247,1018,329],{"class":280},[247,1020,1021],{"class":249,"line":291},[247,1022,1023],{"emptyLinePlaceholder":127},"\n",[247,1025,1026,1028,1030,1032,1034],{"class":249,"line":305},[247,1027,504],{"class":253},[247,1029,507],{"class":257},[247,1031,510],{"class":257},[247,1033,513],{"class":257},[247,1035,271],{"class":270},[247,1037,1038,1040,1042,1045,1047],{"class":249,"line":319},[247,1039,520],{"class":257},[247,1041,281],{"class":280},[247,1043,1044],{"class":257},"serviceAccount:github-actions@gshop-497319.iam.gserviceaccount.com",[247,1046,281],{"class":280},[247,1048,271],{"class":270},[247,1050,1052,1054,1056,1059],{"class":249,"line":1051},6,[247,1053,534],{"class":257},[247,1055,281],{"class":280},[247,1057,1058],{"class":257},"roles\u002Fartifactregistry.writer",[247,1060,329],{"class":280},[247,1062,1064],{"class":249,"line":1063},7,[247,1065,1023],{"emptyLinePlaceholder":127},[247,1067,1069,1071,1073,1075,1077],{"class":249,"line":1068},8,[247,1070,504],{"class":253},[247,1072,507],{"class":257},[247,1074,510],{"class":257},[247,1076,513],{"class":257},[247,1078,271],{"class":270},[247,1080,1082,1084,1086,1088,1090],{"class":249,"line":1081},9,[247,1083,520],{"class":257},[247,1085,281],{"class":280},[247,1087,1044],{"class":257},[247,1089,281],{"class":280},[247,1091,271],{"class":270},[247,1093,1095,1097,1099,1102],{"class":249,"line":1094},10,[247,1096,534],{"class":257},[247,1098,281],{"class":280},[247,1100,1101],{"class":257},"roles\u002Fcontainer.developer",[247,1103,329],{"class":280},[247,1105,1107],{"class":249,"line":1106},11,[247,1108,1023],{"emptyLinePlaceholder":127},[247,1110,1112,1114,1116,1118,1121,1123,1126],{"class":249,"line":1111},12,[247,1113,504],{"class":253},[247,1115,996],{"class":257},[247,1117,999],{"class":257},[247,1119,1120],{"class":257}," keys",[247,1122,258],{"class":257},[247,1124,1125],{"class":257}," sa-key.json",[247,1127,271],{"class":270},[247,1129,1131],{"class":249,"line":1130},13,[247,1132,1133],{"class":257},"  --iam-account=github-actions@gshop-497319.iam.gserviceaccount.com\n",[438,1135,1136,1137,1140,1141,1144],{},"GitHub org → Settings → Secrets → New organization secret，名稱 ",[244,1138,1139],{},"GCP_SA_KEY","，貼入 ",[244,1142,1143],{},"sa-key.json"," 內容。",[227,1146,1148],{"id":1147},"workflow-範例","Workflow 範例",[237,1150,1152],{"className":565,"code":1151,"language":567,"meta":242,"style":242},"name: Deploy gshop-api\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v4\n      - uses: google-github-actions\u002Fauth@v2\n        with:\n          credentials_json: ${{ secrets.GCP_SA_KEY }}\n      - uses: google-github-actions\u002Fsetup-gcloud@v2\n      - run: gcloud auth configure-docker asia-east1-docker.pkg.dev\n      - name: Build and push image\n        run: |\n          docker build -t asia-east1-docker.pkg.dev\u002Fgshop-497319\u002Fgshop\u002Fapi:latest .\n          docker push asia-east1-docker.pkg.dev\u002Fgshop-497319\u002Fgshop\u002Fapi:latest\n      - uses: google-github-actions\u002Fget-gke-credentials@v2\n        with:\n          cluster_name: gshop-cluster\n          location: asia-east1\n      - run: kubectl rollout restart deployment\u002Fgshop-api\n",[244,1153,1154,1164,1168,1176,1183,1190,1198,1202,1209,1216,1226,1233,1245,1256,1264,1275,1287,1300,1313,1325,1331,1337,1349,1356,1367,1378],{"__ignoreMap":242},[247,1155,1156,1159,1161],{"class":249,"line":250},[247,1157,1158],{"class":574},"name",[247,1160,593],{"class":280},[247,1162,1163],{"class":257}," Deploy gshop-api\n",[247,1165,1166],{"class":249,"line":274},[247,1167,1023],{"emptyLinePlaceholder":127},[247,1169,1170,1174],{"class":249,"line":291},[247,1171,1173],{"class":1172},"sfNiH","on",[247,1175,578],{"class":280},[247,1177,1178,1181],{"class":249,"line":305},[247,1179,1180],{"class":574},"  push",[247,1182,578],{"class":280},[247,1184,1185,1188],{"class":249,"line":319},[247,1186,1187],{"class":574},"    branches",[247,1189,578],{"class":280},[247,1191,1192,1195],{"class":249,"line":1051},[247,1193,1194],{"class":280},"      -",[247,1196,1197],{"class":257}," main\n",[247,1199,1200],{"class":249,"line":1063},[247,1201,1023],{"emptyLinePlaceholder":127},[247,1203,1204,1207],{"class":249,"line":1068},[247,1205,1206],{"class":574},"jobs",[247,1208,578],{"class":280},[247,1210,1211,1214],{"class":249,"line":1081},[247,1212,1213],{"class":574},"  deploy",[247,1215,578],{"class":280},[247,1217,1218,1221,1223],{"class":249,"line":1094},[247,1219,1220],{"class":574},"    runs-on",[247,1222,593],{"class":280},[247,1224,1225],{"class":257}," ubuntu-latest\n",[247,1227,1228,1231],{"class":249,"line":1106},[247,1229,1230],{"class":574},"    steps",[247,1232,578],{"class":280},[247,1234,1235,1237,1240,1242],{"class":249,"line":1111},[247,1236,1194],{"class":280},[247,1238,1239],{"class":574}," uses",[247,1241,593],{"class":280},[247,1243,1244],{"class":257}," actions\u002Fcheckout@v4\n",[247,1246,1247,1249,1251,1253],{"class":249,"line":1130},[247,1248,1194],{"class":280},[247,1250,1239],{"class":574},[247,1252,593],{"class":280},[247,1254,1255],{"class":257}," google-github-actions\u002Fauth@v2\n",[247,1257,1259,1262],{"class":249,"line":1258},14,[247,1260,1261],{"class":574},"        with",[247,1263,578],{"class":280},[247,1265,1267,1270,1272],{"class":249,"line":1266},15,[247,1268,1269],{"class":574},"          credentials_json",[247,1271,593],{"class":280},[247,1273,1274],{"class":257}," ${{ secrets.GCP_SA_KEY }}\n",[247,1276,1278,1280,1282,1284],{"class":249,"line":1277},16,[247,1279,1194],{"class":280},[247,1281,1239],{"class":574},[247,1283,593],{"class":280},[247,1285,1286],{"class":257}," google-github-actions\u002Fsetup-gcloud@v2\n",[247,1288,1290,1292,1295,1297],{"class":249,"line":1289},17,[247,1291,1194],{"class":280},[247,1293,1294],{"class":574}," run",[247,1296,593],{"class":280},[247,1298,1299],{"class":257}," gcloud auth configure-docker asia-east1-docker.pkg.dev\n",[247,1301,1303,1305,1308,1310],{"class":249,"line":1302},18,[247,1304,1194],{"class":280},[247,1306,1307],{"class":574}," name",[247,1309,593],{"class":280},[247,1311,1312],{"class":257}," Build and push image\n",[247,1314,1316,1319,1321],{"class":249,"line":1315},19,[247,1317,1318],{"class":574},"        run",[247,1320,593],{"class":280},[247,1322,1324],{"class":1323},"s7zQu"," |\n",[247,1326,1328],{"class":249,"line":1327},20,[247,1329,1330],{"class":257},"          docker build -t asia-east1-docker.pkg.dev\u002Fgshop-497319\u002Fgshop\u002Fapi:latest .\n",[247,1332,1334],{"class":249,"line":1333},21,[247,1335,1336],{"class":257},"          docker push asia-east1-docker.pkg.dev\u002Fgshop-497319\u002Fgshop\u002Fapi:latest\n",[247,1338,1340,1342,1344,1346],{"class":249,"line":1339},22,[247,1341,1194],{"class":280},[247,1343,1239],{"class":574},[247,1345,593],{"class":280},[247,1347,1348],{"class":257}," google-github-actions\u002Fget-gke-credentials@v2\n",[247,1350,1352,1354],{"class":249,"line":1351},23,[247,1353,1261],{"class":574},[247,1355,578],{"class":280},[247,1357,1359,1362,1364],{"class":249,"line":1358},24,[247,1360,1361],{"class":574},"          cluster_name",[247,1363,593],{"class":280},[247,1365,1366],{"class":257}," gshop-cluster\n",[247,1368,1370,1373,1375],{"class":249,"line":1369},25,[247,1371,1372],{"class":574},"          location",[247,1374,593],{"class":280},[247,1376,1377],{"class":257}," asia-east1\n",[247,1379,1381,1383,1385,1387],{"class":249,"line":1380},26,[247,1382,1194],{"class":280},[247,1384,1294],{"class":574},[247,1386,593],{"class":280},[247,1388,1389],{"class":257}," kubectl rollout restart deployment\u002Fgshop-api\n",[880,1391,1392],{},[438,1393,1394],{},"GitHub Actions runner 是 ubuntu amd64，build 出的 image 天生就是 amd64，不需要 Cloud Build",[221,1396],{},[170,1398,1399],{"id":1399},"常用指令",[237,1401,1403],{"className":239,"code":1402,"language":241,"meta":242,"style":242},"# 查 pod 狀態\nkubectl get pods\n\n# 查 ingress IP\nkubectl get ingress gshop-ingress\n\n# 查某服務 log\nkubectl logs -l app=gshop-api --tail=50\n\n# 查 backend 健康狀態\ngcloud compute backend-services get-health \u003Cbackend-name> --global\n\n# 列出所有 backend\ngcloud compute backend-services list --global\n\n# 查 NEG\ngcloud compute network-endpoint-groups list\n",[244,1404,1405,1410,1420,1424,1429,1441,1445,1450,1466,1470,1475,1503,1507,1512,1525,1529,1534],{"__ignoreMap":242},[247,1406,1407],{"class":249,"line":250},[247,1408,1409],{"class":715},"# 查 pod 狀態\n",[247,1411,1412,1414,1417],{"class":249,"line":274},[247,1413,254],{"class":253},[247,1415,1416],{"class":257}," get",[247,1418,1419],{"class":257}," pods\n",[247,1421,1422],{"class":249,"line":291},[247,1423,1023],{"emptyLinePlaceholder":127},[247,1425,1426],{"class":249,"line":305},[247,1427,1428],{"class":715},"# 查 ingress IP\n",[247,1430,1431,1433,1435,1438],{"class":249,"line":319},[247,1432,254],{"class":253},[247,1434,1416],{"class":257},[247,1436,1437],{"class":257}," ingress",[247,1439,1440],{"class":257}," gshop-ingress\n",[247,1442,1443],{"class":249,"line":1051},[247,1444,1023],{"emptyLinePlaceholder":127},[247,1446,1447],{"class":249,"line":1063},[247,1448,1449],{"class":715},"# 查某服務 log\n",[247,1451,1452,1454,1457,1460,1463],{"class":249,"line":1068},[247,1453,254],{"class":253},[247,1455,1456],{"class":257}," logs",[247,1458,1459],{"class":257}," -l",[247,1461,1462],{"class":257}," app=gshop-api",[247,1464,1465],{"class":257}," --tail=50\n",[247,1467,1468],{"class":249,"line":1081},[247,1469,1023],{"emptyLinePlaceholder":127},[247,1471,1472],{"class":249,"line":1094},[247,1473,1474],{"class":715},"# 查 backend 健康狀態\n",[247,1476,1477,1479,1482,1485,1488,1491,1494,1497,1500],{"class":249,"line":1106},[247,1478,504],{"class":253},[247,1480,1481],{"class":257}," compute",[247,1483,1484],{"class":257}," backend-services",[247,1486,1487],{"class":257}," get-health",[247,1489,1490],{"class":280}," \u003C",[247,1492,1493],{"class":257},"backend-nam",[247,1495,1496],{"class":270},"e",[247,1498,1499],{"class":280},">",[247,1501,1502],{"class":257}," --global\n",[247,1504,1505],{"class":249,"line":1111},[247,1506,1023],{"emptyLinePlaceholder":127},[247,1508,1509],{"class":249,"line":1130},[247,1510,1511],{"class":715},"# 列出所有 backend\n",[247,1513,1514,1516,1518,1520,1523],{"class":249,"line":1258},[247,1515,504],{"class":253},[247,1517,1481],{"class":257},[247,1519,1484],{"class":257},[247,1521,1522],{"class":257}," list",[247,1524,1502],{"class":257},[247,1526,1527],{"class":249,"line":1266},[247,1528,1023],{"emptyLinePlaceholder":127},[247,1530,1531],{"class":249,"line":1277},[247,1532,1533],{"class":715},"# 查 NEG\n",[247,1535,1536,1538,1540,1543],{"class":249,"line":1289},[247,1537,504],{"class":253},[247,1539,1481],{"class":257},[247,1541,1542],{"class":257}," network-endpoint-groups",[247,1544,1545],{"class":257}," list\n",[1547,1548,1549],"style",{},"html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}",{"title":242,"searchDepth":274,"depth":274,"links":1551},[1552,1553,1557,1564,1569,1573],{"id":172,"depth":274,"text":172},{"id":225,"depth":274,"text":225,"children":1554},[1555,1556],{"id":229,"depth":291,"text":230},{"id":435,"depth":291,"text":436},{"id":456,"depth":274,"text":456,"children":1558},[1559,1560,1561,1562,1563],{"id":459,"depth":291,"text":460},{"id":481,"depth":291,"text":482},{"id":544,"depth":291,"text":545},{"id":661,"depth":291,"text":662},{"id":746,"depth":291,"text":747},{"id":889,"depth":274,"text":890,"children":1565},[1566,1567,1568],{"id":893,"depth":291,"text":894},{"id":944,"depth":291,"text":945},{"id":959,"depth":291,"text":959},{"id":970,"depth":274,"text":971,"children":1570},[1571,1572],{"id":983,"depth":291,"text":984},{"id":1147,"depth":291,"text":1148},{"id":1399,"depth":274,"text":1399},"2026-06-16","記錄將個人電商（GShop）部署到 GKE Autopilot 的完整流程。","md","\u002Fimages\u002Fgke.jpg",{},{"title":14,"description":1575},"0WXx7GmLgxmdtZM5CXX1oRaqKSqXhVwlR1hreTnFxdY",{"id":1582,"title":10,"author":1583,"body":1585,"date":3873,"description":3874,"extension":1576,"externalUrl":43,"image":3875,"meta":3876,"minRead":319,"navigation":127,"path":11,"seo":3877,"stem":12,"__hash__":3878},"blog\u002Farticles\u002Fdaily-snapshot.md",{"name":163,"avatar":1584},{"src":165,"alt":163},{"type":167,"value":1586,"toc":3858},[1587,1590,1593,1613,1627,1630,1632,1636,1642,1648,1650,1654,1657,1833,1835,1839,1842,3305,3308,3345,3347,3351,3476,3478,3481,3484,3555,3557,3561,3564,3569,3575,3578,3582,3585,3731,3737,3741,3758,3761,3765,3768,3770,3777,3779,3782,3832,3834,3837,3844,3855],[170,1588,1589],{"id":1589},"問題背景",[438,1591,1592],{},"訂單管理後台有一個總覽頁面，需要顯示以下統計數字：",[174,1594,1595,1598,1601,1604,1607,1610],{},[177,1596,1597],{},"當日營業額與訂單數",[177,1599,1600],{},"新增用戶數",[177,1602,1603],{},"平均客單價",[177,1605,1606],{},"熱銷商品 Top 10",[177,1608,1609],{},"各類別銷售佔比",[177,1611,1612],{},"各付款方式分佈",[438,1614,1615,1616,1619,1620,1619,1623,1626],{},"起初資料量小，直接對 ",[244,1617,1618],{},"orders","、",[244,1621,1622],{},"order_items",[244,1624,1625],{},"payments"," 做聚合查詢沒有問題。",[438,1628,1629],{},"隨著訂單累積，這些查詢開始拖慢整個頁面，每次載入需要數秒，而且每個進入後台的管理員都會觸發一次跨表掃描。",[221,1631],{},[170,1633,1635],{"id":1634},"解法daily-snapshot","解法：Daily Snapshot",[438,1637,1638,1639],{},"核心思路：",[180,1640,1641],{},"不在用戶請求時計算，改成每天固定時間預先算好，存進一張 Snapshot Table，查詢時直接讀一筆記錄。",[237,1643,1646],{"className":1644,"code":1645,"language":446},[444],"每天 00:00 Cron Job 執行\n      ↓\n讀取昨日訂單、商品、付款資料，執行聚合計算\n      ↓\n將結果寫入 daily_snapshots 表（一天一筆）\n      ↓\n前端請求總覽時，直接 SELECT 最新一筆 snapshot\n",[244,1647,1645],{"__ignoreMap":242},[221,1649],{},[170,1651,1653],{"id":1652},"snapshot-table-設計","Snapshot Table 設計",[438,1655,1656],{},"Snapshot 除了基本的金額與訂單數，還用 JSON 欄位儲存熱銷商品、類別、付款方式等排行資料：",[237,1658,1662],{"className":1659,"code":1660,"language":1661,"meta":242,"style":242},"language-js shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u002F\u002F Sequelize Model\nDailySnapshot = {\n  date: DataTypes.DATEONLY, \u002F\u002F 唯一鍵，一天一筆\n  revenue: DataTypes.DECIMAL, \u002F\u002F 當日營業額（paid\u002Fshipped\u002Fdelivered）\n  orderCount: DataTypes.INTEGER, \u002F\u002F 當日總訂單數\n  newUserCount: DataTypes.INTEGER, \u002F\u002F 當日新增用戶數\n  avgOrderValue: DataTypes.DECIMAL,\n  topProducts: DataTypes.JSON, \u002F\u002F [{ productId, productName, totalRevenue, totalQuantity }]\n  topCategories: DataTypes.JSON, \u002F\u002F [{ categoryId, categoryName, totalRevenue, totalQuantity }]\n  paymentMethods: DataTypes.JSON, \u002F\u002F [{ method, count, amount }]\n};\n","js",[244,1663,1664,1669,1679,1701,1720,1739,1757,1773,1792,1810,1828],{"__ignoreMap":242},[247,1665,1666],{"class":249,"line":250},[247,1667,1668],{"class":715},"\u002F\u002F Sequelize Model\n",[247,1670,1671,1674,1676],{"class":249,"line":274},[247,1672,1673],{"class":270},"DailySnapshot ",[247,1675,787],{"class":280},[247,1677,1678],{"class":280}," {\n",[247,1680,1681,1684,1686,1689,1692,1695,1698],{"class":249,"line":291},[247,1682,1683],{"class":574},"  date",[247,1685,593],{"class":280},[247,1687,1688],{"class":270}," DataTypes",[247,1690,1691],{"class":280},".",[247,1693,1694],{"class":270},"DATEONLY",[247,1696,1697],{"class":280},",",[247,1699,1700],{"class":715}," \u002F\u002F 唯一鍵，一天一筆\n",[247,1702,1703,1706,1708,1710,1712,1715,1717],{"class":249,"line":305},[247,1704,1705],{"class":574},"  revenue",[247,1707,593],{"class":280},[247,1709,1688],{"class":270},[247,1711,1691],{"class":280},[247,1713,1714],{"class":270},"DECIMAL",[247,1716,1697],{"class":280},[247,1718,1719],{"class":715}," \u002F\u002F 當日營業額（paid\u002Fshipped\u002Fdelivered）\n",[247,1721,1722,1725,1727,1729,1731,1734,1736],{"class":249,"line":319},[247,1723,1724],{"class":574},"  orderCount",[247,1726,593],{"class":280},[247,1728,1688],{"class":270},[247,1730,1691],{"class":280},[247,1732,1733],{"class":270},"INTEGER",[247,1735,1697],{"class":280},[247,1737,1738],{"class":715}," \u002F\u002F 當日總訂單數\n",[247,1740,1741,1744,1746,1748,1750,1752,1754],{"class":249,"line":1051},[247,1742,1743],{"class":574},"  newUserCount",[247,1745,593],{"class":280},[247,1747,1688],{"class":270},[247,1749,1691],{"class":280},[247,1751,1733],{"class":270},[247,1753,1697],{"class":280},[247,1755,1756],{"class":715}," \u002F\u002F 當日新增用戶數\n",[247,1758,1759,1762,1764,1766,1768,1770],{"class":249,"line":1063},[247,1760,1761],{"class":574},"  avgOrderValue",[247,1763,593],{"class":280},[247,1765,1688],{"class":270},[247,1767,1691],{"class":280},[247,1769,1714],{"class":270},[247,1771,1772],{"class":280},",\n",[247,1774,1775,1778,1780,1782,1784,1787,1789],{"class":249,"line":1068},[247,1776,1777],{"class":574},"  topProducts",[247,1779,593],{"class":280},[247,1781,1688],{"class":270},[247,1783,1691],{"class":280},[247,1785,1786],{"class":270},"JSON",[247,1788,1697],{"class":280},[247,1790,1791],{"class":715}," \u002F\u002F [{ productId, productName, totalRevenue, totalQuantity }]\n",[247,1793,1794,1797,1799,1801,1803,1805,1807],{"class":249,"line":1081},[247,1795,1796],{"class":574},"  topCategories",[247,1798,593],{"class":280},[247,1800,1688],{"class":270},[247,1802,1691],{"class":280},[247,1804,1786],{"class":270},[247,1806,1697],{"class":280},[247,1808,1809],{"class":715}," \u002F\u002F [{ categoryId, categoryName, totalRevenue, totalQuantity }]\n",[247,1811,1812,1815,1817,1819,1821,1823,1825],{"class":249,"line":1094},[247,1813,1814],{"class":574},"  paymentMethods",[247,1816,593],{"class":280},[247,1818,1688],{"class":270},[247,1820,1691],{"class":280},[247,1822,1786],{"class":270},[247,1824,1697],{"class":280},[247,1826,1827],{"class":715}," \u002F\u002F [{ method, count, amount }]\n",[247,1829,1830],{"class":249,"line":1106},[247,1831,1832],{"class":280},"};\n",[221,1834],{},[170,1836,1838],{"id":1837},"builddailysnapshot-實作","buildDailySnapshot 實作",[438,1840,1841],{},"以下是實際的計算函式，對四張表同時發出查詢後一次 upsert 進 snapshot table：",[237,1843,1845],{"className":1659,"code":1844,"language":1661,"meta":242,"style":242},"import sequelize from \"..\u002Fconfig\u002Fdb.js\";\nimport { DailySnapshot } from \"..\u002Fmodels\u002Findex.js\";\n\nexport async function buildDailySnapshot(targetDate) {\n  \u002F\u002F 預設計算昨天\n  const date =\n    targetDate ||\n    (() => {\n      const d = new Date();\n      d.setDate(d.getDate() - 1);\n      return d;\n    })();\n\n  const dateStr = toLocalDateStr(date);\n  const start = new Date(date);\n  start.setHours(0, 0, 0, 0);\n  const end = new Date(start);\n  end.setDate(end.getDate() + 1);\n\n  \u002F\u002F 四支查詢並行執行，減少等待時間\n  const [[revenue], [userRow], topProducts, topCategories, paymentMethods] =\n    await Promise.all([\n      \u002F\u002F 營業額、訂單數\n      sequelize.query(\n        `\n        SELECT\n          COALESCE(SUM(total_amount) FILTER (WHERE status IN ('paid','shipped','delivered')), 0) AS revenue,\n          COUNT(*) FILTER (WHERE status IN ('paid','shipped','delivered')) AS paid_count,\n          COUNT(*) AS order_count\n        FROM orders WHERE created_at >= :start AND created_at \u003C :end\n      `,\n        { replacements: { start, end }, type: sequelize.QueryTypes.SELECT },\n      ),\n\n      \u002F\u002F 新增用戶數\n      sequelize.query(\n        `\n        SELECT COUNT(*) AS count FROM users\n        WHERE created_at >= :start AND created_at \u003C :end\n      `,\n        { replacements: { start, end }, type: sequelize.QueryTypes.SELECT },\n      ),\n\n      \u002F\u002F 熱銷商品 Top 10\n      sequelize.query(\n        `\n        SELECT oi.product_id AS \"productId\", oi.product_name AS \"productName\",\n               SUM(oi.subtotal) AS \"totalRevenue\", SUM(oi.quantity) AS \"totalQuantity\"\n        FROM order_items oi JOIN orders o ON o.id = oi.order_id\n        WHERE o.created_at >= :start AND o.created_at \u003C :end AND o.status != 'cancelled'\n        GROUP BY oi.product_id, oi.product_name\n        ORDER BY \"totalRevenue\" DESC LIMIT 10\n      `,\n        { replacements: { start, end }, type: sequelize.QueryTypes.SELECT },\n      ),\n\n      \u002F\u002F 各類別銷售 Top 10\n      sequelize.query(\n        `\n        SELECT p.category_id AS \"categoryId\", c.name AS \"categoryName\",\n               SUM(oi.subtotal) AS \"totalRevenue\", SUM(oi.quantity) AS \"totalQuantity\"\n        FROM order_items oi\n        JOIN orders o ON o.id = oi.order_id\n        JOIN products p ON p.id = oi.product_id\n        JOIN categories c ON c.id = p.category_id\n        WHERE o.created_at >= :start AND o.created_at \u003C :end AND o.status != 'cancelled'\n        GROUP BY p.category_id, c.name\n        ORDER BY \"totalRevenue\" DESC LIMIT 10\n      `,\n        { replacements: { start, end }, type: sequelize.QueryTypes.SELECT },\n      ),\n\n      \u002F\u002F 付款方式分佈\n      sequelize.query(\n        `\n        SELECT pay.method, COUNT(*) AS count, SUM(pay.amount) AS amount\n        FROM payments pay JOIN orders o ON o.id = pay.order_id\n        WHERE o.created_at >= :start AND o.created_at \u003C :end\n          AND o.status IN ('paid','shipped','delivered')\n        GROUP BY pay.method\n      `,\n        { replacements: { start, end }, type: sequelize.QueryTypes.SELECT },\n      ),\n    ]);\n\n  const rev = parseFloat(revenue.revenue) || 0;\n  const paidCount = parseInt(revenue.paid_count) || 0;\n\n  \u002F\u002F upsert：重跑不會產生重複資料\n  await DailySnapshot.upsert({\n    date: dateStr,\n    revenue: rev,\n    orderCount: parseInt(revenue.order_count) || 0,\n    newUserCount: parseInt(userRow.count) || 0,\n    avgOrderValue: paidCount > 0 ? parseFloat((rev \u002F paidCount).toFixed(2)) : 0,\n    topProducts: topProducts.map((r) => ({\n      ...r,\n      totalRevenue: parseFloat(r.totalRevenue),\n      totalQuantity: parseInt(r.totalQuantity),\n    })),\n    topCategories: topCategories.map((r) => ({\n      ...r,\n      totalRevenue: parseFloat(r.totalRevenue),\n      totalQuantity: parseInt(r.totalQuantity),\n    })),\n    paymentMethods: paymentMethods.map((r) => ({\n      method: r.method,\n      count: parseInt(r.count),\n      amount: parseFloat(r.amount),\n    })),\n  });\n}\n",[244,1846,1847,1868,1893,1897,1923,1928,1939,1947,1960,1981,2015,2024,2034,2038,2059,2080,2112,2134,2163,2167,2172,2211,2227,2232,2245,2250,2255,2261,2267,2273,2279,2287,2330,2338,2343,2349,2360,2365,2371,2377,2384,2419,2426,2431,2437,2448,2453,2459,2465,2471,2477,2483,2489,2496,2531,2538,2543,2549,2560,2565,2571,2576,2582,2588,2594,2600,2605,2611,2616,2623,2658,2665,2670,2676,2687,2692,2698,2704,2710,2716,2722,2729,2764,2771,2779,2784,2815,2845,2850,2856,2874,2886,2898,2925,2952,3004,3035,3045,3068,3091,3101,3129,3138,3159,3180,3189,3217,3235,3257,3280,3289,3299],{"__ignoreMap":242},[247,1848,1849,1852,1855,1858,1860,1863,1865],{"class":249,"line":250},[247,1850,1851],{"class":1323},"import",[247,1853,1854],{"class":270}," sequelize ",[247,1856,1857],{"class":1323},"from",[247,1859,811],{"class":280},[247,1861,1862],{"class":257},"..\u002Fconfig\u002Fdb.js",[247,1864,281],{"class":280},[247,1866,1867],{"class":280},";\n",[247,1869,1870,1872,1875,1878,1881,1884,1886,1889,1891],{"class":249,"line":274},[247,1871,1851],{"class":1323},[247,1873,1874],{"class":280}," {",[247,1876,1877],{"class":270}," DailySnapshot",[247,1879,1880],{"class":280}," }",[247,1882,1883],{"class":1323}," from",[247,1885,811],{"class":280},[247,1887,1888],{"class":257},"..\u002Fmodels\u002Findex.js",[247,1890,281],{"class":280},[247,1892,1867],{"class":280},[247,1894,1895],{"class":249,"line":291},[247,1896,1023],{"emptyLinePlaceholder":127},[247,1898,1899,1902,1906,1909,1912,1915,1919,1921],{"class":249,"line":305},[247,1900,1901],{"class":1323},"export",[247,1903,1905],{"class":1904},"spNyl"," async",[247,1907,1908],{"class":1904}," function",[247,1910,1911],{"class":842}," buildDailySnapshot",[247,1913,1914],{"class":280},"(",[247,1916,1918],{"class":1917},"sHdIc","targetDate",[247,1920,858],{"class":280},[247,1922,1678],{"class":280},[247,1924,1925],{"class":249,"line":319},[247,1926,1927],{"class":715},"  \u002F\u002F 預設計算昨天\n",[247,1929,1930,1933,1936],{"class":249,"line":1051},[247,1931,1932],{"class":1904},"  const",[247,1934,1935],{"class":270}," date",[247,1937,1938],{"class":280}," =\n",[247,1940,1941,1944],{"class":249,"line":1063},[247,1942,1943],{"class":270},"    targetDate",[247,1945,1946],{"class":280}," ||\n",[247,1948,1949,1952,1955,1958],{"class":249,"line":1068},[247,1950,1951],{"class":574},"    (",[247,1953,1954],{"class":280},"()",[247,1956,1957],{"class":1904}," =>",[247,1959,1678],{"class":280},[247,1961,1962,1965,1968,1971,1974,1977,1979],{"class":249,"line":1081},[247,1963,1964],{"class":1904},"      const",[247,1966,1967],{"class":270}," d",[247,1969,1970],{"class":280}," =",[247,1972,1973],{"class":280}," new",[247,1975,1976],{"class":842}," Date",[247,1978,1954],{"class":574},[247,1980,1867],{"class":280},[247,1982,1983,1986,1988,1991,1993,1996,1998,2001,2004,2007,2011,2013],{"class":249,"line":1094},[247,1984,1985],{"class":270},"      d",[247,1987,1691],{"class":280},[247,1989,1990],{"class":842},"setDate",[247,1992,1914],{"class":574},[247,1994,1995],{"class":270},"d",[247,1997,1691],{"class":280},[247,1999,2000],{"class":842},"getDate",[247,2002,2003],{"class":574},"() ",[247,2005,2006],{"class":280},"-",[247,2008,2010],{"class":2009},"sbssI"," 1",[247,2012,858],{"class":574},[247,2014,1867],{"class":280},[247,2016,2017,2020,2022],{"class":249,"line":1106},[247,2018,2019],{"class":1323},"      return",[247,2021,1967],{"class":270},[247,2023,1867],{"class":280},[247,2025,2026,2029,2032],{"class":249,"line":1111},[247,2027,2028],{"class":280},"    }",[247,2030,2031],{"class":574},")()",[247,2033,1867],{"class":280},[247,2035,2036],{"class":249,"line":1130},[247,2037,1023],{"emptyLinePlaceholder":127},[247,2039,2040,2042,2045,2047,2050,2052,2055,2057],{"class":249,"line":1258},[247,2041,1932],{"class":1904},[247,2043,2044],{"class":270}," dateStr",[247,2046,1970],{"class":280},[247,2048,2049],{"class":842}," toLocalDateStr",[247,2051,1914],{"class":574},[247,2053,2054],{"class":270},"date",[247,2056,858],{"class":574},[247,2058,1867],{"class":280},[247,2060,2061,2063,2066,2068,2070,2072,2074,2076,2078],{"class":249,"line":1266},[247,2062,1932],{"class":1904},[247,2064,2065],{"class":270}," start",[247,2067,1970],{"class":280},[247,2069,1973],{"class":280},[247,2071,1976],{"class":842},[247,2073,1914],{"class":574},[247,2075,2054],{"class":270},[247,2077,858],{"class":574},[247,2079,1867],{"class":280},[247,2081,2082,2085,2087,2090,2092,2095,2097,2100,2102,2104,2106,2108,2110],{"class":249,"line":1277},[247,2083,2084],{"class":270},"  start",[247,2086,1691],{"class":280},[247,2088,2089],{"class":842},"setHours",[247,2091,1914],{"class":574},[247,2093,2094],{"class":2009},"0",[247,2096,1697],{"class":280},[247,2098,2099],{"class":2009}," 0",[247,2101,1697],{"class":280},[247,2103,2099],{"class":2009},[247,2105,1697],{"class":280},[247,2107,2099],{"class":2009},[247,2109,858],{"class":574},[247,2111,1867],{"class":280},[247,2113,2114,2116,2119,2121,2123,2125,2127,2130,2132],{"class":249,"line":1289},[247,2115,1932],{"class":1904},[247,2117,2118],{"class":270}," end",[247,2120,1970],{"class":280},[247,2122,1973],{"class":280},[247,2124,1976],{"class":842},[247,2126,1914],{"class":574},[247,2128,2129],{"class":270},"start",[247,2131,858],{"class":574},[247,2133,1867],{"class":280},[247,2135,2136,2139,2141,2143,2145,2148,2150,2152,2154,2157,2159,2161],{"class":249,"line":1302},[247,2137,2138],{"class":270},"  end",[247,2140,1691],{"class":280},[247,2142,1990],{"class":842},[247,2144,1914],{"class":574},[247,2146,2147],{"class":270},"end",[247,2149,1691],{"class":280},[247,2151,2000],{"class":842},[247,2153,2003],{"class":574},[247,2155,2156],{"class":280},"+",[247,2158,2010],{"class":2009},[247,2160,858],{"class":574},[247,2162,1867],{"class":280},[247,2164,2165],{"class":249,"line":1315},[247,2166,1023],{"emptyLinePlaceholder":127},[247,2168,2169],{"class":249,"line":1327},[247,2170,2171],{"class":715},"  \u002F\u002F 四支查詢並行執行，減少等待時間\n",[247,2173,2174,2176,2179,2182,2185,2188,2191,2193,2196,2198,2201,2203,2206,2209],{"class":249,"line":1333},[247,2175,1932],{"class":1904},[247,2177,2178],{"class":280}," [[",[247,2180,2181],{"class":270},"revenue",[247,2183,2184],{"class":280},"],",[247,2186,2187],{"class":280}," [",[247,2189,2190],{"class":270},"userRow",[247,2192,2184],{"class":280},[247,2194,2195],{"class":270}," topProducts",[247,2197,1697],{"class":280},[247,2199,2200],{"class":270}," topCategories",[247,2202,1697],{"class":280},[247,2204,2205],{"class":270}," paymentMethods",[247,2207,2208],{"class":280},"]",[247,2210,1938],{"class":280},[247,2212,2213,2216,2219,2221,2224],{"class":249,"line":1339},[247,2214,2215],{"class":1323},"    await",[247,2217,2218],{"class":253}," Promise",[247,2220,1691],{"class":280},[247,2222,2223],{"class":842},"all",[247,2225,2226],{"class":574},"([\n",[247,2228,2229],{"class":249,"line":1351},[247,2230,2231],{"class":715},"      \u002F\u002F 營業額、訂單數\n",[247,2233,2234,2237,2239,2242],{"class":249,"line":1358},[247,2235,2236],{"class":270},"      sequelize",[247,2238,1691],{"class":280},[247,2240,2241],{"class":842},"query",[247,2243,2244],{"class":574},"(\n",[247,2246,2247],{"class":249,"line":1369},[247,2248,2249],{"class":280},"        `\n",[247,2251,2252],{"class":249,"line":1380},[247,2253,2254],{"class":257},"        SELECT\n",[247,2256,2258],{"class":249,"line":2257},27,[247,2259,2260],{"class":257},"          COALESCE(SUM(total_amount) FILTER (WHERE status IN ('paid','shipped','delivered')), 0) AS revenue,\n",[247,2262,2264],{"class":249,"line":2263},28,[247,2265,2266],{"class":257},"          COUNT(*) FILTER (WHERE status IN ('paid','shipped','delivered')) AS paid_count,\n",[247,2268,2270],{"class":249,"line":2269},29,[247,2271,2272],{"class":257},"          COUNT(*) AS order_count\n",[247,2274,2276],{"class":249,"line":2275},30,[247,2277,2278],{"class":257},"        FROM orders WHERE created_at >= :start AND created_at \u003C :end\n",[247,2280,2282,2285],{"class":249,"line":2281},31,[247,2283,2284],{"class":280},"      `",[247,2286,1772],{"class":280},[247,2288,2290,2293,2296,2298,2300,2302,2304,2306,2309,2312,2314,2317,2319,2322,2324,2327],{"class":249,"line":2289},32,[247,2291,2292],{"class":280},"        {",[247,2294,2295],{"class":574}," replacements",[247,2297,593],{"class":280},[247,2299,1874],{"class":280},[247,2301,2065],{"class":270},[247,2303,1697],{"class":280},[247,2305,2118],{"class":270},[247,2307,2308],{"class":280}," },",[247,2310,2311],{"class":574}," type",[247,2313,593],{"class":280},[247,2315,2316],{"class":270}," sequelize",[247,2318,1691],{"class":280},[247,2320,2321],{"class":270},"QueryTypes",[247,2323,1691],{"class":280},[247,2325,2326],{"class":270},"SELECT",[247,2328,2329],{"class":280}," },\n",[247,2331,2333,2336],{"class":249,"line":2332},33,[247,2334,2335],{"class":574},"      )",[247,2337,1772],{"class":280},[247,2339,2341],{"class":249,"line":2340},34,[247,2342,1023],{"emptyLinePlaceholder":127},[247,2344,2346],{"class":249,"line":2345},35,[247,2347,2348],{"class":715},"      \u002F\u002F 新增用戶數\n",[247,2350,2352,2354,2356,2358],{"class":249,"line":2351},36,[247,2353,2236],{"class":270},[247,2355,1691],{"class":280},[247,2357,2241],{"class":842},[247,2359,2244],{"class":574},[247,2361,2363],{"class":249,"line":2362},37,[247,2364,2249],{"class":280},[247,2366,2368],{"class":249,"line":2367},38,[247,2369,2370],{"class":257},"        SELECT COUNT(*) AS count FROM users\n",[247,2372,2374],{"class":249,"line":2373},39,[247,2375,2376],{"class":257},"        WHERE created_at >= :start AND created_at \u003C :end\n",[247,2378,2380,2382],{"class":249,"line":2379},40,[247,2381,2284],{"class":280},[247,2383,1772],{"class":280},[247,2385,2387,2389,2391,2393,2395,2397,2399,2401,2403,2405,2407,2409,2411,2413,2415,2417],{"class":249,"line":2386},41,[247,2388,2292],{"class":280},[247,2390,2295],{"class":574},[247,2392,593],{"class":280},[247,2394,1874],{"class":280},[247,2396,2065],{"class":270},[247,2398,1697],{"class":280},[247,2400,2118],{"class":270},[247,2402,2308],{"class":280},[247,2404,2311],{"class":574},[247,2406,593],{"class":280},[247,2408,2316],{"class":270},[247,2410,1691],{"class":280},[247,2412,2321],{"class":270},[247,2414,1691],{"class":280},[247,2416,2326],{"class":270},[247,2418,2329],{"class":280},[247,2420,2422,2424],{"class":249,"line":2421},42,[247,2423,2335],{"class":574},[247,2425,1772],{"class":280},[247,2427,2429],{"class":249,"line":2428},43,[247,2430,1023],{"emptyLinePlaceholder":127},[247,2432,2434],{"class":249,"line":2433},44,[247,2435,2436],{"class":715},"      \u002F\u002F 熱銷商品 Top 10\n",[247,2438,2440,2442,2444,2446],{"class":249,"line":2439},45,[247,2441,2236],{"class":270},[247,2443,1691],{"class":280},[247,2445,2241],{"class":842},[247,2447,2244],{"class":574},[247,2449,2451],{"class":249,"line":2450},46,[247,2452,2249],{"class":280},[247,2454,2456],{"class":249,"line":2455},47,[247,2457,2458],{"class":257},"        SELECT oi.product_id AS \"productId\", oi.product_name AS \"productName\",\n",[247,2460,2462],{"class":249,"line":2461},48,[247,2463,2464],{"class":257},"               SUM(oi.subtotal) AS \"totalRevenue\", SUM(oi.quantity) AS \"totalQuantity\"\n",[247,2466,2468],{"class":249,"line":2467},49,[247,2469,2470],{"class":257},"        FROM order_items oi JOIN orders o ON o.id = oi.order_id\n",[247,2472,2474],{"class":249,"line":2473},50,[247,2475,2476],{"class":257},"        WHERE o.created_at >= :start AND o.created_at \u003C :end AND o.status != 'cancelled'\n",[247,2478,2480],{"class":249,"line":2479},51,[247,2481,2482],{"class":257},"        GROUP BY oi.product_id, oi.product_name\n",[247,2484,2486],{"class":249,"line":2485},52,[247,2487,2488],{"class":257},"        ORDER BY \"totalRevenue\" DESC LIMIT 10\n",[247,2490,2492,2494],{"class":249,"line":2491},53,[247,2493,2284],{"class":280},[247,2495,1772],{"class":280},[247,2497,2499,2501,2503,2505,2507,2509,2511,2513,2515,2517,2519,2521,2523,2525,2527,2529],{"class":249,"line":2498},54,[247,2500,2292],{"class":280},[247,2502,2295],{"class":574},[247,2504,593],{"class":280},[247,2506,1874],{"class":280},[247,2508,2065],{"class":270},[247,2510,1697],{"class":280},[247,2512,2118],{"class":270},[247,2514,2308],{"class":280},[247,2516,2311],{"class":574},[247,2518,593],{"class":280},[247,2520,2316],{"class":270},[247,2522,1691],{"class":280},[247,2524,2321],{"class":270},[247,2526,1691],{"class":280},[247,2528,2326],{"class":270},[247,2530,2329],{"class":280},[247,2532,2534,2536],{"class":249,"line":2533},55,[247,2535,2335],{"class":574},[247,2537,1772],{"class":280},[247,2539,2541],{"class":249,"line":2540},56,[247,2542,1023],{"emptyLinePlaceholder":127},[247,2544,2546],{"class":249,"line":2545},57,[247,2547,2548],{"class":715},"      \u002F\u002F 各類別銷售 Top 10\n",[247,2550,2552,2554,2556,2558],{"class":249,"line":2551},58,[247,2553,2236],{"class":270},[247,2555,1691],{"class":280},[247,2557,2241],{"class":842},[247,2559,2244],{"class":574},[247,2561,2563],{"class":249,"line":2562},59,[247,2564,2249],{"class":280},[247,2566,2568],{"class":249,"line":2567},60,[247,2569,2570],{"class":257},"        SELECT p.category_id AS \"categoryId\", c.name AS \"categoryName\",\n",[247,2572,2574],{"class":249,"line":2573},61,[247,2575,2464],{"class":257},[247,2577,2579],{"class":249,"line":2578},62,[247,2580,2581],{"class":257},"        FROM order_items oi\n",[247,2583,2585],{"class":249,"line":2584},63,[247,2586,2587],{"class":257},"        JOIN orders o ON o.id = oi.order_id\n",[247,2589,2591],{"class":249,"line":2590},64,[247,2592,2593],{"class":257},"        JOIN products p ON p.id = oi.product_id\n",[247,2595,2597],{"class":249,"line":2596},65,[247,2598,2599],{"class":257},"        JOIN categories c ON c.id = p.category_id\n",[247,2601,2603],{"class":249,"line":2602},66,[247,2604,2476],{"class":257},[247,2606,2608],{"class":249,"line":2607},67,[247,2609,2610],{"class":257},"        GROUP BY p.category_id, c.name\n",[247,2612,2614],{"class":249,"line":2613},68,[247,2615,2488],{"class":257},[247,2617,2619,2621],{"class":249,"line":2618},69,[247,2620,2284],{"class":280},[247,2622,1772],{"class":280},[247,2624,2626,2628,2630,2632,2634,2636,2638,2640,2642,2644,2646,2648,2650,2652,2654,2656],{"class":249,"line":2625},70,[247,2627,2292],{"class":280},[247,2629,2295],{"class":574},[247,2631,593],{"class":280},[247,2633,1874],{"class":280},[247,2635,2065],{"class":270},[247,2637,1697],{"class":280},[247,2639,2118],{"class":270},[247,2641,2308],{"class":280},[247,2643,2311],{"class":574},[247,2645,593],{"class":280},[247,2647,2316],{"class":270},[247,2649,1691],{"class":280},[247,2651,2321],{"class":270},[247,2653,1691],{"class":280},[247,2655,2326],{"class":270},[247,2657,2329],{"class":280},[247,2659,2661,2663],{"class":249,"line":2660},71,[247,2662,2335],{"class":574},[247,2664,1772],{"class":280},[247,2666,2668],{"class":249,"line":2667},72,[247,2669,1023],{"emptyLinePlaceholder":127},[247,2671,2673],{"class":249,"line":2672},73,[247,2674,2675],{"class":715},"      \u002F\u002F 付款方式分佈\n",[247,2677,2679,2681,2683,2685],{"class":249,"line":2678},74,[247,2680,2236],{"class":270},[247,2682,1691],{"class":280},[247,2684,2241],{"class":842},[247,2686,2244],{"class":574},[247,2688,2690],{"class":249,"line":2689},75,[247,2691,2249],{"class":280},[247,2693,2695],{"class":249,"line":2694},76,[247,2696,2697],{"class":257},"        SELECT pay.method, COUNT(*) AS count, SUM(pay.amount) AS amount\n",[247,2699,2701],{"class":249,"line":2700},77,[247,2702,2703],{"class":257},"        FROM payments pay JOIN orders o ON o.id = pay.order_id\n",[247,2705,2707],{"class":249,"line":2706},78,[247,2708,2709],{"class":257},"        WHERE o.created_at >= :start AND o.created_at \u003C :end\n",[247,2711,2713],{"class":249,"line":2712},79,[247,2714,2715],{"class":257},"          AND o.status IN ('paid','shipped','delivered')\n",[247,2717,2719],{"class":249,"line":2718},80,[247,2720,2721],{"class":257},"        GROUP BY pay.method\n",[247,2723,2725,2727],{"class":249,"line":2724},81,[247,2726,2284],{"class":280},[247,2728,1772],{"class":280},[247,2730,2732,2734,2736,2738,2740,2742,2744,2746,2748,2750,2752,2754,2756,2758,2760,2762],{"class":249,"line":2731},82,[247,2733,2292],{"class":280},[247,2735,2295],{"class":574},[247,2737,593],{"class":280},[247,2739,1874],{"class":280},[247,2741,2065],{"class":270},[247,2743,1697],{"class":280},[247,2745,2118],{"class":270},[247,2747,2308],{"class":280},[247,2749,2311],{"class":574},[247,2751,593],{"class":280},[247,2753,2316],{"class":270},[247,2755,1691],{"class":280},[247,2757,2321],{"class":270},[247,2759,1691],{"class":280},[247,2761,2326],{"class":270},[247,2763,2329],{"class":280},[247,2765,2767,2769],{"class":249,"line":2766},83,[247,2768,2335],{"class":574},[247,2770,1772],{"class":280},[247,2772,2774,2777],{"class":249,"line":2773},84,[247,2775,2776],{"class":574},"    ])",[247,2778,1867],{"class":280},[247,2780,2782],{"class":249,"line":2781},85,[247,2783,1023],{"emptyLinePlaceholder":127},[247,2785,2787,2789,2792,2794,2797,2799,2801,2803,2805,2808,2811,2813],{"class":249,"line":2786},86,[247,2788,1932],{"class":1904},[247,2790,2791],{"class":270}," rev",[247,2793,1970],{"class":280},[247,2795,2796],{"class":842}," parseFloat",[247,2798,1914],{"class":574},[247,2800,2181],{"class":270},[247,2802,1691],{"class":280},[247,2804,2181],{"class":270},[247,2806,2807],{"class":574},") ",[247,2809,2810],{"class":280},"||",[247,2812,2099],{"class":2009},[247,2814,1867],{"class":280},[247,2816,2818,2820,2823,2825,2828,2830,2832,2834,2837,2839,2841,2843],{"class":249,"line":2817},87,[247,2819,1932],{"class":1904},[247,2821,2822],{"class":270}," paidCount",[247,2824,1970],{"class":280},[247,2826,2827],{"class":842}," parseInt",[247,2829,1914],{"class":574},[247,2831,2181],{"class":270},[247,2833,1691],{"class":280},[247,2835,2836],{"class":270},"paid_count",[247,2838,2807],{"class":574},[247,2840,2810],{"class":280},[247,2842,2099],{"class":2009},[247,2844,1867],{"class":280},[247,2846,2848],{"class":249,"line":2847},88,[247,2849,1023],{"emptyLinePlaceholder":127},[247,2851,2853],{"class":249,"line":2852},89,[247,2854,2855],{"class":715},"  \u002F\u002F upsert：重跑不會產生重複資料\n",[247,2857,2859,2862,2864,2866,2869,2871],{"class":249,"line":2858},90,[247,2860,2861],{"class":1323},"  await",[247,2863,1877],{"class":270},[247,2865,1691],{"class":280},[247,2867,2868],{"class":842},"upsert",[247,2870,1914],{"class":574},[247,2872,2873],{"class":280},"{\n",[247,2875,2877,2880,2882,2884],{"class":249,"line":2876},91,[247,2878,2879],{"class":574},"    date",[247,2881,593],{"class":280},[247,2883,2044],{"class":270},[247,2885,1772],{"class":280},[247,2887,2889,2892,2894,2896],{"class":249,"line":2888},92,[247,2890,2891],{"class":574},"    revenue",[247,2893,593],{"class":280},[247,2895,2791],{"class":270},[247,2897,1772],{"class":280},[247,2899,2901,2904,2906,2908,2910,2912,2914,2917,2919,2921,2923],{"class":249,"line":2900},93,[247,2902,2903],{"class":574},"    orderCount",[247,2905,593],{"class":280},[247,2907,2827],{"class":842},[247,2909,1914],{"class":574},[247,2911,2181],{"class":270},[247,2913,1691],{"class":280},[247,2915,2916],{"class":270},"order_count",[247,2918,2807],{"class":574},[247,2920,2810],{"class":280},[247,2922,2099],{"class":2009},[247,2924,1772],{"class":280},[247,2926,2928,2931,2933,2935,2937,2939,2941,2944,2946,2948,2950],{"class":249,"line":2927},94,[247,2929,2930],{"class":574},"    newUserCount",[247,2932,593],{"class":280},[247,2934,2827],{"class":842},[247,2936,1914],{"class":574},[247,2938,2190],{"class":270},[247,2940,1691],{"class":280},[247,2942,2943],{"class":270},"count",[247,2945,2807],{"class":574},[247,2947,2810],{"class":280},[247,2949,2099],{"class":2009},[247,2951,1772],{"class":280},[247,2953,2955,2958,2960,2962,2965,2967,2970,2972,2975,2978,2981,2983,2985,2987,2990,2992,2995,2998,3000,3002],{"class":249,"line":2954},95,[247,2956,2957],{"class":574},"    avgOrderValue",[247,2959,593],{"class":280},[247,2961,2822],{"class":270},[247,2963,2964],{"class":280}," >",[247,2966,2099],{"class":2009},[247,2968,2969],{"class":280}," ?",[247,2971,2796],{"class":842},[247,2973,2974],{"class":574},"((",[247,2976,2977],{"class":270},"rev",[247,2979,2980],{"class":280}," \u002F",[247,2982,2822],{"class":270},[247,2984,858],{"class":574},[247,2986,1691],{"class":280},[247,2988,2989],{"class":842},"toFixed",[247,2991,1914],{"class":574},[247,2993,2994],{"class":2009},"2",[247,2996,2997],{"class":574},")) ",[247,2999,593],{"class":280},[247,3001,2099],{"class":2009},[247,3003,1772],{"class":280},[247,3005,3007,3010,3012,3014,3016,3019,3021,3023,3026,3028,3030,3033],{"class":249,"line":3006},96,[247,3008,3009],{"class":574},"    topProducts",[247,3011,593],{"class":280},[247,3013,2195],{"class":270},[247,3015,1691],{"class":280},[247,3017,3018],{"class":842},"map",[247,3020,1914],{"class":574},[247,3022,1914],{"class":280},[247,3024,3025],{"class":1917},"r",[247,3027,858],{"class":280},[247,3029,1957],{"class":1904},[247,3031,3032],{"class":574}," (",[247,3034,2873],{"class":280},[247,3036,3038,3041,3043],{"class":249,"line":3037},97,[247,3039,3040],{"class":280},"      ...",[247,3042,3025],{"class":270},[247,3044,1772],{"class":280},[247,3046,3048,3051,3053,3055,3057,3059,3061,3064,3066],{"class":249,"line":3047},98,[247,3049,3050],{"class":574},"      totalRevenue",[247,3052,593],{"class":280},[247,3054,2796],{"class":842},[247,3056,1914],{"class":574},[247,3058,3025],{"class":270},[247,3060,1691],{"class":280},[247,3062,3063],{"class":270},"totalRevenue",[247,3065,858],{"class":574},[247,3067,1772],{"class":280},[247,3069,3071,3074,3076,3078,3080,3082,3084,3087,3089],{"class":249,"line":3070},99,[247,3072,3073],{"class":574},"      totalQuantity",[247,3075,593],{"class":280},[247,3077,2827],{"class":842},[247,3079,1914],{"class":574},[247,3081,3025],{"class":270},[247,3083,1691],{"class":280},[247,3085,3086],{"class":270},"totalQuantity",[247,3088,858],{"class":574},[247,3090,1772],{"class":280},[247,3092,3094,3096,3099],{"class":249,"line":3093},100,[247,3095,2028],{"class":280},[247,3097,3098],{"class":574},"))",[247,3100,1772],{"class":280},[247,3102,3104,3107,3109,3111,3113,3115,3117,3119,3121,3123,3125,3127],{"class":249,"line":3103},101,[247,3105,3106],{"class":574},"    topCategories",[247,3108,593],{"class":280},[247,3110,2200],{"class":270},[247,3112,1691],{"class":280},[247,3114,3018],{"class":842},[247,3116,1914],{"class":574},[247,3118,1914],{"class":280},[247,3120,3025],{"class":1917},[247,3122,858],{"class":280},[247,3124,1957],{"class":1904},[247,3126,3032],{"class":574},[247,3128,2873],{"class":280},[247,3130,3132,3134,3136],{"class":249,"line":3131},102,[247,3133,3040],{"class":280},[247,3135,3025],{"class":270},[247,3137,1772],{"class":280},[247,3139,3141,3143,3145,3147,3149,3151,3153,3155,3157],{"class":249,"line":3140},103,[247,3142,3050],{"class":574},[247,3144,593],{"class":280},[247,3146,2796],{"class":842},[247,3148,1914],{"class":574},[247,3150,3025],{"class":270},[247,3152,1691],{"class":280},[247,3154,3063],{"class":270},[247,3156,858],{"class":574},[247,3158,1772],{"class":280},[247,3160,3162,3164,3166,3168,3170,3172,3174,3176,3178],{"class":249,"line":3161},104,[247,3163,3073],{"class":574},[247,3165,593],{"class":280},[247,3167,2827],{"class":842},[247,3169,1914],{"class":574},[247,3171,3025],{"class":270},[247,3173,1691],{"class":280},[247,3175,3086],{"class":270},[247,3177,858],{"class":574},[247,3179,1772],{"class":280},[247,3181,3183,3185,3187],{"class":249,"line":3182},105,[247,3184,2028],{"class":280},[247,3186,3098],{"class":574},[247,3188,1772],{"class":280},[247,3190,3192,3195,3197,3199,3201,3203,3205,3207,3209,3211,3213,3215],{"class":249,"line":3191},106,[247,3193,3194],{"class":574},"    paymentMethods",[247,3196,593],{"class":280},[247,3198,2205],{"class":270},[247,3200,1691],{"class":280},[247,3202,3018],{"class":842},[247,3204,1914],{"class":574},[247,3206,1914],{"class":280},[247,3208,3025],{"class":1917},[247,3210,858],{"class":280},[247,3212,1957],{"class":1904},[247,3214,3032],{"class":574},[247,3216,2873],{"class":280},[247,3218,3220,3223,3225,3228,3230,3233],{"class":249,"line":3219},107,[247,3221,3222],{"class":574},"      method",[247,3224,593],{"class":280},[247,3226,3227],{"class":270}," r",[247,3229,1691],{"class":280},[247,3231,3232],{"class":270},"method",[247,3234,1772],{"class":280},[247,3236,3238,3241,3243,3245,3247,3249,3251,3253,3255],{"class":249,"line":3237},108,[247,3239,3240],{"class":574},"      count",[247,3242,593],{"class":280},[247,3244,2827],{"class":842},[247,3246,1914],{"class":574},[247,3248,3025],{"class":270},[247,3250,1691],{"class":280},[247,3252,2943],{"class":270},[247,3254,858],{"class":574},[247,3256,1772],{"class":280},[247,3258,3260,3263,3265,3267,3269,3271,3273,3276,3278],{"class":249,"line":3259},109,[247,3261,3262],{"class":574},"      amount",[247,3264,593],{"class":280},[247,3266,2796],{"class":842},[247,3268,1914],{"class":574},[247,3270,3025],{"class":270},[247,3272,1691],{"class":280},[247,3274,3275],{"class":270},"amount",[247,3277,858],{"class":574},[247,3279,1772],{"class":280},[247,3281,3283,3285,3287],{"class":249,"line":3282},110,[247,3284,2028],{"class":280},[247,3286,3098],{"class":574},[247,3288,1772],{"class":280},[247,3290,3292,3295,3297],{"class":249,"line":3291},111,[247,3293,3294],{"class":280},"  }",[247,3296,858],{"class":574},[247,3298,1867],{"class":280},[247,3300,3302],{"class":249,"line":3301},112,[247,3303,3304],{"class":280},"}\n",[438,3306,3307],{},"幾個值得注意的設計細節：",[174,3309,3310,3318,3326,3333],{},[177,3311,3312,3317],{},[180,3313,3314],{},[244,3315,3316],{},"Promise.all","：四支查詢並行發出，不等前一支結束才跑下一支",[177,3319,3320,3325],{},[180,3321,3322],{},[244,3323,3324],{},"FILTER (WHERE status IN (...))","：只統計有效訂單的營業額，排除取消訂單",[177,3327,3328,3332],{},[180,3329,3330],{},[244,3331,2868],{},"：Cron Job 重跑（例如補跑失敗的日期）時不會產生重複記錄",[177,3334,3335,3340,3341,3344],{},[180,3336,3337],{},[244,3338,3339],{},"toLocalDateStr","：手動格式化本地日期，避免 ",[244,3342,3343],{},"toISOString()"," 因時區偏移導致日期錯誤",[221,3346],{},[170,3348,3350],{"id":3349},"cron-job-排程","Cron Job 排程",[237,3352,3354],{"className":1659,"code":3353,"language":1661,"meta":242,"style":242},"import cron from \"node-cron\";\nimport { buildDailySnapshot } from \".\u002Fjobs\u002FdailySnapshot.js\";\n\n\u002F\u002F 每天凌晨 00:05 執行（留 5 分鐘緩衝確保跨日資料落庫）\ncron.schedule(\"5 0 * * *\", async () => {\n  await buildDailySnapshot();\n  console.log(\"[Snapshot] 昨日 snapshot 建立完成\");\n});\n",[244,3355,3356,3374,3395,3399,3404,3434,3444,3467],{"__ignoreMap":242},[247,3357,3358,3360,3363,3365,3367,3370,3372],{"class":249,"line":250},[247,3359,1851],{"class":1323},[247,3361,3362],{"class":270}," cron ",[247,3364,1857],{"class":1323},[247,3366,811],{"class":280},[247,3368,3369],{"class":257},"node-cron",[247,3371,281],{"class":280},[247,3373,1867],{"class":280},[247,3375,3376,3378,3380,3382,3384,3386,3388,3391,3393],{"class":249,"line":274},[247,3377,1851],{"class":1323},[247,3379,1874],{"class":280},[247,3381,1911],{"class":270},[247,3383,1880],{"class":280},[247,3385,1883],{"class":1323},[247,3387,811],{"class":280},[247,3389,3390],{"class":257},".\u002Fjobs\u002FdailySnapshot.js",[247,3392,281],{"class":280},[247,3394,1867],{"class":280},[247,3396,3397],{"class":249,"line":291},[247,3398,1023],{"emptyLinePlaceholder":127},[247,3400,3401],{"class":249,"line":305},[247,3402,3403],{"class":715},"\u002F\u002F 每天凌晨 00:05 執行（留 5 分鐘緩衝確保跨日資料落庫）\n",[247,3405,3406,3409,3411,3414,3416,3418,3421,3423,3425,3427,3430,3432],{"class":249,"line":319},[247,3407,3408],{"class":270},"cron",[247,3410,1691],{"class":280},[247,3412,3413],{"class":842},"schedule",[247,3415,1914],{"class":270},[247,3417,281],{"class":280},[247,3419,3420],{"class":257},"5 0 * * *",[247,3422,281],{"class":280},[247,3424,1697],{"class":280},[247,3426,1905],{"class":1904},[247,3428,3429],{"class":280}," ()",[247,3431,1957],{"class":1904},[247,3433,1678],{"class":280},[247,3435,3436,3438,3440,3442],{"class":249,"line":1051},[247,3437,2861],{"class":1323},[247,3439,1911],{"class":842},[247,3441,1954],{"class":574},[247,3443,1867],{"class":280},[247,3445,3446,3449,3451,3454,3456,3458,3461,3463,3465],{"class":249,"line":1063},[247,3447,3448],{"class":270},"  console",[247,3450,1691],{"class":280},[247,3452,3453],{"class":842},"log",[247,3455,1914],{"class":574},[247,3457,281],{"class":280},[247,3459,3460],{"class":257},"[Snapshot] 昨日 snapshot 建立完成",[247,3462,281],{"class":280},[247,3464,858],{"class":574},[247,3466,1867],{"class":280},[247,3468,3469,3472,3474],{"class":249,"line":1068},[247,3470,3471],{"class":280},"}",[247,3473,858],{"class":270},[247,3475,1867],{"class":280},[221,3477],{},[170,3479,3480],{"id":3480},"查詢方式",[438,3482,3483],{},"總覽 API 改成直接讀 snapshot，不再碰原始資料表：",[237,3485,3487],{"className":1659,"code":3486,"language":1661,"meta":242,"style":242},"\u002F\u002F ✅ 讀最新一筆 snapshot，毫秒級回應\nconst snapshot = await DailySnapshot.findOne({\n  order: [[\"date\", \"DESC\"]],\n});\n",[244,3488,3489,3494,3518,3547],{"__ignoreMap":242},[247,3490,3491],{"class":249,"line":250},[247,3492,3493],{"class":715},"\u002F\u002F ✅ 讀最新一筆 snapshot，毫秒級回應\n",[247,3495,3496,3499,3502,3504,3507,3509,3511,3514,3516],{"class":249,"line":274},[247,3497,3498],{"class":1904},"const",[247,3500,3501],{"class":270}," snapshot ",[247,3503,787],{"class":280},[247,3505,3506],{"class":1323}," await",[247,3508,1877],{"class":270},[247,3510,1691],{"class":280},[247,3512,3513],{"class":842},"findOne",[247,3515,1914],{"class":270},[247,3517,2873],{"class":280},[247,3519,3520,3523,3525,3527,3529,3531,3533,3535,3537,3540,3542,3545],{"class":249,"line":291},[247,3521,3522],{"class":574},"  order",[247,3524,593],{"class":280},[247,3526,2178],{"class":270},[247,3528,281],{"class":280},[247,3530,2054],{"class":257},[247,3532,281],{"class":280},[247,3534,1697],{"class":280},[247,3536,811],{"class":280},[247,3538,3539],{"class":257},"DESC",[247,3541,281],{"class":280},[247,3543,3544],{"class":270},"]]",[247,3546,1772],{"class":280},[247,3548,3549,3551,3553],{"class":249,"line":305},[247,3550,3471],{"class":280},[247,3552,858],{"class":270},[247,3554,1867],{"class":280},[221,3556],{},[170,3558,3560],{"id":3559},"訂單狀態會變動怎麼辦","訂單狀態會變動怎麼辦？",[438,3562,3563],{},"Snapshot 是某個時間點的快照，但訂單狀態會在那之後繼續變動，這是使用這個模式必須正視的問題。",[438,3565,3566],{},[180,3567,3568],{},"舉個例子：",[237,3570,3573],{"className":3571,"code":3572,"language":446},[444],"23:50  訂單建立，狀態 pending\n00:05  Cron Job 跑完 snapshot，這筆訂單未被計入營業額\n09:00  用戶付款，狀態變 paid\n",[244,3574,3572],{"__ignoreMap":242},[438,3576,3577],{},"昨天的 snapshot 永遠不會包含這筆訂單，數字就是錯的。",[227,3579,3581],{"id":3580},"解法一補跑近幾天的-snapshot","解法一：補跑近幾天的 Snapshot",[438,3583,3584],{},"每天除了算昨天，也重算過去 N 天，讓狀態更新能被追上：",[237,3586,3588],{"className":1659,"code":3587,"language":1661,"meta":242,"style":242},"\u002F\u002F 每天重算最近 7 天\ncron.schedule(\"5 0 * * *\", async () => {\n  for (let i = 1; i \u003C= 7; i++) {\n    const d = new Date();\n    d.setDate(d.getDate() - i);\n    await buildDailySnapshot(d);\n  }\n});\n",[244,3589,3590,3595,3621,3660,3677,3704,3718,3723],{"__ignoreMap":242},[247,3591,3592],{"class":249,"line":250},[247,3593,3594],{"class":715},"\u002F\u002F 每天重算最近 7 天\n",[247,3596,3597,3599,3601,3603,3605,3607,3609,3611,3613,3615,3617,3619],{"class":249,"line":274},[247,3598,3408],{"class":270},[247,3600,1691],{"class":280},[247,3602,3413],{"class":842},[247,3604,1914],{"class":270},[247,3606,281],{"class":280},[247,3608,3420],{"class":257},[247,3610,281],{"class":280},[247,3612,1697],{"class":280},[247,3614,1905],{"class":1904},[247,3616,3429],{"class":280},[247,3618,1957],{"class":1904},[247,3620,1678],{"class":280},[247,3622,3623,3626,3628,3631,3634,3636,3638,3641,3643,3646,3649,3651,3653,3656,3658],{"class":249,"line":291},[247,3624,3625],{"class":1323},"  for",[247,3627,3032],{"class":574},[247,3629,3630],{"class":1904},"let",[247,3632,3633],{"class":270}," i",[247,3635,1970],{"class":280},[247,3637,2010],{"class":2009},[247,3639,3640],{"class":280},";",[247,3642,3633],{"class":270},[247,3644,3645],{"class":280}," \u003C=",[247,3647,3648],{"class":2009}," 7",[247,3650,3640],{"class":280},[247,3652,3633],{"class":270},[247,3654,3655],{"class":280},"++",[247,3657,2807],{"class":574},[247,3659,2873],{"class":280},[247,3661,3662,3665,3667,3669,3671,3673,3675],{"class":249,"line":305},[247,3663,3664],{"class":1904},"    const",[247,3666,1967],{"class":270},[247,3668,1970],{"class":280},[247,3670,1973],{"class":280},[247,3672,1976],{"class":842},[247,3674,1954],{"class":574},[247,3676,1867],{"class":280},[247,3678,3679,3682,3684,3686,3688,3690,3692,3694,3696,3698,3700,3702],{"class":249,"line":319},[247,3680,3681],{"class":270},"    d",[247,3683,1691],{"class":280},[247,3685,1990],{"class":842},[247,3687,1914],{"class":574},[247,3689,1995],{"class":270},[247,3691,1691],{"class":280},[247,3693,2000],{"class":842},[247,3695,2003],{"class":574},[247,3697,2006],{"class":280},[247,3699,3633],{"class":270},[247,3701,858],{"class":574},[247,3703,1867],{"class":280},[247,3705,3706,3708,3710,3712,3714,3716],{"class":249,"line":1051},[247,3707,2215],{"class":1323},[247,3709,1911],{"class":842},[247,3711,1914],{"class":574},[247,3713,1995],{"class":270},[247,3715,858],{"class":574},[247,3717,1867],{"class":280},[247,3719,3720],{"class":249,"line":1063},[247,3721,3722],{"class":280},"  }\n",[247,3724,3725,3727,3729],{"class":249,"line":1068},[247,3726,3471],{"class":280},[247,3728,858],{"class":270},[247,3730,1867],{"class":280},[438,3732,3733,3734,3736],{},"適合狀態變動集中在近期（例如大多數訂單在 3 天內完成付款）的情境。",[244,3735,2868],{}," 的設計讓補跑變得安全，不會產生重複資料。",[227,3738,3740],{"id":3739},"解法二只快照終態訂單","解法二：只快照終態訂單",[438,3742,3743,3744,1619,3747,3750,3751,1619,3754,3757],{},"只把 ",[244,3745,3746],{},"delivered",[244,3748,3749],{},"cancelled"," 這類不會再變動的訂單算進 snapshot，",[244,3752,3753],{},"paid",[244,3755,3756],{},"shipped"," 等還在流動中的訂單留給即時查詢。",[438,3759,3760],{},"代價是 snapshot 數字會比實際交易日期滯後幾天，但每一筆都是確定的終態數字。",[227,3762,3764],{"id":3763},"解法三接受誤差","解法三：接受誤差",[438,3766,3767],{},"如果這個總覽是給內部看的管理報表，T+1 有些許誤差通常可以接受。業務決策不需要精確到每一筆，數字的趨勢比精確值更重要。",[221,3769],{},[438,3771,3772,3773,3776],{},"目前採用的是",[180,3774,3775],{},"解法一","，每天重算最近 7 天，在資料準確性與實作複雜度之間取得平衡。",[221,3778],{},[170,3780,3781],{"id":3781},"效果對比",[896,3783,3784,3797],{},[899,3785,3786],{},[902,3787,3788,3791,3794],{},[905,3789,3790],{},"指標",[905,3792,3793],{},"改善前",[905,3795,3796],{},"改善後",[912,3798,3799,3810,3821],{},[902,3800,3801,3804,3807],{},[917,3802,3803],{},"查詢時間",[917,3805,3806],{},"數秒",[917,3808,3809],{},"\u003C 10ms",[902,3811,3812,3815,3818],{},[917,3813,3814],{},"資料庫負載",[917,3816,3817],{},"每次請求跨表掃描",[917,3819,3820],{},"每日一次聚合",[902,3822,3823,3826,3829],{},[917,3824,3825],{},"資料即時性",[917,3827,3828],{},"即時",[917,3830,3831],{},"前一天結算準確",[221,3833],{},[170,3835,3836],{"id":3836},"適用場景",[438,3838,3839,3840,3843],{},"這個模式適合",[180,3841,3842],{},"讀多寫少、資料量大、對即時性要求不高","的統計需求，例如：",[174,3845,3846,3849,3852],{},[177,3847,3848],{},"管理後台的訂單、用戶、收入總覽",[177,3850,3851],{},"報表系統的歷史趨勢圖",[177,3853,3854],{},"定期推播給管理員的每日摘要",[1547,3856,3857],{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}",{"title":242,"searchDepth":274,"depth":274,"links":3859},[3860,3861,3862,3863,3864,3865,3866,3871,3872],{"id":1589,"depth":274,"text":1589},{"id":1634,"depth":274,"text":1635},{"id":1652,"depth":274,"text":1653},{"id":1837,"depth":274,"text":1838},{"id":3349,"depth":274,"text":3350},{"id":3480,"depth":274,"text":3480},{"id":3559,"depth":274,"text":3560,"children":3867},[3868,3869,3870],{"id":3580,"depth":291,"text":3581},{"id":3739,"depth":291,"text":3740},{"id":3763,"depth":291,"text":3764},{"id":3781,"depth":274,"text":3781},{"id":3836,"depth":274,"text":3836},"2026-03-10","資料量龐大導致查詢變慢，透過 Cron Job 將每日統計好的資料寫入 Snapshot Table，查詢不再需要跨表掃描改為單表讀取。","\u002Fimages\u002Fdaily-snapshot.jpg",{},{"title":10,"description":3874},"wWf7247tjW3NLW0rgckerIiTQ17DpIh6yqR3V0OJ3lE",{"id":3880,"title":22,"author":3881,"body":3883,"date":5046,"description":5047,"extension":1576,"externalUrl":43,"image":5048,"meta":5049,"minRead":1068,"navigation":127,"path":23,"seo":5050,"stem":24,"__hash__":5051},"blog\u002Farticles\u002Fsecurity-best-practices.md",{"name":163,"avatar":3882},{"src":165,"alt":163},{"type":167,"value":3884,"toc":5023},[3885,3888,3892,3895,3997,4002,4021,4025,4028,4059,4061,4064,4068,4071,4153,4157,4160,4210,4215,4221,4224,4280,4282,4286,4289,4334,4336,4339,4342,4379,4384,4399,4401,4405,4409,4412,4547,4551,4558,4663,4665,4668,4672,4768,4772,4775,4814,4816,4819,4822,4942,4947,4958,4960,4964,5017,5020],[170,3886,3887],{"id":3887},"身份驗證與授權",[227,3889,3891],{"id":3890},"使用-jwt-的注意事項","使用 JWT 的注意事項",[438,3893,3894],{},"JWT（JSON Web Token）是常見的無狀態驗證方案，但實作細節很容易踩坑。",[237,3896,3900],{"className":3897,"code":3898,"language":3899,"meta":242,"style":242},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u002F\u002F ❌ 錯誤：演算法設為 none，任何人都能偽造 token\njwt.verify(token, secret, { algorithms: ['none'] })\n\n\u002F\u002F ✅ 正確：明確指定演算法\njwt.verify(token, secret, { algorithms: ['HS256'] })\n","ts",[244,3901,3902,3907,3951,3955,3960],{"__ignoreMap":242},[247,3903,3904],{"class":249,"line":250},[247,3905,3906],{"class":715},"\u002F\u002F ❌ 錯誤：演算法設為 none，任何人都能偽造 token\n",[247,3908,3909,3912,3914,3917,3920,3922,3924,3926,3928,3931,3933,3935,3938,3941,3943,3946,3948],{"class":249,"line":274},[247,3910,3911],{"class":270},"jwt",[247,3913,1691],{"class":280},[247,3915,3916],{"class":842},"verify",[247,3918,3919],{"class":270},"(token",[247,3921,1697],{"class":280},[247,3923,261],{"class":270},[247,3925,1697],{"class":280},[247,3927,1874],{"class":280},[247,3929,3930],{"class":574}," algorithms",[247,3932,593],{"class":280},[247,3934,2187],{"class":270},[247,3936,3937],{"class":280},"'",[247,3939,3940],{"class":257},"none",[247,3942,3937],{"class":280},[247,3944,3945],{"class":270},"] ",[247,3947,3471],{"class":280},[247,3949,3950],{"class":270},")\n",[247,3952,3953],{"class":249,"line":291},[247,3954,1023],{"emptyLinePlaceholder":127},[247,3956,3957],{"class":249,"line":305},[247,3958,3959],{"class":715},"\u002F\u002F ✅ 正確：明確指定演算法\n",[247,3961,3962,3964,3966,3968,3970,3972,3974,3976,3978,3980,3982,3984,3986,3989,3991,3993,3995],{"class":249,"line":319},[247,3963,3911],{"class":270},[247,3965,1691],{"class":280},[247,3967,3916],{"class":842},[247,3969,3919],{"class":270},[247,3971,1697],{"class":280},[247,3973,261],{"class":270},[247,3975,1697],{"class":280},[247,3977,1874],{"class":280},[247,3979,3930],{"class":574},[247,3981,593],{"class":280},[247,3983,2187],{"class":270},[247,3985,3937],{"class":280},[247,3987,3988],{"class":257},"HS256",[247,3990,3937],{"class":280},[247,3992,3945],{"class":270},[247,3994,3471],{"class":280},[247,3996,3950],{"class":270},[438,3998,3999],{},[180,4000,4001],{},"常見錯誤：",[174,4003,4004,4015,4018],{},[177,4005,4006,4007,4010,4011,4014],{},"將 JWT 存在 ",[244,4008,4009],{},"localStorage","，容易被 XSS 竊取，應改用 ",[244,4012,4013],{},"httpOnly"," Cookie",[177,4016,4017],{},"Token 有效期設太長，應配合 Refresh Token 機制縮短 Access Token 壽命",[177,4019,4020],{},"沒有實作 Token 撤銷機制，登出後 token 仍然有效",[227,4022,4024],{"id":4023},"最小權限原則principle-of-least-privilege","最小權限原則（Principle of Least Privilege）",[438,4026,4027],{},"每個用戶、服務、資料庫帳號只給完成任務所需的最小權限。",[237,4029,4033],{"className":4030,"code":4031,"language":4032,"meta":242,"style":242},"language-sql shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","-- ❌ 給 API 帳號完整資料庫權限\nGRANT ALL PRIVILEGES ON *.* TO 'api_user'@'%';\n\n-- ✅ 只給必要的讀寫權限\nGRANT SELECT, INSERT, UPDATE ON app_db.orders TO 'api_user'@'localhost';\n","sql",[244,4034,4035,4040,4045,4049,4054],{"__ignoreMap":242},[247,4036,4037],{"class":249,"line":250},[247,4038,4039],{},"-- ❌ 給 API 帳號完整資料庫權限\n",[247,4041,4042],{"class":249,"line":274},[247,4043,4044],{},"GRANT ALL PRIVILEGES ON *.* TO 'api_user'@'%';\n",[247,4046,4047],{"class":249,"line":291},[247,4048,1023],{"emptyLinePlaceholder":127},[247,4050,4051],{"class":249,"line":305},[247,4052,4053],{},"-- ✅ 只給必要的讀寫權限\n",[247,4055,4056],{"class":249,"line":319},[247,4057,4058],{},"GRANT SELECT, INSERT, UPDATE ON app_db.orders TO 'api_user'@'localhost';\n",[221,4060],{},[170,4062,4063],{"id":4063},"輸入驗證與防注入",[227,4065,4067],{"id":4066},"sql-injection","SQL Injection",[438,4069,4070],{},"永遠不要用字串拼接組 SQL 查詢。",[237,4072,4074],{"className":3897,"code":4073,"language":3899,"meta":242,"style":242},"\u002F\u002F ❌ 危險：攻擊者輸入 ' OR '1'='1 即可繞過驗證\nconst query = `SELECT * FROM users WHERE email = '${email}'`\n\n\u002F\u002F ✅ 使用參數化查詢\nconst query = 'SELECT * FROM users WHERE email = $1'\nawait db.query(query, [email])\n",[244,4075,4076,4081,4109,4113,4118,4133],{"__ignoreMap":242},[247,4077,4078],{"class":249,"line":250},[247,4079,4080],{"class":715},"\u002F\u002F ❌ 危險：攻擊者輸入 ' OR '1'='1 即可繞過驗證\n",[247,4082,4083,4085,4088,4090,4093,4096,4099,4102,4104,4106],{"class":249,"line":274},[247,4084,3498],{"class":1904},[247,4086,4087],{"class":270}," query ",[247,4089,787],{"class":280},[247,4091,4092],{"class":280}," `",[247,4094,4095],{"class":257},"SELECT * FROM users WHERE email = '",[247,4097,4098],{"class":280},"${",[247,4100,4101],{"class":270},"email",[247,4103,3471],{"class":280},[247,4105,3937],{"class":257},[247,4107,4108],{"class":280},"`\n",[247,4110,4111],{"class":249,"line":291},[247,4112,1023],{"emptyLinePlaceholder":127},[247,4114,4115],{"class":249,"line":305},[247,4116,4117],{"class":715},"\u002F\u002F ✅ 使用參數化查詢\n",[247,4119,4120,4122,4124,4126,4128,4131],{"class":249,"line":319},[247,4121,3498],{"class":1904},[247,4123,4087],{"class":270},[247,4125,787],{"class":280},[247,4127,737],{"class":280},[247,4129,4130],{"class":257},"SELECT * FROM users WHERE email = $1",[247,4132,743],{"class":280},[247,4134,4135,4138,4141,4143,4145,4148,4150],{"class":249,"line":1051},[247,4136,4137],{"class":1323},"await",[247,4139,4140],{"class":270}," db",[247,4142,1691],{"class":280},[247,4144,2241],{"class":842},[247,4146,4147],{"class":270},"(query",[247,4149,1697],{"class":280},[247,4151,4152],{"class":270}," [email])\n",[227,4154,4156],{"id":4155},"xsscross-site-scripting","XSS（Cross-Site Scripting）",[438,4158,4159],{},"對所有用戶輸入進行跳脫處理，避免注入惡意腳本。",[237,4161,4163],{"className":3897,"code":4162,"language":3899,"meta":242,"style":242},"import DOMPurify from 'dompurify'\n\n\u002F\u002F ✅ 渲染用戶輸入的 HTML 前先清理\nconst clean = DOMPurify.sanitize(userInput)\n",[244,4164,4165,4181,4185,4190],{"__ignoreMap":242},[247,4166,4167,4169,4172,4174,4176,4179],{"class":249,"line":250},[247,4168,1851],{"class":1323},[247,4170,4171],{"class":270}," DOMPurify ",[247,4173,1857],{"class":1323},[247,4175,737],{"class":280},[247,4177,4178],{"class":257},"dompurify",[247,4180,743],{"class":280},[247,4182,4183],{"class":249,"line":274},[247,4184,1023],{"emptyLinePlaceholder":127},[247,4186,4187],{"class":249,"line":291},[247,4188,4189],{"class":715},"\u002F\u002F ✅ 渲染用戶輸入的 HTML 前先清理\n",[247,4191,4192,4194,4197,4199,4202,4204,4207],{"class":249,"line":305},[247,4193,3498],{"class":1904},[247,4195,4196],{"class":270}," clean ",[247,4198,787],{"class":280},[247,4200,4201],{"class":270}," DOMPurify",[247,4203,1691],{"class":280},[247,4205,4206],{"class":842},"sanitize",[247,4208,4209],{"class":270},"(userInput)\n",[438,4211,4212],{},[180,4213,4214],{},"HTTP Header 防護：",[237,4216,4219],{"className":4217,"code":4218,"language":446},[444],"Content-Security-Policy: default-src 'self'\nX-Content-Type-Options: nosniff\nX-Frame-Options: DENY\n",[244,4220,4218],{"__ignoreMap":242},[227,4222,4223],{"id":4223},"環境變數管理",[237,4225,4227],{"className":239,"code":4226,"language":241,"meta":242,"style":242},"# ❌ 絕對不能 commit 進 git\nDB_PASSWORD=mysecretpassword\nJWT_SECRET=abc123\n\n# ✅ 用 .env 並加入 .gitignore\necho \".env\" >> .gitignore\n",[244,4228,4229,4234,4244,4254,4258,4263],{"__ignoreMap":242},[247,4230,4231],{"class":249,"line":250},[247,4232,4233],{"class":715},"# ❌ 絕對不能 commit 進 git\n",[247,4235,4236,4239,4241],{"class":249,"line":274},[247,4237,4238],{"class":270},"DB_PASSWORD",[247,4240,787],{"class":280},[247,4242,4243],{"class":257},"mysecretpassword\n",[247,4245,4246,4249,4251],{"class":249,"line":291},[247,4247,4248],{"class":270},"JWT_SECRET",[247,4250,787],{"class":280},[247,4252,4253],{"class":257},"abc123\n",[247,4255,4256],{"class":249,"line":305},[247,4257,1023],{"emptyLinePlaceholder":127},[247,4259,4260],{"class":249,"line":319},[247,4261,4262],{"class":715},"# ✅ 用 .env 並加入 .gitignore\n",[247,4264,4265,4267,4269,4272,4274,4277],{"class":249,"line":1051},[247,4266,843],{"class":842},[247,4268,811],{"class":280},[247,4270,4271],{"class":257},".env",[247,4273,281],{"class":280},[247,4275,4276],{"class":280}," >>",[247,4278,4279],{"class":257}," .gitignore\n",[221,4281],{},[170,4283,4285],{"id":4284},"https-與資料傳輸","HTTPS 與資料傳輸",[438,4287,4288],{},"所有生產環境流量必須走 HTTPS，並確保 TLS 設定正確。",[237,4290,4294],{"className":4291,"code":4292,"language":4293,"meta":242,"style":242},"language-nginx shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","server {\n    listen 443 ssl;\n    ssl_protocols TLSv1.2 TLSv1.3;   # 停用舊版 TLS\n    ssl_ciphers HIGH:!aNULL:!MD5;\n\n    # HSTS：強制瀏覽器只走 HTTPS\n    add_header Strict-Transport-Security \"max-age=31536000\" always;\n}\n","nginx",[244,4295,4296,4301,4306,4311,4316,4320,4325,4330],{"__ignoreMap":242},[247,4297,4298],{"class":249,"line":250},[247,4299,4300],{},"server {\n",[247,4302,4303],{"class":249,"line":274},[247,4304,4305],{},"    listen 443 ssl;\n",[247,4307,4308],{"class":249,"line":291},[247,4309,4310],{},"    ssl_protocols TLSv1.2 TLSv1.3;   # 停用舊版 TLS\n",[247,4312,4313],{"class":249,"line":305},[247,4314,4315],{},"    ssl_ciphers HIGH:!aNULL:!MD5;\n",[247,4317,4318],{"class":249,"line":319},[247,4319,1023],{"emptyLinePlaceholder":127},[247,4321,4322],{"class":249,"line":1051},[247,4323,4324],{},"    # HSTS：強制瀏覽器只走 HTTPS\n",[247,4326,4327],{"class":249,"line":1063},[247,4328,4329],{},"    add_header Strict-Transport-Security \"max-age=31536000\" always;\n",[247,4331,4332],{"class":249,"line":1068},[247,4333,3304],{},[221,4335],{},[170,4337,4338],{"id":4338},"依賴管理",[438,4340,4341],{},"第三方套件是常見的攻擊入口，需要定期審查。",[237,4343,4345],{"className":239,"code":4344,"language":241,"meta":242,"style":242},"# 掃描已知漏洞\nnpm audit\n\n# 自動修復低風險漏洞\nnpm audit fix\n",[244,4346,4347,4352,4360,4364,4369],{"__ignoreMap":242},[247,4348,4349],{"class":249,"line":250},[247,4350,4351],{"class":715},"# 掃描已知漏洞\n",[247,4353,4354,4357],{"class":249,"line":274},[247,4355,4356],{"class":253},"npm",[247,4358,4359],{"class":257}," audit\n",[247,4361,4362],{"class":249,"line":291},[247,4363,1023],{"emptyLinePlaceholder":127},[247,4365,4366],{"class":249,"line":305},[247,4367,4368],{"class":715},"# 自動修復低風險漏洞\n",[247,4370,4371,4373,4376],{"class":249,"line":319},[247,4372,4356],{"class":253},[247,4374,4375],{"class":257}," audit",[247,4377,4378],{"class":257}," fix\n",[438,4380,4381],{},[180,4382,4383],{},"最佳實踐：",[174,4385,4386,4393,4396],{},[177,4387,4388,4389,4392],{},"鎖定套件版本（",[244,4390,4391],{},"package-lock.json"," 要 commit）",[177,4394,4395],{},"定期更新依賴，訂閱安全通報（如 GitHub Dependabot）",[177,4397,4398],{},"不引入不必要的套件，減少攻擊面",[221,4400],{},[170,4402,4404],{"id":4403},"api-安全","API 安全",[227,4406,4408],{"id":4407},"rate-limiting","Rate Limiting",[438,4410,4411],{},"防止暴力破解與 DDoS。",[237,4413,4415],{"className":3897,"code":4414,"language":3899,"meta":242,"style":242},"import rateLimit from 'express-rate-limit'\n\nconst loginLimiter = rateLimit({\n  windowMs: 15 * 60 * 1000, \u002F\u002F 15 分鐘\n  max: 10,                   \u002F\u002F 最多 10 次嘗試\n  message: '嘗試次數過多，請稍後再試'\n})\n\napp.post('\u002Fauth\u002Flogin', loginLimiter, loginHandler)\n",[244,4416,4417,4433,4437,4453,4479,4494,4508,4514,4518],{"__ignoreMap":242},[247,4418,4419,4421,4424,4426,4428,4431],{"class":249,"line":250},[247,4420,1851],{"class":1323},[247,4422,4423],{"class":270}," rateLimit ",[247,4425,1857],{"class":1323},[247,4427,737],{"class":280},[247,4429,4430],{"class":257},"express-rate-limit",[247,4432,743],{"class":280},[247,4434,4435],{"class":249,"line":274},[247,4436,1023],{"emptyLinePlaceholder":127},[247,4438,4439,4441,4444,4446,4449,4451],{"class":249,"line":291},[247,4440,3498],{"class":1904},[247,4442,4443],{"class":270}," loginLimiter ",[247,4445,787],{"class":280},[247,4447,4448],{"class":842}," rateLimit",[247,4450,1914],{"class":270},[247,4452,2873],{"class":280},[247,4454,4455,4458,4460,4463,4466,4469,4471,4474,4476],{"class":249,"line":305},[247,4456,4457],{"class":574},"  windowMs",[247,4459,593],{"class":280},[247,4461,4462],{"class":2009}," 15",[247,4464,4465],{"class":280}," *",[247,4467,4468],{"class":2009}," 60",[247,4470,4465],{"class":280},[247,4472,4473],{"class":2009}," 1000",[247,4475,1697],{"class":280},[247,4477,4478],{"class":715}," \u002F\u002F 15 分鐘\n",[247,4480,4481,4484,4486,4489,4491],{"class":249,"line":319},[247,4482,4483],{"class":574},"  max",[247,4485,593],{"class":280},[247,4487,4488],{"class":2009}," 10",[247,4490,1697],{"class":280},[247,4492,4493],{"class":715},"                   \u002F\u002F 最多 10 次嘗試\n",[247,4495,4496,4499,4501,4503,4506],{"class":249,"line":1051},[247,4497,4498],{"class":574},"  message",[247,4500,593],{"class":280},[247,4502,737],{"class":280},[247,4504,4505],{"class":257},"嘗試次數過多，請稍後再試",[247,4507,743],{"class":280},[247,4509,4510,4512],{"class":249,"line":1063},[247,4511,3471],{"class":280},[247,4513,3950],{"class":270},[247,4515,4516],{"class":249,"line":1068},[247,4517,1023],{"emptyLinePlaceholder":127},[247,4519,4520,4523,4525,4528,4530,4532,4535,4537,4539,4542,4544],{"class":249,"line":1081},[247,4521,4522],{"class":270},"app",[247,4524,1691],{"class":280},[247,4526,4527],{"class":842},"post",[247,4529,1914],{"class":270},[247,4531,3937],{"class":280},[247,4533,4534],{"class":257},"\u002Fauth\u002Flogin",[247,4536,3937],{"class":280},[247,4538,1697],{"class":280},[247,4540,4541],{"class":270}," loginLimiter",[247,4543,1697],{"class":280},[247,4545,4546],{"class":270}," loginHandler)\n",[227,4548,4550],{"id":4549},"cors-設定","CORS 設定",[438,4552,4553,4554,4557],{},"不要用 ",[244,4555,4556],{},"*"," 允許所有來源。",[237,4559,4561],{"className":3897,"code":4560,"language":3899,"meta":242,"style":242},"\u002F\u002F ❌ 允許所有來源\napp.use(cors({ origin: '*' }))\n\n\u002F\u002F ✅ 明確指定允許的來源\napp.use(cors({\n  origin: ['https:\u002F\u002Fyourdomain.com'],\n  credentials: true\n}))\n",[244,4562,4563,4568,4602,4606,4611,4627,4647,4657],{"__ignoreMap":242},[247,4564,4565],{"class":249,"line":250},[247,4566,4567],{"class":715},"\u002F\u002F ❌ 允許所有來源\n",[247,4569,4570,4572,4574,4577,4579,4582,4584,4586,4589,4591,4593,4595,4597,4599],{"class":249,"line":274},[247,4571,4522],{"class":270},[247,4573,1691],{"class":280},[247,4575,4576],{"class":842},"use",[247,4578,1914],{"class":270},[247,4580,4581],{"class":842},"cors",[247,4583,1914],{"class":270},[247,4585,814],{"class":280},[247,4587,4588],{"class":574}," origin",[247,4590,593],{"class":280},[247,4592,737],{"class":280},[247,4594,4556],{"class":257},[247,4596,3937],{"class":280},[247,4598,1880],{"class":280},[247,4600,4601],{"class":270},"))\n",[247,4603,4604],{"class":249,"line":291},[247,4605,1023],{"emptyLinePlaceholder":127},[247,4607,4608],{"class":249,"line":305},[247,4609,4610],{"class":715},"\u002F\u002F ✅ 明確指定允許的來源\n",[247,4612,4613,4615,4617,4619,4621,4623,4625],{"class":249,"line":319},[247,4614,4522],{"class":270},[247,4616,1691],{"class":280},[247,4618,4576],{"class":842},[247,4620,1914],{"class":270},[247,4622,4581],{"class":842},[247,4624,1914],{"class":270},[247,4626,2873],{"class":280},[247,4628,4629,4632,4634,4636,4638,4641,4643,4645],{"class":249,"line":1051},[247,4630,4631],{"class":574},"  origin",[247,4633,593],{"class":280},[247,4635,2187],{"class":270},[247,4637,3937],{"class":280},[247,4639,4640],{"class":257},"https:\u002F\u002Fyourdomain.com",[247,4642,3937],{"class":280},[247,4644,2208],{"class":270},[247,4646,1772],{"class":280},[247,4648,4649,4652,4654],{"class":249,"line":1063},[247,4650,4651],{"class":574},"  credentials",[247,4653,593],{"class":280},[247,4655,4656],{"class":1172}," true\n",[247,4658,4659,4661],{"class":249,"line":1068},[247,4660,3471],{"class":280},[247,4662,4601],{"class":270},[221,4664],{},[170,4666,4667],{"id":4667},"基礎設施安全",[227,4669,4671],{"id":4670},"kubernetes-gke","Kubernetes \u002F GKE",[237,4673,4675],{"className":565,"code":4674,"language":567,"meta":242,"style":242},"# ✅ 不以 root 身份執行容器\nsecurityContext:\n  runAsNonRoot: true\n  runAsUser: 1000\n  readOnlyRootFilesystem: true\n\n# ✅ 限制資源，防止單一 Pod 吃光資源\nresources:\n  limits:\n    cpu: \"500m\"\n    memory: \"256Mi\"\n",[244,4676,4677,4682,4689,4698,4708,4717,4721,4726,4733,4740,4754],{"__ignoreMap":242},[247,4678,4679],{"class":249,"line":250},[247,4680,4681],{"class":715},"# ✅ 不以 root 身份執行容器\n",[247,4683,4684,4687],{"class":249,"line":274},[247,4685,4686],{"class":574},"securityContext",[247,4688,578],{"class":280},[247,4690,4691,4694,4696],{"class":249,"line":291},[247,4692,4693],{"class":574},"  runAsNonRoot",[247,4695,593],{"class":280},[247,4697,4656],{"class":1172},[247,4699,4700,4703,4705],{"class":249,"line":305},[247,4701,4702],{"class":574},"  runAsUser",[247,4704,593],{"class":280},[247,4706,4707],{"class":2009}," 1000\n",[247,4709,4710,4713,4715],{"class":249,"line":319},[247,4711,4712],{"class":574},"  readOnlyRootFilesystem",[247,4714,593],{"class":280},[247,4716,4656],{"class":1172},[247,4718,4719],{"class":249,"line":1051},[247,4720,1023],{"emptyLinePlaceholder":127},[247,4722,4723],{"class":249,"line":1063},[247,4724,4725],{"class":715},"# ✅ 限制資源，防止單一 Pod 吃光資源\n",[247,4727,4728,4731],{"class":249,"line":1068},[247,4729,4730],{"class":574},"resources",[247,4732,578],{"class":280},[247,4734,4735,4738],{"class":249,"line":1081},[247,4736,4737],{"class":574},"  limits",[247,4739,578],{"class":280},[247,4741,4742,4745,4747,4749,4752],{"class":249,"line":1094},[247,4743,4744],{"class":574},"    cpu",[247,4746,593],{"class":280},[247,4748,811],{"class":280},[247,4750,4751],{"class":257},"500m",[247,4753,329],{"class":280},[247,4755,4756,4759,4761,4763,4766],{"class":249,"line":1106},[247,4757,4758],{"class":574},"    memory",[247,4760,593],{"class":280},[247,4762,811],{"class":280},[247,4764,4765],{"class":257},"256Mi",[247,4767,329],{"class":280},[227,4769,4771],{"id":4770},"secret-管理","Secret 管理",[438,4773,4774],{},"不要把 secret 直接寫在 YAML 裡。",[237,4776,4778],{"className":239,"code":4777,"language":241,"meta":242,"style":242},"# ✅ 使用 Kubernetes Secret\nkubectl create secret generic db-secret \\\n  --from-literal=password=mysecretpassword\n\n# 或使用 Google Secret Manager \u002F AWS Secrets Manager\n",[244,4779,4780,4785,4800,4805,4809],{"__ignoreMap":242},[247,4781,4782],{"class":249,"line":250},[247,4783,4784],{"class":715},"# ✅ 使用 Kubernetes Secret\n",[247,4786,4787,4789,4791,4793,4795,4798],{"class":249,"line":274},[247,4788,254],{"class":253},[247,4790,258],{"class":257},[247,4792,261],{"class":257},[247,4794,264],{"class":257},[247,4796,4797],{"class":257}," db-secret",[247,4799,271],{"class":270},[247,4801,4802],{"class":249,"line":291},[247,4803,4804],{"class":257},"  --from-literal=password=mysecretpassword\n",[247,4806,4807],{"class":249,"line":305},[247,4808,1023],{"emptyLinePlaceholder":127},[247,4810,4811],{"class":249,"line":319},[247,4812,4813],{"class":715},"# 或使用 Google Secret Manager \u002F AWS Secrets Manager\n",[221,4815],{},[170,4817,4818],{"id":4818},"日誌與監控",[438,4820,4821],{},"記錄足夠的資訊幫助事後調查，但避免記錄敏感資料。",[237,4823,4825],{"className":3897,"code":4824,"language":3899,"meta":242,"style":242},"\u002F\u002F ❌ 日誌包含密碼\nlogger.info(`Login attempt: ${email} \u002F ${password}`)\n\n\u002F\u002F ✅ 只記錄必要資訊\nlogger.info(`Login attempt: ${email}`, { ip: req.ip, userAgent: req.headers['user-agent'] })\n",[244,4826,4827,4832,4869,4873,4878],{"__ignoreMap":242},[247,4828,4829],{"class":249,"line":250},[247,4830,4831],{"class":715},"\u002F\u002F ❌ 日誌包含密碼\n",[247,4833,4834,4837,4839,4842,4844,4847,4850,4852,4854,4856,4859,4861,4864,4867],{"class":249,"line":274},[247,4835,4836],{"class":270},"logger",[247,4838,1691],{"class":280},[247,4840,4841],{"class":842},"info",[247,4843,1914],{"class":270},[247,4845,4846],{"class":280},"`",[247,4848,4849],{"class":257},"Login attempt: ",[247,4851,4098],{"class":280},[247,4853,4101],{"class":270},[247,4855,3471],{"class":280},[247,4857,4858],{"class":257}," \u002F ",[247,4860,4098],{"class":280},[247,4862,4863],{"class":270},"password",[247,4865,4866],{"class":280},"}`",[247,4868,3950],{"class":270},[247,4870,4871],{"class":249,"line":291},[247,4872,1023],{"emptyLinePlaceholder":127},[247,4874,4875],{"class":249,"line":305},[247,4876,4877],{"class":715},"\u002F\u002F ✅ 只記錄必要資訊\n",[247,4879,4880,4882,4884,4886,4888,4890,4892,4894,4896,4898,4900,4902,4905,4907,4910,4912,4915,4917,4920,4922,4924,4926,4929,4931,4934,4936,4938,4940],{"class":249,"line":319},[247,4881,4836],{"class":270},[247,4883,1691],{"class":280},[247,4885,4841],{"class":842},[247,4887,1914],{"class":270},[247,4889,4846],{"class":280},[247,4891,4849],{"class":257},[247,4893,4098],{"class":280},[247,4895,4101],{"class":270},[247,4897,4866],{"class":280},[247,4899,1697],{"class":280},[247,4901,1874],{"class":280},[247,4903,4904],{"class":574}," ip",[247,4906,593],{"class":280},[247,4908,4909],{"class":270}," req",[247,4911,1691],{"class":280},[247,4913,4914],{"class":270},"ip",[247,4916,1697],{"class":280},[247,4918,4919],{"class":574}," userAgent",[247,4921,593],{"class":280},[247,4923,4909],{"class":270},[247,4925,1691],{"class":280},[247,4927,4928],{"class":270},"headers[",[247,4930,3937],{"class":280},[247,4932,4933],{"class":257},"user-agent",[247,4935,3937],{"class":280},[247,4937,3945],{"class":270},[247,4939,3471],{"class":280},[247,4941,3950],{"class":270},[438,4943,4944],{},[180,4945,4946],{},"應該監控的指標：",[174,4948,4949,4952,4955],{},[177,4950,4951],{},"異常登入失敗次數",[177,4953,4954],{},"非預期的 API 錯誤率上升",[177,4956,4957],{},"非工作時間的大量資料存取",[221,4959],{},[170,4961,4963],{"id":4962},"安全開發流程devsecops","安全開發流程（DevSecOps）",[896,4965,4966,4976],{},[899,4967,4968],{},[902,4969,4970,4973],{},[905,4971,4972],{},"階段",[905,4974,4975],{},"實踐",[912,4977,4978,4986,5001,5009],{},[902,4979,4980,4983],{},[917,4981,4982],{},"開發",[917,4984,4985],{},"Code Review、靜態分析（ESLint security rules）",[902,4987,4988,4991],{},[917,4989,4990],{},"CI\u002FCD",[917,4992,4993,4994,1619,4997,5000],{},"自動化掃描（",[244,4995,4996],{},"npm audit",[244,4998,4999],{},"trivy"," 掃 Docker image）",[902,5002,5003,5006],{},[917,5004,5005],{},"部署",[917,5007,5008],{},"最小權限、Secret Manager、網路隔離",[902,5010,5011,5014],{},[917,5012,5013],{},"運營",[917,5015,5016],{},"日誌監控、定期滲透測試、漏洞回報機制",[438,5018,5019],{},"資安不是一次性的工作，而是需要在整個開發流程中持續落實的文化。",[1547,5021,5022],{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}",{"title":242,"searchDepth":274,"depth":274,"links":5024},[5025,5029,5034,5035,5036,5040,5044,5045],{"id":3887,"depth":274,"text":3887,"children":5026},[5027,5028],{"id":3890,"depth":291,"text":3891},{"id":4023,"depth":291,"text":4024},{"id":4063,"depth":274,"text":4063,"children":5030},[5031,5032,5033],{"id":4066,"depth":291,"text":4067},{"id":4155,"depth":291,"text":4156},{"id":4223,"depth":291,"text":4223},{"id":4284,"depth":274,"text":4285},{"id":4338,"depth":274,"text":4338},{"id":4403,"depth":274,"text":4404,"children":5037},[5038,5039],{"id":4407,"depth":291,"text":4408},{"id":4549,"depth":291,"text":4550},{"id":4667,"depth":274,"text":4667,"children":5041},[5042,5043],{"id":4670,"depth":291,"text":4671},{"id":4770,"depth":291,"text":4771},{"id":4818,"depth":274,"text":4818},{"id":4962,"depth":274,"text":4963},"2025-12-05","整理日常開發中常用的資安觀念與實踐方式。","\u002Fimages\u002Fsecurity.jpg",{},{"title":22,"description":5047},"zWX3ru6e2VKC3sHo1ft7LY1UaT_FOrgnGi5WvJmxyJU",{"data":5053,"body":5054},{},{"type":5055,"children":5056},"root",[5057],{"type":5058,"tag":438,"props":5059,"children":5060},"element",{},[5061],{"type":446,"value":5062},"我專精於後端工程與雲端基礎設施。包含設計與建置 REST\u002FgRPC API、分散式系統架構、資料庫 schema 設計與優化、Kubernetes 叢集建置與維運、CI\u002FCD 流程自動化，以及為擴展後端的團隊提供技術顧問服務。",{"data":5064,"body":5065},{},{"type":5055,"children":5066},[5067],{"type":5058,"tag":438,"props":5068,"children":5069},{},[5070],{"type":446,"value":5071},"我從需求收集與系統設計開始，在寫下第一行程式碼之前先產出架構文件。接著以小而經過充分測試的增量進行迭代——整合測試、壓力測試，以及從第一天起就內建的可觀測性。我與產品和前端團隊密切合作，確保介面乾淨且有版本管理。",{"data":5073,"body":5074},{},{"type":5055,"children":5075},[5076],{"type":5058,"tag":438,"props":5077,"children":5078},{},[5079],{"type":446,"value":5080},"當然。早期新創公司能從務實的架構選擇中受益，這些選擇可以擴展而不需要完全重寫。我幫助團隊快速推進，同時不累積沉重的技術債。",1782321731779]