@stackonward/cli 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +248 -0
  2. package/dist/index.js +14093 -603
  3. package/dist/index.js.map +1 -1
  4. package/dist/resources/deploy/ci/github/caller.yml +38 -0
  5. package/dist/resources/deploy/ci/onedev/import.yml +14 -0
  6. package/dist/resources/deploy/nginx/cache.conf +17 -0
  7. package/dist/resources/deploy/nginx/compression.conf +23 -0
  8. package/dist/resources/deploy/nginx/proxy.conf +15 -0
  9. package/dist/resources/deploy/profiles/go-service/compose.yaml +19 -0
  10. package/dist/resources/deploy/profiles/nuxt-node/Dockerfile +17 -0
  11. package/dist/resources/deploy/profiles/static-files/nginx/site.conf.template +55 -0
  12. package/dist/resources/deploy/profiles/static-nginx/Dockerfile +18 -0
  13. package/dist/resources/deploy/profiles/static-nginx/nginx/default.conf.template +28 -0
  14. package/dist/resources/deploy/targets/compose-host/deploy.config.example.json +114 -0
  15. package/dist/resources/deploy/traffic/nginx/stackonward-nginx-traffic +96 -0
  16. package/dist/resources/registry.json +30 -30
  17. package/dist/resources/templates/base/.claude/docs/architecture.md +4 -0
  18. package/dist/resources/templates/base/AGENTS.md +15 -0
  19. package/dist/resources/templates/base/CLAUDE.md +15 -0
  20. package/dist/resources/templates/go-backend/.dockerignore +25 -3
  21. package/dist/resources/templates/go-backend/deploy.config.json +26 -0
  22. package/dist/resources/templates/go-backend/docs/runtime-provenance.json +9 -6
  23. package/dist/resources/templates/go-backend/docs/source-provenance.json +33 -22
  24. package/dist/resources/templates/go-backend/internal/common/enums/register_locale.go +7 -0
  25. package/dist/resources/templates/go-backend/internal/common/enums/register_locale_test.go +30 -0
  26. package/dist/resources/templates/go-backend/internal/common/locale/locale.go +102 -76
  27. package/dist/resources/templates/go-backend/internal/common/locale/locale_test.go +168 -45
  28. package/dist/resources/templates/go-backend/internal/core/commandreplay/model.go +5 -5
  29. package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence.go +20 -7
  30. package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence_test.go +51 -22
  31. package/dist/resources/templates/go-backend/internal/core/commandreplay/repository.go +2 -2
  32. package/dist/resources/templates/go-backend/internal/core/commandreplay/service.go +13 -24
  33. package/dist/resources/templates/go-backend/internal/core/commandreplay/service_persistence_test.go +98 -13
  34. package/dist/resources/templates/go-backend/internal/core/commandreplay/service_test.go +61 -36
  35. package/dist/resources/templates/go-backend/internal/core/commandreplay/types.go +6 -6
  36. package/dist/resources/templates/go-backend/internal/core/integrationevent/config.go +24 -0
  37. package/dist/resources/templates/go-backend/internal/core/integrationevent/errors.go +53 -0
  38. package/dist/resources/templates/go-backend/internal/core/integrationevent/model.go +71 -0
  39. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence.go +193 -0
  40. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_postgres_test.go +178 -0
  41. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_test.go +168 -0
  42. package/dist/resources/templates/go-backend/internal/core/integrationevent/provider.go +16 -0
  43. package/dist/resources/templates/go-backend/internal/core/integrationevent/registry.go +69 -0
  44. package/dist/resources/templates/go-backend/internal/core/integrationevent/repository.go +17 -0
  45. package/dist/resources/templates/go-backend/internal/core/integrationevent/service.go +283 -0
  46. package/dist/resources/templates/go-backend/internal/core/integrationevent/service_test.go +433 -0
  47. package/dist/resources/templates/go-backend/internal/core/integrationevent/types.go +67 -0
  48. package/dist/resources/templates/go-backend/migrations/000001_infrastructure.down.sql +6 -0
  49. package/dist/resources/templates/go-backend/migrations/000001_infrastructure.up.sql +176 -16
  50. package/dist/resources/templates/go-backend/tests/infrastructure.sql +109 -6
  51. package/dist/resources/templates/nuxt/.claude/docs/stack.md +5 -1
  52. package/dist/resources/templates/nuxt/.dockerignore +19 -0
  53. package/dist/resources/templates/nuxt/deploy.config.json +27 -0
  54. package/dist/resources/templates/nuxt/package.json +8 -8
  55. package/package.json +5 -3
@@ -3,73 +3,196 @@ package locale
3
3
  import (
4
4
  "encoding/json"
5
5
  "reflect"
6
+ "strings"
6
7
  "testing"
7
8
  )
8
9
 
9
- func TestNormalizeCode(t *testing.T) {
10
- cases := map[string]string{
11
- "en-US": "en",
12
- "zh-CN": "cn",
13
- "zh-TW": "tw",
14
- "zh-HK": "tw",
15
- "ja-JP": "ja",
16
- "jp": "ja",
17
- "hi-IN": "hi",
18
- "in": "hi",
19
- "de": "de",
20
- "id-ID": "",
21
- "bad-x": "",
22
- }
23
- for input, want := range cases {
24
- if got := NormalizeCode(input); got != want {
25
- t.Fatalf("NormalizeCode(%q) = %q, want %q", input, got, want)
10
+ var expectedMappings = []Mapping{
11
+ {ExternalTag: "en", ShortCode: "en", Direction: DirectionLTR},
12
+ {ExternalTag: "zh-Hans", ShortCode: "cn", Direction: DirectionLTR},
13
+ {ExternalTag: "zh-Hant", ShortCode: "tw", Direction: DirectionLTR},
14
+ {ExternalTag: "ja", ShortCode: "ja", Direction: DirectionLTR},
15
+ {ExternalTag: "ko", ShortCode: "ko", Direction: DirectionLTR},
16
+ {ExternalTag: "es", ShortCode: "es", Direction: DirectionLTR},
17
+ {ExternalTag: "fr", ShortCode: "fr", Direction: DirectionLTR},
18
+ {ExternalTag: "de", ShortCode: "de", Direction: DirectionLTR},
19
+ {ExternalTag: "pt-BR", ShortCode: "pt", Direction: DirectionLTR},
20
+ {ExternalTag: "it", ShortCode: "it", Direction: DirectionLTR},
21
+ {ExternalTag: "ru", ShortCode: "ru", Direction: DirectionLTR},
22
+ {ExternalTag: "ar", ShortCode: "ar", Direction: DirectionRTL},
23
+ {ExternalTag: "hi", ShortCode: "hi", Direction: DirectionLTR},
24
+ {ExternalTag: "id", ShortCode: "id", Direction: DirectionLTR},
25
+ {ExternalTag: "th", ShortCode: "th", Direction: DirectionLTR},
26
+ {ExternalTag: "vi", ShortCode: "vi", Direction: DirectionLTR},
27
+ {ExternalTag: "tr", ShortCode: "tr", Direction: DirectionLTR},
28
+ {ExternalTag: "pl", ShortCode: "pl", Direction: DirectionLTR},
29
+ {ExternalTag: "nl", ShortCode: "nl", Direction: DirectionLTR},
30
+ }
31
+
32
+ func TestMappingsRoundTripAllSupportedLocales(t *testing.T) {
33
+ if got := Mappings(); !reflect.DeepEqual(got, expectedMappings) {
34
+ t.Fatalf("Mappings() = %#v, want %#v", got, expectedMappings)
35
+ }
36
+
37
+ wantCodes := make([]string, 0, len(expectedMappings))
38
+ for _, mapping := range expectedMappings {
39
+ wantCodes = append(wantCodes, mapping.ShortCode)
40
+
41
+ if got, ok := ShortCodeForExternalTag(mapping.ExternalTag); !ok || got != mapping.ShortCode {
42
+ t.Errorf("ShortCodeForExternalTag(%q) = %q, %v; want %q, true", mapping.ExternalTag, got, ok, mapping.ShortCode)
43
+ }
44
+ if got, ok := ExternalTagForShortCode(mapping.ShortCode); !ok || got != mapping.ExternalTag {
45
+ t.Errorf("ExternalTagForShortCode(%q) = %q, %v; want %q, true", mapping.ShortCode, got, ok, mapping.ExternalTag)
46
+ }
47
+ if got, ok := DirectionForShortCode(mapping.ShortCode); !ok || got != mapping.Direction {
48
+ t.Errorf("DirectionForShortCode(%q) = %q, %v; want %q, true", mapping.ShortCode, got, ok, mapping.Direction)
49
+ }
50
+ if got := NormalizeCode(mapping.ExternalTag); got != mapping.ShortCode {
51
+ t.Errorf("NormalizeCode(%q) = %q, want %q", mapping.ExternalTag, got, mapping.ShortCode)
52
+ }
53
+ if got := NormalizeCode(mapping.ShortCode); got != mapping.ShortCode {
54
+ t.Errorf("NormalizeCode(%q) = %q, want %q", mapping.ShortCode, got, mapping.ShortCode)
55
+ }
56
+ if !IsShortCode(mapping.ShortCode) {
57
+ t.Errorf("IsShortCode(%q) = false, want true", mapping.ShortCode)
58
+ }
59
+
60
+ caseVariantExternal := strings.ToUpper(mapping.ExternalTag)
61
+ if got, ok := ShortCodeForExternalTag(caseVariantExternal); ok || got != "" {
62
+ t.Errorf("ShortCodeForExternalTag(%q) accepted case variant", caseVariantExternal)
26
63
  }
64
+ caseVariantShort := strings.ToUpper(mapping.ShortCode)
65
+ if got, ok := ExternalTagForShortCode(caseVariantShort); ok || got != "" {
66
+ t.Errorf("ExternalTagForShortCode(%q) accepted case variant", caseVariantShort)
67
+ }
68
+ }
69
+
70
+ if got := SupportedCodes(); !reflect.DeepEqual(got, wantCodes) {
71
+ t.Fatalf("SupportedCodes() = %#v, want %#v", got, wantCodes)
72
+ }
73
+ }
74
+
75
+ func TestLocaleLookupsRequireExactMatches(t *testing.T) {
76
+ for _, value := range []string{"zh", "pt-PT", "in", "EN", "zh-hans", "pt-br", "en-US", " en", "en_", "en!", "unknown"} {
77
+ if got, ok := ShortCodeForExternalTag(value); ok || got != "" {
78
+ t.Errorf("ShortCodeForExternalTag(%q) = %q, %v; want empty, false", value, got, ok)
79
+ }
80
+ }
81
+ for _, value := range []string{"zh", "pt-BR", "in", "EN", " en", "en!", "unknown"} {
82
+ if got, ok := ExternalTagForShortCode(value); ok || got != "" {
83
+ t.Errorf("ExternalTagForShortCode(%q) = %q, %v; want empty, false", value, got, ok)
84
+ }
85
+ if got, ok := DirectionForShortCode(value); ok || got != "" {
86
+ t.Errorf("DirectionForShortCode(%q) = %q, %v; want empty, false", value, got, ok)
87
+ }
88
+ if IsShortCode(value) {
89
+ t.Errorf("IsShortCode(%q) = true, want false", value)
90
+ }
91
+ }
92
+ for _, value := range []string{"zh", "pt-PT", "in", "EN", "zh-hans", "pt-br", "en-US", " en", "en!", "unknown"} {
93
+ if got := NormalizeCode(value); got != "" {
94
+ t.Errorf("NormalizeCode(%q) = %q, want empty", value, got)
95
+ }
96
+ }
97
+ }
98
+
99
+ func TestMappingsReturnsDefensiveCopy(t *testing.T) {
100
+ got := Mappings()
101
+ got[0] = Mapping{ExternalTag: "changed", ShortCode: "changed", Direction: DirectionRTL}
102
+ if current := Mappings()[0]; current != expectedMappings[0] {
103
+ t.Fatalf("Mappings() exposed mutable owner data: %#v", current)
27
104
  }
28
105
  }
29
106
 
30
107
  func TestNormalizeTextMap(t *testing.T) {
31
- got := NormalizeTextMap(map[string]string{
32
- "en-US": "English",
33
- "zh-CN": "中文",
34
- "ja-JP": "日本語",
35
- "hi-IN": "हिंदी",
36
- "bad-x": "ignored",
108
+ got, err := NormalizeTextMap(map[string]string{
109
+ "en": "English",
110
+ "zh-Hans": " 简体中文 ",
111
+ "pt-BR": "Português",
112
+ "ar": "العربية",
37
113
  })
114
+ if err != nil {
115
+ t.Fatalf("NormalizeTextMap() error = %v", err)
116
+ }
38
117
  want := map[string]string{
39
118
  "en": "English",
40
- "cn": "中文",
41
- "ja": "日本語",
42
- "hi": "हिंदी",
119
+ "cn": "简体中文",
120
+ "pt": "Português",
121
+ "ar": "العربية",
43
122
  }
44
123
  if !reflect.DeepEqual(got, want) {
45
- t.Fatalf("NormalizeTextMap() = %v, want %v", got, want)
124
+ t.Fatalf("NormalizeTextMap() = %#v, want %#v", got, want)
46
125
  }
47
126
  }
48
127
 
49
- func TestIsShortCode(t *testing.T) {
50
- if !IsShortCode("cn") {
51
- t.Fatal("IsShortCode(cn) = false, want true")
128
+ func TestNormalizeTextMapRejectsInvalidInput(t *testing.T) {
129
+ tests := []struct {
130
+ name string
131
+ input map[string]string
132
+ }{
133
+ {name: "generic Chinese alias", input: map[string]string{"zh": "中文"}},
134
+ {name: "unsupported Portuguese region", input: map[string]string{"pt-PT": "Português"}},
135
+ {name: "legacy Indonesian alias", input: map[string]string{"in": "Bahasa Indonesia"}},
136
+ {name: "case variant", input: map[string]string{"EN": "English"}},
137
+ {name: "empty text", input: map[string]string{"en": " "}},
138
+ {name: "duplicate canonical target", input: map[string]string{"zh-Hans": "简体中文", "cn": "中文"}},
52
139
  }
53
- if IsShortCode("zh-CN") {
54
- t.Fatal("IsShortCode(zh-CN) = true, want false")
55
- }
56
- if IsShortCode("jp") {
57
- t.Fatal("IsShortCode(jp) = true, want false")
58
- }
59
- if IsShortCode("zz") {
60
- t.Fatal("IsShortCode(zz) = true, want false")
140
+ for _, tt := range tests {
141
+ t.Run(tt.name, func(t *testing.T) {
142
+ if _, err := NormalizeTextMap(tt.input); err == nil {
143
+ t.Fatal("NormalizeTextMap() error = nil, want rejection")
144
+ }
145
+ })
61
146
  }
62
147
  }
63
148
 
64
149
  func TestNormalizeLocaleRootedMap(t *testing.T) {
65
- got := NormalizeLocaleRootedMap(map[string]json.RawMessage{
66
- "en-US": json.RawMessage(`{"email":{}}`),
67
- "zh-CN": json.RawMessage(`{"sms":{}}`),
150
+ got, err := NormalizeLocaleRootedMap(map[string]json.RawMessage{
151
+ "zh-Hant": json.RawMessage(`{"sms":{}}`),
152
+ "id": json.RawMessage(`{"email":{}}`),
68
153
  })
69
- if _, ok := got["en"]; !ok {
70
- t.Fatalf("normalized map missing en: %v", got)
154
+ if err != nil {
155
+ t.Fatalf("NormalizeLocaleRootedMap() error = %v", err)
156
+ }
157
+ if _, ok := got["tw"]; !ok {
158
+ t.Fatalf("normalized map missing tw: %#v", got)
159
+ }
160
+ if _, ok := got["id"]; !ok {
161
+ t.Fatalf("normalized map missing id: %#v", got)
162
+ }
163
+ }
164
+
165
+ func TestNormalizeLocaleRootedMapRejectsInvalidInput(t *testing.T) {
166
+ if _, err := NormalizeLocaleRootedMap(map[string]int{"zh": 1}); err == nil {
167
+ t.Fatal("NormalizeLocaleRootedMap() accepted zh alias")
168
+ }
169
+ if _, err := NormalizeLocaleRootedMap(map[string]int{"pt-BR": 1, "pt": 2}); err == nil {
170
+ t.Fatal("NormalizeLocaleRootedMap() accepted duplicate canonical target")
171
+ }
172
+ }
173
+
174
+ func TestNormalizeRawTextMap(t *testing.T) {
175
+ got, err := NormalizeRawTextMap(json.RawMessage(`{"pt-BR":"Português","ar":"العربية"}`))
176
+ if err != nil {
177
+ t.Fatalf("NormalizeRawTextMap() error = %v", err)
71
178
  }
72
- if _, ok := got["cn"]; !ok {
73
- t.Fatalf("normalized map missing cn: %v", got)
179
+ var normalized map[string]string
180
+ if err := json.Unmarshal(got, &normalized); err != nil {
181
+ t.Fatalf("json.Unmarshal() error = %v", err)
182
+ }
183
+ want := map[string]string{"pt": "Português", "ar": "العربية"}
184
+ if !reflect.DeepEqual(normalized, want) {
185
+ t.Fatalf("NormalizeRawTextMap() = %#v, want %#v", normalized, want)
186
+ }
187
+ }
188
+
189
+ func TestNormalizeRawTextMapRejectsInvalidInput(t *testing.T) {
190
+ for _, raw := range []json.RawMessage{
191
+ json.RawMessage(`null`),
192
+ json.RawMessage(`{"pt-PT":"Português"}`),
193
+ } {
194
+ if _, err := NormalizeRawTextMap(raw); err == nil {
195
+ t.Fatalf("NormalizeRawTextMap(%s) error = nil, want rejection", raw)
196
+ }
74
197
  }
75
198
  }
@@ -34,12 +34,12 @@ func (s CommandReplayStatus) IsTerminal() bool {
34
34
  // CommandReplayRecord 跨领域命令幂等回放记录。
35
35
  type CommandReplayRecord struct {
36
36
  database.BaseModel
37
- Namespace string `gorm:"column:namespace;type:varchar(200);not null;uniqueIndex:uk_sys_command_replay_records_namespace_scope_key,priority:1;comment:命令所有者全局命名空间" json:"namespace"`
38
- CommandScope string `gorm:"column:command_scope;type:varchar(128);not null;uniqueIndex:uk_sys_command_replay_records_namespace_scope_key,priority:2;comment:命令幂等作用域" json:"command_scope"`
39
- IdempotencyKey string `gorm:"column:idempotency_key;type:varchar(191);not null;uniqueIndex:uk_sys_command_replay_records_namespace_scope_key,priority:3;comment:请求幂等键" json:"idempotency_key"`
37
+ Namespace string `gorm:"column:namespace;type:varchar(200);not null;uniqueIndex:uk_sys_command_replay_records_principal_scope_key,priority:1;comment:命令所有者全局命名空间" json:"namespace"`
38
+ CommandScope string `gorm:"column:command_scope;type:varchar(128);not null;uniqueIndex:uk_sys_command_replay_records_principal_scope_key,priority:4;comment:命令幂等作用域" json:"command_scope"`
39
+ IdempotencyKey string `gorm:"column:idempotency_key;type:varchar(191);not null;uniqueIndex:uk_sys_command_replay_records_principal_scope_key,priority:5;comment:请求幂等键" json:"idempotency_key"`
40
40
  RequestHash string `gorm:"column:request_hash;type:varchar(64);not null;comment:请求载荷指纹" json:"request_hash"`
41
- PrincipalType *string `gorm:"column:principal_type;type:varchar(32);comment:请求主体类型" json:"principal_type"`
42
- PrincipalRef *string `gorm:"column:principal_ref;type:varchar(64);comment:请求主体公开引用" json:"principal_ref"`
41
+ PrincipalType string `gorm:"column:principal_type;type:varchar(32);not null;uniqueIndex:uk_sys_command_replay_records_principal_scope_key,priority:2;comment:参与幂等作用域的可信调用主体类型" json:"principal_type"`
42
+ PrincipalRef string `gorm:"column:principal_ref;type:varchar(64);not null;uniqueIndex:uk_sys_command_replay_records_principal_scope_key,priority:3;comment:参与幂等作用域的可信调用主体公开引用" json:"principal_ref"`
43
43
  Status CommandReplayStatus `gorm:"column:status;type:varchar(20);not null;default:'started';comment:命令执行状态" json:"status"`
44
44
  ResponsePayload json.RawMessage `gorm:"column:response_payload;type:jsonb;not null;default:'{}';comment:回放响应载荷" json:"response_payload"`
45
45
  ErrorCode *string `gorm:"column:error_code;type:varchar(120);comment:失败错误码" json:"error_code"`
@@ -5,6 +5,7 @@ import (
5
5
  "encoding/json"
6
6
  "errors"
7
7
  "fmt"
8
+ "strconv"
8
9
  "strings"
9
10
  "time"
10
11
 
@@ -32,11 +33,13 @@ func NewCommandReplayRepository(db *gorm.DB) CommandReplayRepository {
32
33
  return &commandReplayRepository{db: db}
33
34
  }
34
35
 
35
- func (r *commandReplayRepository) FindByScope(ctx context.Context, namespace, commandScope, idempotencyKey string) (*CommandReplayRecord, error) {
36
+ func (r *commandReplayRepository) FindByScope(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (*CommandReplayRecord, error) {
36
37
  var record CommandReplayRecord
37
38
  err := database.GetDB(ctx, r.db).
38
39
  Select(commandReplayRecordColumns).
39
40
  Where("namespace = ?", strings.TrimSpace(namespace)).
41
+ Where("principal_type = ?", strings.TrimSpace(principalType)).
42
+ Where("principal_ref = ?", strings.TrimSpace(principalRef)).
40
43
  Where("command_scope = ?", strings.TrimSpace(commandScope)).
41
44
  Where("idempotency_key = ?", strings.TrimSpace(idempotencyKey)).
42
45
  First(&record).Error
@@ -65,17 +68,13 @@ func (r *commandReplayRepository) FindByIDForUpdate(ctx context.Context, id int6
65
68
  return &record, nil
66
69
  }
67
70
 
68
- func (r *commandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, commandScope, idempotencyKey string) (bool, error) {
71
+ func (r *commandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (bool, error) {
69
72
  db := database.GetDB(ctx, r.db)
70
73
  if db.Dialector.Name() != "postgres" {
71
74
  return false, fmt.Errorf("unsupported database dialect for command replay scope lock: %s", db.Dialector.Name())
72
75
  }
73
76
 
74
- scopeKey := strings.Join([]string{
75
- strings.TrimSpace(namespace),
76
- strings.TrimSpace(commandScope),
77
- strings.TrimSpace(idempotencyKey),
78
- }, "\x1f")
77
+ scopeKey := commandReplayScopeKey(namespace, principalType, principalRef, commandScope, idempotencyKey)
79
78
 
80
79
  var acquired bool
81
80
  if err := db.Raw(
@@ -92,6 +91,8 @@ func (r *commandReplayRepository) Create(ctx context.Context, record *CommandRep
92
91
  Clauses(clause.OnConflict{
93
92
  Columns: []clause.Column{
94
93
  {Name: "namespace"},
94
+ {Name: "principal_type"},
95
+ {Name: "principal_ref"},
95
96
  {Name: "command_scope"},
96
97
  {Name: "idempotency_key"},
97
98
  },
@@ -107,6 +108,18 @@ func (r *commandReplayRepository) Create(ctx context.Context, record *CommandRep
107
108
  return nil
108
109
  }
109
110
 
111
+ func commandReplayScopeKey(namespace, principalType, principalRef, commandScope, idempotencyKey string) string {
112
+ fields := [...]string{namespace, principalType, principalRef, commandScope, idempotencyKey}
113
+ var key strings.Builder
114
+ for _, field := range fields {
115
+ normalized := strings.TrimSpace(field)
116
+ key.WriteString(strconv.Itoa(len(normalized)))
117
+ key.WriteByte(':')
118
+ key.WriteString(normalized)
119
+ }
120
+ return key.String()
121
+ }
122
+
110
123
  func (r *commandReplayRepository) MarkSucceeded(ctx context.Context, id int64, responsePayload json.RawMessage, completedAt time.Time) error {
111
124
  result := database.GetDB(ctx, r.db).
112
125
  Model(&CommandReplayRecord{}).
@@ -22,17 +22,21 @@ func TestCommandReplayRepositoryCreateAndFindByScope(t *testing.T) {
22
22
  repo := NewCommandReplayRepository(db)
23
23
  ctx := context.Background()
24
24
 
25
- record := newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-1", time.Now().Add(time.Hour))
25
+ record := newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-1", time.Now().Add(time.Hour))
26
26
  require.NoError(t, repo.Create(ctx, record))
27
27
 
28
- found, err := repo.FindByScope(ctx, " test.command.checkout ", " quote:create ", " idem-1 ")
28
+ found, err := repo.FindByScope(ctx, " test.command.checkout ", " identity ", " user_123 ", " quote:create ", " idem-1 ")
29
29
  require.NoError(t, err)
30
30
  require.NotNil(t, found)
31
31
  require.Equal(t, "test.command.checkout", found.Namespace)
32
32
  require.Equal(t, "quote:create", found.CommandScope)
33
33
  require.Equal(t, "idem-1", found.IdempotencyKey)
34
34
 
35
- missing, err := repo.FindByScope(ctx, "test.command.checkout", "quote:create", "idem-missing")
35
+ wrongPrincipal, err := repo.FindByScope(ctx, "test.command.checkout", "identity", "user_456", "quote:create", "idem-1")
36
+ require.NoError(t, err)
37
+ require.Nil(t, wrongPrincipal)
38
+
39
+ missing, err := repo.FindByScope(ctx, "test.command.checkout", "identity", "user_123", "quote:create", "idem-missing")
36
40
  require.NoError(t, err)
37
41
  require.Nil(t, missing)
38
42
  }
@@ -43,41 +47,64 @@ func TestCommandReplayRepositoryDuplicateCreateReturnsSentinel(t *testing.T) {
43
47
  ctx := context.Background()
44
48
  expiresAt := time.Now().Add(time.Hour)
45
49
 
46
- require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-1", expiresAt)))
47
- err := repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-1", expiresAt))
50
+ require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-1", expiresAt)))
51
+ err := repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-1", expiresAt))
48
52
  require.Error(t, err)
49
53
  require.True(t, errors.Is(err, ErrCommandReplayRecordExists))
50
54
 
51
- require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-2", expiresAt)))
52
- require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:update", "idem-1", expiresAt)))
53
- require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.fulfillment", "quote:create", "idem-1", expiresAt)))
54
- require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("other.command.checkout", "quote:create", "idem-1", expiresAt)))
55
+ require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_456", "quote:create", "idem-1", expiresAt)))
56
+ require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "service", "worker_123", "quote:create", "idem-1", expiresAt)))
57
+ require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-2", expiresAt)))
58
+ require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:update", "idem-1", expiresAt)))
59
+ require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.fulfillment", "identity", "user_123", "quote:create", "idem-1", expiresAt)))
60
+ require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("other.command.checkout", "identity", "user_123", "quote:create", "idem-1", expiresAt)))
55
61
  var total int64
56
62
  require.NoError(t, db.Model(&CommandReplayRecord{}).Count(&total).Error)
57
- require.Equal(t, int64(5), total)
63
+ require.Equal(t, int64(7), total)
58
64
  }
59
65
 
60
66
  func TestCommandReplayRepositoryTryAcquireScopeLockRejectsSQLite(t *testing.T) {
61
67
  db := newCommandReplayTestDB(t)
62
68
  repo := NewCommandReplayRepository(db)
63
69
 
64
- acquired, err := repo.TryAcquireScopeLock(context.Background(), "test.command.checkout", "quote:create", "idem-1")
70
+ acquired, err := repo.TryAcquireScopeLock(context.Background(), "test.command.checkout", "identity", "user_123", "quote:create", "idem-1")
65
71
  require.Error(t, err)
66
72
  require.False(t, acquired)
67
73
  }
68
74
 
75
+ func TestCommandReplayScopeKeyPreservesFiveFieldBoundaries(t *testing.T) {
76
+ base := commandReplayScopeKey(" test.command.checkout ", " identity ", " user_123 ", " quote:create ", " idem-1 ")
77
+ require.Equal(t, commandReplayScopeKey("test.command.checkout", "identity", "user_123", "quote:create", "idem-1"), base)
78
+
79
+ variants := []string{
80
+ commandReplayScopeKey("other.command.checkout", "identity", "user_123", "quote:create", "idem-1"),
81
+ commandReplayScopeKey("test.command.checkout", "service", "user_123", "quote:create", "idem-1"),
82
+ commandReplayScopeKey("test.command.checkout", "identity", "user_456", "quote:create", "idem-1"),
83
+ commandReplayScopeKey("test.command.checkout", "identity", "user_123", "quote:update", "idem-1"),
84
+ commandReplayScopeKey("test.command.checkout", "identity", "user_123", "quote:create", "idem-2"),
85
+ }
86
+ for _, variant := range variants {
87
+ require.NotEqual(t, base, variant)
88
+ }
89
+ require.NotEqual(
90
+ t,
91
+ commandReplayScopeKey("test.command.checkout", "ab", "c", "quote:create", "idem-1"),
92
+ commandReplayScopeKey("test.command.checkout", "a", "bc", "quote:create", "idem-1"),
93
+ )
94
+ }
95
+
69
96
  func TestCommandReplayRepositoryMarkTerminalStates(t *testing.T) {
70
97
  db := newCommandReplayTestDB(t)
71
98
  repo := NewCommandReplayRepository(db)
72
99
  ctx := context.Background()
73
100
  expiresAt := time.Now().Add(time.Hour)
74
101
 
75
- succeededRecord := newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-success", expiresAt)
102
+ succeededRecord := newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-success", expiresAt)
76
103
  require.NoError(t, repo.Create(ctx, succeededRecord))
77
104
  succeededAt := time.Now().Add(time.Minute)
78
105
  require.NoError(t, repo.MarkSucceeded(ctx, succeededRecord.ID, json.RawMessage(`{"quote_no":"quote_1"}`), succeededAt))
79
106
 
80
- loaded, err := repo.FindByScope(ctx, "test.command.checkout", "quote:create", "idem-success")
107
+ loaded, err := repo.FindByScope(ctx, "test.command.checkout", "identity", "user_123", "quote:create", "idem-success")
81
108
  require.NoError(t, err)
82
109
  require.NotNil(t, loaded)
83
110
  require.Equal(t, CommandReplayStatusSucceeded, loaded.Status)
@@ -88,12 +115,12 @@ func TestCommandReplayRepositoryMarkTerminalStates(t *testing.T) {
88
115
  err = repo.MarkFailed(ctx, succeededRecord.ID, "COMMERCE_QUOTE_FAILED", http.StatusConflict, json.RawMessage(`{"error":"failed"}`), time.Now().Add(2*time.Minute))
89
116
  require.Error(t, err)
90
117
 
91
- failedRecord := newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-failed", expiresAt)
118
+ failedRecord := newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-failed", expiresAt)
92
119
  require.NoError(t, repo.Create(ctx, failedRecord))
93
120
  failedAt := time.Now().Add(3 * time.Minute)
94
121
  require.NoError(t, repo.MarkFailed(ctx, failedRecord.ID, " COMMERCE_QUOTE_FAILED ", http.StatusConflict, json.RawMessage(`{"error":"failed"}`), failedAt))
95
122
 
96
- loaded, err = repo.FindByScope(ctx, "test.command.checkout", "quote:create", "idem-failed")
123
+ loaded, err = repo.FindByScope(ctx, "test.command.checkout", "identity", "user_123", "quote:create", "idem-failed")
97
124
  require.NoError(t, err)
98
125
  require.NotNil(t, loaded)
99
126
  require.Equal(t, CommandReplayStatusFailed, loaded.Status)
@@ -116,13 +143,13 @@ func TestCommandReplayRepositoryJoinsAmbientTransaction(t *testing.T) {
116
143
  rollbackErr := errors.New("rollback command idempotency transaction")
117
144
 
118
145
  err := manager.WithTransaction(ctx, func(txCtx context.Context) error {
119
- record := newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-rollback", time.Now().Add(time.Hour))
146
+ record := newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-rollback", time.Now().Add(time.Hour))
120
147
  require.NoError(t, repo.Create(txCtx, record))
121
148
  return rollbackErr
122
149
  })
123
150
  require.ErrorIs(t, err, rollbackErr)
124
151
 
125
- found, err := repo.FindByScope(ctx, "test.command.checkout", "quote:create", "idem-rollback")
152
+ found, err := repo.FindByScope(ctx, "test.command.checkout", "identity", "user_123", "quote:create", "idem-rollback")
126
153
  require.NoError(t, err)
127
154
  require.Nil(t, found)
128
155
  }
@@ -147,8 +174,8 @@ func createCommandReplayRecordSchema(t *testing.T, db *gorm.DB) {
147
174
  command_scope TEXT NOT NULL,
148
175
  idempotency_key TEXT NOT NULL,
149
176
  request_hash TEXT NOT NULL,
150
- principal_type TEXT,
151
- principal_ref TEXT,
177
+ principal_type TEXT NOT NULL,
178
+ principal_ref TEXT NOT NULL,
152
179
  status TEXT NOT NULL DEFAULT 'started',
153
180
  response_payload TEXT NOT NULL DEFAULT '{}',
154
181
  error_code TEXT,
@@ -166,14 +193,16 @@ func createCommandReplayRecordSchema(t *testing.T, db *gorm.DB) {
166
193
  )
167
194
  `).Error)
168
195
  require.NoError(t, db.Exec(`
169
- CREATE UNIQUE INDEX uk_sys_command_replay_records_namespace_scope_key
170
- ON sys_command_replay_records (namespace, command_scope, idempotency_key)
196
+ CREATE UNIQUE INDEX uk_sys_command_replay_records_principal_scope_key
197
+ ON sys_command_replay_records (namespace, principal_type, principal_ref, command_scope, idempotency_key)
171
198
  `).Error)
172
199
  }
173
200
 
174
- func newPersistenceTestCommandReplayRecord(namespace, commandScope, idempotencyKey string, expiresAt time.Time) *CommandReplayRecord {
201
+ func newPersistenceTestCommandReplayRecord(namespace, principalType, principalRef, commandScope, idempotencyKey string, expiresAt time.Time) *CommandReplayRecord {
175
202
  return &CommandReplayRecord{
176
203
  Namespace: namespace,
204
+ PrincipalType: principalType,
205
+ PrincipalRef: principalRef,
177
206
  CommandScope: commandScope,
178
207
  IdempotencyKey: idempotencyKey,
179
208
  RequestHash: strings.Repeat("a", 64),
@@ -8,8 +8,8 @@ import (
8
8
 
9
9
  // CommandReplayRepository 命令幂等回放仓储接口。
10
10
  type CommandReplayRepository interface {
11
- FindByScope(ctx context.Context, namespace, commandScope, idempotencyKey string) (*CommandReplayRecord, error)
12
- TryAcquireScopeLock(ctx context.Context, namespace, commandScope, idempotencyKey string) (bool, error)
11
+ FindByScope(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (*CommandReplayRecord, error)
12
+ TryAcquireScopeLock(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (bool, error)
13
13
  Create(ctx context.Context, record *CommandReplayRecord) error
14
14
  MarkSucceeded(ctx context.Context, id int64, responsePayload json.RawMessage, completedAt time.Time) error
15
15
  MarkFailed(ctx context.Context, id int64, errorCode string, errorStatus int, responsePayload json.RawMessage, completedAt time.Time) error
@@ -74,6 +74,8 @@ func (s *CommandReplayService) Execute(ctx context.Context, req CommandReplayBeg
74
74
  acquired, lockErr := s.repo.TryAcquireScopeLock(
75
75
  txCtx,
76
76
  normalized.namespace,
77
+ normalized.principalType,
78
+ normalized.principalRef,
77
79
  normalized.commandScope,
78
80
  normalized.idempotencyKey,
79
81
  )
@@ -87,6 +89,8 @@ func (s *CommandReplayService) Execute(ctx context.Context, req CommandReplayBeg
87
89
  existing, findErr := s.repo.FindByScope(
88
90
  txCtx,
89
91
  normalized.namespace,
92
+ normalized.principalType,
93
+ normalized.principalRef,
90
94
  normalized.commandScope,
91
95
  normalized.idempotencyKey,
92
96
  )
@@ -112,8 +116,8 @@ func (s *CommandReplayService) Execute(ctx context.Context, req CommandReplayBeg
112
116
  CommandScope: normalized.commandScope,
113
117
  IdempotencyKey: normalized.idempotencyKey,
114
118
  RequestHash: normalized.requestHash,
115
- PrincipalType: cloneStringPtr(normalized.principalType),
116
- PrincipalRef: cloneStringPtr(normalized.principalRef),
119
+ PrincipalType: normalized.principalType,
120
+ PrincipalRef: normalized.principalRef,
117
121
  Status: CommandReplayStatusStarted,
118
122
  ResponsePayload: json.RawMessage(`{}`),
119
123
  ExpiresAt: expiresAt,
@@ -126,6 +130,8 @@ func (s *CommandReplayService) Execute(ctx context.Context, req CommandReplayBeg
126
130
  duplicate, reloadErr := s.repo.FindByScope(
127
131
  txCtx,
128
132
  normalized.namespace,
133
+ normalized.principalType,
134
+ normalized.principalRef,
129
135
  normalized.commandScope,
130
136
  normalized.idempotencyKey,
131
137
  )
@@ -219,14 +225,14 @@ func (s *CommandReplayService) BeginExternal(
219
225
  var lease *CommandReplayExternalLease
220
226
  err = s.withTransaction(ctx, func(txCtx context.Context) error {
221
227
  now := s.now().UTC()
222
- acquired, lockErr := s.repo.TryAcquireScopeLock(txCtx, normalized.namespace, normalized.commandScope, normalized.idempotencyKey)
228
+ acquired, lockErr := s.repo.TryAcquireScopeLock(txCtx, normalized.namespace, normalized.principalType, normalized.principalRef, normalized.commandScope, normalized.idempotencyKey)
223
229
  if lockErr != nil {
224
230
  return apierror.Internal("Failed to acquire command replay scope lock")
225
231
  }
226
232
  if !acquired {
227
233
  return apierror.Conflict(errCodeCommandReplayInProgress, "Command replay is already in progress")
228
234
  }
229
- existing, findErr := s.repo.FindByScope(txCtx, normalized.namespace, normalized.commandScope, normalized.idempotencyKey)
235
+ existing, findErr := s.repo.FindByScope(txCtx, normalized.namespace, normalized.principalType, normalized.principalRef, normalized.commandScope, normalized.idempotencyKey)
230
236
  if findErr != nil {
231
237
  return apierror.Internal("Failed to load command replay record")
232
238
  }
@@ -514,7 +520,7 @@ func newCommandReplayRecord(normalized *normalizedCommandReplayBeginRequest, now
514
520
  return &CommandReplayRecord{
515
521
  Namespace: normalized.namespace, CommandScope: normalized.commandScope,
516
522
  IdempotencyKey: normalized.idempotencyKey, RequestHash: normalized.requestHash,
517
- PrincipalType: cloneStringPtr(normalized.principalType), PrincipalRef: cloneStringPtr(normalized.principalRef),
523
+ PrincipalType: normalized.principalType, PrincipalRef: normalized.principalRef,
518
524
  Status: CommandReplayStatusStarted, ResponsePayload: json.RawMessage(`{}`),
519
525
  ExpiresAt: now.Add(normalized.ttl), Metadata: normalized.metadata,
520
526
  }
@@ -609,17 +615,14 @@ func normalizeCommandReplayBeginRequest(req CommandReplayBeginRequest) (*normali
609
615
  if err != nil {
610
616
  return nil, err
611
617
  }
612
- principalType, err := normalizeOptionalString("principalType", req.PrincipalType, maxPrincipalTypeLength)
618
+ principalType, err := normalizeRequiredString("principalType", req.PrincipalType, maxPrincipalTypeLength)
613
619
  if err != nil {
614
620
  return nil, err
615
621
  }
616
- principalRef, err := normalizeOptionalString("principalRef", req.PrincipalRef, maxPrincipalRefLength)
622
+ principalRef, err := normalizeRequiredString("principalRef", req.PrincipalRef, maxPrincipalRefLength)
617
623
  if err != nil {
618
624
  return nil, err
619
625
  }
620
- if (principalType == nil) != (principalRef == nil) {
621
- return nil, apierror.BadRequest(apierror.CodeValidationFailed, "principalType and principalRef must be provided together")
622
- }
623
626
  ttl := req.TTL
624
627
  if ttl <= 0 {
625
628
  ttl = defaultCommandReplayTTL
@@ -789,20 +792,6 @@ func normalizeRequiredString(fieldName, value string, maxLength int) (string, er
789
792
  return normalized, nil
790
793
  }
791
794
 
792
- func normalizeOptionalString(fieldName string, value *string, maxLength int) (*string, error) {
793
- if value == nil {
794
- return nil, nil
795
- }
796
- normalized := strings.TrimSpace(*value)
797
- if normalized == "" {
798
- return nil, apierror.BadRequest(apierror.CodeValidationFailed, fieldName+" must not be blank")
799
- }
800
- if utf8.RuneCountInString(normalized) > maxLength {
801
- return nil, apierror.BadRequest(apierror.CodeValidationFailed, fieldName+" is too long")
802
- }
803
- return &normalized, nil
804
- }
805
-
806
795
  func (s *CommandReplayService) markSucceeded(ctx context.Context, recordID int64, responsePayload json.RawMessage, completedAt time.Time) error {
807
796
  if markErr := s.repo.MarkSucceeded(ctx, recordID, responsePayload, completedAt); markErr != nil {
808
797
  return apierror.Internal("Failed to mark command replay record as succeeded")