《后端架构设计·架构基础与边界》10.2 输入边界与数据分级

从订单 API 的 JSON 边界讲到业务边界:64 KiB 体积上限、未知字段拒绝、单对象校验、SKU/数量/金额/总额范围与重复项拒绝,再把日志字段分为 public/internal/pii/payment/secret 五类做脱敏,附表驱动测试与真实 go test 输出。

输入边界不是参数校验清单

参数校验清单只回答“这个字段合不合法”,输入边界要回答三个更靠前的问题:请求体允许多大、哪些字段根本不该出现、解析失败时系统暴露多少信息。前者写起来很快,后者决定了一个畸形请求能不能把内存、日志或下游服务拖垮。

订单 API 的输入边界分两层。JSON 层负责体积、字段集合和对象数量;业务层负责格式、范围、重复与总额。两层都通过之后,数据才进入领域模型。这里不重复 API 契约的写法,契约与错误模型可以回看 5.3 错误模型、版本演进与兼容性 ;本节只关心“不可信输入在哪里被截断”。数据分级与输入边界是同一件事的两面:进来的字段要先知道它属于哪一级,出去写日志时才知道该保留、掩码还是完全抹掉。很多泄漏不是因为没做脱敏,而是因为字段没有分级,日志库只能一视同仁地打印。

JSON 层先挡三件事

第一,限制请求体大小。arch-lab 用 64 KiB,io.LimitReader 多读一个字节,超过就返回 ErrBodyTooLarge,而不是先完整读进内存再判断。第二,DisallowUnknownFields。客户端多发一个 admin: true,服务端应该拒绝,而不是静默忽略;静默忽略会让“字段名拼错”变成难查的业务问题,也会让攻击者试探内部字段。第三,拒绝对象之后的多余内容,{"a":1}{"b":2} 只接受第一个对象是危险的。

未知字段拒绝有代价:客户端升级与服务端升级必须协调,旧客户端多发字段会被新服务拒绝。折中是只在写接口严格拒绝,读接口忽略;或者用版本化契约,把新增字段放进明确的扩展对象。arch-lab 选择严格拒绝,因为订单创建是写路径,宁可让集成方早失败,也不让脏字段进入领域层。

业务层:范围、重复与总额

字段规则失败码
customer_idcus_ + 6–32 位小写字母数字INVALID_FORMAT
currencyCNY / USD / EURUNSUPPORTED
items1–20 项OUT_OF_RANGE
items[].skuSKU- + 4–12 位大写字母数字INVALID_FORMAT
items[].sku同一订单内不重复DUPLICATE
items[].quantity1–99OUT_OF_RANGE
items[].unit_price_cents1–5,000,000OUT_OF_RANGE
订单总额≤ 20,000,000 分TOTAL_EXCEEDED
note≤200 字符,禁控制字符TOO_LONG / CONTROL_CHAR

金额用整数分,不用浮点。单价上限 5 万元、数量上限 99、总额上限 20 万元,三个上限互相约束:单品超过总额上限没有意义,数量过大则会在库存与支付环节放大风险。总额累加前先判断 total > MaxTotalCents-line,避免 total + line 溢出;虽然本例的取值范围不会溢出,但这个写法在字段上限调整后仍然成立。请求体里的 unit_price_cents 只作为“客户端期望价”,服务端必须用商品目录重新计价。输入校验通过不代表价格可信:如果直接把客户端价格写入订单,攻击者可以把 1999 分改成 1 分。校验器只保证数值在合理区间,价格权威来自目录服务。

校验器:字段错误而不是一个布尔值

Validate 返回 []FieldError,每个错误带字段路径、稳定错误码和内部消息。字段路径让调用方能定位到 items[1].sku,稳定错误码让客户端可以编程处理,内部消息只进日志。对外响应只暴露前两者,避免把正则、上限和实现细节回显给攻击者。

// file: internal/boundary/order.go
// tier: L1
// verify: go test ./internal/boundary/
package boundary

import (
	"bytes"
	"encoding/json"
	"errors"
	"io"
	"regexp"
	"strconv"
	"strings"
	"unicode"
	"unicode/utf8"
)

const (
	MaxBodyBytes  = 64 << 10
	MaxItems      = 20
	MaxNoteRunes  = 200
	MaxTotalCents = 20_000_000 // 单笔订单上限 20 万元
)

var (
	ErrBodyTooLarge = errors.New("boundary: request body too large")
	ErrTrailingData = errors.New("boundary: body must contain exactly one JSON object")
	reCustomer      = regexp.MustCompile(`^cus_[a-z0-9]{6,32}$`)
	reSKU           = regexp.MustCompile(`^SKU-[A-Z0-9]{4,12}$`)
	currencies      = map[string]bool{"CNY": true, "USD": true, "EUR": true}
)

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

type CreateOrder struct {
	CustomerID string `json:"customer_id"`
	Currency   string `json:"currency"`
	Items      []Item `json:"items"`
	Note       string `json:"note"`
}

// FieldError 指到具体字段,便于调用方修正;对外只返回 code 与 field,
// message 仅用于内部日志和测试断言。
type FieldError struct {
	Field   string
	Code    string
	Message string
}

// DecodeCreateOrder 做 JSON 层的三件事:限制体积、拒绝未知字段、
// 拒绝对象之后的多余内容。业务范围校验放在 Validate 里。
func DecodeCreateOrder(r io.Reader) (CreateOrder, error) {
	var req CreateOrder
	raw, err := io.ReadAll(io.LimitReader(r, MaxBodyBytes+1))
	if err != nil {
		return req, err
	}
	if len(raw) > MaxBodyBytes {
		return req, ErrBodyTooLarge
	}
	dec := json.NewDecoder(bytes.NewReader(raw))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&req); err != nil {
		return req, err
	}
	if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		return req, ErrTrailingData
	}
	return req, nil
}

func Validate(req CreateOrder) []FieldError {
	var errs []FieldError
	add := func(field, code, msg string) {
		errs = append(errs, FieldError{Field: field, Code: code, Message: msg})
	}
	if !reCustomer.MatchString(req.CustomerID) {
		add("customer_id", "INVALID_FORMAT", "want cus_ plus 6-32 lowercase alnum")
	}
	if !currencies[req.Currency] {
		add("currency", "UNSUPPORTED", "want one of CNY, USD, EUR")
	}
	if len(req.Items) == 0 || len(req.Items) > MaxItems {
		add("items", "OUT_OF_RANGE", "want 1-20 items")
	}
	if utf8.RuneCountInString(req.Note) > MaxNoteRunes {
		add("note", "TOO_LONG", "note exceeds 200 runes")
	}
	if strings.ContainsFunc(req.Note, func(r rune) bool { return r != '\n' && r != '\t' && unicode.IsControl(r) }) {
		add("note", "CONTROL_CHAR", "control characters are not allowed")
	}
	seen := make(map[string]bool, len(req.Items))
	var total int64
	for i, it := range req.Items {
		prefix := "items[" + strconv.Itoa(i) + "]"
		if !reSKU.MatchString(it.SKU) {
			add(prefix+".sku", "INVALID_FORMAT", "want SKU- plus 4-12 uppercase alnum")
		}
		if seen[it.SKU] {
			add(prefix+".sku", "DUPLICATE", "sku appears more than once")
		}
		seen[it.SKU] = true
		if it.Quantity < 1 || it.Quantity > 99 {
			add(prefix+".quantity", "OUT_OF_RANGE", "want 1-99")
		}
		if it.UnitPriceCents < 1 || it.UnitPriceCents > 5_000_000 {
			add(prefix+".unit_price_cents", "OUT_OF_RANGE", "want 1-5000000")
		}
		line := it.UnitPriceCents * int64(it.Quantity)
		if line > 0 && total > MaxTotalCents-line {
			add("items", "TOTAL_EXCEEDED", "order total exceeds 20000000 cents")
			continue
		}
		total += line
	}
	return errs
}

note 按 rune 计数而不是字节,避免中文备注被过早截断;控制字符允许换行和制表,其余拒绝,防止日志注入和终端转义序列。sku 用大写字母数字的固定前缀,是为了让来源可辨认,不是为了安全;真正的商品存在性由目录服务判断。

数据分级的四类

级别例子日志策略
public订单号、商品 SKU原样
internal状态、金额、耗时原样,但控制基数
pii邮箱、手机、姓名、地址、IP掩码
payment卡号、CVV、银行账号只留后四位,CVV 不记录
secret密码、令牌、API key完全抹掉

分级放在字段声明处,而不是日志调用处。同一个 customer_email 在订单服务、通知服务和风控服务里都应该是 pii;如果每个调用点各自决定要不要脱敏,迟早有一个漏掉。分级也要落在数据字典里,和字段的血缘、保留期放在一起,否则新服务接入时只能靠猜。CVV 特殊:它不是“掩码后可以记录”,而是任何日志、追踪、事件里都不应该出现。分级表里把它和卡号放在同一级,但策略要单独写成“禁止落盘”。同理,完整的 Authorization 头和支付回调签名也不能进日志,它们属于 secret。

脱敏是分级的下游

脱敏函数按级别分派,而不是按字段名分派。邮箱保留首字符和域名,手机号保留后四位,姓名保留首字,卡号只留后四位;未知级别默认抹掉,宁可少记日志,也不要因为新增枚举值而泄漏。域名保留是运维需要,它不指向具体个人。

// file: internal/boundary/redact.go
// tier: L1
// verify: go test ./internal/boundary/
package boundary

import (
	"strings"
	"unicode/utf8"
)

// Class 是日志字段的分级。分级在前,脱敏在后:先声明字段属于哪一类,
// 再按类选择脱敏策略,避免每个调用点各写一套正则。
type Class int

const (
	Public Class = iota
	Internal
	PII
	Payment
	Secret
)

func MaskValue(c Class, v string) string {
	switch c {
	case Public, Internal:
		return v
	case PII:
		return maskPII(v)
	case Payment:
		return maskPayment(v)
	default:
		return "[REDACTED]"
	}
}

func maskPII(v string) string {
	if at := strings.IndexByte(v, '@'); at > 0 {
		r, _ := utf8.DecodeRuneInString(v[:at])
		return string(r) + "***" + v[at:]
	}
	if d := digits(v); len(d) >= 7 {
		return "***" + d[len(d)-4:]
	}
	r, size := utf8.DecodeRuneInString(v)
	if size == 0 {
		return ""
	}
	return string(r) + "**"
}

func maskPayment(v string) string {
	d := digits(v)
	if len(d) < 4 {
		return "****"
	}
	return "****" + d[len(d)-4:]
}

func digits(s string) string {
	var b strings.Builder
	for _, r := range s {
		if r >= '0' && r <= '9' {
			b.WriteRune(r)
		}
	}
	return b.String()
}

这个实现刻意不追求“看起来像原值”。掩码的目标是让日志仍然可读、可关联,而不是保留格式供人还原。卡号保留后四位是为了客服核对,邮箱保留域名是为了排查投递问题;除此之外的字符都应该消失。

表驱动测试:八类坏输入

校验测试用变异法:先构造一条合法订单,每次只改一个字段,断言返回的错误字段与错误码。这样新增规则时只需加一行,不会把测试写成大段重复代码。JSON 层单独测未知字段、尾随对象和超限体积;脱敏用一张输入输出表。

// file: internal/boundary/boundary_test.go
// tier: L1
// verify: go test ./internal/boundary/
package boundary

import (
	"errors"
	"strings"
	"testing"
)

func validOrder() CreateOrder {
	return CreateOrder{
		CustomerID: "cus_abc123",
		Currency:   "CNY",
		Items:      []Item{{SKU: "SKU-AB12", Quantity: 2, UnitPriceCents: 1999}},
		Note:       "请放前台\n谢谢",
	}
}

func TestValidateTable(t *testing.T) {
	cases := []struct {
		name  string
		mut   func(*CreateOrder)
		field string
		code  string
	}{
		{"customer malformed", func(o *CreateOrder) { o.CustomerID = "CUS_123" }, "customer_id", "INVALID_FORMAT"},
		{"currency unsupported", func(o *CreateOrder) { o.Currency = "JPY" }, "currency", "UNSUPPORTED"},
		{"no items", func(o *CreateOrder) { o.Items = nil }, "items", "OUT_OF_RANGE"},
		{"bad sku", func(o *CreateOrder) { o.Items[0].SKU = "sku-ab" }, "items[0].sku", "INVALID_FORMAT"},
		{"duplicate sku", func(o *CreateOrder) { o.Items = append(o.Items, o.Items[0]) }, "items[1].sku", "DUPLICATE"},
		{"quantity zero", func(o *CreateOrder) { o.Items[0].Quantity = 0 }, "items[0].quantity", "OUT_OF_RANGE"},
		{"note control char", func(o *CreateOrder) { o.Note = "ok\x00hidden" }, "note", "CONTROL_CHAR"},
		{"total exceeded", func(o *CreateOrder) {
			o.Items = []Item{{SKU: "SKU-AB12", Quantity: 99, UnitPriceCents: 5_000_000}}
		}, "items", "TOTAL_EXCEEDED"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			o := validOrder()
			tc.mut(&o)
			if !hasFieldErr(Validate(o), tc.field, tc.code) {
				t.Fatalf("want %s/%s, got %v", tc.field, tc.code, Validate(o))
			}
		})
	}
}

func TestDecodeRejectsUnknownAndTrailing(t *testing.T) {
	base := `{"customer_id":"cus_abc123","currency":"CNY","items":[{"sku":"SKU-AB12","quantity":1,"unit_price_cents":100}]}`
	if _, err := DecodeCreateOrder(strings.NewReader(base)); err != nil {
		t.Fatalf("valid body rejected: %v", err)
	}
	unknown := strings.Replace(base, `}`, `,"admin":true}`, 1)
	if _, err := DecodeCreateOrder(strings.NewReader(unknown)); err == nil {
		t.Fatal("unknown field accepted")
	}
	if _, err := DecodeCreateOrder(strings.NewReader(base + base)); !errors.Is(err, ErrTrailingData) {
		t.Fatalf("trailing body err = %v, want ErrTrailingData", err)
	}
	big := strings.Repeat(" ", MaxBodyBytes) + base
	if _, err := DecodeCreateOrder(strings.NewReader(big)); !errors.Is(err, ErrBodyTooLarge) {
		t.Fatalf("oversized body err = %v, want ErrBodyTooLarge", err)
	}
}

func TestMaskValueTable(t *testing.T) {
	cases := []struct {
		class Class
		in    string
		want  string
	}{
		{Public, "order", "order"},
		{Internal, "ord_01", "ord_01"},
		{PII, "alice@example.com", "a***@example.com"},
		{PII, "13800138000", "***8000"},
		{PII, "李雷", "李**"},
		{Payment, "6222 0202 0011 2233 445", "****3445"},
		{Secret, "sk_live_abc", "[REDACTED]"},
	}
	for _, tc := range cases {
		if got := MaskValue(tc.class, tc.in); got != tc.want {
			t.Fatalf("MaskValue(%d, %q) = %q, want %q", tc.class, tc.in, got, tc.want)
		}
	}
}

func hasFieldErr(errs []FieldError, field, code string) bool {
	for _, e := range errs {
		if e.Field == field && e.Code == code {
			return true
		}
	}
	return false
}

实测输出

模块为 archlab(go 1.26.0),执行 gofmt -l .、go vet ./...、go test ./internal/boundary/ -v,格式化与静态检查无输出,测试输出节选如下(省略子测试行)。八个变异用例命中预期字段与错误码,未知字段、尾随对象和 64 KiB 超限请求都被挡在解码层,七条脱敏断言全部通过:

=== RUN   TestValidateTable
--- PASS: TestValidateTable (0.00s)
=== RUN   TestDecodeRejectsUnknownAndTrailing
--- PASS: TestDecodeRejectsUnknownAndTrailing (0.00s)
=== RUN   TestMaskValueTable
--- PASS: TestMaskValueTable (0.00s)
PASS
ok  	archlab/internal/boundary	0.603s

常见错误与边界

第一,只校验类型不校验范围。int 能装下负数,string 能装下一兆字节。第二,忽略未知字段。静默忽略会让拼写错误变成脏数据。第三,把客户端价格当权威。校验通过不等于可信,价格必须由服务端目录重算。第四,错误响应泄漏结构体字段名,日志默认记录完整请求体。这两处都在校验器之外,却同样属于输出边界。第五,按字段名散落脱敏。新增一个 contact_email 就漏一次,应该按分级统一处理。第六,把 CVV 当成“掩码后可存”。它不是可脱敏字段,而是禁止记录字段。

边界也要写清楚:本节只覆盖同步 JSON API,文件上传、表单、gRPC 和消息体需要各自的体积与解析边界;分级只解决日志与展示,不替代加密、访问控制和保留期策略;DisallowUnknownFields 对重复键无效(后值覆盖前值),严格场景需要额外的 schema 校验。输入校验是纵深防御的一层,不是唯一一层。

小结

输入边界分两层:JSON 层限制体积、拒绝未知字段与尾随对象;业务层限制格式、范围、重复与总额。校验器返回字段级错误而不是一个布尔值,稳定错误码给客户端,内部消息进日志。数据分为 public、internal、pii、payment、secret 五级,脱敏按级别分派,未知级别默认抹掉。八个表驱动用例、四条解码断言和七条脱敏断言在 gofmt、go vet、go test 下全部通过,畸形输入在进入领域模型之前就被截断。

阅读导航:上一节:10.1 认证、授权与服务间身份 · 下一节:10.3 第三方支付渠道的信任边界与密钥管理 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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