我們的目標:打造一個超穩的圖片上傳系統!先讓圖能乖乖存在我們電腦裡,未來就算想搬家到 S3 這種超大的雲端倉庫,也只要換個零件就好,程式碼不用大改
這次我們要做甚麼功能?
簡單講,我們要蓋一個「圖片收發中心」,它有以下幾個基本規矩:
櫃檯服務:後台要有一個漂亮的「上傳圖片」頁面,讓你可以選圖、上傳。
檔案警衛:只收 JPEG、PNG、WebP 這三種「良民圖」,而且每張圖的體重不能超過 5MB(不然伺服器會被撐爆啦!)。
檔名變身術:把大家亂取的檔名(像 我家貓咪.jpg)通通換成一串獨一無二的 UUID(就像發給它一張永久身分證),這樣絕對不會跟別人的圖「撞衫」!
封面設定:把這張圖的網址,寫到文章的資料庫裡,它就是這篇文章的**「封面擔當」**。
未來換引擎:我們訂一個 Storage 介面(想像成一個萬用規格書)。現在先用本機儲存 (LocalStorage) 應急,以後想換 S3?免驚!換個符合規格書的「引擎」就好!
go-echo-blog/
├─ cmd/server/main.go # 掛 Body 限制、注入 Storage
├─ internal/http/handlers/
│ ├─ admin_uploads.go # 後台 UI:上傳頁/表單
│ ├─ api_uploads.go # 後台 API:處理上傳(受 Session 保護)
│ └─ admin_posts.go # 文章表單支援選擇封面
├─ internal/storage/
│ ├─ storage.go # 介面:Save/URL/Delete
│ └─ local/local.go # 本機儲存實作
├─ internal/storage/util/safe_name.go # 產生安全檔名(UUID + 副檔名)
├─ internal/storage/types.go # 媒體描述型別
├─ migrations/
│ └─ 20251010_add_cover_image.sql # 新增 posts.cover_image 欄位
├─ web/templates/pages/
│ ├─ admin_uploads.html # 上傳頁面
│ └─ admin_posts_form_partial.html # 新增「封面 URL」欄位
└─ .env.example # UPLOAD_DIR、MAX_UPLOAD_MB
# 檔案會存到這個資料夾(跑 server 的機器上)
UPLOAD_DIR=./uploads
# 檔案大小上限(MB)
MAX_UPLOAD_MB=5
migrations/20251010_add_cover_image.sql
-- +goose Up
ALTER TABLE posts ADD COLUMN IF NOT EXISTS cover_image TEXT;
-- +goose Down
ALTER TABLE posts DROP COLUMN IF EXISTS cover_image;
若你之前就有
cover_image
,這段會自動略過(IF NOT EXISTS)。
internal/storage/storage.go 說:
「不管你是誰,你都要會這三招!」
internal/storage/storage.go
package storage
import "context"
type ObjectInfo struct {
Name string // 儲存後的檔名(不含路徑)
URL string // 對外可取用的 URL(本機可用 /uploads/...)
Size int64
MIME string
}
type Storage interface {
Save(ctx context.Context, filename string, data []byte, mime string) (ObjectInfo, error)
Delete(ctx context.Context, filename string) error
URL(ctx context.Context, filename string) (string, error)
}
internal/storage/types.go
package storage
var AllowedMIMEs = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/webp": true,
}
internal/storage/util/safe_name.go
package util
import (
"path/filepath"
"strings"
"github.com/google/uuid"
)
// SafeFileName 以 UUID 生成安全檔名,並保留合法副檔名
func SafeFileName(orig string) string {
ext := strings.ToLower(filepath.Ext(orig))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp":
default:
ext = ".jpg" // 預設給 .jpg
}
return uuid.NewString() + ext
}
然後,為了檔名安全和副檔名檢查,我們還有兩位小幫手:
safe_name.go (檔名身分證產生器):把 我的可愛貓咪.jpg 變成 9527e02e-c75c-4a37-8b01-f8a425f38a5b.jpg,保證不撞名!
types.go (圖片種類檢查員):它只讓 .jpg, .png, .webp 的圖進來,其他像是 .exe 或奇怪的檔案? 「歹勢,請回!」
對外提供檔案:記得在
cmd/server/main.go
把/uploads
靜態出來。internal/http/handlers/api_uploads.go
func (s *LocalStorage) Save(...) (...) {
// 1. 先確認資料夾有沒有,沒有就建一個。
// 2. 接著把檔案寫進去。
// 3. 算出它的公開網址,例如 /uploads/9527.jpg
// 4. 回傳成功!
}
// ... Delete 和 URL 也很簡單,就是刪除檔案和組合網址而已。
⚠️ 超重要! 圖片存在電腦裡,但網頁看不到啊!所以要在 cmd/server/main.go 裡設定:只要有人連到 /uploads 這個網址,就去我們本機的 UPLOAD_DIR 資料夾裡撈圖給他看!這就是「靜態檔案服務」。
internal/http/handlers/api_uploads.go 裡的 Post 函式就是它的工作流程:
📥 收件!:先確認你是不是真的有選圖,有沒有叫 file 這個名字。
⚖️ 量體重!:檢查這張圖有沒有超過 5 MB(MAX_UPLOAD_MB)。「唉唷,夭壽喔!太重了!」就直接拒收!
🔍 驗身分!:檢查檔案的 MIME(像是它偷偷藏在檔案頭裡的真實身分證),確認它是我們允許的 JPEG/PNG/WebP 嗎?「你不是圖?掰掰!」
🪪 發身分證!:把原來的檔名丟掉,用 UUID 重新取一個獨一無二的安全檔名。
📦 送入庫!:呼叫 Store.Save(),把圖交給我們的「小幫手」(目前是 LocalStorage)存起來。
📬 回報!:成功後,把圖片的新檔名和網址回傳給前端頁面。
internal/http/handlers/api_uploads.go
package handlers
import (
"bytes"
"io"
"net/http"
"os"
"strconv"
"your/module/internal/storage"
"your/module/internal/storage/util"
"github.com/labstack/echo/v4"
)
type UploadsHandler struct {
Store storage.Storage
MaxMB int64
}
func NewUploadsHandler(store storage.Storage) *UploadsHandler {
maxMB, _ := strconv.ParseInt(os.Getenv("MAX_UPLOAD_MB"), 10, 64)
if maxMB <= 0 { maxMB = 5 }
return &UploadsHandler{Store: store, MaxMB: maxMB}
}
func (h *UploadsHandler) Post(c echo.Context) error {
fh, err := c.FormFile("file")
if err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{"error": "沒有收到檔案 field: file"})
}
if fh.Size > h.MaxMB*1024*1024 {
return c.JSON(http.StatusRequestEntityTooLarge, echo.Map{"error": "檔案太大"})
}
f, err := fh.Open()
if err != nil { return c.JSON(http.StatusBadRequest, echo.Map{"error": "檔案讀取失敗"}) }
defer f.Close()
var buf bytes.Buffer
if _, err := io.Copy(&buf, f); err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "上傳失敗"})
}
data := buf.Bytes()
mime := http.DetectContentType(data[:min(512, len(data))])
if !storage.AllowedMIMEs[mime] {
return c.JSON(http.StatusBadRequest, echo.Map{"error": "只支援 JPEG/PNG/WebP"})
}
safe := util.SafeFileName(fh.Filename)
obj, err := h.Store.Save(c.Request().Context(), safe, data, mime)
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "儲存失敗"})
}
return c.JSON(http.StatusOK, echo.Map{"filename": obj.Name, "url": obj.URL, "size": obj.Size, "mime": obj.MIME})
}
func min(a, b int) int { if a < b { return a }; return b }
選圖:<input type="file" ...>
按鈕:上傳
JavaScript:按下按鈕後,會把圖片包進 FormData 裡,然後 fetch 給我們第 6 點做的 API 櫃檯。成功就會在 裡看到回傳的 JSON 資訊!
web/templates/pages/admin_uploads.html
{{ define "content" }}
<h1 class="text-xl font-bold mb-4">上傳圖片</h1>
<form id="upload-form" class="space-y-4">
<input type="file" id="file" name="file" accept="image/*" class="border p-2">
<button type="button" id="btn" class="bg-black text-white px-4 py-2 rounded">上傳</button>
</form>
<pre id="result" class="mt-4 text-xs bg-gray-100 p-2 rounded"></pre>
<script>
document.getElementById('btn').addEventListener('click', async () => {
const f = document.getElementById('file').files[0];
if (!f) return alert('請選擇一張圖片');
const fd = new FormData();
fd.append('file', f);
const res = await fetch('/api/admin/uploads', { method: 'POST', body: fd, credentials: 'include' });
document.getElementById('result').textContent = JSON.stringify(await res.json(), null, 2);
});
</script>
{{ end }}
後台:文章編輯表單多一個 封面 URL 的 欄位。
前台:文章顯示頁 (post_show.html) 看到 cover_image 有東西,就用 把它秀出來,當作文章封面!
web/templates/pages/admin_posts_form_partial.html
<label class="block text-sm mb-2">封面 URL</label>
<input type="text" name="cover_image" value="{{ .post.CoverImage }}" class="border p-2 w-full" placeholder="/uploads/xxx.jpg">
web/templates/pages/post_show.html
{{ if .post.CoverImage }}
<img src="{{ .post.CoverImage }}" alt="cover" class="mb-4 w-full max-h-96 object-cover rounded">
{{ end }}
cmd/server/main.go
(節錄)
// …前略
uploadDir := os.Getenv("UPLOAD_DIR")
if uploadDir == "" { uploadDir = "./uploads" }
e.Static("/uploads", uploadDir)
store := local.New(uploadDir, "/uploads")
up := handlers.NewUploadsHandler(store)
e.POST("/api/admin/uploads", up.Post)
e.GET("/admin/uploads/new", adminUploads.New)
// …後略
10.未來要換 S3?
你可能會問:「搞那麼複雜幹嘛?」
因為我們只換「輪胎」,不用換「車」!
未來某天,老闆說:「流量爆炸了!本機存圖快掛了,全部給我搬到 S3 雲端倉庫!」
你只要:
寫一個 s3.go,它也要符合我們訂的 Storage 規格書 (會 Save, Delete, URL)。
修改 cmd/server/main.go 裡,第 9 點的第 2 步驟,讓程式跑起來時,用 S3 取代 local.New(...)!
💥 狀況一:網站回傳 413 Request Entity Too Large
意思是什麼? 你的檔案太大了,大到伺服器都嚇一跳,直接拒絕接收你的請求!
你該怎麼辦?
先去調整門檻: 檢查 .env 檔案,把 MAX_UPLOAD_MB 的值調大一點。
不然就減肥: 把你的圖片用軟體或網站先壓縮、縮小一點再傳!
💥 狀況二:網站回傳 415 Unsupported Media Type 或 400 Bad Request
意思是什麼?
415:你傳的檔案「身分不對」,不是我們說好的 JPEG/PNG/WebP。你是不是想偷傳 .exe 或 .zip?
400:你根本沒選圖,或傳送表單時「file」這個欄位名字寫錯了!
你該怎麼辦?
檢查檔案: 確定你傳的是圖片,不要亂塞奇怪格式的東西!
檢查欄位: 回頭看 api_uploads.go,是不是有正確的 c.FormFile("file") 在接收?
💥 狀況三:上傳成功了,但文章頁面圖片顯示 404 Not Found
意思是什麼? 圖片有存到你的電腦裡,但網頁瀏覽器找不到它!
你該怎麼辦?
檢查靜態服務: 這個最重要!你是不是忘了在 cmd/server/main.go 裡,告訴網站:「只要有人連 /uploads 這個網址,就去我硬碟的 ./uploads 資料夾撈圖?」請確定你有寫 e.Static("/uploads", uploadDir) 這一行!
檢查網址: 看看資料庫存的 cover_image 網址對不對?是不是 /uploads/xxx.jpg?開頭的 / 有沒有漏掉?
💥 狀況四:資料庫有網址,但前台文章沒顯示封面
意思是什麼? 圖片都存好了,網址也寫進資料庫了,但網頁模板沒寫好。
你該怎麼辦?
檢查模板: 回頭看 post_show.html,你是不是有正確寫上 {{ if .post.CoverImage }} ... 這些判斷式?如果資料庫欄位是空的,它當然不會顯示出來!