iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
Modern Web

Golang x Echo 30 天:零基礎GO , 後端入門系列 第 20

以 Go + Echo 打造部落格|第 4 篇:文章 CRUD

  • 分享至 

  • xImage
  •  

今天衝功能:做出文章 CRUD(新增、讀取、更新、刪除)。我們同時做兩條線:

  1. 後台 UI:管理員頁面(先不做登入,第 7 篇補 Session 登入),表單含 CSRF 防護。
  2. API/api/admin/posts/* 提供 JSON 版 CRUD(之後第 8 篇會加 JWT)。
    完成後,你可以在瀏覽器用表單建立文章,也能用 curl 走 API。步驟清楚、表單驗證簡單易懂,國中生也能跟著做 😄

步驟清單(骨架 → 填充)

  1. 建立資料結構與儲存層(pgxpool 操作 posts)。
  2. 後台模板:列表頁 + 表單頁(沿用第 3 篇 layout/partials)。
  3. 後台路由與 CSRF:/admin/posts 系列(含 Method Override 讓 HTML 支援 PUT/DELETE)。
  4. API 路由:/api/admin/posts 系列(JSON)。
  5. 基本驗證:標題必填、slug 合法、內容最短長度。

完整程式(可直接跑)

下面是新增或更新的檔案。放到對應路徑後,make run 就能跑。
名詞小辭典:
CSRF(跨站請求偽造):攻擊者誘導你發出你不知情的請求;用隨機 token驗證可避免。

1) Domain:文章結構

internal/core/domain/post.go

package domain 
 
import "time" 
 
type Post struct { 
	ID          int64      `json:"id"` 
	AuthorID    int64      `json:"author_id"` 
	Title       string     `json:"title"` 
	Slug        string     `json:"slug"` 
	Summary     *string    `json:"summary,omitempty"` 
	ContentMD   string     `json:"content_md"` 
	CoverImage  *string    `json:"cover_image,omitempty"` 
	Status      string     `json:"status"` // "draft" | "published" 
	PublishedAt *time.Time `json:"published_at,omitempty"` 
	CreatedAt   time.Time  `json:"created_at"` 
	UpdatedAt   time.Time  `json:"updated_at"` 
} 

2) Repository:Postgres 操作

internal/storage/postgres/posts.go

package postgres 
 
import ( 
	"context" 
	"errors" 
	"strings" 
	"time" 
 
	"github.com/jackc/pgx/v5" 
	"github.com/jackc/pgx/v5/pgxpool" 
 
	"example.com/go-echo-blog/internal/core/domain" 
) 
 
var ErrNotFound = errors.New("not found") 
 
type PostRepo struct { 
	Pool *pgxpool.Pool 
} 
 
func NewPostRepo(pool *pgxpool.Pool) *PostRepo { return &PostRepo{Pool: pool} } 
 
type ListPostsOpts struct { 
	Page    int 
	PerPage int 
	Status  string // "", "draft", "published" 
} 
 
func (r *PostRepo) List(ctx context.Context, opts ListPostsOpts) ([]domain.Post, int, error) { 
	if opts.PerPage <= 0 || opts.PerPage > 100 { opts.PerPage = 10 } 
	if opts.Page <= 0 { opts.Page = 1 } 
	offset := (opts.Page - 1) * opts.PerPage 
 
	base := ` 
SELECT id, author_id, title, slug, summary, content_md, cover_image, status, published_at, created_at, updated_at 
FROM posts 
` 
	where := "" 
	args := []any{} 
	if opts.Status == "draft" || opts.Status == "published" { 
		where = "WHERE status = $1" 
		args = append(args, opts.Status) 
	} 
	order := " ORDER BY COALESCE(published_at, created_at) DESC, id DESC" 
	limit := " LIMIT $2 OFFSET $3" 
	if where == "" { 
		where = "WHERE TRUE" 
	} 
	args = append(args, opts.PerPage, offset) 
 
	rows, err := r.Pool.Query(ctx, base+where+order+limit, args...) 
	if err != nil { return nil, 0, err } 
	defer rows.Close() 
 
	var items []domain.Post 
	for rows.Next() { 
		var p domain.Post 
		if err := rows.Scan( 
			&p.ID, &p.AuthorID, &p.Title, &p.Slug, &p.Summary, &p.ContentMD, 
			&p.CoverImage, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, 
		); err != nil { 
			return nil, 0, err 
		} 
		items = append(items, p) 
	} 
	if rows.Err() != nil { return nil, 0, rows.Err() } 
 
	// count 
	var total int 
	if err := r.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM posts "+strings.TrimPrefix(where, "WHERE")).Scan(&total); err != nil { 
		_ = r.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM posts").Scan(&total) 
	} 
	return items, total, nil 
} 
 
func (r *PostRepo) GetByID(ctx context.Context, id int64) (domain.Post, error) { 
	var p domain.Post 
	err := r.Pool.QueryRow(ctx, ` 
SELECT id, author_id, title, slug, summary, content_md, cover_image, status, published_at, created_at, updated_at 
FROM posts WHERE id=$1`, id).Scan( 
		&p.ID, &p.AuthorID, &p.Title, &p.Slug, &p.Summary, &p.ContentMD, 
		&p.CoverImage, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, 
	) 
	if errors.Is(err, pgx.ErrNoRows) { return p, ErrNotFound } 
	return p, err 
} 
 
type CreatePostParams struct { 
	AuthorID   int64 
	Title      string 
	Slug       string 
	Summary    *string 
	ContentMD  string 
	CoverImage *string 
	Status     string // draft/published 
} 
 
func (r *PostRepo) Create(ctx context.Context, in CreatePostParams) (int64, error) { 
	var publishedAt *time.Time 
	if in.Status == "published" { 
		now := time.Now() 
		publishedAt = &now 
	} 
	var id int64 
	err := r.Pool.QueryRow(ctx, ` 
INSERT INTO posts (author_id, title, slug, summary, content_md, cover_image, status, published_at) 
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) 
RETURNING id 
`, in.AuthorID, in.Title, in.Slug, in.Summary, in.ContentMD, in.CoverImage, in.Status, publishedAt).Scan(&id) 
	return id, err 
} 
 
type UpdatePostParams struct { 
	Title      string 
	Slug       string 
	Summary    *string 
	ContentMD  string 
	CoverImage *string 
	Status     string 
} 
 
func (r *PostRepo) Update(ctx context.Context, id int64, in UpdatePostParams) error { 
	var publishedAt *time.Time 
	if in.Status == "published" { 
		now := time.Now() 
		publishedAt = &now 
	} 
	ct, err := r.Pool.Exec(ctx, ` 
UPDATE posts 
SET title=$1, slug=$2, summary=$3, content_md=$4, cover_image=$5, status=$6, published_at=$7, updated_at=now() 
WHERE id=$8 
`, in.Title, in.Slug, in.Summary, in.ContentMD, in.CoverImage, in.Status, publishedAt, id) 
	if err != nil { return err } 
	if ct.RowsAffected() == 0 { return ErrNotFound } 
	return nil 
} 
 
func (r *PostRepo) Delete(ctx context.Context, id int64) error { 
	ct, err := r.Pool.Exec(ctx, `DELETE FROM posts WHERE id=$1`, id) 
	if err != nil { return err } 
	if ct.RowsAffected() == 0 { return ErrNotFound } 
	return nil 
} 

3) 後台 Handlers(HTML + CSRF)

internal/http/handlers/admin_posts.go

package handlers 
 
import ( 
	"net/http" 
	"regexp" 
	"strconv" 
	"strings" 
	"time" 
 
	"github.com/labstack/echo/v4" 
 
	"example.com/go-echo-blog/internal/storage/postgres" 
) 
 
type AdminPostsHandler struct { 
	Repo     *postgres.PostRepo 
	SiteName string 
} 
 
func NewAdminPostsHandler(r *postgres.PostRepo, site string) *AdminPostsHandler { 
	return &AdminPostsHandler{Repo: r, SiteName: site} 
} 
 
func parseIntDefault(s string, def int) int { 
	if n, err := strconv.Atoi(s); err == nil && n > 0 { return n } 
	return def 
} 
 
func validSlug(slug string) bool { 
	matched, _ := regexp.MatchString(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, slug) 
	return matched && len(slug) >= 2 && len(slug) <= 100 
} 
 
func (h *AdminPostsHandler) List(c echo.Context) error { 
	page := parseIntDefault(c.QueryParam("page"), 1) 
	status := c.QueryParam("status") // "", "draft", "published" 
	items, total, err := h.Repo.List(c.Request().Context(), postgres.ListPostsOpts{ 
		Page: page, PerPage: 10, Status: status, 
	}) 
	if err != nil { 
		return c.String(http.StatusInternalServerError, err.Error()) 
	} 
	now := time.Now().In(time.FixedZone("Asia/Taipei", 8*60*60)) 
	data := map[string]any{ 
		"Title":    "文章管理", 
		"SiteName": h.SiteName, 
		"Year":     now.Year(), 
		"Now":      now.Format("2006-01-02 15:04:05"), 
		"Items":    items, 
		"Page":     page, 
		"Total":    total, 
		"Status":   status, 
		"CSRF":     c.Get("csrf"), 
	} 
	return c.Render(http.StatusOK, "pages/admin/posts_list.html", data) 
} 
 
func (h *AdminPostsHandler) New(c echo.Context) error { 
	now := time.Now().In(time.FixedZone("Asia/Taipei", 8*60*60)) 
	data := map[string]any{ 
		"Title":    "新增文章", 
		"SiteName": h.SiteName, 
		"Year":     now.Year(), 
		"Now":      now.Format("2006-01-02 15:04:05"), 
		"CSRF":     c.Get("csrf"), 
	} 
	return c.Render(http.StatusOK, "pages/admin/posts_form.html", data) 
} 
 
func (h *AdminPostsHandler) Create(c echo.Context) error { 
	title := strings.TrimSpace(c.FormValue("title")) 
	slug := strings.TrimSpace(c.FormValue("slug")) 
	summary := strings.TrimSpace(c.FormValue("summary")) 
	content := strings.TrimSpace(c.FormValue("content_md")) 
	status := c.FormValue("status") 
	if status != "published" { status = "draft" } 
 
	var summaryPtr *string 
	if summary != "" { summaryPtr = &summary } 
 
	if title == "" || !validSlug(slug) || len(content) < 5 { 
		return c.String(http.StatusBadRequest, "validation failed: title required, slug [a-z0-9-], content>=5") 
	} 
 
	id, err := h.Repo.Create(c.Request().Context(), postgres.CreatePostParams{ 
		AuthorID: 1, Title: title, Slug: slug, Summary: summaryPtr, 
		ContentMD: content, Status: status, 
	}) 
	if err != nil { return c.String(http.StatusInternalServerError, err.Error()) } 
	return c.Redirect(http.StatusSeeOther, "/admin/posts?created="+strconv.FormatInt(id, 10)) 
} 
 
func (h *AdminPostsHandler) Edit(c echo.Context) error { 
	id, _ := strconv.ParseInt(c.Param("id"), 10, 64) 
	p, err := h.Repo.GetByID(c.Request().Context(), id) 
	if err != nil { return c.String(http.StatusNotFound, "post not found") } 
 
	now := time.Now().In(time.FixedZone("Asia/Taipei", 8*60*60)) 
	data := map[string]any{ 
		"Title":    "編輯文章", 
		"SiteName": h.SiteName, 
		"Year":     now.Year(), 
		"Now":      now.Format("2006-01-02 15:04:05"), 
		"Post":     p, 
		"CSRF":     c.Get("csrf"), 
	} 
	return c.Render(http.StatusOK, "pages/admin/posts_form.html", data) 
} 
 
func (h *AdminPostsHandler) Update(c echo.Context) error { 
	id, _ := strconv.ParseInt(c.Param("id"), 10, 64) 
	title := strings.TrimSpace(c.FormValue("title")) 
	slug := strings.TrimSpace(c.FormValue("slug")) 
	summary := strings.TrimSpace(c.FormValue("summary")) 
	content := strings.TrimSpace(c.FormValue("content_md")) 
	status := c.FormValue("status") 
	if status != "published" { status = "draft" } 
	var summaryPtr *string 
	if summary != "" { summaryPtr = &summary } 
 
	if title == "" || !validSlug(slug) || len(content) < 5 { 
		return c.String(http.StatusBadRequest, "validation failed: title required, slug [a-z0-9-], content>=5") 
	} 
 
	err := h.Repo.Update(c.Request().Context(), id, postgres.UpdatePostParams{ 
		Title: title, Slug: slug, Summary: summaryPtr, ContentMD: content, Status: status, 
	}) 
	if err != nil { return c.String(http.StatusInternalServerError, err.Error()) } 
	return c.Redirect(http.StatusSeeOther, "/admin/posts?updated="+strconv.FormatInt(id, 10)) 
} 
 
func (h *AdminPostsHandler) Delete(c echo.Context) error { 
	id, _ := strconv.ParseInt(c.Param("id"), 10, 64) 
	if err := h.Repo.Delete(c.Request().Context(), id); err != nil { 
		return c.String(http.StatusInternalServerError, err.Error()) 
	} 
	return c.Redirect(http.StatusSeeOther, "/admin/posts?deleted="+strconv.FormatInt(id, 10)) 
} 

4) API Handlers(JSON 版)

internal/http/handlers/api_posts.go

package handlers 
 
import ( 
	"net/http" 
	"strconv" 
	"strings" 
 
	"github.com/labstack/echo/v4" 
 
	"example.com/go-echo-blog/internal/core/domain" 
	"example.com/go-echo-blog/internal/storage/postgres" 
) 
 
type APIPostsHandler struct { 
	Repo *postgres.PostRepo 
} 
 
func NewAPIPostsHandler(r *postgres.PostRepo) *APIPostsHandler { return &APIPostsHandler{Repo: r} } 
 
func (h *APIPostsHandler) List(c echo.Context) error { 
	page := 1 
	if v := c.QueryParam("page"); v != "" { 
		if n, err := strconv.Atoi(v); err == nil && n > 0 { page = n } 
	} 
	items, total, err := h.Repo.List(c.Request().Context(), postgres.ListPostsOpts{Page: page, PerPage: 10}) 
	if err != nil { return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()}) } 
	return c.JSON(http.StatusOK, echo.Map{"items": items, "total": total, "page": page}) 
} 
 
func (h *APIPostsHandler) Get(c echo.Context) error { 
	id, _ := strconv.ParseInt(c.Param("id"), 10, 64) 
	p, err := h.Repo.GetByID(c.Request().Context(), id) 
	if err != nil { return c.JSON(http.StatusNotFound, echo.Map{"error": "not found"}) } 
	return c.JSON(http.StatusOK, p) 
} 
 
type upsertReq struct { 
	AuthorID   int64   `json:"author_id"` 
	Title      string  `json:"title"` 
	Slug       string  `json:"slug"` 
	Summary    *string `json:"summary"` 
	ContentMD  string  `json:"content_md"` 
	CoverImage *string `json:"cover_image"` 
	Status     string  `json:"status"` // draft/published 
} 
 
func (h *APIPostsHandler) Create(c echo.Context) error { 
	var req upsertReq 
	if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, echo.Map{"error": "bad json"}) } 
	req.Title = strings.TrimSpace(req.Title) 
	req.Slug = strings.TrimSpace(req.Slug) 
	req.ContentMD = strings.TrimSpace(req.ContentMD) 
	if req.Status != "published" { req.Status = "draft" } 
	if req.Title == "" || !validSlug(req.Slug) || len(req.ContentMD) < 5 { 
		return c.JSON(http.StatusBadRequest, echo.Map{"error": "validation failed"}) 
	} 
	if req.AuthorID == 0 { req.AuthorID = 1 } 
	id, err := h.Repo.Create(c.Request().Context(), postgres.CreatePostParams{ 
		AuthorID: req.AuthorID, Title: req.Title, Slug: req.Slug, Summary: req.Summary, 
		ContentMD: req.ContentMD, CoverImage: req.CoverImage, Status: req.Status, 
	}) 
	if err != nil { return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()}) } 
	return c.JSON(http.StatusCreated, echo.Map{"id": id}) 
} 
 
func (h *APIPostsHandler) Update(c echo.Context) error { 
	id, _ := strconv.ParseInt(c.Param("id"), 10, 64) 
	var req upsertReq 
	if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, echo.Map{"error": "bad json"}) } 
	req.Title = strings.TrimSpace(req.Title) 
	req.Slug = strings.TrimSpace(req.Slug) 
	req.ContentMD = strings.TrimSpace(req.ContentMD) 
	if req.Status != "published" { req.Status = "draft" } 
	if req.Title == "" || !validSlug(req.Slug) || len(req.ContentMD) < 5 { 
		return c.JSON(http.StatusBadRequest, echo.Map{"error": "validation failed"}) 
	} 
	if err := h.Repo.Update(c.Request().Context(), id, postgres.UpdatePostParams{ 
		Title: req.Title, Slug: req.Slug, Summary: req.Summary, ContentMD: req.ContentMD, CoverImage: req.CoverImage, Status: req.Status, 
	}); err != nil { 
		return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()}) 
	} 
	return c.JSON(http.StatusOK, echo.Map{"ok": true}) 
} 
 
func (h *APIPostsHandler) Delete(c echo.Context) error { 
	id, _ := strconv.ParseInt(c.Param("id"), 10, 64) 
	if err := h.Repo.Delete(c.Request().Context(), id); err != nil { 
		return c.JSON(http.StatusInternalServerError, echo.Map{"error": err.Error()}) 
	} 
	return c.JSON(http.StatusOK, echo.Map{"ok": true}) 
} 

5) 後台模板

web/templates/pages/admin/posts_list.html

{{ define "pages/admin/posts_list" -}} 
{{ template "layouts/base" . }} 
{{ define "title" -}}文章管理{{ end }} 
{{ define "content" -}} 
<section class="grid gap-4"> 
  <div class="flex items-center justify-between"> 
    <h1 class="text-2xl font-bold">📝 文章管理</h1> 
    <a href="/admin/posts/new" class="inline-flex items-center px-4 py-2 rounded bg-sky-600 text-white hover:bg-sky-700">新增文章</a> 
  </div> 
 
  <form class="flex items-center gap-2" method="get" action="/admin/posts"> 
    <label class="text-sm text-slate-600">狀態</label> 
    <select name="status" class="border rounded px-2 py-1"> 
      <option value="" {{if eq .Status ""}}selected{{end}}>全部</option> 
      <option value="draft" {{if eq .Status "draft"}}selected{{end}}>草稿</option> 
      <option value="published" {{if eq .Status "published"}}selected{{end}}>已發佈</option> 
    </select> 
    <button class="border rounded px-3 py-1 bg-white hover:bg-slate-50">套用</button> 
  </form> 
 
  <div class="rounded border bg-white"> 
    <table class="w-full text-left"> 
      <thead class="bg-slate-50"> 
        <tr> 
          <th class="p-3">ID</th> 
          <th class="p-3">標題</th> 
          <th class="p-3">Slug</th> 
          <th class="p-3">狀態</th> 
          <th class="p-3">動作</th> 
        </tr> 
      </thead> 
      <tbody> 
        {{range .Items}} 
        <tr class="border-t"> 
          <td class="p-3">{{.ID}}</td> 
          <td class="p-3">{{.Title}}</td> 
          <td class="p-3 font-mono text-sm">{{.Slug}}</td> 
          <td class="p-3">{{.Status}}</td> 
          <td class="p-3 space-x-2"> 
            <a class="text-sky-700 hover:underline" href="/admin/posts/{{.ID}}/edit">編輯</a> 
            <form class="inline" method="post" action="/admin/posts/{{.ID}}/delete"> 
              <input type="hidden" name="_method" value="DELETE"> 
              <input type="hidden" name="csrf" value="{{$.CSRF}}"> 
              <button class="text-red-600 hover:underline" onclick="return confirm('確定要刪除?')">刪除</button> 
            </form> 
          </td> 
        </tr> 
        {{else}} 
        <tr><td class="p-3 text-slate-500" colspan="5">目前沒有文章</td></tr> 
        {{end}} 
      </tbody> 
    </table> 
  </div> 
</section> 
{{- end }} 
{{- end }} 

web/templates/pages/admin/posts_form.html

{{ define "pages/admin/posts_form" -}} 
{{ template "layouts/base" . }} 
{{ define "title" -}}{{ if .Post }}編輯文章{{ else }}新增文章{{ end }}{{ end }} 
{{ define "content" -}} 
<section class="grid gap-6"> 
  <h1 class="text-2xl font-bold">{{ if .Post }}編輯文章 #{{.Post.ID}}{{ else }}新增文章{{ end }}</h1> 
 
  <form class="grid gap-4 max-w-3xl" method="post" action="{{ if .Post }}/admin/posts/{{.Post.ID}}{{ else }}/admin/posts{{ end }}"> 
    {{ if .Post }}<input type="hidden" name="_method" value="PUT">{{ end }} 
    <input type="hidden" name="csrf" value="{{ .CSRF }}"> 
 
    <label class="grid gap-1"> 
      <span class="text-sm text-slate-600">標題 *</span> 
      <input name="title" class="border rounded px-3 py-2" required value="{{ if .Post }}{{.Post.Title}}{{ end }}"> 
    </label> 
 
    <label class="grid gap-1"> 
      <span class="text-sm text-slate-600">Slug *(只能小寫英數與 - )</span> 
      <input name="slug" class="border rounded px-3 py-2 font-mono" required value="{{ if .Post }}{{.Post.Slug}}{{ end }}"> 
    </label> 
 
    <label class="grid gap-1"> 
      <span class="text-sm text-slate-600">摘要(可選)</span> 
      <textarea name="summary" class="border rounded px-3 py-2" rows="2">{{ if .Post }}{{.Post.Summary}}{{ end }}</textarea> 
    </label> 
 
    <label class="grid gap-1"> 
      <span class="text-sm text-slate-600">內容(Markdown)*</span> 
      <textarea name="content_md" class="border rounded px-3 py-2 font-mono" rows="10" required>{{ if .Post }}{{.Post.ContentMD}}{{ end }}</textarea> 
    </label> 
 
    <label class="grid gap-1"> 
      <span class="text-sm text-slate-600">狀態</span> 
      <select name="status" class="border rounded px-3 py-2"> 
        <option value="draft" {{if and .Post (eq .Post.Status "draft")}}selected{{end}}>草稿</option> 
        <option value="published" {{if and .Post (eq .Post.Status "published")}}selected{{end}}>已發佈</option> 
      </select> 
    </label> 
 
    <div class="flex gap-3"> 
      <button class="px-4 py-2 rounded bg-sky-600 text-white hover:bg-sky-700">{{ if .Post }}更新{{ else }}新增{{ end }}</button> 
      <a href="/admin/posts" class="px-4 py-2 rounded border bg-white hover:bg-slate-50">返回列表</a> 
    </div> 
  </form> 
</section> 
{{- end }} 
{{- end }} 

6) 路由與中介層(CSRF、Method Override)

cmd/server/main.go(新增/更新片段)

// 省略前段既有程式... 
 
	// HTML 表單支援 _method=PUT/DELETE 
	e.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{ 
		Getter: middleware.MethodFromForm("_method"), 
	})) 
 
	// Repos + Handlers 
	postRepo := pgstore.NewPostRepo(db.Pool) 
	admin := handlers.NewAdminPostsHandler(postRepo, site) 
	api := handlers.NewAPIPostsHandler(postRepo) 
 
	// CSRF(Cookie Token + form:csrf/header:X-CSRF-Token) 
	adminGroup := e.Group("/admin", 
		middleware.CSRFWithConfig(middleware.CSRFConfig{ 
			CookieName:  "csrf", 
			TokenLookup: "form:csrf,header:X-CSRF-Token", 
			ContextKey:  "csrf", 
		}), 
	) 
 
	// 後台 UI 
	adminGroup.GET("/posts", admin.List) 
	adminGroup.GET("/posts/new", admin.New) 
	adminGroup.POST("/posts", admin.Create) 
	adminGroup.GET("/posts/:id/edit", admin.Edit) 
	adminGroup.PUT("/posts/:id", admin.Update) 
	adminGroup.DELETE("/posts/:id/delete", admin.Delete) 
 
	// API 
	apiGroup := e.Group("/api/admin/posts") 
	apiGroup.GET("", api.List) 
	apiGroup.GET("/:id", api.Get) 
	apiGroup.POST("", api.Create) 
	apiGroup.PUT("/:id", api.Update) 
	apiGroup.DELETE("/:id", api.Delete) 
 
// 省略後段既有程式... 

測試區(curl / Postman)

A) API 測試(JSON)

新增文章

curl -sX POST http://localhost:1323/api/admin/posts   -H "Content-Type: application/json"   -d '{ 
    "author_id": 1, 
    "title": "第一篇測試文", 
    "slug": "first-post", 
    "summary": "示範用", 
    "content_md": "# Hello 
這是內容", 
    "status": "draft" 
  }' | jq . 

列表

curl -s http://localhost:1323/api/admin/posts?page=1 | jq . 

取得單篇(假設 ID=1)

curl -s http://localhost:1323/api/admin/posts/1 | jq . 

更新

curl -sX PUT http://localhost:1323/api/admin/posts/1   -H "Content-Type: application/json"   -d '{ 
    "title":"第一篇測試文(更新)", 
    "slug":"first-post", 
    "content_md":"## 更新內容", 
    "status":"published" 
  }' | jq . 

刪除

curl -sX DELETE http://localhost:1323/api/admin/posts/1 | jq . 

B) 後台 UI(CSRF)

取得新建表單頁,抓 CSRF 值

curl -s -c cookies.txt http://localhost:1323/admin/posts/new |   grep -o 'name="csrf" value="[^"]*"' | head -n1 

送出新增表單(帶 CSRF token 與 Cookie)

TOKEN="把剛剛抓到的字串去掉前綴只留值" 
curl -s -b cookies.txt -X POST http://localhost:1323/admin/posts   -d "csrf=$TOKEN"   -d "title=用表單新增"   -d "slug=form-post"   -d "summary=表單測試"   -d "content_md=哈囉MD"   -d "status=draft"   -i 

刪除(Method Override + CSRF)

TOKEN="..." 
curl -s -b cookies.txt -X POST http://localhost:1323/admin/posts/1/delete   -d "csrf=$TOKEN"   -d "_method=DELETE"   -i 

常見坑(排雷指南)🧯

  1. validation failedtitle 必填、slug 只能小寫英數和 -、內容至少 5 字。
  2. token mismatch / CSRF 相關:要帶 Cookie,表單欄位名稱 csrf 或 Header X-CSRF-Token
  3. method not allowed:HTML 表單需要 _method 欄位配合 MethodOverride
  4. not found:編輯/刪除 ID 不存在。
  5. duplicate key value violates unique constraint "posts_slug_key"slug 要唯一。
  6. 公司網路擋 CDN:Tailwind 抓不到就會變素面,之後可改本地 build。

小結 & 下一篇預告

今天把文章 CRUD 兩條線都通了:後台 UI(CSRF 防護) + API(JSON)


上一篇
以 Go + Echo 打造部落格|第 3 篇:模板與靜態資源
下一篇
以 Go + Echo 打造部落格|第 5 篇:文章列表與分頁
系列文
Golang x Echo 30 天:零基礎GO , 後端入門24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言