《后端架构设计·架构基础与边界》5.1 订单 API 的资源边界与命令语义

以 arch-lab 的五个订单端点为例,给出资源与命令的分界线、幂等键与 If-Match 的约定,用可运行的 net/http 处理器和表驱动测试固定参数校验、重放与错误映射,并说明哪些字段绝不能由客户端决定,以及 201 与 200 的取舍。

从第 4 章交接到最外层

第 4 章把外部渠道的模型挡在防腐层外,订单领域因此拿到了干净的输入。再往外一层就是 HTTP:调用方不关心聚合、端口或 ACL,只看到路径、方法、请求头和状态码。站内 API 设计与契约治理 已经系统讲过 REST 建模、版本化与契约测试,这一节不重复综述,只回答 arch-lab 的五个订单端点各自代表什么、哪些词该出现在 URL 里、哪些语义必须由请求头承担。

先定一条总原则:资源是名词,命令是动词,命令只能落在状态机上。 订单可以被查询、取消、支付,不能被“更新成任意状态”;因此卷一不提供 PUT/PATCH /orders/{id},状态变化只经过 cancel 与 pay 两个动作子资源。

资源边界:五个端点只暴露两类东西

方法与路径类别语义成功主要失败
POST /api/v1/orders命令创建订单并预留库存201,重放 200400、409
GET /api/v1/orders/{id}查询读取单个订单视图200404
POST /api/v1/orders/{id}/cancel命令取消订单并释放库存200404、409、422
POST /api/v1/orders/{id}/pay命令接收支付结果并推进状态200404、409、422
GET /api/v1/orders?customer_id=&status=&limit=查询按客户与状态列出订单200400

cancel 与 pay 放在 {id} 之下,因为操作对象始终是那一张订单;/cancelOrder?id=... 这类顶层动词路径会让订单不再是可寻址对象。

查询端点只读,不能顺带触发库存释放或支付重试;把 GET ?refresh=true 做成“顺便同步支付状态”会破坏重试、缓存与链路追踪。

命令语义表

命令前置状态成功后的状态重试语义冲突分类
创建无CREATED同键同体重放,返回首次结果同键异体为 IDEMPOTENCY_CONFLICT
取消CREATED、INVENTORY_RESERVEDCANCELLED已是 CANCELLED 时返回当前视图,不重复释放版本不符为 VERSION_CONFLICT,其余状态为 INVALID_STATE
支付INVENTORY_RESERVEDPAID同一 provider_ref 重复回调视为成功金额或订单号不符为 PAYMENT_FAILED

这张表是处理器与领域层的契约:处理器只翻译命令,状态是否允许由聚合判断;把“能不能取消”写进 handler 的 if 一定和状态机漂移。

幂等头约定

创建订单必须带 Idempotency-Key,格式为 16–128 个 URL 安全字符(A-Z、a-z、0-9、.、_、~、-),由客户端生成,服务端不解析内部结构。作用域是 (customer_id, method, path, key);同键异体返回 409 IDEMPOTENCY_CONFLICT。重放返回 200 加首次响应体和 Idempotent-Replayed: true;返回 201 会让审计失真。

cancel 与 pay 不强制要求幂等键:目标状态由订单 ID 和状态机唯一确定,重复取消在 CANCELLED 下是无操作,重复支付由 provider_ref 去重。若客户端仍带键,服务端按同一规则记录,但不能用它替代状态判断。

版本前置条件:ETag 与 If-Match

GET /orders/{id} 返回 ETag: "3",值是订单的 Version。cancel 与 pay 可以带 If-Match: "3":

  • 不带 If-Match 表示不做前置检查,适合后台补偿任务;这是兼容性选择,不是推荐默认值。带了就必须严格匹配版本,否则 409 VERSION_CONFLICT。
  • 只接受带引号的整数;裸数字 3 不符合实体标签语法。

版本检查放在应用服务里,和仓储的乐观锁共用同一个 Version。处理器只负责把 If-Match 解析成整数;如果在这里查一次订单再比较,等于把并发窗口重新打开。

校验顺序与状态码

参数校验按“越便宜越靠前”执行:路由 → 幂等键 → JSON 形状 → 字段范围 → 领域命令,无效请求不触达数据库。

情况状态码错误码
未知字段、非法 JSON、数量越界、非法 limit400INVALID_ARGUMENT
订单不存在404NOT_FOUND
版本或幂等键冲突409VERSION_CONFLICT / IDEMPOTENCY_CONFLICT
当前状态不允许该命令422INVALID_STATE
未分类的内部错误500INTERNAL

创建请求体不接受 total_cents。金额由服务端按明细重算,客户端传来的总价即使“碰巧正确”也不能采信;DisallowUnknownFields 会让这种字段直接变成 400,而不是被静默忽略。

路由与参数校验的实现

下面是最小可运行版本,省略鉴权与指标,保留五个端点的路由、请求体限制、字段校验、If-Match 解析与错误映射;处理器不 import 领域包。

// file: internal/platform/httpserver/server.go
// tier: L1
// verify: go test ./internal/platform/httpserver/
package httpserver

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"regexp"
	"strconv"
	"strings"
)

var (
	ErrNotFound            = errors.New("order not found")
	ErrConflict            = errors.New("version conflict")
	ErrInvalidState        = errors.New("invalid state transition")
	ErrIdempotencyConflict = errors.New("idempotency conflict")
	idRE                   = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
	keyRE                  = regexp.MustCompile(`^[A-Za-z0-9._~-]{16,128}$`)
	currencyRE             = regexp.MustCompile(`^[A-Z]{3}$`)
	errIfMatch             = errors.New(`If-Match must be a quoted version such as "3"`)
)
var validStatus = map[string]bool{"CREATED": true, "INVENTORY_RESERVED": true, "PAID": true, "FULFILLED": true, "CANCELLED": true, "REFUNDED": true}

type Item struct {
	SKU            string `json:"sku"`
	Quantity       int    `json:"quantity"`
	UnitPriceCents int64  `json:"unit_price_cents"`
}

// Order 是 HTTP 视图,不是领域聚合;领域模型见 internal/order。
type Order struct {
	ID         string `json:"id"`
	Status     string `json:"status"`
	TotalCents int64  `json:"total_cents"`
	Version    int    `json:"version"`
}
type Service interface {
	Create(ctx context.Context, customerID string, items []Item, currency, key string) (Order, bool, error)
	Get(ctx context.Context, id string) (Order, error)
	Cancel(ctx context.Context, id string, version int) (Order, error)
	Pay(ctx context.Context, id string, version int) (Order, error)
	List(ctx context.Context, customerID, status string, limit int) ([]Order, error)
}
type Server struct{ svc Service }

func New(svc Service) http.Handler {
	s := &Server{svc: svc}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/v1/orders", s.create)
	mux.HandleFunc("GET /api/v1/orders", s.list)
	mux.HandleFunc("GET /api/v1/orders/{id}", s.get)
	mux.HandleFunc("POST /api/v1/orders/{id}/cancel", s.cancel)
	mux.HandleFunc("POST /api/v1/orders/{id}/pay", s.pay)
	return mux
}
func (s *Server) create(w http.ResponseWriter, r *http.Request) {
	key := r.Header.Get("Idempotency-Key")
	if !keyRE.MatchString(key) {
		writeError(w, 400, "INVALID_ARGUMENT", "Idempotency-Key must be 16-128 URL-safe characters")
		return
	}
	var body struct {
		CustomerID string `json:"customer_id"`
		Items      []Item `json:"items"`
		Currency   string `json:"currency"`
	}
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
	decoder := json.NewDecoder(r.Body)
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(&body); err != nil {
		writeError(w, 400, "INVALID_ARGUMENT", "invalid JSON body")
		return
	}
	if len(body.CustomerID) == 0 || len(body.CustomerID) > 64 {
		writeError(w, 400, "INVALID_ARGUMENT", "customer_id must be 1-64 characters")
		return
	}
	if len(body.Items) < 1 || len(body.Items) > 100 {
		writeError(w, 400, "INVALID_ARGUMENT", "items must contain 1-100 entries")
		return
	}
	for _, it := range body.Items {
		if strings.TrimSpace(it.SKU) == "" || it.Quantity < 1 || it.Quantity > 99 || it.UnitPriceCents < 0 {
			writeError(w, 400, "INVALID_ARGUMENT", "invalid item")
			return
		}
	}
	if !currencyRE.MatchString(body.Currency) {
		writeError(w, 400, "INVALID_ARGUMENT", "currency must be a 3-letter ISO code")
		return
	}
	order, replayed, err := s.svc.Create(r.Context(), body.CustomerID, body.Items, body.Currency, key)
	if err != nil {
		writeServiceError(w, err)
		return
	}
	w.Header().Set("Location", "/api/v1/orders/"+order.ID)
	w.Header().Set("ETag", strconv.Quote(strconv.Itoa(order.Version)))
	if replayed {
		w.Header().Set("Idempotent-Replayed", "true")
		writeJSON(w, 200, order)
		return
	}
	writeJSON(w, 201, order)
}
func (s *Server) get(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if !idRE.MatchString(id) {
		writeError(w, 400, "INVALID_ARGUMENT", "id is invalid")
		return
	}
	order, err := s.svc.Get(r.Context(), id)
	if err != nil {
		writeServiceError(w, err)
		return
	}
	w.Header().Set("ETag", strconv.Quote(strconv.Itoa(order.Version)))
	writeJSON(w, 200, order)
}
func (s *Server) cancel(w http.ResponseWriter, r *http.Request) { s.mutate(w, r, s.svc.Cancel) }
func (s *Server) pay(w http.ResponseWriter, r *http.Request)    { s.mutate(w, r, s.svc.Pay) }
func (s *Server) mutate(w http.ResponseWriter, r *http.Request,
	command func(context.Context, string, int) (Order, error)) {
	id := r.PathValue("id")
	if !idRE.MatchString(id) {
		writeError(w, 400, "INVALID_ARGUMENT", "id is invalid")
		return
	}
	version, err := parseIfMatch(r.Header.Get("If-Match"))
	if err != nil {
		writeError(w, 400, "INVALID_ARGUMENT", err.Error())
		return
	}
	order, err := command(r.Context(), id, version)
	if err != nil {
		writeServiceError(w, err)
		return
	}
	w.Header().Set("ETag", strconv.Quote(strconv.Itoa(order.Version)))
	writeJSON(w, 200, order)
}
func (s *Server) list(w http.ResponseWriter, r *http.Request) {
	query := r.URL.Query()
	customerID, status, limit := query.Get("customer_id"), query.Get("status"), 20
	if status != "" && !validStatus[status] {
		writeError(w, 400, "INVALID_ARGUMENT", "unknown status")
		return
	}
	if raw := query.Get("limit"); raw != "" {
		n, err := strconv.Atoi(raw)
		if err != nil || n < 1 || n > 100 {
			writeError(w, 400, "INVALID_ARGUMENT", "limit must be an integer in 1-100")
			return
		}
		limit = n
	}
	orders, err := s.svc.List(r.Context(), customerID, status, limit)
	if err != nil {
		writeServiceError(w, err)
		return
	}
	writeJSON(w, 200, map[string]any{"items": orders, "limit": limit})
}
func parseIfMatch(raw string) (int, error) {
	if raw == "" {
		return 0, nil
	}
	if len(raw) < 3 || raw[0] != '"' || raw[len(raw)-1] != '"' {
		return 0, errIfMatch
	}
	version, err := strconv.Atoi(raw[1 : len(raw)-1])
	if err != nil || version < 1 {
		return 0, errIfMatch
	}
	return version, nil
}
func writeServiceError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, ErrNotFound):
		writeError(w, 404, "NOT_FOUND", "order not found")
	case errors.Is(err, ErrConflict):
		writeError(w, 409, "VERSION_CONFLICT", "order version changed")
	case errors.Is(err, ErrIdempotencyConflict):
		writeError(w, 409, "IDEMPOTENCY_CONFLICT", "idempotency key reused with a different request")
	case errors.Is(err, ErrInvalidState):
		writeError(w, 422, "INVALID_STATE", "command is not allowed in the current state")
	default:
		writeError(w, 500, "INTERNAL", "internal error")
	}
}
func writeError(w http.ResponseWriter, status int, code, message string) {
	writeJSON(w, status, map[string]string{"code": code, "message": message, "request_id": ""})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(value)
}

用表驱动测试固定边界

测试用可注入错误的 fakeService 覆盖成功、参数错误、未找到、版本冲突、非法状态与内部错误,断言状态码和错误码而非完整 JSON,避免无关字段调整触发失败。

// file: internal/platform/httpserver/server_test.go
// tier: L1
// verify: go test ./internal/platform/httpserver/ -v
package httpserver

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

type fakeService struct {
	err     error
	version int
}

func (f *fakeService) Create(context.Context, string, []Item, string, string) (Order, bool, error) {
	return Order{ID: "o-1", Version: 1}, false, f.err
}
func (f *fakeService) Get(_ context.Context, id string) (Order, error) {
	return Order{ID: id, Version: 2}, f.err
}
func (f *fakeService) Cancel(_ context.Context, _ string, version int) (Order, error) {
	f.version = version
	return Order{ID: "o-1", Version: version + 1}, f.err
}
func (f *fakeService) Pay(ctx context.Context, id string, version int) (Order, error) {
	return f.Cancel(ctx, id, version)
}
func (f *fakeService) List(context.Context, string, string, int) ([]Order, error) { return nil, f.err }
func TestHTTPContract(t *testing.T) {
	valid := `{"customer_id":"c-1","currency":"CNY","items":[{"sku":"s","quantity":1,"unit_price_cents":500}]}`
	cases := []struct {
		name, method, target, key, body, ifMatch string
		svcErr                                   error
		wantStatus                               int
		wantCode                                 string
		wantVersion                              int
	}{
		{"create", http.MethodPost, "/api/v1/orders", strings.Repeat("k", 16), valid, "", nil, 201, "", 0},
		{"create missing key", http.MethodPost, "/api/v1/orders", "", valid, "", nil, 400, "INVALID_ARGUMENT", 0},
		{"create key conflict", http.MethodPost, "/api/v1/orders", strings.Repeat("k", 16), valid, "", ErrIdempotencyConflict, 409, "IDEMPOTENCY_CONFLICT", 0},
		{"create bad item", http.MethodPost, "/api/v1/orders", strings.Repeat("k", 16),
			`{"customer_id":"c-1","currency":"CNY","items":[{"sku":"s","quantity":0,"unit_price_cents":1}]}`, "", nil, 400, "INVALID_ARGUMENT", 0},
		{"get missing", http.MethodGet, "/api/v1/orders/o-1", "", "", "", ErrNotFound, 404, "NOT_FOUND", 0},
		{"cancel conflict", http.MethodPost, "/api/v1/orders/o-1/cancel", "", "", `"2"`, ErrConflict, 409, "VERSION_CONFLICT", 2},
		{"pay invalid state", http.MethodPost, "/api/v1/orders/o-1/pay", "", "", "", ErrInvalidState, 422, "INVALID_STATE", 0},
		{"list bad limit", http.MethodGet, "/api/v1/orders?limit=0", "", "", "", nil, 400, "INVALID_ARGUMENT", 0},
		{"internal", http.MethodGet, "/api/v1/orders/o-1", "", "", "", context.DeadlineExceeded, 500, "INTERNAL", 0},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			fake := &fakeService{err: tc.svcErr}
			req := httptest.NewRequest(tc.method, tc.target, strings.NewReader(tc.body))
			req.Header.Set("Content-Type", "application/json")
			if tc.key != "" {
				req.Header.Set("Idempotency-Key", tc.key)
			}
			if tc.ifMatch != "" {
				req.Header.Set("If-Match", tc.ifMatch)
			}
			rec := httptest.NewRecorder()
			New(fake).ServeHTTP(rec, req)
			if rec.Code != tc.wantStatus {
				t.Fatalf("status = %d, want %d; body=%s", rec.Code, tc.wantStatus, rec.Body)
			}
			if tc.wantCode != "" && !strings.Contains(rec.Body.String(), `"code":"`+tc.wantCode+`"`) {
				t.Fatalf("body = %s, want code %s", rec.Body, tc.wantCode)
			}
			if tc.wantVersion != 0 && fake.version != tc.wantVersion {
				t.Errorf("version = %d, want %d", fake.version, tc.wantVersion)
			}
		})
	}
}

实测

本机 macOS(darwin/arm64,Go 1.26.0)独立模块的真实输出:

$ gofmt -l .
$ go vet ./...
$ go test ./... -count=1
ok  	archlab/internal/platform/httpserver	0.295s

gofmt -l . 与 go vet 均无输出;测试覆盖表中 9 个分支,0.295s 不是性能承诺。

常见错误与边界

第一,把 201 当成“成功”的同义词;重放必须返回 200,否则客户端重试会被监控算成新建,报表偏大。

第二,用幂等键替代业务唯一约束;键只解决同一请求重复提交,不同客户用不同键创建同一笔业务订单仍需业务层去重。

小结

订单 API 的资源边界可以压缩成三句话:订单是资源,取消与支付是订单上的命令,查询永远只读。幂等键负责“同一请求重复到达”,If-Match 负责“基于旧版本的并发写入”,两者不能互相替代。下一节把这份手工契约搬到 OpenAPI,用测试守住漂移。

阅读导航:上一节:4.3 防腐层与外部系统隔离 · 下一节:5.2 契约优先与 OpenAPI 工作流 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查