@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.
- package/README.md +248 -0
- package/dist/index.js +14093 -603
- package/dist/index.js.map +1 -1
- 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/deploy.config.json +27 -0
- package/dist/resources/templates/nuxt/package.json +8 -8
- package/package.json +5 -3
package/dist/resources/templates/go-backend/internal/core/commandreplay/service_persistence_test.go
CHANGED
|
@@ -46,7 +46,7 @@ func TestCommandReplayExecuteStandaloneCommitsBusinessFailure(t *testing.T) {
|
|
|
46
46
|
t.Fatalf("Execute() result = %#v, want failed result", result)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
|
|
49
|
+
stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
|
|
50
50
|
if err != nil {
|
|
51
51
|
t.Fatalf("FindByScope() error = %v", err)
|
|
52
52
|
}
|
|
@@ -101,7 +101,7 @@ func TestCommandReplayExecuteStandaloneRollsBackInfrastructureError(t *testing.T
|
|
|
101
101
|
t.Fatalf("Execute() error = %v, want infraErr", err)
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
|
|
104
|
+
stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
|
|
105
105
|
if err != nil {
|
|
106
106
|
t.Fatalf("FindByScope() error = %v", err)
|
|
107
107
|
}
|
|
@@ -147,7 +147,7 @@ func TestCommandReplayExecuteJoinsOuterTransactionRollback(t *testing.T) {
|
|
|
147
147
|
t.Fatalf("WithTransaction() error = %v, want rollbackErr", err)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
|
|
150
|
+
stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
|
|
151
151
|
if err != nil {
|
|
152
152
|
t.Fatalf("FindByScope() error = %v", err)
|
|
153
153
|
}
|
|
@@ -214,7 +214,7 @@ func TestCommandReplayExecuteRejectsConcurrentDuplicateOnPostgres(t *testing.T)
|
|
|
214
214
|
t.Fatalf("business executions = %d, want 1", executions.Load())
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
|
|
217
|
+
stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
|
|
218
218
|
if err != nil {
|
|
219
219
|
t.Fatalf("FindByScope() error = %v", err)
|
|
220
220
|
}
|
|
@@ -223,6 +223,89 @@ func TestCommandReplayExecuteRejectsConcurrentDuplicateOnPostgres(t *testing.T)
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
func TestCommandReplayExecuteAllowsConcurrentSameKeyForDifferentPrincipalsOnPostgres(t *testing.T) {
|
|
227
|
+
db := openPersistentCommandReplayPostgres(t)
|
|
228
|
+
repo := commandreplay.NewCommandReplayRepository(db)
|
|
229
|
+
service := commandreplay.NewCommandReplayService(repo, database.NewTransactionManager(db))
|
|
230
|
+
ctx := context.Background()
|
|
231
|
+
firstStarted := make(chan struct{})
|
|
232
|
+
releaseFirst := make(chan struct{})
|
|
233
|
+
firstDone := make(chan error, 1)
|
|
234
|
+
secondDone := make(chan error, 1)
|
|
235
|
+
releasedFirst := false
|
|
236
|
+
defer func() {
|
|
237
|
+
if !releasedFirst {
|
|
238
|
+
close(releaseFirst)
|
|
239
|
+
}
|
|
240
|
+
}()
|
|
241
|
+
var executions atomic.Int32
|
|
242
|
+
|
|
243
|
+
go func() {
|
|
244
|
+
_, err := service.Execute(ctx, persistentCommandReplayRequest(json.RawMessage(`{"amount":100}`)), func(context.Context, *commandreplay.CommandReplayRecord) (*commandreplay.CommandReplayCommandResult, error) {
|
|
245
|
+
executions.Add(1)
|
|
246
|
+
close(firstStarted)
|
|
247
|
+
<-releaseFirst
|
|
248
|
+
return &commandreplay.CommandReplayCommandResult{
|
|
249
|
+
Status: commandreplay.CommandReplayStatusSucceeded,
|
|
250
|
+
ResponsePayload: json.RawMessage(`{"status":"first"}`),
|
|
251
|
+
}, nil
|
|
252
|
+
})
|
|
253
|
+
firstDone <- err
|
|
254
|
+
}()
|
|
255
|
+
|
|
256
|
+
select {
|
|
257
|
+
case <-firstStarted:
|
|
258
|
+
case <-time.After(5 * time.Second):
|
|
259
|
+
t.Fatal("first principal did not enter its transaction")
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
secondRequest := persistentCommandReplayRequest(json.RawMessage(`{"amount":100}`))
|
|
263
|
+
secondRequest.PrincipalRef = "user_456"
|
|
264
|
+
go func() {
|
|
265
|
+
_, err := service.Execute(ctx, secondRequest, func(context.Context, *commandreplay.CommandReplayRecord) (*commandreplay.CommandReplayCommandResult, error) {
|
|
266
|
+
executions.Add(1)
|
|
267
|
+
return &commandreplay.CommandReplayCommandResult{
|
|
268
|
+
Status: commandreplay.CommandReplayStatusSucceeded,
|
|
269
|
+
ResponsePayload: json.RawMessage(`{"status":"second"}`),
|
|
270
|
+
}, nil
|
|
271
|
+
})
|
|
272
|
+
secondDone <- err
|
|
273
|
+
}()
|
|
274
|
+
|
|
275
|
+
select {
|
|
276
|
+
case err := <-secondDone:
|
|
277
|
+
if err != nil {
|
|
278
|
+
t.Fatalf("second principal Execute() error = %v", err)
|
|
279
|
+
}
|
|
280
|
+
case <-time.After(5 * time.Second):
|
|
281
|
+
t.Fatal("second principal was blocked by another principal's scope lock")
|
|
282
|
+
}
|
|
283
|
+
close(releaseFirst)
|
|
284
|
+
releasedFirst = true
|
|
285
|
+
|
|
286
|
+
select {
|
|
287
|
+
case err := <-firstDone:
|
|
288
|
+
if err != nil {
|
|
289
|
+
t.Fatalf("first principal Execute() error = %v", err)
|
|
290
|
+
}
|
|
291
|
+
case <-time.After(5 * time.Second):
|
|
292
|
+
t.Fatal("first principal did not complete")
|
|
293
|
+
}
|
|
294
|
+
if executions.Load() != 2 {
|
|
295
|
+
t.Fatalf("business executions = %d, want 2", executions.Load())
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for _, principalRef := range []string{"user_123", "user_456"} {
|
|
299
|
+
stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", principalRef, "quote:123", "key-123")
|
|
300
|
+
if err != nil {
|
|
301
|
+
t.Fatalf("FindByScope(%s) error = %v", principalRef, err)
|
|
302
|
+
}
|
|
303
|
+
if stored == nil || stored.Status != commandreplay.CommandReplayStatusSucceeded {
|
|
304
|
+
t.Fatalf("stored record for %s = %#v, want succeeded", principalRef, stored)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
226
309
|
func newPersistentCommandReplayService(t *testing.T) (*commandreplay.CommandReplayService, commandreplay.CommandReplayRepository) {
|
|
227
310
|
t.Helper()
|
|
228
311
|
service, repo, _, _ := newPersistentCommandReplayFixture(t)
|
|
@@ -248,13 +331,15 @@ type sqliteCommandReplayRepository struct {
|
|
|
248
331
|
commandreplay.CommandReplayRepository
|
|
249
332
|
}
|
|
250
333
|
|
|
251
|
-
func (r sqliteCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, commandScope, idempotencyKey string) (bool, error) {
|
|
334
|
+
func (r sqliteCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (bool, error) {
|
|
252
335
|
return true, nil
|
|
253
336
|
}
|
|
254
337
|
|
|
255
338
|
func persistentCommandReplayRequest(payload json.RawMessage) commandreplay.CommandReplayBeginRequest {
|
|
256
339
|
return commandreplay.CommandReplayBeginRequest{
|
|
257
340
|
Namespace: "test.command.replay",
|
|
341
|
+
PrincipalType: "identity",
|
|
342
|
+
PrincipalRef: "user_123",
|
|
258
343
|
CommandScope: "quote:123",
|
|
259
344
|
IdempotencyKey: "key-123",
|
|
260
345
|
RequestPayload: payload,
|
|
@@ -275,8 +360,8 @@ func createPersistentCommandReplayRecordSchema(t *testing.T, db *gorm.DB) {
|
|
|
275
360
|
command_scope TEXT NOT NULL,
|
|
276
361
|
idempotency_key TEXT NOT NULL,
|
|
277
362
|
request_hash TEXT NOT NULL,
|
|
278
|
-
principal_type TEXT,
|
|
279
|
-
principal_ref TEXT,
|
|
363
|
+
principal_type TEXT NOT NULL,
|
|
364
|
+
principal_ref TEXT NOT NULL,
|
|
280
365
|
status TEXT NOT NULL DEFAULT 'started',
|
|
281
366
|
response_payload TEXT NOT NULL DEFAULT '{}',
|
|
282
367
|
error_code TEXT,
|
|
@@ -296,8 +381,8 @@ func createPersistentCommandReplayRecordSchema(t *testing.T, db *gorm.DB) {
|
|
|
296
381
|
t.Fatalf("create command replay schema: %v", err)
|
|
297
382
|
}
|
|
298
383
|
if err := db.Exec(`
|
|
299
|
-
CREATE UNIQUE INDEX
|
|
300
|
-
ON sys_command_replay_records (namespace, command_scope, idempotency_key)
|
|
384
|
+
CREATE UNIQUE INDEX uk_sys_command_replay_records_principal_scope_key
|
|
385
|
+
ON sys_command_replay_records (namespace, principal_type, principal_ref, command_scope, idempotency_key)
|
|
301
386
|
`).Error; err != nil {
|
|
302
387
|
t.Fatalf("create command replay unique index: %v", err)
|
|
303
388
|
}
|
|
@@ -381,8 +466,8 @@ func createPersistentCommandReplayPostgresSchema(t *testing.T, db *gorm.DB) {
|
|
|
381
466
|
command_scope VARCHAR(128) NOT NULL,
|
|
382
467
|
idempotency_key VARCHAR(191) NOT NULL,
|
|
383
468
|
request_hash CHAR(64) NOT NULL,
|
|
384
|
-
principal_type VARCHAR(32),
|
|
385
|
-
principal_ref VARCHAR(64),
|
|
469
|
+
principal_type VARCHAR(32) NOT NULL,
|
|
470
|
+
principal_ref VARCHAR(64) NOT NULL,
|
|
386
471
|
status VARCHAR(20) NOT NULL DEFAULT 'started',
|
|
387
472
|
response_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
388
473
|
error_code VARCHAR(120),
|
|
@@ -397,8 +482,8 @@ func createPersistentCommandReplayPostgresSchema(t *testing.T, db *gorm.DB) {
|
|
|
397
482
|
external_attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
398
483
|
external_last_error_code VARCHAR(120),
|
|
399
484
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
400
|
-
CONSTRAINT
|
|
401
|
-
UNIQUE (namespace, command_scope, idempotency_key)
|
|
485
|
+
CONSTRAINT uk_sys_command_replay_records_principal_scope_key
|
|
486
|
+
UNIQUE (namespace, principal_type, principal_ref, command_scope, idempotency_key)
|
|
402
487
|
)
|
|
403
488
|
`).Error; err != nil {
|
|
404
489
|
t.Fatalf("create PostgreSQL command replay schema: %v", err)
|
|
@@ -18,14 +18,16 @@ import (
|
|
|
18
18
|
"{{module}}/pkg/apierror"
|
|
19
19
|
)
|
|
20
20
|
|
|
21
|
-
const
|
|
21
|
+
const (
|
|
22
|
+
commandReplayTestNamespace = "test.command.replay"
|
|
23
|
+
commandReplayTestPrincipalType = "identity"
|
|
24
|
+
commandReplayTestPrincipalRef = "user_123"
|
|
25
|
+
)
|
|
22
26
|
|
|
23
27
|
var commandReplayTestNow = time.Date(2026, 5, 28, 10, 0, 0, 0, time.UTC)
|
|
24
28
|
|
|
25
29
|
func TestCommandReplayExecuteSucceeds(t *testing.T) {
|
|
26
30
|
service, repo := newTestCommandReplayService(t)
|
|
27
|
-
principalType := "identity"
|
|
28
|
-
principalRef := "user_123"
|
|
29
31
|
fnCalled := 0
|
|
30
32
|
|
|
31
33
|
result, err := service.Execute(context.Background(), CommandReplayBeginRequest{
|
|
@@ -33,8 +35,8 @@ func TestCommandReplayExecuteSucceeds(t *testing.T) {
|
|
|
33
35
|
CommandScope: " quote:123 ",
|
|
34
36
|
IdempotencyKey: " key-123 ",
|
|
35
37
|
RequestPayload: json.RawMessage(`{"currency":"USD","amount":100}`),
|
|
36
|
-
PrincipalType:
|
|
37
|
-
PrincipalRef:
|
|
38
|
+
PrincipalType: " identity ",
|
|
39
|
+
PrincipalRef: " user_123 ",
|
|
38
40
|
}, func(ctx context.Context, record *CommandReplayRecord) (*CommandReplayCommandResult, error) {
|
|
39
41
|
fnCalled++
|
|
40
42
|
if record.ID == 0 {
|
|
@@ -61,7 +63,7 @@ func TestCommandReplayExecuteSucceeds(t *testing.T) {
|
|
|
61
63
|
t.Fatalf("Execute() ResponsePayload = %s, want canonical object", result.ResponsePayload)
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
|
|
66
|
+
stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
|
|
65
67
|
if stored.Status != CommandReplayStatusSucceeded {
|
|
66
68
|
t.Fatalf("stored Status = %q, want succeeded", stored.Status)
|
|
67
69
|
}
|
|
@@ -77,11 +79,11 @@ func TestCommandReplayExecuteSucceeds(t *testing.T) {
|
|
|
77
79
|
if stored.CompletedAt == nil || !stored.CompletedAt.Equal(commandReplayTestNow) {
|
|
78
80
|
t.Fatalf("stored CompletedAt = %v, want %s", stored.CompletedAt, commandReplayTestNow)
|
|
79
81
|
}
|
|
80
|
-
if stored.PrincipalType
|
|
81
|
-
t.Fatalf("stored PrincipalType = %
|
|
82
|
+
if stored.PrincipalType != commandReplayTestPrincipalType {
|
|
83
|
+
t.Fatalf("stored PrincipalType = %q, want %q", stored.PrincipalType, commandReplayTestPrincipalType)
|
|
82
84
|
}
|
|
83
|
-
if stored.PrincipalRef
|
|
84
|
-
t.Fatalf("stored PrincipalRef = %
|
|
85
|
+
if stored.PrincipalRef != commandReplayTestPrincipalRef {
|
|
86
|
+
t.Fatalf("stored PrincipalRef = %q, want %q", stored.PrincipalRef, commandReplayTestPrincipalRef)
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
89
|
|
|
@@ -140,6 +142,29 @@ func TestCommandReplayExecuteRejectsHashConflict(t *testing.T) {
|
|
|
140
142
|
assertAPIError(t, err, http.StatusConflict, errCodeCommandReplayConflict)
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
func TestCommandReplayExecuteIsolatesSameKeyByPrincipal(t *testing.T) {
|
|
146
|
+
service, repo := newTestCommandReplayService(t)
|
|
147
|
+
first := beginRequest(json.RawMessage(`{"amount":100}`))
|
|
148
|
+
second := beginRequest(json.RawMessage(`{"amount":100}`))
|
|
149
|
+
second.PrincipalRef = "user_456"
|
|
150
|
+
|
|
151
|
+
firstResult, err := service.Execute(context.Background(), first, successCommandReplayFunc(t))
|
|
152
|
+
if err != nil {
|
|
153
|
+
t.Fatalf("Execute(first) error = %v", err)
|
|
154
|
+
}
|
|
155
|
+
secondResult, err := service.Execute(context.Background(), second, successCommandReplayFunc(t))
|
|
156
|
+
if err != nil {
|
|
157
|
+
t.Fatalf("Execute(second) error = %v", err)
|
|
158
|
+
}
|
|
159
|
+
if firstResult.Replayed || secondResult.Replayed {
|
|
160
|
+
t.Fatalf("new principal-scoped commands must not replay: first=%v second=%v", firstResult.Replayed, secondResult.Replayed)
|
|
161
|
+
}
|
|
162
|
+
if repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123").RequestHash !=
|
|
163
|
+
repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, "user_456", "quote:123", "key-123").RequestHash {
|
|
164
|
+
t.Fatal("principal unexpectedly changed the payload-only request fingerprint")
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
143
168
|
func TestCommandReplayExecuteRejectsExpiredRecord(t *testing.T) {
|
|
144
169
|
service, repo := newTestCommandReplayService(t)
|
|
145
170
|
repo.seed(seedCommandReplayRecord(t, json.RawMessage(`{"amount":100}`), func(record *CommandReplayRecord) {
|
|
@@ -169,7 +194,7 @@ func TestCommandReplayExecuteBusinessFailureCommitsAndReturnsBusinessError(t *te
|
|
|
169
194
|
t.Fatalf("Execute() result = %#v, want failed result", result)
|
|
170
195
|
}
|
|
171
196
|
|
|
172
|
-
stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
|
|
197
|
+
stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
|
|
173
198
|
if stored.Status != CommandReplayStatusFailed {
|
|
174
199
|
t.Fatalf("stored Status = %q, want failed", stored.Status)
|
|
175
200
|
}
|
|
@@ -198,7 +223,7 @@ func TestCommandReplayExecuteInfrastructureErrorDoesNotMarkTerminal(t *testing.T
|
|
|
198
223
|
t.Fatalf("Execute() error = %v, want infraErr", err)
|
|
199
224
|
}
|
|
200
225
|
|
|
201
|
-
stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
|
|
226
|
+
stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
|
|
202
227
|
if stored.Status.IsTerminal() {
|
|
203
228
|
t.Fatalf("stored Status = %q, want non-terminal", stored.Status)
|
|
204
229
|
}
|
|
@@ -258,19 +283,17 @@ func TestCommandReplayExecuteRejectsInvalidNamespace(t *testing.T) {
|
|
|
258
283
|
assertAPIError(t, err, http.StatusBadRequest, apierror.CodeValidationFailed)
|
|
259
284
|
}
|
|
260
285
|
|
|
261
|
-
func
|
|
262
|
-
principalType := "identity"
|
|
263
|
-
principalRef := "ipr_123"
|
|
264
|
-
blank := " \t "
|
|
286
|
+
func TestCommandReplayExecuteRejectsInvalidPrincipal(t *testing.T) {
|
|
265
287
|
tests := []struct {
|
|
266
288
|
name string
|
|
267
|
-
principalType
|
|
268
|
-
principalRef
|
|
289
|
+
principalType string
|
|
290
|
+
principalRef string
|
|
269
291
|
}{
|
|
270
|
-
{name: "missing principal ref", principalType:
|
|
271
|
-
{name: "missing principal type", principalRef:
|
|
272
|
-
{name: "blank principal pair", principalType:
|
|
273
|
-
{name: "
|
|
292
|
+
{name: "missing principal ref", principalType: commandReplayTestPrincipalType},
|
|
293
|
+
{name: "missing principal type", principalRef: commandReplayTestPrincipalRef},
|
|
294
|
+
{name: "blank principal pair", principalType: " \t ", principalRef: " \t "},
|
|
295
|
+
{name: "overlong principal type", principalType: strings.Repeat("a", maxPrincipalTypeLength+1), principalRef: commandReplayTestPrincipalRef},
|
|
296
|
+
{name: "overlong principal ref", principalType: commandReplayTestPrincipalType, principalRef: strings.Repeat("a", maxPrincipalRefLength+1)},
|
|
274
297
|
}
|
|
275
298
|
|
|
276
299
|
for _, tt := range tests {
|
|
@@ -372,7 +395,7 @@ func TestCommandReplayExternalLifecycle(t *testing.T) {
|
|
|
372
395
|
if lease.ExecutionToken == "" || lease.AttemptCount != 1 || lease.Reservation.ReferenceNo != "cpo_1" {
|
|
373
396
|
t.Fatalf("lease = %#v", lease)
|
|
374
397
|
}
|
|
375
|
-
stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
|
|
398
|
+
stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
|
|
376
399
|
if !stored.HasExternalReservation() || stored.ExecutionTokenHash == nil || stored.ExternalAttemptCount != 1 {
|
|
377
400
|
t.Fatalf("stored external record = %#v", stored)
|
|
378
401
|
}
|
|
@@ -389,7 +412,7 @@ func TestCommandReplayExternalLifecycle(t *testing.T) {
|
|
|
389
412
|
if err != nil || result.Status != CommandReplayStatusSucceeded {
|
|
390
413
|
t.Fatalf("CompleteExternal() = %#v, %v", result, err)
|
|
391
414
|
}
|
|
392
|
-
stored = repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
|
|
415
|
+
stored = repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
|
|
393
416
|
if stored.HasExternalReservation() || stored.ExecutionTokenHash != nil || stored.Status != CommandReplayStatusSucceeded {
|
|
394
417
|
t.Fatalf("completed stored record = %#v", stored)
|
|
395
418
|
}
|
|
@@ -453,7 +476,7 @@ func TestCommandReplayExternalDeferAndRecoveryClaim(t *testing.T) {
|
|
|
453
476
|
if err := service.DeferExternal(context.Background(), lease.RecordID, lease.ExecutionToken, "provider_timeout", nextRecoveryAt); err != nil {
|
|
454
477
|
t.Fatalf("DeferExternal() error = %v", err)
|
|
455
478
|
}
|
|
456
|
-
stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
|
|
479
|
+
stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
|
|
457
480
|
if stored.ExecutionTokenHash != nil || stored.ExternalLastErrorCode == nil || *stored.ExternalLastErrorCode != "provider_timeout" {
|
|
458
481
|
t.Fatalf("deferred record = %#v", stored)
|
|
459
482
|
}
|
|
@@ -586,6 +609,8 @@ func newTestCommandReplayService(t *testing.T) (*CommandReplayService, *fakeComm
|
|
|
586
609
|
func beginRequest(payload json.RawMessage) CommandReplayBeginRequest {
|
|
587
610
|
return CommandReplayBeginRequest{
|
|
588
611
|
Namespace: commandReplayTestNamespace,
|
|
612
|
+
PrincipalType: commandReplayTestPrincipalType,
|
|
613
|
+
PrincipalRef: commandReplayTestPrincipalRef,
|
|
589
614
|
CommandScope: "quote:123",
|
|
590
615
|
IdempotencyKey: "key-123",
|
|
591
616
|
RequestPayload: payload,
|
|
@@ -599,6 +624,8 @@ func seedCommandReplayRecord(t *testing.T, payload json.RawMessage, mutate func(
|
|
|
599
624
|
|
|
600
625
|
record := &CommandReplayRecord{
|
|
601
626
|
Namespace: commandReplayTestNamespace,
|
|
627
|
+
PrincipalType: commandReplayTestPrincipalType,
|
|
628
|
+
PrincipalRef: commandReplayTestPrincipalRef,
|
|
602
629
|
CommandScope: "quote:123",
|
|
603
630
|
IdempotencyKey: "key-123",
|
|
604
631
|
RequestHash: mustCanonicalHash(t, payload),
|
|
@@ -669,18 +696,18 @@ func newFakeCommandReplayRepository() *fakeCommandReplayRepository {
|
|
|
669
696
|
}
|
|
670
697
|
}
|
|
671
698
|
|
|
672
|
-
func (r *fakeCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, commandScope, idempotencyKey string) (bool, error) {
|
|
699
|
+
func (r *fakeCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (bool, error) {
|
|
673
700
|
r.mu.Lock()
|
|
674
701
|
defer r.mu.Unlock()
|
|
675
702
|
|
|
676
703
|
return r.lockAcquired, nil
|
|
677
704
|
}
|
|
678
705
|
|
|
679
|
-
func (r *fakeCommandReplayRepository) FindByScope(ctx context.Context, namespace, commandScope, idempotencyKey string) (*CommandReplayRecord, error) {
|
|
706
|
+
func (r *fakeCommandReplayRepository) FindByScope(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (*CommandReplayRecord, error) {
|
|
680
707
|
r.mu.Lock()
|
|
681
708
|
defer r.mu.Unlock()
|
|
682
709
|
|
|
683
|
-
record := r.records[commandReplayRecordKey(namespace, commandScope, idempotencyKey)]
|
|
710
|
+
record := r.records[commandReplayRecordKey(namespace, principalType, principalRef, commandScope, idempotencyKey)]
|
|
684
711
|
return cloneCommandReplayRecord(record), nil
|
|
685
712
|
}
|
|
686
713
|
|
|
@@ -692,7 +719,7 @@ func (r *fakeCommandReplayRepository) Create(ctx context.Context, record *Comman
|
|
|
692
719
|
r.mu.Lock()
|
|
693
720
|
defer r.mu.Unlock()
|
|
694
721
|
|
|
695
|
-
key := commandReplayRecordKey(record.Namespace, record.CommandScope, record.IdempotencyKey)
|
|
722
|
+
key := commandReplayRecordKey(record.Namespace, record.PrincipalType, record.PrincipalRef, record.CommandScope, record.IdempotencyKey)
|
|
696
723
|
if _, exists := r.records[key]; exists {
|
|
697
724
|
return ErrCommandReplayRecordExists
|
|
698
725
|
}
|
|
@@ -835,17 +862,17 @@ func (r *fakeCommandReplayRepository) seed(record *CommandReplayRecord) {
|
|
|
835
862
|
r.nextID++
|
|
836
863
|
}
|
|
837
864
|
record.ID = stored.ID
|
|
838
|
-
key := commandReplayRecordKey(stored.Namespace, stored.CommandScope, stored.IdempotencyKey)
|
|
865
|
+
key := commandReplayRecordKey(stored.Namespace, stored.PrincipalType, stored.PrincipalRef, stored.CommandScope, stored.IdempotencyKey)
|
|
839
866
|
r.records[key] = stored
|
|
840
867
|
}
|
|
841
868
|
|
|
842
|
-
func (r *fakeCommandReplayRepository) mustFind(t *testing.T, namespace, commandScope, idempotencyKey string) *CommandReplayRecord {
|
|
869
|
+
func (r *fakeCommandReplayRepository) mustFind(t *testing.T, namespace, principalType, principalRef, commandScope, idempotencyKey string) *CommandReplayRecord {
|
|
843
870
|
t.Helper()
|
|
844
871
|
|
|
845
872
|
r.mu.Lock()
|
|
846
873
|
defer r.mu.Unlock()
|
|
847
874
|
|
|
848
|
-
record := r.records[commandReplayRecordKey(namespace, commandScope, idempotencyKey)]
|
|
875
|
+
record := r.records[commandReplayRecordKey(namespace, principalType, principalRef, commandScope, idempotencyKey)]
|
|
849
876
|
if record == nil {
|
|
850
877
|
t.Fatalf("record not found")
|
|
851
878
|
}
|
|
@@ -861,8 +888,8 @@ func (r *fakeCommandReplayRepository) findByIDLocked(id int64) *CommandReplayRec
|
|
|
861
888
|
return nil
|
|
862
889
|
}
|
|
863
890
|
|
|
864
|
-
func commandReplayRecordKey(namespace, commandScope, idempotencyKey string) string {
|
|
865
|
-
return namespace + "\x00" + commandScope + "\x00" + idempotencyKey
|
|
891
|
+
func commandReplayRecordKey(namespace, principalType, principalRef, commandScope, idempotencyKey string) string {
|
|
892
|
+
return namespace + "\x00" + principalType + "\x00" + principalRef + "\x00" + commandScope + "\x00" + idempotencyKey
|
|
866
893
|
}
|
|
867
894
|
|
|
868
895
|
func cloneCommandReplayRecord(record *CommandReplayRecord) *CommandReplayRecord {
|
|
@@ -870,8 +897,6 @@ func cloneCommandReplayRecord(record *CommandReplayRecord) *CommandReplayRecord
|
|
|
870
897
|
return nil
|
|
871
898
|
}
|
|
872
899
|
cloned := *record
|
|
873
|
-
cloned.PrincipalType = cloneStringPtr(record.PrincipalType)
|
|
874
|
-
cloned.PrincipalRef = cloneStringPtr(record.PrincipalRef)
|
|
875
900
|
cloned.ErrorCode = cloneStringPtr(record.ErrorCode)
|
|
876
901
|
cloned.ErrorStatus = cloneIntPtr(record.ErrorStatus)
|
|
877
902
|
cloned.ResponsePayload = append(json.RawMessage(nil), record.ResponsePayload...)
|
|
@@ -18,10 +18,10 @@ type CommandReplayBeginRequest struct {
|
|
|
18
18
|
IdempotencyKey string
|
|
19
19
|
// RequestPayload 命令请求的规范 JSON 对象载荷。
|
|
20
20
|
RequestPayload json.RawMessage
|
|
21
|
-
// PrincipalType
|
|
22
|
-
PrincipalType
|
|
23
|
-
// PrincipalRef
|
|
24
|
-
PrincipalRef
|
|
21
|
+
// PrincipalType 是参与幂等作用域的可信调用主体类型。
|
|
22
|
+
PrincipalType string
|
|
23
|
+
// PrincipalRef 是参与幂等作用域的可信调用主体公开引用。
|
|
24
|
+
PrincipalRef string
|
|
25
25
|
// Metadata 扩展元数据 JSON 对象。
|
|
26
26
|
Metadata json.RawMessage
|
|
27
27
|
// TTL 幂等记录保留时长。
|
|
@@ -106,8 +106,8 @@ type normalizedCommandReplayBeginRequest struct {
|
|
|
106
106
|
commandScope string
|
|
107
107
|
idempotencyKey string
|
|
108
108
|
requestHash string
|
|
109
|
-
principalType
|
|
110
|
-
principalRef
|
|
109
|
+
principalType string
|
|
110
|
+
principalRef string
|
|
111
111
|
metadata json.RawMessage
|
|
112
112
|
ttl time.Duration
|
|
113
113
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
package integrationevent
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
const maxSubscriberCodeLength = 64
|
|
9
|
+
|
|
10
|
+
// ReceiverConfig 定义单个入站事件接收方的稳定订阅者身份。
|
|
11
|
+
type ReceiverConfig struct {
|
|
12
|
+
SubscriberCode string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func (c ReceiverConfig) normalized() (ReceiverConfig, error) {
|
|
16
|
+
c.SubscriberCode = strings.TrimSpace(c.SubscriberCode)
|
|
17
|
+
if c.SubscriberCode == "" {
|
|
18
|
+
return ReceiverConfig{}, fmt.Errorf("integration event subscriber code is required")
|
|
19
|
+
}
|
|
20
|
+
if len(c.SubscriberCode) > maxSubscriberCodeLength {
|
|
21
|
+
return ReceiverConfig{}, fmt.Errorf("integration event subscriber code exceeds %d bytes", maxSubscriberCodeLength)
|
|
22
|
+
}
|
|
23
|
+
return c, nil
|
|
24
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
package integrationevent
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"fmt"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
var (
|
|
9
|
+
// ErrEnvelopeInvalid 表示接收方提交的已验证信封不满足核心持久化合同。
|
|
10
|
+
ErrEnvelopeInvalid = errors.New("integration event envelope is invalid")
|
|
11
|
+
// ErrHandlerNotRegistered 表示没有消费者为精确 source/type/schema 注册处理器。
|
|
12
|
+
ErrHandlerNotRegistered = errors.New("integration event handler is not registered")
|
|
13
|
+
// ErrIdentityConflict 表示同一全局事件身份绑定了不同规范信封。
|
|
14
|
+
ErrIdentityConflict = errors.New("integration event identity conflicts with the persisted envelope")
|
|
15
|
+
// ErrVersionConflict 表示事件版本不高于水位且无法证明是已处理事件。
|
|
16
|
+
ErrVersionConflict = errors.New("integration event aggregate version conflicts with the checkpoint")
|
|
17
|
+
// ErrReceiptStateConflict 表示持久回执处于无法安全重放的状态。
|
|
18
|
+
ErrReceiptStateConflict = errors.New("integration event receipt state conflicts with replay")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// VersionGapError 表示事件版本领先于当前连续水位,调用方应稍后重试同一事件。
|
|
22
|
+
type VersionGapError struct {
|
|
23
|
+
Expected int64
|
|
24
|
+
Actual int64
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (e *VersionGapError) Error() string {
|
|
28
|
+
return fmt.Sprintf("integration event aggregate version gap: expected %d, got %d", e.Expected, e.Actual)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Retryable 标识版本缺口可以在前序事件到达后重试。
|
|
32
|
+
func (e *VersionGapError) Retryable() bool { return true }
|
|
33
|
+
|
|
34
|
+
// HandlerError 表示消费者处理失败,整个入站事务已回滚并可重试。
|
|
35
|
+
type HandlerError struct {
|
|
36
|
+
Cause error
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func (e *HandlerError) Error() string {
|
|
40
|
+
return fmt.Sprintf("integration event handler failed: %v", e.Cause)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Unwrap 返回消费者处理器的原始错误。
|
|
44
|
+
func (e *HandlerError) Unwrap() error { return e.Cause }
|
|
45
|
+
|
|
46
|
+
// Retryable 保留消费者对失败是否可重试的显式分类。
|
|
47
|
+
func (e *HandlerError) Retryable() bool { return IsRetryable(e.Cause) }
|
|
48
|
+
|
|
49
|
+
// IsRetryable 判断错误是否允许发送方稍后重试同一事件身份。
|
|
50
|
+
func IsRetryable(err error) bool {
|
|
51
|
+
var retryable interface{ Retryable() bool }
|
|
52
|
+
return errors.As(err, &retryable) && retryable.Retryable()
|
|
53
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
package integrationevent
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"time"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// ReceiptProcessingStatus 表示订阅者回执的持久处理状态。
|
|
9
|
+
type ReceiptProcessingStatus string
|
|
10
|
+
|
|
11
|
+
const (
|
|
12
|
+
ReceiptProcessingStatusProcessing ReceiptProcessingStatus = "processing"
|
|
13
|
+
ReceiptProcessingStatusProcessed ReceiptProcessingStatus = "processed"
|
|
14
|
+
ReceiptProcessingStatusFailed ReceiptProcessingStatus = "failed"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// InboxRecord 保存全局唯一事件身份及其规范信封。
|
|
18
|
+
type InboxRecord struct {
|
|
19
|
+
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
|
20
|
+
SourceSystem string `gorm:"column:source_system;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_inbox_identity,priority:1"`
|
|
21
|
+
EventID string `gorm:"column:event_id;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_inbox_identity,priority:2"`
|
|
22
|
+
EventType string `gorm:"column:event_type;type:varchar(191);not null"`
|
|
23
|
+
SchemaVersion string `gorm:"column:schema_version;type:varchar(32);not null"`
|
|
24
|
+
ProductCode string `gorm:"column:product_code;type:varchar(50);not null"`
|
|
25
|
+
AggregateType string `gorm:"column:aggregate_type;type:varchar(128);not null"`
|
|
26
|
+
AggregateRef string `gorm:"column:aggregate_ref;type:varchar(191);not null"`
|
|
27
|
+
AggregateVersion int64 `gorm:"column:aggregate_version;not null"`
|
|
28
|
+
PayloadHash string `gorm:"column:payload_hash;type:char(64);not null"`
|
|
29
|
+
EventPayload json.RawMessage `gorm:"column:event_payload;type:jsonb;not null"`
|
|
30
|
+
ReceivedAt time.Time `gorm:"column:received_at;type:timestamptz(3);not null"`
|
|
31
|
+
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz(3);not null"`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// TableName 指定全局入站事件身份表。
|
|
35
|
+
func (InboxRecord) TableName() string { return "sys_integration_event_inbox" }
|
|
36
|
+
|
|
37
|
+
// ReceiptRecord 保存单个订阅者对一个全局事件的处理回执。
|
|
38
|
+
type ReceiptRecord struct {
|
|
39
|
+
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
|
40
|
+
SourceSystem string `gorm:"column:source_system;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_receipts_subscriber_event,priority:1"`
|
|
41
|
+
SubscriberCode string `gorm:"column:subscriber_code;type:varchar(64);not null;uniqueIndex:uk_sys_integration_event_receipts_subscriber_event,priority:2"`
|
|
42
|
+
EventID string `gorm:"column:event_id;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_receipts_subscriber_event,priority:3"`
|
|
43
|
+
Status ReceiptProcessingStatus `gorm:"column:status;type:varchar(20);not null"`
|
|
44
|
+
ReceiptStatus *ReceiptOutcome `gorm:"column:receipt_status;type:varchar(20)"`
|
|
45
|
+
ErrorCode *string `gorm:"column:error_code;type:varchar(120)"`
|
|
46
|
+
ProcessingStartedAt time.Time `gorm:"column:processing_started_at;type:timestamptz(3);not null"`
|
|
47
|
+
ProcessedAt *time.Time `gorm:"column:processed_at;type:timestamptz(3)"`
|
|
48
|
+
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz(3);not null"`
|
|
49
|
+
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz(3);not null"`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// TableName 指定订阅者事件回执表。
|
|
53
|
+
func (ReceiptRecord) TableName() string { return "sys_integration_event_receipts" }
|
|
54
|
+
|
|
55
|
+
// AggregateCheckpoint 保存单个订阅者对一个上游聚合的最后连续版本。
|
|
56
|
+
type AggregateCheckpoint struct {
|
|
57
|
+
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
|
58
|
+
SourceSystem string `gorm:"column:source_system;type:varchar(191);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:1"`
|
|
59
|
+
SubscriberCode string `gorm:"column:subscriber_code;type:varchar(64);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:2"`
|
|
60
|
+
AggregateType string `gorm:"column:aggregate_type;type:varchar(128);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:3"`
|
|
61
|
+
AggregateRef string `gorm:"column:aggregate_ref;type:varchar(191);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:4"`
|
|
62
|
+
LastAggregateVersion int64 `gorm:"column:last_aggregate_version;not null"`
|
|
63
|
+
LastEventID string `gorm:"column:last_event_id;type:varchar(191);not null"`
|
|
64
|
+
LastPayloadHash string `gorm:"column:last_payload_hash;type:char(64);not null"`
|
|
65
|
+
RevisionNo int64 `gorm:"column:revision_no;not null"`
|
|
66
|
+
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz(3);not null"`
|
|
67
|
+
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz(3);not null"`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// TableName 指定订阅者聚合连续水位表。
|
|
71
|
+
func (AggregateCheckpoint) TableName() string { return "sys_integration_aggregate_checkpoints" }
|