讀者對象:具備基本 Go 後端與 React 前端開發經驗的 Web 工程師。
技術棧:Go + sqlx (MSSQL) + React + TypeScript + Mantine UI + Azure Blob Storage
完成目標:在後端 API 接收檔案上傳、儲存至 Azure Blob Storage,並在前端提供拖放介面讓使用者上傳、列出、刪除附件,最後在公開頁面顯示可下載連結。
目錄
架構概覽
使用者瀏覽器
│
├─ 管理介面 (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 DB | 10 筆 item → 11 次 API、11 次 DB |
| N+1(後端各自查詢) | 1 + N 次 DB | 10 筆 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 呼叫也失敗(例如網路短暫斷線)。
建議處理方式:
- 短期:記錄
blobName到錯誤 log,人工清理 - 長期:建立定期清理任務,查找 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
)
小結
本文涵蓋的完整實作流程:
- Azure 設定:建立 Storage Account、啟用公開 blob 存取、取得 Connection String、部署時存為 Container Apps Secret
- 後端:
BlobClient封裝上傳/刪除/URL解析;Handler 處理 multipart 上傳、DB 寫入、補償刪除;DB 只存中繼資料 - 前端:
FormData上傳(不手動設 Content-Type);Mantine Dropzone 拖放介面;公開頁面以blob_url直接提供下載連結 - 效能:用 Batch Fetch 解決 N+1,List API 在 2 次 DB 查詢內回傳完整資料(含附件)
- 容錯:上傳失敗時補償刪除 Blob;刪除失敗時繼續刪除 DB 記錄並記錄 log
這套模式適用於任何需要「主體 + 附件」關聯的功能(公告、申請表、報告等),可依需求調整鑑權邏輯與允許的檔案類型。