《后端架构设计·架构基础与边界》4.3 防腐层与外部系统隔离

第三方支付渠道有自己的状态码、签名与回调重试。本篇在 arch-lab 写一个可运行的防腐层:外部 DTO 到领域事件的翻译、失败码映射、未知状态兜底、回调验签与重放保护,并用表驱动测试覆盖成功、拒付、未知状态、签名错误与重复回调,最后讨论引入 ACL 的代价与不值得引入的判据。

外部系统的模型不是你的模型

支付渠道返回 succeeded、failed、pending,物流接口返回 IN_TRANSIT、DELIVERED、EXCEPTION,每个渠道还有自己的签名算法、错误码和重试策略。如果这些字符串直接进入订单状态机,领域模型就会被外部契约牵着走:渠道改一个状态名,订单代码跟着改;渠道多一个未知状态,系统要么崩溃,要么悄悄当成成功。站内暂时没有以“防腐层”为题的专文,依赖倒置的原则可以参看 六边形架构 ;本节补的是 arch-lab 里可运行的 ACL 实现。

防腐层(Anti-Corruption Layer,ACL)的职责不是“再包一层”,而是在边界上做三件明确的事:把外部 DTO 翻译成领域类型、把外部错误码映射成领域错误、把无法识别的输入显式隔离。它不替领域做决策,也不把外部字段原样透传。

ACL 的职责边界

职责ACL 做ACL 不做
数据翻译外部 DTO → payment.Event不把渠道字段塞进领域结构体
状态映射已知状态查表,未知状态标记 UNKNOWN不猜测未知状态是成功还是失败
错误映射渠道错误码 → 领域拒付原因不决定订单是否应该取消
签名验证回调来源与完整性不判断金额是否与订单一致
重放按 event_id 去重不实现订单幂等(那是领域职责)

这张表是后面所有代码的判据。只要某段逻辑需要知道“订单当前状态”“金额对不对”“能不能退款”,它就不属于 ACL。

领域事件与外部 DTO

先定义领域认识的最小事实集。payment.Event 只有事件 ID、渠道引用、订单 ID、状态、金额、币种和拒付原因,没有任何渠道私有字段:

// file: internal/payment/payment.go
// tier: L1
// verify: go test ./internal/payment/
package payment

import "errors"

type Status string

const (
	StatusAuthorized Status = "AUTHORIZED"
	StatusDeclined   Status = "DECLINED"
	StatusPending    Status = "PENDING"
	StatusRefunded   Status = "REFUNDED"
	StatusUnknown    Status = "UNKNOWN"
)

type DeclineReason string

const (
	ReasonNone              DeclineReason = ""
	ReasonCardDeclined      DeclineReason = "CARD_DECLINED"
	ReasonInsufficientFunds DeclineReason = "INSUFFICIENT_FUNDS"
	ReasonExpiredCard       DeclineReason = "EXPIRED_CARD"
	ReasonUnknown           DeclineReason = "UNKNOWN"
)

// Event 是领域认识的最小事实集,不含外部渠道的原始字段。
type Event struct {
	EventID     string
	ProviderRef string
	OrderID     string
	Status      Status
	AmountCents int64
	Currency    string
	Reason      DeclineReason
}

var (
	ErrMalformed      = errors.New("malformed callback")
	ErrSignature      = errors.New("invalid signature")
	ErrDuplicateEvent = errors.New("duplicate event")
	ErrUnknownStatus  = errors.New("unknown provider status")
)

外部 DTO 与翻译器放在 internal/adapters/paymentgw。状态表和原因表是唯一允许外部字符串进入领域的地方:

// file: internal/adapters/paymentgw/translator.go
// tier: L1
// verify: go test ./internal/adapters/paymentgw/ -v
package paymentgw

import (
	"context"
	"fmt"
	"strings"

	"archlab/internal/payment"
)

// Callback 是外部渠道的 DTO,字段名、状态字符串和签名格式都由渠道决定,
// 只在适配器内部出现,不进入领域包。
type Callback struct {
	EventID     string
	ProviderRef string
	OrderID     string
	Status      string
	FailureCode string
	AmountCents int64
	Currency    string
	Signature   string
	Payload     []byte // 参与验签的原始字节,必须是解码前的 body
}

type SignatureVerifier interface {
	Verify(payload []byte, signature string) error
}

// ReplayStore.MarkSeen 返回 true 表示首次出现,false 表示重复。
type ReplayStore interface {
	MarkSeen(eventID string) bool
}

type Translator struct {
	verifier SignatureVerifier
	replay   ReplayStore
}

func NewTranslator(verifier SignatureVerifier, replay ReplayStore) *Translator {
	return &Translator{verifier: verifier, replay: replay}
}

var statusTable = map[string]payment.Status{
	"succeeded": payment.StatusAuthorized,
	"failed":    payment.StatusDeclined,
	"pending":   payment.StatusPending,
	"refunded":  payment.StatusRefunded,
}

var reasonTable = map[string]payment.DeclineReason{
	"card_declined":      payment.ReasonCardDeclined,
	"insufficient_funds": payment.ReasonInsufficientFunds,
	"expired_card":       payment.ReasonExpiredCard,
}

func (t *Translator) Translate(ctx context.Context, cb Callback) (payment.Event, error) {
	if err := ctx.Err(); err != nil {
		return payment.Event{}, err
	}
	if cb.EventID == "" || cb.ProviderRef == "" || cb.OrderID == "" ||
		cb.Currency == "" || cb.AmountCents <= 0 || len(cb.Payload) == 0 {
		return payment.Event{}, fmt.Errorf("%w: missing required fields", payment.ErrMalformed)
	}
	// 先验签,再消费重放标记:签名错误的请求不应该占用 event_id。
	if err := t.verifier.Verify(cb.Payload, cb.Signature); err != nil {
		return payment.Event{}, fmt.Errorf("%w: %v", payment.ErrSignature, err)
	}
	if !t.replay.MarkSeen(cb.EventID) {
		return payment.Event{}, fmt.Errorf("%w: %s", payment.ErrDuplicateEvent, cb.EventID)
	}

	event := payment.Event{
		EventID: cb.EventID, ProviderRef: cb.ProviderRef, OrderID: cb.OrderID,
		AmountCents: cb.AmountCents, Currency: strings.ToUpper(cb.Currency),
	}
	status, ok := statusTable[strings.ToLower(cb.Status)]
	if !ok {
		event.Status = payment.StatusUnknown
		return event, fmt.Errorf("%w: %q", payment.ErrUnknownStatus, cb.Status)
	}
	event.Status = status
	if status == payment.StatusDeclined {
		event.Reason = reasonTable[strings.ToLower(cb.FailureCode)]
		if event.Reason == "" {
			event.Reason = payment.ReasonUnknown
		}
	}
	return event, nil
}

未知状态的处理值得单独说。很多实现会把不认识的字符串默认成失败,或者直接返回成功;前者把“渠道新增状态”变成业务事故,后者更危险。这里的做法是返回 StatusUnknown 和一个 ErrUnknownStatus,调用方既能把事件落库隔离,也能让告警触发人工核对。错误与事件同时返回不是矛盾:事件提供可观测的上下文,错误阻止调用方把它当成正常结果。

签名与重放保护

验签算法由渠道决定,但责任边界固定:ACL 验证“请求确实来自渠道且未被篡改”,不验证“金额与订单一致”。下面用 HMAC 假实现替代真实渠道签名,接口形态不变:

// file: internal/adapters/paymentgw/verify.go
// tier: L1
// verify: go test ./internal/adapters/paymentgw/
package paymentgw

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"sync"
)

// HMACVerifier 用共享密钥验证渠道签名。生产环境应替换为渠道官方算法,
// 但"验签在适配器、失败返回 ErrSignature"这条边界不变。
type HMACVerifier struct {
	Secret []byte
}

func (v HMACVerifier) Verify(payload []byte, signature string) error {
	if len(v.Secret) == 0 {
		return errors.New("paymentgw: empty signing secret")
	}
	mac := hmac.New(sha256.New, v.Secret)
	mac.Write(payload)
	expected := hex.EncodeToString(mac.Sum(nil))
	if !hmac.Equal([]byte(expected), []byte(signature)) {
		return errors.New("paymentgw: signature mismatch")
	}
	return nil
}

// MemoryReplayStore 只用于单进程演示;多实例部署必须换成共享存储,
// 但接口语义仍是"首次返回 true,重复返回 false"。
type MemoryReplayStore struct {
	mu   sync.Mutex
	seen map[string]struct{}
}

func NewMemoryReplayStore() *MemoryReplayStore {
	return &MemoryReplayStore{seen: make(map[string]struct{})}
}

func (s *MemoryReplayStore) MarkSeen(eventID string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, exists := s.seen[eventID]; exists {
		return false
	}
	s.seen[eventID] = struct{}{}
	return true
}

两个顺序细节容易写错。第一,必须用解码前的原始 body 验签,不能先 JSON 反序列化再重新序列化,否则字段顺序和空白变化会让签名永远对不上。第二,先验签再 MarkSeen:如果攻击者用伪造签名刷同一个 event_id,先占位会让真正的回调被误判为重复。

表驱动测试

测试覆盖成功、已知拒付、未知状态、签名错误、字段缺失,以及重复回调。每个用例都用新的翻译器,避免重放状态串味:

// file: internal/adapters/paymentgw/translator_test.go
// tier: L1
// verify: go test ./internal/adapters/paymentgw/ -v
package paymentgw

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"testing"

	"archlab/internal/payment"
)

var testSecret = []byte("fake-gateway-secret")

func sign(payload []byte) string {
	mac := hmac.New(sha256.New, testSecret)
	mac.Write(payload)
	return hex.EncodeToString(mac.Sum(nil))
}

func validCallback() Callback {
	payload := []byte(`{"event_id":"evt-1","status":"succeeded"}`)
	return Callback{
		EventID: "evt-1", ProviderRef: "prov-1", OrderID: "o-1",
		Status: "succeeded", AmountCents: 1000, Currency: "cny",
		Signature: sign(payload), Payload: payload,
	}
}

func newTranslator() *Translator {
	return NewTranslator(HMACVerifier{Secret: testSecret}, NewMemoryReplayStore())
}

func TestTranslateTable(t *testing.T) {
	cases := []struct {
		name       string
		mutate     func(*Callback)
		wantErr    error
		wantStatus payment.Status
		wantReason payment.DeclineReason
	}{
		{"success", func(*Callback) {}, nil, payment.StatusAuthorized, payment.ReasonNone},
		{"declined with known reason", func(cb *Callback) {
			cb.Status, cb.FailureCode = "failed", "card_declined"
		}, nil, payment.StatusDeclined, payment.ReasonCardDeclined},
		{"unknown status is not swallowed", func(cb *Callback) {
			cb.Status = "PROCESSING_V2"
		}, payment.ErrUnknownStatus, payment.StatusUnknown, payment.ReasonNone},
		{"bad signature", func(cb *Callback) {
			cb.Signature = "deadbeef"
		}, payment.ErrSignature, "", payment.ReasonNone},
		{"missing amount", func(cb *Callback) {
			cb.AmountCents = 0
		}, payment.ErrMalformed, "", payment.ReasonNone},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			cb := validCallback()
			tc.mutate(&cb)
			got, err := newTranslator().Translate(context.Background(), cb)
			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("err = %v, want %v", err, tc.wantErr)
			}
			if tc.wantErr == nil && (got.Status != tc.wantStatus || got.Reason != tc.wantReason) {
				t.Fatalf("event = %+v", got)
			}
		})
	}
}

func TestDuplicateCallback(t *testing.T) {
	translator := newTranslator()
	cb := validCallback()
	if _, err := translator.Translate(context.Background(), cb); err != nil {
		t.Fatal(err)
	}
	if _, err := translator.Translate(context.Background(), cb); !errors.Is(err, payment.ErrDuplicateEvent) {
		t.Fatalf("err = %v, want ErrDuplicateEvent", err)
	}
}

func TestBadSignatureDoesNotConsumeEventID(t *testing.T) {
	translator := newTranslator()
	cb := validCallback()
	cb.Signature = "deadbeef"
	if _, err := translator.Translate(context.Background(), cb); !errors.Is(err, payment.ErrSignature) {
		t.Fatalf("err = %v, want ErrSignature", err)
	}
	cb.Signature = sign(cb.Payload)
	if _, err := translator.Translate(context.Background(), cb); err != nil {
		t.Fatalf("valid retry after bad signature: %v", err)
	}
}

真实运行结果

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),在 .local/checks 的临时模块中执行:

$ go test -race ./... -v -count=1
=== RUN   TestTranslateTable
--- PASS: TestTranslateTable (0.00s)
=== RUN   TestDuplicateCallback
--- PASS: TestDuplicateCallback (0.00s)
=== RUN   TestBadSignatureDoesNotConsumeEventID
--- PASS: TestBadSignatureDoesNotConsumeEventID (0.00s)
PASS
ok  	archlab/internal/adapters/paymentgw	1.293s
?   	archlab/internal/payment	[no test files]

ACL 不做什么

第一,不做业务决策。金额是否等于订单应付、订单当前是否允许支付、拒付后要不要取消订单,全部由订单聚合判断。ACL 只把渠道事实翻译成领域事实。第二,不把外部字段透传进领域。Callback 里的 Status、FailureCode、Signature 不会出现在 payment.Event 中;领域只拿到枚举和归一化后的金额、币种。透传会让每次渠道升级都变成领域模型升级。

第三,不吞掉未知状态。未知状态返回 StatusUnknown 加 ErrUnknownStatus,而不是默认失败或默认成功。吞掉未知状态等于把“系统不认识”伪装成“系统确定”,事故时无法区分。

隔离的代价与不值得引入的判据

ACL 不是免费的。第一层代价是多一次翻译:每个外部字段都要有明确的领域归宿,字段缺失时要决定是报错、给默认值还是标记未知。第二层是状态机不匹配:渠道的 pending 可能对应领域的“已授权待捕获”,也可能对应“等待风控”,ACL 只能翻译事实,不能替领域补状态。第三层是维护成本:渠道升级、状态新增、签名算法更换,都要改适配器并补测试。以下情况不值得引入完整 ACL:只调用一次、失败可以人工处理的内部脚本;外部接口的模型本来就与领域一致,且由同一个团队维护;原型阶段还没确定领域模型,过早翻译只会把错误固化。判据不是“外部系统是否重要”,而是“外部模型的变化是否会迫使领域模型变化”。如果答案是肯定的,ACL 值得;如果外部模型稳定且与领域同构,薄适配器就够了。

交接到第 5 章

到这里,卷一的边界工具已经齐了三件:模块描述符与校验器、端口契约与依赖测试、防腐层与外部隔离。下一章进入 API 边界,讨论订单资源如何建模、命令与查询如何分开、POST /api/v1/orders 的语义如何与领域状态机对齐。那是在系统最外层继续使用同一套思路:先定义契约,再写实现。

常见错误与边界

第一,先反序列化再验签。JSON 重新编码可能改变字段顺序和空白,导致合法回调永远验签失败;签名必须针对原始 body。

第二,把重放保护放在验签之前。伪造请求可以提前占用 event_id,让真实回调被当成重复丢弃。

第三,未知状态默认成失败。渠道新增状态时,所有相关订单会被错误拒绝,而且日志里看不出“这是未知状态”。

第四,让 ACL 直接修改订单。ACL 返回领域事件,由应用服务调用订单聚合;一旦 ACL 持有仓储并写订单,边界就消失了。

小结

防腐层是把外部模型挡在领域之外的一层翻译与隔离。arch-lab 用 payment.Event 定义领域事实,用状态表与原因表做映射,用签名与重放保护守住入口,用 StatusUnknown 与 ErrUnknownStatus 显式暴露未知状态,再用表驱动测试覆盖成功、拒付、未知、签名错误与重复回调。它不替领域做决策,也不把外部字段透传进来。下一章从最外层继续:订单 API 的资源边界与命令语义。

阅读导航:上一节:4.2 端口与适配器在 arch-lab 的落地:依赖测试与包边界 · 下一节:5.1 订单 API 的资源边界与命令语义 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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