@stackonward/cli 1.3.3 → 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 (111) hide show
  1. package/README.md +248 -0
  2. package/dist/index.js +14124 -635
  3. package/dist/index.js.map +1 -1
  4. package/dist/resources/components/nuxt/site-source/app/app.vue +4 -2
  5. package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +50 -50
  6. package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationHeader.vue → app/Header.vue} +3 -3
  7. package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationShell.vue → app/Shell.vue} +2 -2
  8. package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationSidebar.vue → app/Sidebar.vue} +2 -2
  9. package/dist/resources/components/nuxt/site-source/app/components/{ui/CheckoutStatus.vue → checkout/Status.vue} +8 -3
  10. package/dist/resources/components/nuxt/site-source/app/components/{ui/CheckoutSummary.vue → checkout/Summary.vue} +11 -11
  11. package/dist/resources/components/nuxt/site-source/app/components/{site/CookieConsent.vue → consent/Banner.vue} +3 -3
  12. package/dist/resources/components/nuxt/site-source/app/components/{site/ContentArticlePage.vue → content/Article.vue} +3 -3
  13. package/dist/resources/components/nuxt/site-source/app/components/{site/ContentListPage.vue → content/List.vue} +2 -2
  14. package/dist/resources/components/nuxt/site-source/app/components/{ui/GoogleSignInButton.vue → google/SignInButton.vue} +3 -3
  15. package/dist/resources/components/nuxt/site-source/app/components/{site/LocaleSwitcher.vue → locale/Menu.vue} +2 -2
  16. package/dist/resources/components/nuxt/site-source/app/components/site/{MarketingFooter.vue → Footer.vue} +8 -8
  17. package/dist/resources/components/nuxt/site-source/app/components/site/{MarketingHeader.vue → Header.vue} +27 -27
  18. package/dist/resources/components/nuxt/site-source/app/components/site/Shell.vue +29 -0
  19. package/dist/resources/components/nuxt/site-source/app/components/{site/ThemeSwitcher.vue → theme/Menu.vue} +2 -2
  20. package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseButton.vue → Button.vue} +1 -8
  21. package/dist/resources/components/nuxt/site-source/app/components/ui/InfiniteScroll.vue +2 -2
  22. package/dist/resources/components/nuxt/site-source/app/components/{ui/YoutubeEmbed.vue → youtube/Embed.vue} +10 -2
  23. package/dist/resources/components/nuxt/site-source/app/composables/useConsent.ts +8 -10
  24. package/dist/resources/components/nuxt/site-source/app/composables/useNavigation.ts +9 -14
  25. package/dist/resources/components/nuxt/site-source/app/composables/useSeo.ts +3 -1
  26. package/dist/resources/components/nuxt/site-source/app/composables/useSiteShell.ts +19 -22
  27. package/dist/resources/components/nuxt/site-source/app/consent/types.ts +59 -0
  28. package/dist/resources/components/nuxt/site-source/app/content/types.ts +17 -0
  29. package/dist/resources/components/nuxt/site-source/app/layouts/app.vue +2 -2
  30. package/dist/resources/components/nuxt/site-source/app/layouts/default.vue +2 -2
  31. package/dist/resources/components/nuxt/site-source/app/navigation/types.ts +68 -0
  32. package/dist/resources/components/nuxt/site-source/app/plugins/consent.ts +2 -2
  33. package/dist/resources/components/nuxt/site-source/app/site/config.ts +35 -0
  34. package/dist/resources/components/registry.json +63 -51
  35. package/dist/resources/deploy/ci/github/caller.yml +38 -0
  36. package/dist/resources/deploy/ci/onedev/import.yml +14 -0
  37. package/dist/resources/deploy/nginx/cache.conf +17 -0
  38. package/dist/resources/deploy/nginx/compression.conf +23 -0
  39. package/dist/resources/deploy/nginx/proxy.conf +15 -0
  40. package/dist/resources/deploy/profiles/go-service/compose.yaml +19 -0
  41. package/dist/resources/deploy/profiles/nuxt-node/Dockerfile +17 -0
  42. package/dist/resources/deploy/profiles/static-files/nginx/site.conf.template +55 -0
  43. package/dist/resources/deploy/profiles/static-nginx/Dockerfile +18 -0
  44. package/dist/resources/deploy/profiles/static-nginx/nginx/default.conf.template +28 -0
  45. package/dist/resources/deploy/targets/compose-host/deploy.config.example.json +114 -0
  46. package/dist/resources/deploy/traffic/nginx/stackonward-nginx-traffic +96 -0
  47. package/dist/resources/registry.json +30 -30
  48. package/dist/resources/templates/base/.claude/docs/architecture.md +4 -0
  49. package/dist/resources/templates/base/AGENTS.md +15 -0
  50. package/dist/resources/templates/base/CLAUDE.md +15 -0
  51. package/dist/resources/templates/go-backend/.dockerignore +25 -3
  52. package/dist/resources/templates/go-backend/deploy.config.json +26 -0
  53. package/dist/resources/templates/go-backend/docs/runtime-provenance.json +9 -6
  54. package/dist/resources/templates/go-backend/docs/source-provenance.json +33 -22
  55. package/dist/resources/templates/go-backend/internal/common/enums/register_locale.go +7 -0
  56. package/dist/resources/templates/go-backend/internal/common/enums/register_locale_test.go +30 -0
  57. package/dist/resources/templates/go-backend/internal/common/locale/locale.go +102 -76
  58. package/dist/resources/templates/go-backend/internal/common/locale/locale_test.go +168 -45
  59. package/dist/resources/templates/go-backend/internal/core/commandreplay/model.go +5 -5
  60. package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence.go +20 -7
  61. package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence_test.go +51 -22
  62. package/dist/resources/templates/go-backend/internal/core/commandreplay/repository.go +2 -2
  63. package/dist/resources/templates/go-backend/internal/core/commandreplay/service.go +13 -24
  64. package/dist/resources/templates/go-backend/internal/core/commandreplay/service_persistence_test.go +98 -13
  65. package/dist/resources/templates/go-backend/internal/core/commandreplay/service_test.go +61 -36
  66. package/dist/resources/templates/go-backend/internal/core/commandreplay/types.go +6 -6
  67. package/dist/resources/templates/go-backend/internal/core/integrationevent/config.go +24 -0
  68. package/dist/resources/templates/go-backend/internal/core/integrationevent/errors.go +53 -0
  69. package/dist/resources/templates/go-backend/internal/core/integrationevent/model.go +71 -0
  70. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence.go +193 -0
  71. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_postgres_test.go +178 -0
  72. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_test.go +168 -0
  73. package/dist/resources/templates/go-backend/internal/core/integrationevent/provider.go +16 -0
  74. package/dist/resources/templates/go-backend/internal/core/integrationevent/registry.go +69 -0
  75. package/dist/resources/templates/go-backend/internal/core/integrationevent/repository.go +17 -0
  76. package/dist/resources/templates/go-backend/internal/core/integrationevent/service.go +283 -0
  77. package/dist/resources/templates/go-backend/internal/core/integrationevent/service_test.go +433 -0
  78. package/dist/resources/templates/go-backend/internal/core/integrationevent/types.go +67 -0
  79. package/dist/resources/templates/go-backend/migrations/000001_infrastructure.down.sql +6 -0
  80. package/dist/resources/templates/go-backend/migrations/000001_infrastructure.up.sql +176 -16
  81. package/dist/resources/templates/go-backend/tests/infrastructure.sql +109 -6
  82. package/dist/resources/templates/nuxt/.claude/docs/stack.md +5 -1
  83. package/dist/resources/templates/nuxt/.dockerignore +19 -0
  84. package/dist/resources/templates/nuxt/README.md +3 -3
  85. package/dist/resources/templates/nuxt/app/app.config.ts +1 -1
  86. package/dist/resources/templates/nuxt/app/i18n/locales/en.json +1 -1
  87. package/dist/resources/templates/nuxt/app/pages/app/index.vue +1 -1
  88. package/dist/resources/templates/nuxt/app/pages/authors/[slug].vue +2 -2
  89. package/dist/resources/templates/nuxt/app/pages/blog/[slug].vue +4 -3
  90. package/dist/resources/templates/nuxt/app/pages/blog/category/[category].vue +2 -2
  91. package/dist/resources/templates/nuxt/app/pages/blog/index.vue +2 -2
  92. package/dist/resources/templates/nuxt/app/pages/legal/cookies.vue +3 -3
  93. package/dist/resources/templates/nuxt/app/pages/legal/privacy.vue +2 -2
  94. package/dist/resources/templates/nuxt/app/pages/legal/terms.vue +2 -2
  95. package/dist/resources/templates/nuxt/app/pages/ui/index.vue +45 -45
  96. package/dist/resources/templates/nuxt/deploy.config.json +27 -0
  97. package/dist/resources/templates/nuxt/nuxt.config.ts +4 -10
  98. package/dist/resources/templates/nuxt/package.json +8 -8
  99. package/dist/resources/templates/nuxt/test/e2e/rendering.test.ts +1 -0
  100. package/dist/resources/templates/nuxt/test/unit/consent.test.ts +4 -7
  101. package/package.json +5 -3
  102. package/dist/resources/components/nuxt/site-source/app/components/site/MarketingShell.vue +0 -33
  103. package/dist/resources/components/nuxt/site-source/app/types/site-shell.ts +0 -172
  104. /package/dist/resources/components/nuxt/site-source/app/components/{site/ConsentSettingsButton.vue → consent/Settings.vue} +0 -0
  105. /package/dist/resources/components/nuxt/site-source/app/components/{site/StaticPage.vue → content/Document.vue} +0 -0
  106. /package/dist/resources/components/nuxt/site-source/app/components/{site/ToastViewport.vue → feedback/Toast.vue} +0 -0
  107. /package/dist/resources/components/nuxt/site-source/app/components/site/{SiteBrand.vue → Brand.vue} +0 -0
  108. /package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseGradientBorder.vue → GradientBorder.vue} +0 -0
  109. /package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseModal.vue → Modal.vue} +0 -0
  110. /package/dist/resources/components/nuxt/site-source/app/components/ui/{BasePopover.vue → Popover.vue} +0 -0
  111. /package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseText.vue → Text.vue} +0 -0
@@ -2,6 +2,7 @@ package locale
2
2
 
3
3
  import (
4
4
  "encoding/json"
5
+ "fmt"
5
6
  "strings"
6
7
  )
7
8
 
@@ -10,124 +11,143 @@ const (
10
11
  AdminDefaultCode = "cn"
11
12
  )
12
13
 
13
- var supportedCodes = []string{
14
- "en",
15
- "cn",
16
- "tw",
17
- "de",
18
- "es",
19
- "fr",
20
- "it",
21
- "ja",
22
- "ko",
23
- "pt",
24
- "hi",
25
- "ar",
14
+ // Direction 表示语言内容的排版方向。
15
+ type Direction string
16
+
17
+ const (
18
+ DirectionLTR Direction = "ltr"
19
+ DirectionRTL Direction = "rtl"
20
+ )
21
+
22
+ // Mapping 定义规范外部语言标签与内部短码之间的唯一映射。
23
+ type Mapping struct {
24
+ ExternalTag string
25
+ ShortCode string
26
+ Direction Direction
26
27
  }
27
28
 
28
- var supportedCodeSet = buildSupportedCodeSet()
29
+ var mappings = []Mapping{
30
+ {ExternalTag: "en", ShortCode: "en", Direction: DirectionLTR},
31
+ {ExternalTag: "zh-Hans", ShortCode: "cn", Direction: DirectionLTR},
32
+ {ExternalTag: "zh-Hant", ShortCode: "tw", Direction: DirectionLTR},
33
+ {ExternalTag: "ja", ShortCode: "ja", Direction: DirectionLTR},
34
+ {ExternalTag: "ko", ShortCode: "ko", Direction: DirectionLTR},
35
+ {ExternalTag: "es", ShortCode: "es", Direction: DirectionLTR},
36
+ {ExternalTag: "fr", ShortCode: "fr", Direction: DirectionLTR},
37
+ {ExternalTag: "de", ShortCode: "de", Direction: DirectionLTR},
38
+ {ExternalTag: "pt-BR", ShortCode: "pt", Direction: DirectionLTR},
39
+ {ExternalTag: "it", ShortCode: "it", Direction: DirectionLTR},
40
+ {ExternalTag: "ru", ShortCode: "ru", Direction: DirectionLTR},
41
+ {ExternalTag: "ar", ShortCode: "ar", Direction: DirectionRTL},
42
+ {ExternalTag: "hi", ShortCode: "hi", Direction: DirectionLTR},
43
+ {ExternalTag: "id", ShortCode: "id", Direction: DirectionLTR},
44
+ {ExternalTag: "th", ShortCode: "th", Direction: DirectionLTR},
45
+ {ExternalTag: "vi", ShortCode: "vi", Direction: DirectionLTR},
46
+ {ExternalTag: "tr", ShortCode: "tr", Direction: DirectionLTR},
47
+ {ExternalTag: "pl", ShortCode: "pl", Direction: DirectionLTR},
48
+ {ExternalTag: "nl", ShortCode: "nl", Direction: DirectionLTR},
49
+ }
50
+
51
+ var externalTagIndex, shortCodeIndex = buildIndexes()
29
52
 
30
- func buildSupportedCodeSet() map[string]struct{} {
31
- result := make(map[string]struct{}, len(supportedCodes))
32
- for _, code := range supportedCodes {
33
- result[code] = struct{}{}
53
+ func buildIndexes() (map[string]Mapping, map[string]Mapping) {
54
+ byExternalTag := make(map[string]Mapping, len(mappings))
55
+ byShortCode := make(map[string]Mapping, len(mappings))
56
+ for _, mapping := range mappings {
57
+ byExternalTag[mapping.ExternalTag] = mapping
58
+ byShortCode[mapping.ShortCode] = mapping
34
59
  }
60
+ return byExternalTag, byShortCode
61
+ }
62
+
63
+ // Mappings 返回按稳定顺序排列的全部语言映射。
64
+ func Mappings() []Mapping {
65
+ result := make([]Mapping, len(mappings))
66
+ copy(result, mappings)
35
67
  return result
36
68
  }
37
69
 
70
+ // SupportedCodes 返回按稳定顺序排列的全部内部短码。
38
71
  func SupportedCodes() []string {
39
- result := make([]string, len(supportedCodes))
40
- copy(result, supportedCodes)
72
+ result := make([]string, 0, len(mappings))
73
+ for _, mapping := range mappings {
74
+ result = append(result, mapping.ShortCode)
75
+ }
41
76
  return result
42
77
  }
43
78
 
44
- func NormalizeCode(value string) string {
45
- code := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "_", "-")))
46
- switch code {
47
- case "en", "en-us", "en-gb":
48
- return "en"
49
- case "cn", "zh", "zh-cn", "zh-hans", "zh-hans-cn":
50
- return "cn"
51
- case "tw", "hk", "zh-tw", "zh-hk", "zh-hant", "zh-hant-tw", "zh-hant-hk":
52
- return "tw"
53
- case "ja", "jp", "ja-jp":
54
- return "ja"
55
- case "ko", "ko-kr":
56
- return "ko"
57
- case "de", "de-de":
58
- return "de"
59
- case "es", "es-es":
60
- return "es"
61
- case "fr", "fr-fr":
62
- return "fr"
63
- case "it", "it-it":
64
- return "it"
65
- case "pt", "pt-br", "pt-pt":
66
- return "pt"
67
- case "hi", "in", "hi-in":
68
- return "hi"
69
- case "ar", "ar-sa":
70
- return "ar"
71
- }
72
- return ""
79
+ // ShortCodeForExternalTag 将规范外部语言标签精确映射为内部短码。
80
+ func ShortCodeForExternalTag(externalTag string) (string, bool) {
81
+ mapping, ok := externalTagIndex[externalTag]
82
+ return mapping.ShortCode, ok
73
83
  }
74
84
 
75
- func NormalizeCodeOrDefault(value string, fallback string) string {
76
- if code := NormalizeCode(value); code != "" {
77
- return code
85
+ // ExternalTagForShortCode 将内部短码精确映射为规范外部语言标签。
86
+ func ExternalTagForShortCode(shortCode string) (string, bool) {
87
+ mapping, ok := shortCodeIndex[shortCode]
88
+ return mapping.ExternalTag, ok
89
+ }
90
+
91
+ // DirectionForShortCode 返回内部短码对应的排版方向。
92
+ func DirectionForShortCode(shortCode string) (Direction, bool) {
93
+ mapping, ok := shortCodeIndex[shortCode]
94
+ return mapping.Direction, ok
95
+ }
96
+
97
+ // NormalizeCode 将规范外部语言标签或内部短码精确转换为内部短码。
98
+ func NormalizeCode(value string) string {
99
+ if mapping, ok := externalTagIndex[value]; ok {
100
+ return mapping.ShortCode
78
101
  }
79
- if code := NormalizeCode(fallback); code != "" {
80
- return code
102
+ if mapping, ok := shortCodeIndex[value]; ok {
103
+ return mapping.ShortCode
81
104
  }
82
- return DefaultCode
105
+ return ""
83
106
  }
84
107
 
108
+ // IsShortCode 判断值是否为受支持的精确内部短码。
85
109
  func IsShortCode(value string) bool {
86
- code := strings.TrimSpace(value)
87
- if code == "" || code != strings.ToLower(code) {
88
- return false
89
- }
90
- _, ok := supportedCodeSet[code]
110
+ _, ok := shortCodeIndex[value]
91
111
  return ok
92
112
  }
93
113
 
94
- func NormalizeTextMap(input map[string]string) map[string]string {
95
- if len(input) == 0 {
96
- return map[string]string{}
97
- }
114
+ // NormalizeTextMap 将语言键精确转换为内部短码并拒绝无效或冲突输入。
115
+ func NormalizeTextMap(input map[string]string) (map[string]string, error) {
98
116
  result := make(map[string]string, len(input))
99
117
  for key, value := range input {
100
118
  code := NormalizeCode(key)
119
+ if code == "" {
120
+ return nil, fmt.Errorf("unsupported locale key %q", key)
121
+ }
101
122
  text := strings.TrimSpace(value)
102
- if code == "" || text == "" {
103
- continue
123
+ if text == "" {
124
+ return nil, fmt.Errorf("locale %q has empty text", key)
104
125
  }
105
126
  if _, exists := result[code]; exists {
106
- continue
127
+ return nil, fmt.Errorf("duplicate locale mapping for short code %q", code)
107
128
  }
108
129
  result[code] = text
109
130
  }
110
- return result
131
+ return result, nil
111
132
  }
112
133
 
113
- func NormalizeLocaleRootedMap[T any](input map[string]T) map[string]T {
114
- if len(input) == 0 {
115
- return map[string]T{}
116
- }
134
+ // NormalizeLocaleRootedMap 将语言根键精确转换为内部短码并拒绝无效或冲突输入。
135
+ func NormalizeLocaleRootedMap[T any](input map[string]T) (map[string]T, error) {
117
136
  result := make(map[string]T, len(input))
118
137
  for key, value := range input {
119
138
  code := NormalizeCode(key)
120
139
  if code == "" {
121
- continue
140
+ return nil, fmt.Errorf("unsupported locale key %q", key)
122
141
  }
123
142
  if _, exists := result[code]; exists {
124
- continue
143
+ return nil, fmt.Errorf("duplicate locale mapping for short code %q", code)
125
144
  }
126
145
  result[code] = value
127
146
  }
128
- return result
147
+ return result, nil
129
148
  }
130
149
 
150
+ // NormalizeRawTextMap 将 JSON 文本映射规范化为内部短码键。
131
151
  func NormalizeRawTextMap(raw json.RawMessage) (json.RawMessage, error) {
132
152
  if len(raw) == 0 {
133
153
  return json.RawMessage(`{}`), nil
@@ -136,7 +156,13 @@ func NormalizeRawTextMap(raw json.RawMessage) (json.RawMessage, error) {
136
156
  if err := json.Unmarshal(raw, &input); err != nil {
137
157
  return nil, err
138
158
  }
139
- normalized := NormalizeTextMap(input)
159
+ if input == nil {
160
+ return nil, fmt.Errorf("locale text map must be a JSON object")
161
+ }
162
+ normalized, err := NormalizeTextMap(input)
163
+ if err != nil {
164
+ return nil, err
165
+ }
140
166
  payload, err := json.Marshal(normalized)
141
167
  if err != nil {
142
168
  return nil, err
@@ -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{}).