从零构建文章服务
如何准备项目和工具?
New-Item -ItemType Directory article-api
Set-Location article-api
New-Item -ItemType Directory idl
go install github.com/cloudwego/hertz/cmd/hz@v0.9.7
hz --version
go version
- PATH: 工具命令不可用时,把 Go bin 目录加入当前终端 PATH,再执行版本检查;
- 依赖: 先保存下面的 go.mod,完成文件后 go mod tidy 会补齐间接依赖与 go.sum;
go.mod
module example.com/article-api
go 1.26.0
require (
github.com/cloudwego/hertz v0.10.6
github.com/apache/thrift v0.13.0
github.com/golang-jwt/jwt/v5 v5.3.0
gorm.io/gorm v1.31.1
gorm.io/driver/mysql v1.6.0
)
replace github.com/apache/thrift => github.com/apache/thrift v0.13.0
如何定义接口并生成代码?
idl/article.thrift
namespace go article
struct Article {
1: i64 id
2: i64 author_id
3: string title
4: string content
}
struct CreateArticleRequest {
1: required string title (api.body="title")
2: required string content (api.body="content")
}
struct UpdateArticleRequest {
1: required i64 id (api.path="id")
2: required string title (api.body="title")
3: required string content (api.body="content")
}
struct GetArticleRequest {
1: required i64 id (api.path="id")
}
struct ListArticlesRequest {
1: optional i32 page = 1 (api.query="page")
2: optional i32 page_size = 20 (api.query="page_size")
}
struct ListArticlesResponse {
1: list<Article> items
2: i64 total
3: i32 page
4: i32 page_size
}
struct DeleteArticleResponse {}
service ArticleService {
Article Create(1: CreateArticleRequest req)
(api.post="/api/v1/articles")
ListArticlesResponse List(1: ListArticlesRequest req)
(api.get="/api/v1/articles")
Article Get(1: GetArticleRequest req)
(api.get="/api/v1/articles/:id")
Article Update(1: UpdateArticleRequest req)
(api.put="/api/v1/articles/:id")
DeleteArticleResponse Delete(1: GetArticleRequest req)
(api.delete="/api/v1/articles/:id")
}
hz new -module example.com/article-api -idl idl/article.thrift
- 检查: 生成 biz/model、biz/handler、biz/router、router.go、router_gen.go;Thrift 固定 0.13.0,避免 ReadStructBegin 等签名不兼容;
- 所有权: 保留生成路由与 model;只替换后面明确列出的 Handler 和根 main.go,不能把入口移到 cmd 后继续调用根包 register;
- 更新: 修改 IDL 后执行 hz update -idl idl/article.thrift,目录参数与 new 一致,检查生成差异后测试;
如何定义业务数据与错误?
internal/article/entity.go
package article
import (
"errors"
)
var (
ErrNotFound = errors.New("article not found")
ErrPermissionDenied = errors.New("permission denied")
ErrInvalidInput = errors.New("invalid article input")
)
type Article struct {
ID int64
AuthorID int64
Title string
Content string
}
type CreateInput struct {
AuthorID int64
Title string
Content string
}
- 数据: 领域对象不包含 HTTP、RPC 或 GORM 类型;输入错误、不存在和冲突由 Handler 映射为公开业务码;
如何实现业务服务?
internal/article/service.go
package article
import (
"context"
"errors"
"fmt"
"strings"
)
type Repository interface {
Create(ctx context.Context, a *Article) error
FindByID(ctx context.Context, id int64) (*Article, error)
List(ctx context.Context, offset, limit int) ([]Article, int64, error)
Update(ctx context.Context, a *Article) error
Delete(ctx context.Context, id int64) error
}
type Service struct{ repo Repository }
func NewService(repo Repository) (*Service, error) {
if repo == nil {
return nil, errors.New("repository is required")
}
return &Service{repo: repo}, nil
}
func (s *Service) Create(ctx context.Context, in CreateInput) (*Article, error) {
in.Title = strings.TrimSpace(in.Title)
if in.AuthorID <= 0 || in.Title == "" || len([]rune(in.Title)) > 100 || in.Content == "" || len(in.Content) > 60000 {
return nil, ErrInvalidInput
}
a := &Article{AuthorID: in.AuthorID, Title: in.Title, Content: in.Content}
if err := s.repo.Create(ctx, a); err != nil {
return nil, fmt.Errorf("create article: %w", err)
}
return a, nil
}
func (s *Service) Delete(ctx context.Context, actorID, articleID int64) error {
a, err := s.repo.FindByID(ctx, articleID)
if err != nil {
return err
}
if a.AuthorID != actorID {
return ErrPermissionDenied
}
return s.repo.Delete(ctx, articleID)
}
func (s *Service) Get(ctx context.Context, id int64) (*Article, error) {
if id <= 0 {
return nil, ErrInvalidInput
}
return s.repo.FindByID(ctx, id)
}
func (s *Service) List(ctx context.Context, page, size int) ([]Article, int64, error) {
if page < 1 || page > 1000000 || size < 1 || size > 100 {
return nil, 0, ErrInvalidInput
}
return s.repo.List(ctx, (page-1)*size, size)
}
func (s *Service) Update(ctx context.Context, actor, id int64, title, content string) (*Article, error) {
title = strings.TrimSpace(title)
if actor <= 0 || id <= 0 || title == "" || len([]rune(title)) > 100 || content == "" || len(content) > 60000 {
return nil, ErrInvalidInput
}
a, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
if a.AuthorID != actor {
return nil, ErrPermissionDenied
}
a.Title = title
a.Content = content
if err = s.repo.Update(ctx, a); err != nil {
return nil, fmt.Errorf("update article: %w", err)
}
return a, nil
}
- 输入: 检查字段长度与正数 ID;文章标题按 Unicode 字符计数,正文按字节限制;作者不可变,更新和删除先检查所有权;
如何用 GORM 实现存储?
internal/article/repository_gorm.go
package article
import (
"context"
"errors"
"fmt"
"gorm.io/gorm"
"time"
)
type articleRow struct {
ID int64 `gorm:"primaryKey"`
AuthorID int64 `gorm:"not null;index"`
Title string `gorm:"size:100;not null"`
Content string `gorm:"type:text;not null"`
CreatedAt time.Time
UpdatedAt time.Time
}
type GormRepository struct{ db *gorm.DB }
func NewGormRepository(db *gorm.DB) *GormRepository {
return &GormRepository{db: db}
}
func (r *GormRepository) FindByID(ctx context.Context, id int64) (*Article, error) {
var row articleRow
err := r.db.WithContext(ctx).First(&row, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("find article %d: %w", id, err)
}
return &Article{ID: row.ID, AuthorID: row.AuthorID, Title: row.Title, Content: row.Content}, nil
}
func (articleRow) TableName() string { return "articles" }
func (r *GormRepository) Create(ctx context.Context, a *Article) error {
row := articleRow{AuthorID: a.AuthorID, Title: a.Title, Content: a.Content}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return err
}
a.ID = row.ID
return nil
}
func (r *GormRepository) List(ctx context.Context, offset, limit int) ([]Article, int64, error) {
q := r.db.WithContext(ctx).Model(&articleRow{})
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []articleRow
if err := q.Order("id DESC").Offset(offset).Limit(limit).Find(&rows).Error; err != nil {
return nil, 0, err
}
items := make([]Article, 0, len(rows))
for _, a := range rows {
items = append(items, Article{ID: a.ID, AuthorID: a.AuthorID, Title: a.Title, Content: a.Content})
}
return items, total, nil
}
func (r *GormRepository) Update(ctx context.Context, a *Article) error {
// Explicit fields preserve empty values; mysql clientFoundRows makes no-op PUT count as matched.
tx := r.db.WithContext(ctx).Model(&articleRow{}).Where("id = ? AND author_id = ?", a.ID, a.AuthorID).
Updates(map[string]any{"title": a.Title, "content": a.Content})
if tx.Error != nil {
return tx.Error
}
if tx.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
func (r *GormRepository) Delete(ctx context.Context, id int64) error {
tx := r.db.WithContext(ctx).Delete(&articleRow{}, id)
if tx.Error != nil {
return tx.Error
}
if tx.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
- 表名: 使用 TableName 对齐 SQL 的 users/articles,避免被推导为 user_rows/article_rows;
- 错误: First 无记录转为领域错误;用户唯一键通过 TranslateError 转为 ErrDuplicatedKey;
- 更新: DSN 带 clientFoundRows=true,让相同内容的 PUT 按匹配行计数;本例并发编辑采用最后写入生效,列表与总数不保证同一快照;
如何建立数据表?
migrations/001_create_articles.sql
CREATE TABLE articles (
id BIGINT NOT NULL AUTO_INCREMENT,
author_id BIGINT NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
INDEX idx_articles_author_id_id (author_id, id)
);
- 数据约束: 文章作者不可变,分页按 id 降序,author_id/id 索引为作者查询预留;
- 迁移: Server 不自动建表,先执行后面的独立命令;
如何读取配置并连接 MySQL?
internal/platform/database/open.go
package database
import (
"context"
"database/sql"
"errors"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"os"
"time"
)
func Open() (*gorm.DB, *sql.DB, error) {
dsn := os.Getenv("ARTICLE_DATABASE_DSN")
if dsn == "" {
return nil, nil, errors.New("ARTICLE_DATABASE_DSN is required")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{TranslateError: true})
if err != nil {
return nil, nil, err
}
pool, err := db.DB()
if err != nil {
return nil, nil, err
}
pool.SetMaxOpenConns(10)
pool.SetMaxIdleConns(5)
pool.SetConnMaxLifetime(3 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err = pool.PingContext(ctx); err != nil {
pool.Close()
return nil, nil, err
}
return db, pool, nil
}
- 配置: ARTICLE_DATABASE_DSN 必填,密码和完整 DSN 不进入普通日志;
- 池: max open=10、max idle=5 是本地示例值;Ping 有 3 秒预算,退出时关闭 pool;
如何把 HTTP 请求交给业务服务?
biz/handler/article/article_service.go
package article
import (
"context"
"errors"
model "example.com/article-api/biz/model/article"
domain "example.com/article-api/internal/article"
"github.com/cloudwego/hertz/pkg/app"
"log"
)
// Inject runs before generated handlers; each Engine owns its Service.
func Inject(svc *domain.Service) app.HandlerFunc {
return func(ctx context.Context, c *app.RequestContext) { c.Set("article_service", svc); c.Next(ctx) }
}
func service(c *app.RequestContext) *domain.Service {
v, _ := c.Get("article_service")
return v.(*domain.Service)
}
func actor(c *app.RequestContext) int64 { v, _ := c.Get("user_id"); id, _ := v.(int64); return id }
func output(a *domain.Article) *model.Article {
return &model.Article{ID: a.ID, AuthorID: a.AuthorID, Title: a.Title, Content: a.Content}
}
func fail(c *app.RequestContext, err error) {
status, code, message := 500, "INTERNAL", "internal server error"
switch {
case errors.Is(err, domain.ErrInvalidInput):
status, code, message = 400, "INVALID_ARGUMENT", "invalid article"
case errors.Is(err, domain.ErrNotFound):
status, code, message = 404, "ARTICLE_NOT_FOUND", "article not found"
case errors.Is(err, domain.ErrPermissionDenied):
status, code, message = 403, "PERMISSION_DENIED", "permission denied"
default:
log.Printf("request_id=%s error=%v", c.GetString("request_id"), err)
}
c.JSON(status, map[string]any{"code": code, "message": message, "request_id": c.GetString("request_id")})
}
func Create(ctx context.Context, c *app.RequestContext) {
var req model.CreateArticleRequest
if err := c.BindAndValidate(&req); err != nil {
fail(c, domain.ErrInvalidInput)
return
}
a, err := service(c).Create(ctx, domain.CreateInput{AuthorID: actor(c), Title: req.Title, Content: req.Content})
if err != nil {
fail(c, err)
return
}
c.JSON(201, output(a))
}
func Get(ctx context.Context, c *app.RequestContext) {
var req model.GetArticleRequest
if err := c.BindAndValidate(&req); err != nil {
fail(c, domain.ErrInvalidInput)
return
}
a, err := service(c).Get(ctx, req.ID)
if err != nil {
fail(c, err)
return
}
c.JSON(200, output(a))
}
func List(ctx context.Context, c *app.RequestContext) {
req := model.NewListArticlesRequest()
if err := c.BindAndValidate(req); err != nil {
fail(c, domain.ErrInvalidInput)
return
}
page, size := int(req.GetPage()), int(req.GetPageSize())
items, total, err := service(c).List(ctx, page, size)
if err != nil {
fail(c, err)
return
}
models := make([]*model.Article, 0, len(items))
for i := range items {
models = append(models, output(&items[i]))
}
c.JSON(200, &model.ListArticlesResponse{Items: models, Total: total, Page: int32(page), PageSize: int32(size)})
}
func Update(ctx context.Context, c *app.RequestContext) {
var req model.UpdateArticleRequest
if err := c.BindAndValidate(&req); err != nil {
fail(c, domain.ErrInvalidInput)
return
}
a, err := service(c).Update(ctx, actor(c), req.ID, req.Title, req.Content)
if err != nil {
fail(c, err)
return
}
c.JSON(200, output(a))
}
func Delete(ctx context.Context, c *app.RequestContext) {
var req model.GetArticleRequest
if err := c.BindAndValidate(&req); err != nil || req.ID <= 0 {
fail(c, domain.ErrInvalidInput)
return
}
if err := service(c).Delete(ctx, actor(c), req.ID); err != nil {
fail(c, err)
return
}
c.Status(204)
}
- 适配: 每个 Engine 通过中间件注入 Service,避免包级单例;创建 201、读取或更新 200、删除 204,错误带 request_id;
如何组装并启动进程?
main.go
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
handler "example.com/article-api/biz/handler/article"
"example.com/article-api/internal/article"
"example.com/article-api/internal/platform/auth"
"example.com/article-api/internal/platform/database"
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/server"
"log"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"
)
func makeEngine(svc *article.Service, key []byte) *server.Hertz {
h := server.Default(server.WithHostPorts(":8888"), server.WithReadTimeout(3*time.Second), server.WithWriteTimeout(5*time.Second), server.WithMaxRequestBodySize(1<<20), server.WithExitWaitTime(5*time.Second))
h.Use(func(ctx context.Context, c *app.RequestContext) {
var id [16]byte
if _, err := rand.Read(id[:]); err != nil {
c.AbortWithStatus(500)
return
}
requestID := hex.EncodeToString(id[:])
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
start := time.Now()
c.Next(ctx)
log.Printf("request_id=%s method=%s status=%d elapsed=%s", requestID, c.Method(), c.Response.StatusCode(), time.Since(start))
})
h.Use(auth.RequireWrites(key), handler.Inject(svc))
register(h)
return h
}
func run() error {
key := []byte(os.Getenv("ARTICLE_JWT_KEY"))
if len(key) < 32 {
return errors.New("ARTICLE_JWT_KEY must contain at least 32 bytes")
}
db, pool, err := database.Open()
if err != nil {
return err
}
defer pool.Close()
svc, err := article.NewService(article.NewGormRepository(db))
if err != nil {
return err
}
h := makeEngine(svc, key)
var ready atomic.Bool
ready.Store(true)
h.GET("/livez", func(ctx context.Context, c *app.RequestContext) { c.Status(200) })
h.GET("/readyz", func(ctx context.Context, c *app.RequestContext) {
if !ready.Load() {
c.Status(503)
return
}
check, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
if pool.PingContext(check) != nil {
c.Status(503)
return
}
c.Status(200)
})
var startErr error
h.SetCustomSignalWaiter(func(errs chan error) error {
signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(signals)
select {
case err := <-errs:
startErr = err
return err
case <-signals:
ready.Store(false)
time.Sleep(time.Second)
return nil
}
})
h.Spin()
return startErr
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
- 入口: 根 main.go 调用生成的 register(h);Request ID、超时、认证、依赖注入依次安装;
- 退出: 先关闭 readiness,等待 1 秒示例摘流时间,再由 Spin 关闭,最后关闭 DB;按实际平台调整时间;
- 边界: Recovery 的默认 500 不保证业务错误 JSON 格式,需要统一时再配置自定义 Recovery;
如何不依赖 MySQL 验证业务?
main_test.go
package main
import (
"context"
"encoding/json"
"example.com/article-api/internal/article"
"example.com/article-api/internal/platform/auth"
"fmt"
"github.com/cloudwego/hertz/pkg/common/ut"
"strings"
"testing"
)
type memoryRepo struct {
rows map[int64]article.Article
next int64
}
func (r *memoryRepo) Create(_ context.Context, a *article.Article) error {
r.next++
a.ID = r.next
r.rows[a.ID] = *a
return nil
}
func (r *memoryRepo) FindByID(_ context.Context, id int64) (*article.Article, error) {
a, ok := r.rows[id]
if !ok {
return nil, article.ErrNotFound
}
return &a, nil
}
func (r *memoryRepo) List(_ context.Context, o, n int) ([]article.Article, int64, error) {
items := []article.Article{}
for i := r.next; i > 0; i-- {
if a, ok := r.rows[i]; ok {
items = append(items, a)
}
}
total := len(items)
if o > total {
o = total
}
end := o + n
if end > total {
end = total
}
return items[o:end], int64(total), nil
}
func (r *memoryRepo) Update(_ context.Context, a *article.Article) error {
r.rows[a.ID] = *a
return nil
}
func (r *memoryRepo) Delete(_ context.Context, id int64) error { delete(r.rows, id); return nil }
func TestArticleLifecycle(t *testing.T) {
repo := &memoryRepo{rows: map[int64]article.Article{}}
svc, _ := article.NewService(repo)
key := []byte("test-only-key-with-at-least-32-bytes")
one, _ := auth.Sign(key, 1)
two, _ := auth.Sign(key, 2)
h := makeEngine(svc, key)
request := func(method, url, body, token string, want int) []byte {
t.Helper()
headers := []ut.Header{{Key: "Content-Type", Value: "application/json"}}
if token != "" {
headers = append(headers, ut.Header{Key: "Authorization", Value: "Bearer " + token})
}
resp := ut.PerformRequest(h.Engine, method, url, &ut.Body{Body: strings.NewReader(body), Len: len(body)}, headers...).Result()
if resp.StatusCode() != want {
t.Fatalf("%s %s: want %d got %d: %s", method, url, want, resp.StatusCode(), resp.Body())
}
if resp.Header.Get("X-Request-ID") == "" {
t.Fatal("missing request ID")
}
return append([]byte(nil), resp.Body()...)
}
body := request("POST", "/api/v1/articles", "{\"title\":\"Hertz\",\"content\":\"notes\"}", "", 401)
if len(repo.rows) != 0 {
t.Fatal("unauthenticated write")
}
body = request("POST", "/api/v1/articles", "{\"title\":\"Hertz\",\"content\":\"notes\"}", one, 201)
var created struct{ ID int64 }
if err := json.Unmarshal(body, &created); err != nil {
t.Fatal(err)
}
url := fmt.Sprintf("/api/v1/articles/%d", created.ID)
request("GET", url, "", "", 200)
request("GET", "/api/v1/articles?page=1&page_size=20", "", "", 200)
request("PUT", url, "{\"title\":\"Updated\",\"content\":\"notes\"}", two, 403)
if repo.rows[created.ID].Title != "Hertz" {
t.Fatal("forbidden update changed data")
}
request("PUT", url, "{\"title\":\"Updated\",\"content\":\"notes\"}", one, 200)
request("POST", "/api/v1/articles", "{}", one, 400)
request("GET", "/api/v1/articles?page_size=101", "", "", 400)
request("DELETE", url, "", two, 403)
if len(repo.rows) != 1 {
t.Fatal("forbidden delete changed data")
}
request("DELETE", url, "", one, 204)
request("GET", url, "", "", 404)
}
- 范围: ut.PerformRequest 不经过网络,验证 Engine、路由、中间件与 CRUD;数据库和真实 HTTP 监听仍需运行验收;
如何执行编译与测试?
go mod tidy
go test ./...
go vet ./...
go build ./...
# 有可用 C 编译器时检查并发问题
go test -race ./...
- 保存: IDL、生成代码、go.mod 与 go.sum 一起保存;工具升级后重新生成并验证;
如何准备数据库与应用容器?
compose.yaml
services:
mysql:
image: mysql:8.4
environment:
MYSQL_DATABASE: notes
MYSQL_USER: notes
MYSQL_PASSWORD: notes_dev
MYSQL_ROOT_PASSWORD: root_dev
ports: ["127.0.0.1:3306:3306"]
volumes: ["db:/var/lib/mysql"]
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 2s
timeout: 2s
retries: 30
app:
build: .
environment:
ARTICLE_DATABASE_DSN: "notes:notes_dev@tcp(mysql:3306)/notes?parseTime=true&loc=UTC&clientFoundRows=true"
ARTICLE_JWT_KEY: "${ARTICLE_JWT_KEY:?set ARTICLE_JWT_KEY}"
ports: ["127.0.0.1:8888:8888"]
depends_on:
mysql: { condition: service_healthy }
volumes:
db:
Dockerfile
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/server .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
.dockerignore
.git
.env
*.exe
- 本地: 示例密码只用于学习,端口绑定 127.0.0.1,两个项目不要同时占用相同端口;
- 数据: 命名卷保存 MySQL 数据;应用以 nonroot 运行,真实发布按团队要求固定镜像 digest;
如何配置本地环境并初始化数据库?
docker compose up -d --wait mysql
$env:ARTICLE_DATABASE_DSN = 'notes:notes_dev@tcp(127.0.0.1:3306)/notes?parseTime=true&loc=UTC&clientFoundRows=true'
$env:ARTICLE_JWT_KEY = -join (1..4 | ForEach-Object { [guid]::NewGuid().ToString('N') })
$token = go run ./cmd/token 1
$other = go run ./cmd/token 2
go run ./cmd/migrate
- 终端: 环境变量只属于当前终端;另开服务终端时必须设置相同 DSN,Hertz 还要复制同一个 JWT 密钥;
如何启动服务并验收接口?
go run .
- 请求终端: 服务保持运行,在另一个终端执行下面命令;Hertz 请求终端使用上节生成的 Token;
$headers = @{ Authorization = "Bearer $token" }
$base = 'http://127.0.0.1:8888'
Invoke-WebRequest "$base/readyz"
$body = @{ title = 'Hertz 入门'; content = '文章服务' } | ConvertTo-Json
$a = Invoke-RestMethod -Method Post "$base/api/v1/articles" -Headers $headers -ContentType 'application/json' -Body $body
Invoke-RestMethod "$base/api/v1/articles/$($a.id)"
Invoke-RestMethod "$base/api/v1/articles?page=1&page_size=20"
$body = @{ title = '已更新'; content = '完整 CRUD' } | ConvertTo-Json
Invoke-RestMethod -Method Put "$base/api/v1/articles/$($a.id)" -Headers $headers -ContentType 'application/json' -Body $body
Invoke-RestMethod -Method Delete "$base/api/v1/articles/$($a.id)" -Headers $headers
# 再查询应返回 404;无 Token 创建应返回 401。
- 预期: 完成 CRUD;删除前使用 $other 修改作者 1 的文章应返回 403;
- 停止: 本机应用 Ctrl+C,检查在途请求结束后退出;
如何验证容器交付?
docker compose up --build -d app
# 使用上一节相同请求验收
docker compose logs app
docker compose down
- 前提: 已执行迁移,并停止占用 8888 的本机应用;Hertz 当前终端保留 ARTICLE_JWT_KEY;
- 清理: down 保留命名卷,下一次启动可继续使用数据;
代码来源和验证范围是什么?
- 框架: CloudWeGo hertz 文档;生成、Handler、协议与生命周期依据官方资料;
- 应用示例: 业务分层、数据模型、CRUD 与迁移命令由本笔记组织,不是框架强制架构;
- 数据库: GORM 错误处理、字段更新;jwt/v5 API 用于身份校验;
- 已验证: 代码生成、go test、go vet、go build;当前环境无 Docker 和 C 编译器,MySQL/Compose 与 race 检查需在具备依赖的环境执行;