@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.
- package/README.md +248 -0
- package/dist/index.js +14124 -635
- package/dist/index.js.map +1 -1
- package/dist/resources/components/nuxt/site-source/app/app.vue +4 -2
- package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +50 -50
- package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationHeader.vue → app/Header.vue} +3 -3
- package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationShell.vue → app/Shell.vue} +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationSidebar.vue → app/Sidebar.vue} +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/{ui/CheckoutStatus.vue → checkout/Status.vue} +8 -3
- package/dist/resources/components/nuxt/site-source/app/components/{ui/CheckoutSummary.vue → checkout/Summary.vue} +11 -11
- package/dist/resources/components/nuxt/site-source/app/components/{site/CookieConsent.vue → consent/Banner.vue} +3 -3
- package/dist/resources/components/nuxt/site-source/app/components/{site/ContentArticlePage.vue → content/Article.vue} +3 -3
- package/dist/resources/components/nuxt/site-source/app/components/{site/ContentListPage.vue → content/List.vue} +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/{ui/GoogleSignInButton.vue → google/SignInButton.vue} +3 -3
- package/dist/resources/components/nuxt/site-source/app/components/{site/LocaleSwitcher.vue → locale/Menu.vue} +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/site/{MarketingFooter.vue → Footer.vue} +8 -8
- package/dist/resources/components/nuxt/site-source/app/components/site/{MarketingHeader.vue → Header.vue} +27 -27
- package/dist/resources/components/nuxt/site-source/app/components/site/Shell.vue +29 -0
- package/dist/resources/components/nuxt/site-source/app/components/{site/ThemeSwitcher.vue → theme/Menu.vue} +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseButton.vue → Button.vue} +1 -8
- package/dist/resources/components/nuxt/site-source/app/components/ui/InfiniteScroll.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/{ui/YoutubeEmbed.vue → youtube/Embed.vue} +10 -2
- package/dist/resources/components/nuxt/site-source/app/composables/useConsent.ts +8 -10
- package/dist/resources/components/nuxt/site-source/app/composables/useNavigation.ts +9 -14
- package/dist/resources/components/nuxt/site-source/app/composables/useSeo.ts +3 -1
- package/dist/resources/components/nuxt/site-source/app/composables/useSiteShell.ts +19 -22
- package/dist/resources/components/nuxt/site-source/app/consent/types.ts +59 -0
- package/dist/resources/components/nuxt/site-source/app/content/types.ts +17 -0
- package/dist/resources/components/nuxt/site-source/app/layouts/app.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/layouts/default.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/navigation/types.ts +68 -0
- package/dist/resources/components/nuxt/site-source/app/plugins/consent.ts +2 -2
- package/dist/resources/components/nuxt/site-source/app/site/config.ts +35 -0
- package/dist/resources/components/registry.json +63 -51
- package/dist/resources/deploy/ci/github/caller.yml +38 -0
- package/dist/resources/deploy/ci/onedev/import.yml +14 -0
- package/dist/resources/deploy/nginx/cache.conf +17 -0
- package/dist/resources/deploy/nginx/compression.conf +23 -0
- package/dist/resources/deploy/nginx/proxy.conf +15 -0
- package/dist/resources/deploy/profiles/go-service/compose.yaml +19 -0
- package/dist/resources/deploy/profiles/nuxt-node/Dockerfile +17 -0
- package/dist/resources/deploy/profiles/static-files/nginx/site.conf.template +55 -0
- package/dist/resources/deploy/profiles/static-nginx/Dockerfile +18 -0
- package/dist/resources/deploy/profiles/static-nginx/nginx/default.conf.template +28 -0
- package/dist/resources/deploy/targets/compose-host/deploy.config.example.json +114 -0
- package/dist/resources/deploy/traffic/nginx/stackonward-nginx-traffic +96 -0
- package/dist/resources/registry.json +30 -30
- package/dist/resources/templates/base/.claude/docs/architecture.md +4 -0
- package/dist/resources/templates/base/AGENTS.md +15 -0
- package/dist/resources/templates/base/CLAUDE.md +15 -0
- package/dist/resources/templates/go-backend/.dockerignore +25 -3
- package/dist/resources/templates/go-backend/deploy.config.json +26 -0
- package/dist/resources/templates/go-backend/docs/runtime-provenance.json +9 -6
- package/dist/resources/templates/go-backend/docs/source-provenance.json +33 -22
- package/dist/resources/templates/go-backend/internal/common/enums/register_locale.go +7 -0
- package/dist/resources/templates/go-backend/internal/common/enums/register_locale_test.go +30 -0
- package/dist/resources/templates/go-backend/internal/common/locale/locale.go +102 -76
- package/dist/resources/templates/go-backend/internal/common/locale/locale_test.go +168 -45
- package/dist/resources/templates/go-backend/internal/core/commandreplay/model.go +5 -5
- package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence.go +20 -7
- package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence_test.go +51 -22
- package/dist/resources/templates/go-backend/internal/core/commandreplay/repository.go +2 -2
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service.go +13 -24
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service_persistence_test.go +98 -13
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service_test.go +61 -36
- package/dist/resources/templates/go-backend/internal/core/commandreplay/types.go +6 -6
- package/dist/resources/templates/go-backend/internal/core/integrationevent/config.go +24 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/errors.go +53 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/model.go +71 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence.go +193 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_postgres_test.go +178 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_test.go +168 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/provider.go +16 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/registry.go +69 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/repository.go +17 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/service.go +283 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/service_test.go +433 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/types.go +67 -0
- package/dist/resources/templates/go-backend/migrations/000001_infrastructure.down.sql +6 -0
- package/dist/resources/templates/go-backend/migrations/000001_infrastructure.up.sql +176 -16
- package/dist/resources/templates/go-backend/tests/infrastructure.sql +109 -6
- package/dist/resources/templates/nuxt/.claude/docs/stack.md +5 -1
- package/dist/resources/templates/nuxt/.dockerignore +19 -0
- package/dist/resources/templates/nuxt/README.md +3 -3
- package/dist/resources/templates/nuxt/app/app.config.ts +1 -1
- package/dist/resources/templates/nuxt/app/i18n/locales/en.json +1 -1
- package/dist/resources/templates/nuxt/app/pages/app/index.vue +1 -1
- package/dist/resources/templates/nuxt/app/pages/authors/[slug].vue +2 -2
- package/dist/resources/templates/nuxt/app/pages/blog/[slug].vue +4 -3
- package/dist/resources/templates/nuxt/app/pages/blog/category/[category].vue +2 -2
- package/dist/resources/templates/nuxt/app/pages/blog/index.vue +2 -2
- package/dist/resources/templates/nuxt/app/pages/legal/cookies.vue +3 -3
- package/dist/resources/templates/nuxt/app/pages/legal/privacy.vue +2 -2
- package/dist/resources/templates/nuxt/app/pages/legal/terms.vue +2 -2
- package/dist/resources/templates/nuxt/app/pages/ui/index.vue +45 -45
- package/dist/resources/templates/nuxt/deploy.config.json +27 -0
- package/dist/resources/templates/nuxt/nuxt.config.ts +4 -10
- package/dist/resources/templates/nuxt/package.json +8 -8
- package/dist/resources/templates/nuxt/test/e2e/rendering.test.ts +1 -0
- package/dist/resources/templates/nuxt/test/unit/consent.test.ts +4 -7
- package/package.json +5 -3
- package/dist/resources/components/nuxt/site-source/app/components/site/MarketingShell.vue +0 -33
- package/dist/resources/components/nuxt/site-source/app/types/site-shell.ts +0 -172
- /package/dist/resources/components/nuxt/site-source/app/components/{site/ConsentSettingsButton.vue → consent/Settings.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/{site/StaticPage.vue → content/Document.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/{site/ToastViewport.vue → feedback/Toast.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/site/{SiteBrand.vue → Brand.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseGradientBorder.vue → GradientBorder.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseModal.vue → Modal.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/ui/{BasePopover.vue → Popover.vue} +0 -0
- /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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
80
|
-
return
|
|
102
|
+
if mapping, ok := shortCodeIndex[value]; ok {
|
|
103
|
+
return mapping.ShortCode
|
|
81
104
|
}
|
|
82
|
-
return
|
|
105
|
+
return ""
|
|
83
106
|
}
|
|
84
107
|
|
|
108
|
+
// IsShortCode 判断值是否为受支持的精确内部短码。
|
|
85
109
|
func IsShortCode(value string) bool {
|
|
86
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
103
|
-
|
|
123
|
+
if text == "" {
|
|
124
|
+
return nil, fmt.Errorf("locale %q has empty text", key)
|
|
104
125
|
}
|
|
105
126
|
if _, exists := result[code]; exists {
|
|
106
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
140
|
+
return nil, fmt.Errorf("unsupported locale key %q", key)
|
|
122
141
|
}
|
|
123
142
|
if _, exists := result[code]; exists {
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
33
|
-
"zh-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
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
|
-
"
|
|
42
|
-
"
|
|
119
|
+
"cn": "简体中文",
|
|
120
|
+
"pt": "Português",
|
|
121
|
+
"ar": "العربية",
|
|
43
122
|
}
|
|
44
123
|
if !reflect.DeepEqual(got, want) {
|
|
45
|
-
t.Fatalf("NormalizeTextMap() =
|
|
124
|
+
t.Fatalf("NormalizeTextMap() = %#v, want %#v", got, want)
|
|
46
125
|
}
|
|
47
126
|
}
|
|
48
127
|
|
|
49
|
-
func
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
t.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
"
|
|
67
|
-
"
|
|
150
|
+
got, err := NormalizeLocaleRootedMap(map[string]json.RawMessage{
|
|
151
|
+
"zh-Hant": json.RawMessage(`{"sms":{}}`),
|
|
152
|
+
"id": json.RawMessage(`{"email":{}}`),
|
|
68
153
|
})
|
|
69
|
-
if
|
|
70
|
-
t.Fatalf("
|
|
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
|
-
|
|
73
|
-
|
|
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:
|
|
38
|
-
CommandScope string `gorm:"column:command_scope;type:varchar(128);not null;uniqueIndex:
|
|
39
|
-
IdempotencyKey string `gorm:"column:idempotency_key;type:varchar(191);not null;uniqueIndex:
|
|
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
|
|
42
|
-
PrincipalRef
|
|
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 :=
|
|
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{}).
|