实现七月迭代公共技术基础
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
This commit is contained in:
124
pkg/idempotency/fingerprint.go
Normal file
124
pkg/idempotency/fingerprint.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Package idempotency 提供创建类命令的幂等身份与请求指纹公共契约。
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
const (
|
||||
// FingerprintAlgorithmV1 是请求指纹算法的首个稳定版本。
|
||||
FingerprintAlgorithmV1 = "sha256-canonical-json-v1"
|
||||
)
|
||||
|
||||
// Scope 表示请求 ID 的幂等作用域。
|
||||
type Scope struct {
|
||||
Subject string `json:"subject"`
|
||||
Operation string `json:"operation"`
|
||||
}
|
||||
|
||||
// FingerprintValue 表示带算法版本的请求指纹。
|
||||
type FingerprintValue struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Record 表示业务模块自行持久化的幂等事实最小投影。
|
||||
type Record struct {
|
||||
Scope Scope `json:"scope"`
|
||||
RequestID string `json:"request_id"`
|
||||
Fingerprint FingerprintValue `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// ReplayKind 表示已有事实与本次命令的关系。
|
||||
type ReplayKind string
|
||||
|
||||
const (
|
||||
// ReplaySame 表示相同命令重放,应返回原业务结果。
|
||||
ReplaySame ReplayKind = "same"
|
||||
// ReplayConflict 表示同作用域、同请求 ID 携带了不同业务内容。
|
||||
ReplayConflict ReplayKind = "conflict"
|
||||
// ReplayUnrelated 表示不是同一幂等作用域内的命令。
|
||||
ReplayUnrelated ReplayKind = "unrelated"
|
||||
)
|
||||
|
||||
var volatileFieldNames = map[string]struct{}{
|
||||
"authorization": {},
|
||||
"cookie": {},
|
||||
"nonce": {},
|
||||
"sign": {},
|
||||
"signature": {},
|
||||
"timestamp": {},
|
||||
"token": {},
|
||||
}
|
||||
|
||||
// Fingerprint 对影响业务结果的规范化字段计算稳定指纹。
|
||||
//
|
||||
// 调用方仍应优先传入专用命令 DTO;本函数会递归排除约定的易变传输字段,
|
||||
// 并去除字符串首尾空白,避免签名、令牌或对象字段顺序污染业务身份。
|
||||
func Fingerprint(command any) (FingerprintValue, error) {
|
||||
raw, err := sonic.ConfigStd.Marshal(command)
|
||||
if err != nil {
|
||||
return FingerprintValue{}, err
|
||||
}
|
||||
var value any
|
||||
if err := sonic.Unmarshal(raw, &value); err != nil {
|
||||
return FingerprintValue{}, err
|
||||
}
|
||||
normalized := normalize(value)
|
||||
canonical, err := sonic.ConfigStd.Marshal(normalized)
|
||||
if err != nil {
|
||||
return FingerprintValue{}, err
|
||||
}
|
||||
sum := sha256.Sum256(canonical)
|
||||
return FingerprintValue{
|
||||
Algorithm: FingerprintAlgorithmV1,
|
||||
Value: hex.EncodeToString(sum[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateScope 校验幂等作用域和稳定请求 ID 是否完整。
|
||||
func ValidateScope(scope Scope, requestID string) bool {
|
||||
return strings.TrimSpace(scope.Subject) != "" &&
|
||||
strings.TrimSpace(scope.Operation) != "" &&
|
||||
strings.TrimSpace(requestID) != ""
|
||||
}
|
||||
|
||||
// Classify 比较已有幂等事实与本次命令。
|
||||
func Classify(existing Record, scope Scope, requestID string, fingerprint FingerprintValue) ReplayKind {
|
||||
if existing.Scope != scope || existing.RequestID != requestID {
|
||||
return ReplayUnrelated
|
||||
}
|
||||
if reflect.DeepEqual(existing.Fingerprint, fingerprint) {
|
||||
return ReplaySame
|
||||
}
|
||||
return ReplayConflict
|
||||
}
|
||||
|
||||
func normalize(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
if _, volatile := volatileFieldNames[strings.ToLower(strings.TrimSpace(key))]; volatile {
|
||||
continue
|
||||
}
|
||||
result[key] = normalize(item)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
result[index] = normalize(item)
|
||||
}
|
||||
return result
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
58
pkg/idempotency/fingerprint_test.go
Normal file
58
pkg/idempotency/fingerprint_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package idempotency_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/idempotency"
|
||||
)
|
||||
|
||||
func TestFingerprintIgnoresTransportFieldsAndNormalizesObjectOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
left, err := idempotency.Fingerprint(map[string]any{
|
||||
"amount": 100,
|
||||
"remark": " 月度充值 ",
|
||||
"token": "token-a",
|
||||
"nested": map[string]any{"enabled": true, "count": 2},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("计算第一个请求指纹失败:%v", err)
|
||||
}
|
||||
right, err := idempotency.Fingerprint(map[string]any{
|
||||
"nested": map[string]any{"count": 2, "enabled": true},
|
||||
"timestamp": 1720000000,
|
||||
"remark": "月度充值",
|
||||
"amount": 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("计算第二个请求指纹失败:%v", err)
|
||||
}
|
||||
|
||||
if left.Algorithm != idempotency.FingerprintAlgorithmV1 {
|
||||
t.Fatalf("算法版本不正确:%s", left.Algorithm)
|
||||
}
|
||||
if left.Value != right.Value {
|
||||
t.Fatalf("仅传输字段或对象顺序变化不应改变指纹:%s != %s", left.Value, right.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyReplayDistinguishesScopeAndConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := idempotency.Record{
|
||||
Scope: idempotency.Scope{Subject: "account:7", Operation: "agent-recharge.create"},
|
||||
RequestID: "request-1",
|
||||
Fingerprint: idempotency.FingerprintValue{Algorithm: idempotency.FingerprintAlgorithmV1, Value: "abc"},
|
||||
}
|
||||
|
||||
if got := idempotency.Classify(original, original.Scope, "request-1", original.Fingerprint); got != idempotency.ReplaySame {
|
||||
t.Fatalf("相同命令应识别为重放,得到:%s", got)
|
||||
}
|
||||
if got := idempotency.Classify(original, original.Scope, "request-1", idempotency.FingerprintValue{Algorithm: idempotency.FingerprintAlgorithmV1, Value: "def"}); got != idempotency.ReplayConflict {
|
||||
t.Fatalf("同作用域同请求 ID 的不同内容应冲突,得到:%s", got)
|
||||
}
|
||||
otherScope := idempotency.Scope{Subject: "account:8", Operation: original.Scope.Operation}
|
||||
if got := idempotency.Classify(original, otherScope, "request-1", original.Fingerprint); got != idempotency.ReplayUnrelated {
|
||||
t.Fatalf("不同主体不应相互污染,得到:%s", got)
|
||||
}
|
||||
}
|
||||
86
pkg/idempotency/postgres_integration_test.go
Normal file
86
pkg/idempotency/postgres_integration_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package idempotency_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/idempotency"
|
||||
)
|
||||
|
||||
func TestPostgresUniqueConstraintArbitratesConcurrentFirstSubmission(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
if err := db.Exec(`
|
||||
CREATE TEMP TABLE test_command_idempotency (
|
||||
subject varchar(100) NOT NULL,
|
||||
operation varchar(100) NOT NULL,
|
||||
request_id varchar(100) NOT NULL,
|
||||
fingerprint varchar(128) NOT NULL,
|
||||
result_id bigint NOT NULL,
|
||||
UNIQUE (subject, operation, request_id)
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建幂等契约测试表失败:%v", err)
|
||||
}
|
||||
|
||||
fingerprint, err := idempotency.Fingerprint(map[string]any{"amount": 100})
|
||||
if err != nil {
|
||||
t.Fatalf("计算指纹失败:%v", err)
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan int64, 2)
|
||||
errs := make(chan error, 2)
|
||||
var wait sync.WaitGroup
|
||||
for _, resultID := range []int64{101, 102} {
|
||||
wait.Add(1)
|
||||
go func(id int64) {
|
||||
defer wait.Done()
|
||||
<-start
|
||||
result := db.WithContext(context.Background()).Exec(`
|
||||
INSERT INTO test_command_idempotency
|
||||
(subject, operation, request_id, fingerprint, result_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (subject, operation, request_id) DO NOTHING`,
|
||||
"account:7", "agent-recharge.create", "request-1", fingerprint.Value, id,
|
||||
)
|
||||
results <- result.RowsAffected
|
||||
errs <- result.Error
|
||||
}(resultID)
|
||||
}
|
||||
close(start)
|
||||
wait.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
|
||||
var inserted int64
|
||||
for resultErr := range errs {
|
||||
if resultErr != nil {
|
||||
t.Fatalf("并发写入失败:%v", resultErr)
|
||||
}
|
||||
}
|
||||
for affected := range results {
|
||||
inserted += affected
|
||||
}
|
||||
if inserted != 1 {
|
||||
t.Fatalf("并发首写必须只有一个数据库事实,实际插入:%d", inserted)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Table("test_command_idempotency").Count(&count).Error; err != nil {
|
||||
t.Fatalf("统计幂等事实失败:%v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("幂等事实数量错误:%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotencyScopeDoesNotDependOnRedis(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scope := idempotency.Scope{Subject: "account:7", Operation: "example.create"}
|
||||
if !idempotency.ValidateScope(scope, "request-1") {
|
||||
t.Fatal("完整作用域和请求 ID 应通过校验")
|
||||
}
|
||||
// 公共幂等契约没有 Redis 参数;Redis 仅可由业务作为削峰优化,不能参与最终裁决。
|
||||
}
|
||||
Reference in New Issue
Block a user