實作教學:使用 Azure Blob Storage 上傳附件檔案

讀者對象:具備基本 Go 後端與 React 前端開發經驗的 Web 工程師。
技術棧:Go + sqlx (MSSQL) + React + TypeScript + Mantine UI + Azure Blob Storage
完成目標:在後端 API 接收檔案上傳、儲存至 Azure Blob Storage,並在前端提供拖放介面讓使用者上傳、列出、刪除附件,最後在公開頁面顯示可下載連結。


目錄

  1. 架構概覽
  2. Azure 前置設定
  3. 後端實作
  1. 前端實作
  1. 效能考量:N+1 查詢問題
  2. 部署至 Azure Container Apps
  3. 常見問題與除錯

架構概覽

使用者瀏覽器
   │
   ├─ 管理介面 (React)
   │      │  POST multipart/form-data
   │      ▼
   │   後端 API (Go)
   │      │  azure-sdk-for-go
   │      ▼
   │   Azure Blob Storage ← 實際儲存二進位檔案
   │      │  公開 blob_url
   │      ▼
   └─ 公開頁面 (React)  直接使用 blob_url 提供下載連結
          │
          ▼
       資料庫 (MSSQL)  只儲存中繼資料 (file_name, blob_url, content_type, file_size)

設計原則:

  • 後端負責「鑑權 → 上傳 Blob → 寫入 DB 中繼資料」這三步的原子化處理
  • 資料庫只存 URL 和中繼資料,不存二進位內容
  • Blob 容器設定為公開讀取(Blob 層級),讓前端可直接用 URL 下載,不需要後端代理

Azure 前置設定

1. 建立 Storage Account

在 Azure Portal 或 Azure CLI 建立儲存體帳戶:

az storage account create \
  --name mystorageaccount \
  --resource-group my-resource-group \
  --location japaneast \
  --sku Standard_LRS \
  --kind StorageV2

注意:Azure 新版儲存體帳戶預設停用匿名公開存取
若需要公開 URL 直接下載,必須在帳戶層級啟用後,再於容器層級設定:

# 啟用帳戶層級的 Blob 公開存取
az storage account update \
  --name mystorageaccount \
  --resource-group my-resource-group \
  --allow-blob-public-access true

2. 取得 Connection String

az storage account show-connection-string \
  --name mystorageaccount \
  --resource-group my-resource-group \
  --output tsv

輸出範例:

DefaultEndpointsProtocol=https;AccountName=mystorageaccount;AccountKey=<base64-key>;...

⚠️ 安全提醒:Connection String 包含完整存取金鑰,切勿提交進 Git。
在 Azure Container Apps / App Service 中應存成 Secret,再以環境變數參照。

3. 建立容器並設定公開存取

az storage container create \
  --name my-container \
  --account-name mystorageaccount \
  --account-key "<your-account-key>" \
  --public-access blob \
  --auth-mode key

--public-access blob 表示:

  • 容器本身不可列出(私有)
  • 容器內每個 blob 的 URL 可直接讀取(公開)

後端實作

資料庫 Migration

建立一張中繼資料表,只存 blob URL 和檔案資訊,不存二進位內容:

-- migrations/0017_attachments.up.sql

IF NOT EXISTS (
    SELECT 1 FROM sys.objects
    WHERE object_id = OBJECT_ID('dbo.item_attachments') AND type = 'U'
)
BEGIN
    CREATE TABLE item_attachments (
        id          UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
        item_id     UNIQUEIDENTIFIER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
        file_name   NVARCHAR(255)    NOT NULL,
        blob_url    NVARCHAR(2000)   NOT NULL,
        content_type NVARCHAR(100)   NOT NULL DEFAULT '',
        file_size   BIGINT           NOT NULL DEFAULT 0,
        created_at  DATETIME2        NOT NULL DEFAULT SYSUTCDATETIME()
    );

    CREATE INDEX idx_item_attachments_item_id ON item_attachments(item_id);
END;
-- migrations/0017_attachments.down.sql
DROP TABLE IF EXISTS item_attachments;

重點說明:

  • ON DELETE CASCADE:刪除父項目時自動刪除所有附件的 DB 記錄(但 Blob Storage 的實體檔案需另行刪除)
  • blob_url 儲存完整 URL,前端可直接用作 <a href>window.open

Model 定義

// internal/item/models.go
package item

import (
    "time"
    "github.com/google/uuid"
)

// Item 是業務主體(可以是公告、文章、報告等)
type Item struct {
    ID          uuid.UUID    `db:"id"          json:"id"`
    Title       string       `db:"title"       json:"title"`
    CreatedAt   time.Time    `db:"created_at"  json:"created_at"`
    UpdatedAt   time.Time    `db:"updated_at"  json:"updated_at"`
    Attachments []Attachment `db:"-"           json:"attachments"` // db:"-" 表示不直接映射欄位,由程式碼填入
}

// Attachment 儲存附件的中繼資料
type Attachment struct {
    ID          uuid.UUID `db:"id"           json:"id"`
    ItemID      uuid.UUID `db:"item_id"      json:"item_id"`
    FileName    string    `db:"file_name"    json:"file_name"`
    BlobURL     string    `db:"blob_url"     json:"blob_url"`
    ContentType string    `db:"content_type" json:"content_type"`
    FileSize    int64     `db:"file_size"    json:"file_size"`
    CreatedAt   time.Time `db:"created_at"   json:"created_at"`
}

db:"-" 的意義:
Attachments 欄位不對應資料庫欄位(因為是一對多關聯,不在同一張表)。
我們在查詢後手動填入,這樣 JSON 序列化時仍會輸出 "attachments": [...]


Blob Storage 客戶端

// internal/storage/blob.go
package storage

import (
    "context"
    "fmt"
    "io"
    "net/url"
    "strings"

    "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
    "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
)

type BlobClient struct {
    client    *azblob.Client
    container string
}

// NewBlobClient 建立 Azure Blob Storage 客戶端。
// connectionString 為空時回傳 nil(功能停用,不影響其他服務啟動)。
func NewBlobClient(connectionString, containerName string) (*BlobClient, error) {
    if connectionString == "" {
        return nil, nil // 附件功能未設定時,靜默略過
    }
    if containerName == "" {
        return nil, fmt.Errorf("azure storage container name is required")
    }

    client, err := azblob.NewClientFromConnectionString(connectionString, nil)
    if err != nil {
        return nil, fmt.Errorf("create blob client: %w", err)
    }

    // 嘗試建立容器;若已存在則忽略錯誤
    if _, err := client.CreateContainer(context.Background(), containerName, nil); err != nil {
        if !strings.Contains(err.Error(), "ContainerAlreadyExists") {
            return nil, fmt.Errorf("create blob container: %w", err)
        }
    }

    return &BlobClient{client: client, container: containerName}, nil
}

// Upload 上傳資料流到指定的 blob 名稱,回傳公開 URL。
func (b *BlobClient) Upload(ctx context.Context, blobName string, data io.Reader, contentType string) (string, error) {
    if contentType == "" {
        contentType = "application/octet-stream"
    }

    _, err := b.client.UploadStream(ctx, b.container, blobName, data, &azblob.UploadStreamOptions{
        HTTPHeaders: &blob.HTTPHeaders{BlobContentType: &contentType},
    })
    if err != nil {
        return "", fmt.Errorf("upload blob %s: %w", blobName, err)
    }

    // 組合公開 URL:https://<account>.blob.core.windows.net/<container>/<blobName>
    blobURL := fmt.Sprintf(
        "%s/%s/%s",
        strings.TrimRight(b.client.URL(), "/"),
        b.container,
        strings.TrimLeft(blobName, "/"),
    )
    return blobURL, nil
}

// Delete 刪除指定的 blob。
func (b *BlobClient) Delete(ctx context.Context, blobName string) error {
    _, err := b.client.DeleteBlob(ctx, b.container, blobName, nil)
    if err != nil {
        return fmt.Errorf("delete blob %s: %w", blobName, err)
    }
    return nil
}

// BlobNameFromURL 從完整 URL 反推出 blob 名稱(用於刪除操作)。
func (b *BlobClient) BlobNameFromURL(rawURL string) (string, error) {
    parsed, err := url.Parse(rawURL)
    if err != nil {
        return "", fmt.Errorf("parse blob url: %w", err)
    }

    // path = /<container>/<blobName>
    path := strings.TrimPrefix(parsed.Path, "/")
    prefix := b.container + "/"
    if !strings.HasPrefix(path, prefix) {
        return "", fmt.Errorf("blob url %q is not in container %q", rawURL, b.container)
    }

    blobName, err := url.PathUnescape(strings.TrimPrefix(path, prefix))
    if err != nil {
        return "", fmt.Errorf("decode blob name: %w", err)
    }
    return blobName, nil
}

Blob 命名策略:

建議使用層級路徑 + UUID 避免碰撞:

// items/{item_id}/{attachment_id}-{sanitized_filename}
blobName := fmt.Sprintf("items/%s/%s-%s", itemID, attachmentID, sanitizeFileName(originalName))

sanitizeFileName 應將中文、空格、特殊字元轉成 ASCII 安全字元:

func sanitizeFileName(name string) string {
    base := filepath.Base(name)
    cleaned := strings.Map(func(r rune) rune {
        switch {
        case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
            return r
        case r == '.', r == '-', r == '_':
            return r
        default:
            return '_'
        }
    }, base)
    if cleaned == "" {
        return "attachment"
    }
    return cleaned
}

資料庫查詢層 (Queries)

// internal/item/queries.go(節錄附件相關部分)
package item

import (
    "context"
    "fmt"
    "strings"

    "github.com/google/uuid"
    "github.com/jmoiron/sqlx"
)

type Queries struct {
    db *sqlx.DB
}

// ListAttachments 列出某個 item 的所有附件。
func (q *Queries) ListAttachments(ctx context.Context, itemID uuid.UUID) ([]Attachment, error) {
    var items []Attachment
    err := q.db.SelectContext(ctx, &items, `
        SELECT CAST(id AS CHAR(36)) AS id,
               CAST(item_id AS CHAR(36)) AS item_id,
               file_name, blob_url, content_type, file_size, created_at
        FROM item_attachments
        WHERE item_id = @p1
        ORDER BY created_at ASC`,
        itemID,
    )
    if err != nil {
        return nil, fmt.Errorf("list attachments for item %s: %w", itemID, err)
    }
    if items == nil {
        items = []Attachment{}
    }
    return items, nil
}

// InsertAttachment 寫入一筆附件中繼資料。
func (q *Queries) InsertAttachment(ctx context.Context, a Attachment) error {
    _, err := q.db.ExecContext(ctx, `
        INSERT INTO item_attachments (id, item_id, file_name, blob_url, content_type, file_size)
        VALUES (@p1, @p2, @p3, @p4, @p5, @p6)`,
        a.ID, a.ItemID, a.FileName, a.BlobURL, a.ContentType, a.FileSize,
    )
    if err != nil {
        return fmt.Errorf("insert attachment %s: %w", a.ID, err)
    }
    return nil
}

// DeleteAttachment 刪除一筆附件中繼資料並回傳刪除前的資料(用來取得 BlobURL)。
func (q *Queries) DeleteAttachment(ctx context.Context, id uuid.UUID) (*Attachment, error) {
    var a Attachment
    // 先 SELECT 取得 blob_url,再 DELETE
    err := q.db.GetContext(ctx, &a, `
        SELECT CAST(id AS CHAR(36)) AS id,
               CAST(item_id AS CHAR(36)) AS item_id,
               file_name, blob_url, content_type, file_size, created_at
        FROM item_attachments WHERE id = @p1`, id,
    )
    if err != nil {
        return nil, fmt.Errorf("get attachment %s: %w", id, err)
    }
    if _, err = q.db.ExecContext(ctx, `DELETE FROM item_attachments WHERE id = @p1`, id); err != nil {
        return nil, fmt.Errorf("delete attachment %s: %w", id, err)
    }
    return &a, nil
}

Service 層

// internal/item/service.go(節錄附件相關部分)
package item

import (
    "context"
    "github.com/google/uuid"
)

type Querier interface {
    ListAttachments(ctx context.Context, itemID uuid.UUID) ([]Attachment, error)
    InsertAttachment(ctx context.Context, a Attachment) error
    DeleteAttachment(ctx context.Context, id uuid.UUID) (*Attachment, error)
    // ... 其他方法
}

type Service struct {
    queries Querier
    logger  *zap.Logger
}

func (s *Service) ListAttachments(ctx context.Context, itemID uuid.UUID) ([]Attachment, error) {
    return s.queries.ListAttachments(ctx, itemID)
}

func (s *Service) InsertAttachment(ctx context.Context, a Attachment) error {
    return s.queries.InsertAttachment(ctx, a)
}

func (s *Service) DeleteAttachment(ctx context.Context, id uuid.UUID) (*Attachment, error) {
    return s.queries.DeleteAttachment(ctx, id)
}

HTTP Handler

這是整個流程的核心,特別是 UploadAttachment

// internal/item/handler.go
package item

import (
    "context"
    "encoding/json"
    "fmt"
    "mime/multipart"
    "net/http"
    "path/filepath"
    "strings"

    "github.com/yourorg/yourapp/internal/storage"
    "github.com/google/uuid"
)

type Store interface {
    ListAttachments(ctx context.Context, itemID uuid.UUID) ([]Attachment, error)
    InsertAttachment(ctx context.Context, a Attachment) error
    DeleteAttachment(ctx context.Context, id uuid.UUID) (*Attachment, error)
}

type Handler struct {
    store      Store
    blobClient *storage.BlobClient
    // ... logger, validator, problemWriter 等
}

// UploadAttachment 處理 multipart/form-data 檔案上傳
// POST /api/items/{id}/attachments
func (h *Handler) UploadAttachment(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    itemID, ok := parseUUIDPathValue(r, w, "id")
    if !ok {
        return
    }

    // 1. 確認 Blob 客戶端已設定
    if h.blobClient == nil {
        http.Error(w, "attachments are not configured", http.StatusNotImplemented)
        return
    }

    // 2. 解析 multipart form(限制 32MB 記憶體,超過部分寫入臨時檔案)
    if err := r.ParseMultipartForm(32 << 20); err != nil {
        http.Error(w, "invalid multipart form", http.StatusBadRequest)
        return
    }

    // 3. 收集所有上傳的檔案(支援同時上傳多個)
    files := collectFiles(r.MultipartForm)
    if len(files) == 0 {
        http.Error(w, "no files uploaded", http.StatusBadRequest)
        return
    }

    created := make([]Attachment, 0, len(files))

    for _, fileHeader := range files {
        file, err := fileHeader.Open()
        if err != nil {
            http.Error(w, "failed to open uploaded file", http.StatusInternalServerError)
            return
        }

        contentType := fileHeader.Header.Get("Content-Type")
        if contentType == "" {
            contentType = "application/octet-stream"
        }

        // 4. 產生唯一的 blob 名稱
        attachmentID := uuid.New()
        blobName := fmt.Sprintf(
            "items/%s/%s-%s",
            itemID,
            attachmentID,
            sanitizeFileName(fileHeader.Filename),
        )

        // 5. 上傳到 Azure Blob Storage
        blobURL, err := h.blobClient.Upload(ctx, blobName, file, contentType)
        _ = file.Close()
        if err != nil {
            http.Error(w, "failed to upload file", http.StatusInternalServerError)
            return
        }

        // 6. 寫入 DB(若失敗則回滾:刪除剛上傳的 blob)
        attachment := Attachment{
            ID:          attachmentID,
            ItemID:      itemID,
            FileName:    fileHeader.Filename,
            BlobURL:     blobURL,
            ContentType: contentType,
            FileSize:    fileHeader.Size,
        }
        if err := h.store.InsertAttachment(ctx, attachment); err != nil {
            _ = h.blobClient.Delete(ctx, blobName) // 補償操作:刪除已上傳的 blob
            http.Error(w, "failed to save attachment metadata", http.StatusInternalServerError)
            return
        }

        created = append(created, attachment)
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    _ = json.NewEncoder(w).Encode(created)
}

// DeleteAttachment 刪除附件(先刪 Blob,再刪 DB)
// DELETE /api/items/{id}/attachments/{attachmentId}
func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    itemID, ok := parseUUIDPathValue(r, w, "id")
    if !ok {
        return
    }
    attachmentID, ok := parseUUIDPathValue(r, w, "attachmentId")
    if !ok {
        return
    }

    // 1. 先取得附件資料(包含 blob_url)
    attachments, err := h.store.ListAttachments(ctx, itemID)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    target := findAttachment(attachments, attachmentID)
    if target == nil {
        http.Error(w, "attachment not found", http.StatusNotFound)
        return
    }

    // 2. 刪除 Blob Storage 中的實體檔案
    if h.blobClient != nil && target.BlobURL != "" {
        blobName, err := h.blobClient.BlobNameFromURL(target.BlobURL)
        if err == nil {
            _ = h.blobClient.Delete(ctx, blobName) // 即使失敗也繼續刪除 DB 記錄
        }
    }

    // 3. 刪除 DB 中繼資料
    deleted, err := h.store.DeleteAttachment(ctx, attachmentID)
    if err != nil {
        http.Error(w, "failed to delete attachment", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(deleted)
}

// ListAttachments 列出某 item 的所有附件
// GET /api/items/{id}/attachments
func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    itemID, ok := parseUUIDPathValue(r, w, "id")
    if !ok {
        return
    }

    items, err := h.store.ListAttachments(ctx, itemID)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(items)
}

// --- 輔助函式 ---

func parseUUIDPathValue(r *http.Request, w http.ResponseWriter, key string) (uuid.UUID, bool) {
    id, err := uuid.Parse(r.PathValue(key))
    if err != nil {
        http.Error(w, fmt.Sprintf("invalid %s", key), http.StatusBadRequest)
        return uuid.Nil, false
    }
    return id, true
}

func collectFiles(form *multipart.Form) []*multipart.FileHeader {
    if form == nil {
        return nil
    }
    var files []*multipart.FileHeader
    for _, entries := range form.File {
        files = append(files, entries...)
    }
    return files
}

func findAttachment(items []Attachment, id uuid.UUID) *Attachment {
    for i := range items {
        if items[i].ID == id {
            return &items[i]
        }
    }
    return nil
}

刪除流程的容錯設計:

刪除 Blob Storage ──→ 成功 ──→ 刪除 DB 記錄 ──→ 完成
                  └──→ 失敗 ──→ 仍繼續刪除 DB 記錄(孤立 blob,可定期清理)

上傳 Blob Storage ──→ 成功 ──→ 寫入 DB 記錄 ──→ 成功 ──→ 完成
                                              └──→ 失敗 ──→ 補償刪除 blob(避免孤立檔案)

路由註冊

// cmd/main.go(節錄)
import (
    "github.com/yourorg/yourapp/internal/item"
    "github.com/yourorg/yourapp/internal/storage"
)

// 初始化 Blob 客戶端(connectionString 空白時回傳 nil,不影響啟動)
blobClient, err := storage.NewBlobClient(
    cfg.AzureStorage.ConnectionString,
    cfg.AzureStorage.ContainerName,
)
if err != nil {
    logger.Fatal("Failed to initialize Azure Blob Storage client", zap.Error(err))
}

itemQueries := item.NewQueries(dbPool)
itemService := item.NewService(logger, itemQueries)
itemHandler := item.NewHandler(logger, validator, problemWriter, itemService, blobClient)

// 公開路由(無需鑑權)
mux.HandleFunc("GET /api/items/{id}/attachments", itemHandler.ListAttachments)

// 需要管理員角色的路由
mux.HandleFunc("POST /api/items/{id}/attachments",
    authMiddleware.HandlerFunc(
        roleMiddleware.RequireRoles("admin", "super_admin")(itemHandler.UploadAttachment),
    ),
)
mux.HandleFunc("DELETE /api/items/{id}/attachments/{attachmentId}",
    authMiddleware.HandlerFunc(
        roleMiddleware.RequireRoles("admin", "super_admin")(itemHandler.DeleteAttachment),
    ),
)

設定管理

// internal/config/config.go(節錄)

type AzureStorage struct {
    ConnectionString string `yaml:"connection_string" envconfig:"AZURE_STORAGE_CONNECTION_STRING"`
    ContainerName    string `yaml:"container_name"    envconfig:"AZURE_STORAGE_CONTAINER_NAME"`
}

type Config struct {
    // ...
    AzureStorage AzureStorage `yaml:"azure_storage"`
}

環境變數(.env 本機開發):

AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=mystorageaccount;AccountKey=xxx;...
AZURE_STORAGE_CONTAINER_NAME=my-container

本機開發提示:若不需要實際上傳,直接不設定 AZURE_STORAGE_CONNECTION_STRING
NewBlobClient 會回傳 nil,Handler 中的 if h.blobClient == nil 判斷會回傳 501 Not Implemented,不影響其他功能開發。


前端實作

型別定義

// src/types/item.ts

export interface ItemFile {
    id: string;
    item_id: string;
    file_name: string;
    blob_url: string;
    content_type: string;
    file_size: number;
    created_at: string;
}

export interface Item {
    id: string;
    title: string;
    created_at: string;
    updated_at: string;
    attachments: ItemFile[]; // 由 API list 回應中直接包含,避免 N+1
}

API 呼叫層

// src/api/itemAttachments.ts

const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '';

export async function uploadAttachment(itemId: string, file: File): Promise<ItemFile[]> {
    const formData = new FormData();
    formData.append('file', file);

    const res = await fetch(`${API_BASE}/api/items/${itemId}/attachments`, {
        method: 'POST',
        credentials: 'include', // 傳送 cookie(session / JWT)
        body: formData,
        // 注意:不要手動設定 Content-Type,讓瀏覽器自動產生含 boundary 的 multipart header
    });

    if (!res.ok) {
        const text = await res.text();
        throw new Error(text || `Upload failed: ${res.status}`);
    }

    return res.json() as Promise<ItemFile[]>;
}

export async function deleteAttachment(itemId: string, attachmentId: string): Promise<void> {
    const res = await fetch(`${API_BASE}/api/items/${itemId}/attachments/${attachmentId}`, {
        method: 'DELETE',
        credentials: 'include',
    });

    if (!res.ok) {
        throw new Error(`Delete failed: ${res.status}`);
    }
}

⚠️ 常見錯誤:手動設定 'Content-Type': 'multipart/form-data' 會導致缺少 boundary 參數,
後端無法解析。使用 FormData 時,永遠讓瀏覽器自動設定 Content-Type


管理介面元件(上傳 / 刪除)

使用 Mantine Dropzone 建立拖放上傳介面:

npm install @mantine/dropzone
// src/components/AttachmentManager.tsx
import { useState } from 'react';
import { Anchor, Group, Stack, Text, ActionIcon } from '@mantine/core';
import { Dropzone, type FileWithPath } from '@mantine/dropzone';
import { notifications } from '@mantine/notifications';
import { IconFile, IconTrash, IconUpload, IconX } from '@tabler/icons-react';
import { uploadAttachment, deleteAttachment } from '@/api/itemAttachments';
import type { ItemFile } from '@/types/item';

const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB

interface Props {
    itemId: string;
    attachments: ItemFile[];
    onChanged: (updated: ItemFile[]) => void; // 附件變動時通知父元件更新
    readOnly?: boolean;
}

function formatFileSize(bytes: number): string {
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export function AttachmentManager({ itemId, attachments, onChanged, readOnly }: Props) {
    const [uploading, setUploading] = useState(false);

    const handleDrop = async (files: FileWithPath[]) => {
        setUploading(true);
        try {
            for (const file of files) {
                const newFiles = await uploadAttachment(itemId, file);
                // 每次上傳後更新附件列表(後端回傳新增的附件)
                onChanged([...attachments, ...newFiles]);
            }
            notifications.show({ color: 'green', message: '檔案上傳成功' });
        } catch (e) {
            notifications.show({ color: 'red', message: `上傳失敗:${e instanceof Error ? e.message : '未知錯誤'}` });
        } finally {
            setUploading(false);
        }
    };

    const handleDelete = async (file: ItemFile) => {
        try {
            await deleteAttachment(itemId, file.id);
            onChanged(attachments.filter(a => a.id !== file.id));
            notifications.show({ color: 'green', message: '附件已刪除' });
        } catch {
            notifications.show({ color: 'red', message: '刪除失敗' });
        }
    };

    return (
        <Stack gap='sm'>
            {/* 已上傳的附件列表 */}
            {attachments.map((file) => (
                <Group key={file.id} gap='xs' align='center' wrap='nowrap'>
                    <IconFile size={14} />
                    <Anchor href={file.blob_url} target='_blank' rel='noopener noreferrer' size='sm' style={{ flex: 1 }}>
                        {file.file_name}
                    </Anchor>
                    <Text size='xs' c='dimmed'>{formatFileSize(file.file_size)}</Text>
                    {!readOnly && (
                        <ActionIcon
                            color='red'
                            variant='subtle'
                            size='sm'
                            onClick={() => void handleDelete(file)}
                        >
                            <IconTrash size={14} />
                        </ActionIcon>
                    )}
                </Group>
            ))}

            {/* 拖放上傳區域(僅管理員可見) */}
            {!readOnly && (
                <Dropzone
                    onDrop={(files) => void handleDrop(files)}
                    onReject={(rejections) => {
                        rejections.forEach(r =>
                            notifications.show({ color: 'red', message: `${r.file.name} 無法上傳` })
                        );
                    }}
                    maxSize={MAX_FILE_SIZE}
                    loading={uploading}
                    accept={[
                        'application/pdf',
                        'application/msword',
                        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                        'image/png',
                        'image/jpeg',
                    ]}
                >
                    <Group justify='center' gap='xs' style={{ pointerEvents: 'none' }}>
                        <Dropzone.Accept><IconUpload size={20} /></Dropzone.Accept>
                        <Dropzone.Reject><IconX size={20} color='red' /></Dropzone.Reject>
                        <Dropzone.Idle><IconUpload size={20} /></Dropzone.Idle>
                        <Stack gap={2}>
                            <Text size='sm' fw={600}>拖放檔案至此,或點擊上傳</Text>
                            <Text size='xs' c='dimmed'>PDF、Word、圖片,最大 10 MB</Text>
                        </Stack>
                    </Group>
                </Dropzone>
            )}
        </Stack>
    );
}

在新增表單中處理「尚未建立的 item」:

// 新增模式:先暫存 pending files,待 item 建立後再批次上傳
const [pendingFiles, setPendingFiles] = useState<File[]>([]);

const handleCreateAndUpload = async () => {
    // 1. 建立 item
    const saved = await createItem(formData);

    // 2. 上傳所有暫存的附件
    if (pendingFiles.length > 0) {
        for (const file of pendingFiles) {
            await uploadAttachment(saved.id, file);
        }
    }

    // 完成
};

// 新增模式的 Dropzone:只加入暫存佇列
<Dropzone
    onDrop={(files) => setPendingFiles(prev => [...prev, ...files])}
    // ...
>

公開展示元件(下載連結)

在公開頁面顯示附件清單,使用者可以直接點擊下載:

// src/components/ItemCard.tsx
import { Anchor, Group, Stack, Text } from '@mantine/core';
import { IconFile } from '@tabler/icons-react';
import type { Item } from '@/types/item';

function formatFileSize(bytes: number): string {
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export function ItemCard({ item }: { item: Item }) {
    return (
        <Stack gap='xs'>
            <Text fw={700}>{item.title}</Text>

            {/* 附件區塊(若有附件才顯示) */}
            {item.attachments && item.attachments.length > 0 && (
                <Stack gap={4} mt={4}>
                    {item.attachments.map((file) => (
                        <Group key={file.id} gap={6} align='center' wrap='nowrap'>
                            <IconFile size={13} color='var(--mantine-color-blue-5)' style={{ flexShrink: 0 }} />
                            <Anchor
                                href={file.blob_url}
                                target='_blank'
                                rel='noopener noreferrer'
                                size='xs'
                            >
                                {file.file_name}
                            </Anchor>
                            <Text size='xs' c='dimmed' style={{ flexShrink: 0 }}>
                                ({formatFileSize(file.file_size)})
                            </Text>
                        </Group>
                    ))}
                </Stack>
            )}
        </Stack>
    );
}

效能考量:N+1 查詢問題

問題描述

如果 List API 只回傳 item 基本資料,前端需要對每個 item 各自呼叫一次附件 API:

GET /api/items          → 取得 10 筆 item
GET /api/items/1/attachments  → 取得第 1 筆的附件
GET /api/items/2/attachments  → 取得第 2 筆的附件
...(共 11 次 API 呼叫)

解決方案:後端 Batch Fetch

在後端的 List() 函式中,用一次額外查詢取得所有相關附件,再在記憶體中組合:

func (q *Queries) List(ctx context.Context) ([]Item, error) {
    // 1. 先取得所有 items
    var items []Item
    if err := q.db.SelectContext(ctx, &items, `SELECT ... FROM items ORDER BY ...`); err != nil {
        return nil, err
    }
    if len(items) == 0 {
        return items, nil
    }

    // 2. 建立 IN 子句的參數(@p1, @p2, ...)
    idPlaceholders := make([]string, len(items))
    idArgs := make([]any, len(items))
    for i, item := range items {
        idPlaceholders[i] = fmt.Sprintf("@p%d", i+1)
        idArgs[i] = item.ID
    }

    // 3. 一次查詢所有相關附件(僅 2 次 DB 往返,不管 items 有多少筆)
    var attachments []Attachment
    attachQuery := fmt.Sprintf(`
        SELECT ... FROM item_attachments
        WHERE item_id IN (%s)
        ORDER BY item_id, created_at ASC`,
        strings.Join(idPlaceholders, ","),
    )
    if err := q.db.SelectContext(ctx, &attachments, attachQuery, idArgs...); err != nil {
        return nil, err
    }

    // 4. 在記憶體中建立 map,並填入各 item 的 Attachments 欄位
    attachByItem := make(map[string][]Attachment, len(items))
    for _, a := range attachments {
        key := a.ItemID.String()
        attachByItem[key] = append(attachByItem[key], a)
    }
    for i, item := range items {
        if files, ok := attachByItem[item.ID.String()]; ok {
            items[i].Attachments = files
        } else {
            items[i].Attachments = []Attachment{}
        }
    }

    return items, nil
}

效能比較:

方法DB 查詢次數說明
N+1(前端各自請求)1 + N 次 API × 1 DB10 筆 item → 11 次 API、11 次 DB
N+1(後端各自查詢)1 + N 次 DB10 筆 item → 11 次 DB
Batch Fetch(本方案)2 次 DB不管幾筆,固定 2 次 DB 往返

部署至 Azure Container Apps

1. 將 Connection String 存為 Secret

az containerapp secret set \
  --name my-backend-app \
  --resource-group my-resource-group \
  --secrets "azure-storage-conn=DefaultEndpointsProtocol=https;AccountName=mystorageaccount;AccountKey=xxx;..."

2. 以環境變數參照 Secret

az containerapp update \
  --name my-backend-app \
  --resource-group my-resource-group \
  --set-env-vars "AZURE_STORAGE_CONNECTION_STRING=secretref:azure-storage-conn" \
  --set-env-vars "AZURE_STORAGE_CONTAINER_NAME=my-container"

secretref: 前綴告訴 Azure Container Apps 從 secret 讀取值,而非當成明文。
這樣 Connection String 不會出現在部署歷史或 ARM template 的純文字中。

3. 確認部署後的設定

az containerapp show \
  --name my-backend-app \
  --resource-group my-resource-group \
  --query "properties.template.containers[0].env" \
  --output table

常見問題與除錯

Q1:上傳後 blob_url 無法存取(403 Forbidden)

原因:容器的公開存取層級未正確設定。

排查步驟:

# 確認帳戶層級是否啟用 Allow Blob Anonymous Access
az storage account show \
  --name mystorageaccount \
  --query "allowBlobPublicAccess"

# 確認容器層級的公開存取設定
az storage container show-permission \
  --name my-container \
  --account-name mystorageaccount \
  --account-key "<key>"
# 應顯示 "publicAccess": "blob"

修正:

# 先啟用帳戶層級
az storage account update --name mystorageaccount --allow-blob-public-access true

# 再設定容器層級
az storage container set-permission \
  --name my-container \
  --account-name mystorageaccount \
  --account-key "<key>" \
  --public-access blob

Q2:後端上傳時回傳 containerNotFound 或容器已存在但仍報錯

原因NewBlobClient 在啟動時會嘗試建立容器,若帳戶層級還未啟用公開存取,
容器會被建立成私有的。之後再用 az storage container set-permission 設定時,
若使用的是 --connection-string 而非 --account-key,可能因連線字串格式差異導致找不到容器。

解決方式:統一使用 --account-name + --account-key 參數:

az storage container set-permission \
  --name my-container \
  --account-name mystorageaccount \
  --account-key "$(az storage account keys list --account-name mystorageaccount --query '[0].value' -o tsv)" \
  --public-access blob

Q3:上傳後 DB 寫入失敗,但 Blob 已存在(孤立檔案)

原因InsertAttachment 失敗前 blobClient.Delete 呼叫也失敗(例如網路短暫斷線)。

建議處理方式:

  1. 短期:記錄 blobName 到錯誤 log,人工清理
  2. 長期:建立定期清理任務,查找 DB 中不存在對應記錄的 blob:
  • 列出 Blob Storage 中所有 blob
  • 比對 DB 中的 blob_url 欄位
  • 刪除孤立的 blob

Q4:前端上傳大檔案時逾時

後端限制(Go http.Server):

server := &http.Server{
    ReadTimeout:  30 * time.Second,  // 預設可能太短
    WriteTimeout: 30 * time.Second,
    // 上傳大檔案時應調整:
    ReadTimeout:  5 * time.Minute,
}

Azure Container Apps 限制
Container Apps 的 HTTP 請求預設有 240 秒逾時,對於大型檔案需注意。

建議:檔案大小限制設在前端(maxSize prop)與後端(ParseMultipartForm 的記憶體限制)雙重把關:

// 最大允許 50MB(超過部分寫入臨時檔案)
if err := r.ParseMultipartForm(50 << 20); err != nil {
    http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
    return
}

Q5:Go 模組安裝 Azure SDK

go get github.com/Azure/azure-sdk-for-go/sdk/storage/azblob@latest

go.mod 中確認版本:

require (
    github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.x.x
)

小結

本文涵蓋的完整實作流程:

  1. Azure 設定:建立 Storage Account、啟用公開 blob 存取、取得 Connection String、部署時存為 Container Apps Secret
  2. 後端BlobClient 封裝上傳/刪除/URL解析;Handler 處理 multipart 上傳、DB 寫入、補償刪除;DB 只存中繼資料
  3. 前端FormData 上傳(不手動設 Content-Type);Mantine Dropzone 拖放介面;公開頁面以 blob_url 直接提供下載連結
  4. 效能:用 Batch Fetch 解決 N+1,List API 在 2 次 DB 查詢內回傳完整資料(含附件)
  5. 容錯:上傳失敗時補償刪除 Blob;刪除失敗時繼續刪除 DB 記錄並記錄 log

這套模式適用於任何需要「主體 + 附件」關聯的功能(公告、申請表、報告等),可依需求調整鑑權邏輯與允許的檔案類型。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *