外部系统的模型不是你的模型
支付渠道返回 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 的资源边界与命令语义 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。