今天衝功能:做出文章 CRUD(新增、讀取、更新、刪除)。我們同時做兩條線:
- 後台 UI:管理員頁面(先不做登入,第 7 篇補 Session 登入),表單含 CSRF 防護。
- API:
/api/admin/posts/*
提供 JSON 版 CRUD(之後第 8 篇會加 JWT)。
完成後,你可以在瀏覽器用表單建立文章,也能用 curl 走 API。步驟清楚、表單驗證簡單易懂,國中生也能跟著做 😄
posts
)。/admin/posts
系列(含 Method Override 讓 HTML 支援 PUT/DELETE)。/api/admin/posts
系列(JSON)。下面是新增或更新的檔案。放到對應路徑後,
make run
就能跑。
名詞小辭典:
CSRF(跨站請求偽造):攻擊者誘導你發出你不知情的請求;用隨機 token驗證可避免。
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"`
}
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
}
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))
}
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})
}
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 }}
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 -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 .
取得新建表單頁,抓 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
validation failed
:title
必填、slug
只能小寫英數和 -
、內容至少 5 字。token mismatch
/ CSRF 相關:要帶 Cookie,表單欄位名稱 csrf
或 Header X-CSRF-Token
。method not allowed
:HTML 表單需要 _method
欄位配合 MethodOverride
。not found
:編輯/刪除 ID 不存在。duplicate key value violates unique constraint "posts_slug_key"
:slug
要唯一。今天把文章 CRUD 兩條線都通了:後台 UI(CSRF 防護) + API(JSON)。