gorig-cli 1.0.24 → 1.0.25
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 +112 -22
- package/commands/create.js +267 -112
- package/commands/doc.js +21 -8
- package/commands/init.js +316 -255
- package/commands/skill.js +55 -17
- package/package.json +1 -1
- package/templates/config.go.ejs +6 -5
- package/templates/config.yaml.ejs +3 -38
- package/templates/controller.go.ejs +11 -42
- package/templates/crud.controller.go.ejs +71 -0
- package/templates/crud.doc.md.ejs +60 -0
- package/templates/crud.dto.go.ejs +103 -0
- package/templates/crud.integration.init_test.go.ejs +17 -0
- package/templates/crud.integration_test.go.ejs +66 -0
- package/templates/crud.model.go.ejs +25 -0
- package/templates/crud.module.README.md.ejs +70 -0
- package/templates/crud.mongo.config.yaml.ejs +19 -0
- package/templates/crud.mysql.config.yaml.ejs +14 -0
- package/templates/crud.router.go.ejs +18 -0
- package/templates/crud.service.go.ejs +179 -0
- package/templates/crud.test.go.ejs +52 -0
- package/templates/dto.go.ejs +16 -0
- package/templates/gitignore.ejs +18 -49
- package/templates/hello.controller.go.ejs +19 -0
- package/templates/hello.router.go.ejs +12 -0
- package/templates/hello.service.go.ejs +31 -0
- package/templates/hello.test.go.ejs +34 -0
- package/templates/init.go.ejs +1 -27
- package/templates/main.go.ejs +2 -5
- package/templates/model.go.ejs +3 -4
- package/templates/project.README.md.ejs +35 -0
- package/templates/router.go.ejs +4 -8
- package/templates/service.go.ejs +25 -77
- package/templates/skills/gorig-backend/SKILL.md +89 -0
- package/templates/skills/gorig-backend/agents/openai.yaml +4 -0
- package/templates/skills/gorig-backend/assets/api-doc-template.md +59 -0
- package/templates/skills/gorig-backend/assets/gorig.gitignore +65 -0
- package/templates/skills/gorig-backend/assets/module-readme-template.md +58 -0
- package/templates/skills/gorig-backend/references/advanced-data-access.md +275 -0
- package/templates/skills/gorig-backend/references/auth-security.md +194 -0
- package/templates/skills/gorig-backend/references/business-scenarios.md +155 -0
- package/templates/skills/gorig-backend/references/cache.md +301 -0
- package/templates/skills/gorig-backend/references/capability-matrix.md +37 -0
- package/templates/skills/gorig-backend/references/configuration.md +48 -0
- package/templates/skills/gorig-backend/references/framework-api.md +190 -0
- package/templates/skills/gorig-backend/references/messaging.md +143 -0
- package/templates/skills/gorig-backend/references/onboarding-files.md +46 -0
- package/templates/skills/gorig-backend/references/outbound-http.md +162 -0
- package/templates/skills/gorig-backend/references/persistent-crud.md +332 -0
- package/templates/skills/gorig-backend/references/project-bootstrap.md +128 -0
- package/templates/skills/gorig-backend/references/scheduled-tasks.md +231 -0
- package/templates/skills/gorig-backend/references/service-lifecycle.md +51 -0
- package/templates/skills/gorig-backend/references/source-map.md +43 -0
- package/templates/skills/gorig-backend/references/source-policy.md +58 -0
- package/templates/skills/gorig-backend/references/sse.md +121 -0
- package/templates/skills/gorig-backend/references/testing.md +171 -0
- package/templates/skills/gorig-backend/scripts/check-source-links.sh +48 -0
- package/templates/skills/gorig-backend/scripts/detect-gorig-context.sh +67 -0
- package/templates/skills/gorig-backend/scripts/verify-basic-project.sh +108 -0
- package/npm_publish copy.sh +0 -65
- package/templates/internal.go.ejs +0 -49
- package/templates/req.go.ejs +0 -8
- package/templates/resp.go.ejs +0 -8
- package/templates/service.pub.go.ejs +0 -22
- package/templates/skills/claude/gorig-backend/SKILL.md +0 -766
- package/templates/skills/claude/gorig-backend/assets/api-doc-template.md +0 -50
- package/templates/skills/claude/gorig-backend/assets/module-readme-template.md +0 -46
- package/templates/skills/claude/gorig-backend/references/onboarding-files.md +0 -44
- package/templates/skills/codex/gorig-backend/SKILL.md +0 -766
- package/templates/skills/codex/gorig-backend/agents/openai.yaml +0 -4
- package/templates/skills/codex/gorig-backend/assets/api-doc-template.md +0 -50
- package/templates/skills/codex/gorig-backend/assets/module-readme-template.md +0 -46
- package/templates/skills/codex/gorig-backend/references/onboarding-files.md +0 -44
|
@@ -1,766 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: gorig-backend
|
|
3
|
-
description: Senior Go backend development expert for the gorig framework. Use when tasks involve implementing or reviewing gorig services, routers, controllers, services, models, domain modules, dx-based data access, cache, cronx scheduled tasks, messagex pub/sub, SSE streaming, API contract changes, test additions, or module documentation updates. Apply this skill when strict adherence to local gorig/news code patterns and real API behavior is required.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Gorig Backend
|
|
7
|
-
|
|
8
|
-
Implement backend work in gorig with strict framework conventions, deterministic workflow, and mandatory confirmation before coding.
|
|
9
|
-
|
|
10
|
-
## Load Context First
|
|
11
|
-
Read `~/.claude/skills/gorig-backend/references/onboarding-files.md`.
|
|
12
|
-
Build a concrete mental model from local code before proposing changes.
|
|
13
|
-
Treat code behavior as the source of truth for all framework APIs and helper methods.
|
|
14
|
-
State uncertainty explicitly and ask focused questions instead of guessing.
|
|
15
|
-
|
|
16
|
-
## Confirm Change Plan Before Coding
|
|
17
|
-
Provide a concise plan and wait for explicit user confirmation before editing code.
|
|
18
|
-
Include:
|
|
19
|
-
- Business objective and boundary.
|
|
20
|
-
- Data structures and persistence impact.
|
|
21
|
-
- Route list with request/response and error code shape.
|
|
22
|
-
- File-level change list in `Router -> Controller -> Service -> Model/Domainx`.
|
|
23
|
-
- Test and documentation update plan.
|
|
24
|
-
|
|
25
|
-
Do not start implementation until confirmation is received.
|
|
26
|
-
|
|
27
|
-
## Implement With Framework Rules
|
|
28
|
-
Follow these hard constraints:
|
|
29
|
-
- Register services with `serv.RegisterService`; start with `bootstrap.StartUp()`.
|
|
30
|
-
- Register routes with `httpx.RegisterRouter`; reuse existing `httpx` middleware.
|
|
31
|
-
- Add `defer apix.HandlePanic(ctx)` in controllers.
|
|
32
|
-
- Parse request params with `apix.BindParams` or `apix.GetParam*`.
|
|
33
|
-
- Return responses with `apix.HandleData`.
|
|
34
|
-
- Keep business logic in service layer.
|
|
35
|
-
- Use `utils/errors` for errors.
|
|
36
|
-
- Use `utils/logger` or global logger helpers for logging.
|
|
37
|
-
- Do not use `fmt.Println` for runtime logging.
|
|
38
|
-
- Prefer `domainx/dx` APIs for DB operations; align usage with `test/dx_test.go`.
|
|
39
|
-
- Keep names short and clear; avoid long camel-case identifiers.
|
|
40
|
-
- Use snake_case for storage fields and CamelCase for model fields.
|
|
41
|
-
- Keep module layout under `domain/<module>/{router.go, controller.go, service.go, model/*.go}`.
|
|
42
|
-
- Put shared logic in `domain/common`.
|
|
43
|
-
|
|
44
|
-
## Data Access Patterns
|
|
45
|
-
|
|
46
|
-
### Model layer — minimal structure
|
|
47
|
-
The model `D` struct only needs fields and `DConfig()`. Do NOT add `M` type alias or `NewM()` factory:
|
|
48
|
-
```go
|
|
49
|
-
// CORRECT
|
|
50
|
-
type D struct {
|
|
51
|
-
AgentID string `bson:"agent_id" json:"agent_id"`
|
|
52
|
-
// ...
|
|
53
|
-
}
|
|
54
|
-
func (d *D) DConfig() (domainx.ConType, string, string) {
|
|
55
|
-
return domainx.Mongo, global.QuantSageDB, Table
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// WRONG — do not add these
|
|
59
|
-
type M = domainx.Complex[D]
|
|
60
|
-
func NewM() *M { return domainx.UseComplex[D](...) }
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### Index registration — always in service.go init()
|
|
64
|
-
Indexes are registered in `service.go` via `domainx.AutoMigrate`, never in the model file.
|
|
65
|
-
Fields are automatically prefixed with `data.` by the framework — pass bare field names.
|
|
66
|
-
`AutoMigrate` accepts `dx.On[T](ctx).Complex()` as the ConTable factory:
|
|
67
|
-
```go
|
|
68
|
-
func init() {
|
|
69
|
-
domainx.AutoMigrate(
|
|
70
|
-
func() domainx.ConTable { return dx.On[model.D](context.Background()).Complex() },
|
|
71
|
-
domainx.CtIdx(domainx.Idx, "field_a", "field_b", "field_c"), // compound index
|
|
72
|
-
domainx.CtIdx(domainx.Unique, "unique_field"), // unique index
|
|
73
|
-
domainx.CtIdx(domainx.Spatial2D, "lat", "lng"), // 2D spatial index
|
|
74
|
-
)
|
|
75
|
-
}
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
### dx query API — filters
|
|
79
|
-
|
|
80
|
-
All filter methods accept an optional `ignore bool` trailing param. When `true`, the filter is skipped if value is zero/empty — useful for optional query params:
|
|
81
|
-
```go
|
|
82
|
-
dx.On[model.D](ctx).Eq("status", status, status == "") // skip filter if status is empty
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
**Comparison operators:**
|
|
86
|
-
```go
|
|
87
|
-
.Eq("field", value) // ==
|
|
88
|
-
.Ne("field", value) // !=
|
|
89
|
-
.Gt("field", value) // >
|
|
90
|
-
.Gte("field", value) // >=
|
|
91
|
-
.Lt("field", value) // <
|
|
92
|
-
.Lte("field", value) // <=
|
|
93
|
-
.Like("field", "keyword") // LIKE / regex contains
|
|
94
|
-
.In("field", []T{...}) // IN list
|
|
95
|
-
.NotIn("field", []T{...}) // NOT IN list
|
|
96
|
-
.NEmpty("field") // field is not empty / not null
|
|
97
|
-
.WithID(id) // shorthand for Eq("id", id)
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
**Array field operators** (for `[]string` / JSON array fields):
|
|
101
|
-
```go
|
|
102
|
-
.Has("tags", "go") // array contains "go"
|
|
103
|
-
.HasAny("tags", []string{"go","rust"}) // array contains any of the values
|
|
104
|
-
.HasAll("tags", []string{"go","rust"}) // array contains all of the values
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
**Geo-spatial operators** (Mongo only):
|
|
108
|
-
```go
|
|
109
|
-
.Near("lat", "lng", lat, lng, distanceMeters) // separate lat/lng fields
|
|
110
|
-
.NearLoc("location", lat, lng, distanceMeters) // embedded GeoJSON location field
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
**Dynamic condition composition** (when conditions are built in a loop):
|
|
114
|
-
```go
|
|
115
|
-
// Build Matches dynamically, then pass to AddMatches
|
|
116
|
-
m := domainx.NewMatches().
|
|
117
|
-
Eq("type", t).
|
|
118
|
-
Gte("score", minScore, minScore == 0) // ignore param works here too
|
|
119
|
-
dx.On[model.D](ctx).AddMatches(m).Sort("id").Find()
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
### dx query API — sorting & projection
|
|
123
|
-
```go
|
|
124
|
-
.Sort("created_at") // default descending
|
|
125
|
-
.Sort("created_at", true) // ascending (true = ASC)
|
|
126
|
-
.Sort("id") // sort by id descending
|
|
127
|
-
|
|
128
|
-
.Select("id", "name") // fetch only these fields
|
|
129
|
-
.Omit("secret_field") // exclude these fields
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
### dx query API — read operations
|
|
133
|
-
```go
|
|
134
|
-
// Get single record (by ID or by filters)
|
|
135
|
-
item, err := dx.On[model.D](ctx).WithID(id).Get()
|
|
136
|
-
item, err := dx.On[model.D](ctx).Eq("code", code).Sort("id").Get()
|
|
137
|
-
if err != nil { return nil, err }
|
|
138
|
-
if item.IsNil() { /* not found */ }
|
|
139
|
-
_ = item.Data // *model.D
|
|
140
|
-
|
|
141
|
-
// Find multiple records
|
|
142
|
-
results, err := dx.On[model.D](ctx).Eq("status", "active").Sort("id").Find()
|
|
143
|
-
if err != nil { return nil, err }
|
|
144
|
-
for _, m := range results.List() { // List() returns []*model.D
|
|
145
|
-
_ = m
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Count
|
|
149
|
-
count, err := dx.On[model.D](ctx).Eq("type", t).Count()
|
|
150
|
-
|
|
151
|
-
// Sum a numeric field
|
|
152
|
-
total, err := dx.On[model.D](ctx).Eq("user_id", uid).Sum("amount")
|
|
153
|
-
|
|
154
|
-
// Existence check (works with WithID or filters)
|
|
155
|
-
exists, err := dx.On[model.D](ctx).Eq("email", email).Exists()
|
|
156
|
-
|
|
157
|
-
// Cursor-based pagination (lastID = 0 on first page)
|
|
158
|
-
pageResp, err := dx.On[model.D](ctx).Eq("user_id", uid).Sort("id").Page(page, size, lastID)
|
|
159
|
-
// pageResp.Result *[]*domainx.Complex[D]
|
|
160
|
-
// pageResp.Total.Get()
|
|
161
|
-
// pageResp.LastID
|
|
162
|
-
// pageResp.Page, pageResp.Size
|
|
163
|
-
|
|
164
|
-
// Iterate over filtered results one by one
|
|
165
|
-
err := dx.On[model.D](ctx).Eq("flag", true).FindEach(func(item *domainx.Complex[model.D]) *errors.Error {
|
|
166
|
-
// process item.Data
|
|
167
|
-
return nil
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
// Batch-paginated full scan (1000 per batch, safe for large collections)
|
|
171
|
-
err := dx.On[model.D](ctx).Eq("type", t).AllEach(func(item *domainx.Complex[model.D]) *errors.Error {
|
|
172
|
-
// process item.Data
|
|
173
|
-
return nil
|
|
174
|
-
})
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
### dx query API — write operations
|
|
178
|
-
```go
|
|
179
|
-
// Save (insert or update based on ID)
|
|
180
|
-
id, err := dx.On[model.D](ctx, &d).Save()
|
|
181
|
-
|
|
182
|
-
// Manual ID generation before save
|
|
183
|
-
id, err := dx.On[model.D](ctx, &d).GenerateID().Save()
|
|
184
|
-
|
|
185
|
-
// Update single field (by ID or by filters)
|
|
186
|
-
err := dx.On[model.D](ctx).WithID(id).Update("status", "done")
|
|
187
|
-
err := dx.On[model.D](ctx).Eq("code", code).Update("count", newCount)
|
|
188
|
-
|
|
189
|
-
// Update multiple fields at once
|
|
190
|
-
err := dx.On[model.D](ctx).WithID(id).Updates(map[string]interface{}{
|
|
191
|
-
"status": "done",
|
|
192
|
-
"count": newCount,
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
// Delete (by ID or by filters)
|
|
196
|
-
err := dx.On[model.D](ctx).WithID(id).Delete()
|
|
197
|
-
err := dx.On[model.D](ctx).Eq("expired", true).Delete()
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
## Cache Package
|
|
201
|
-
|
|
202
|
-
Use `cache.New[T]` to create a typed cache backed by Memory, JSON file, SQLite, or Redis:
|
|
203
|
-
|
|
204
|
-
```go
|
|
205
|
-
import "github.com/jom-io/gorig/cache"
|
|
206
|
-
|
|
207
|
-
// In-memory (go-cache), args: defaultExpiration, cleanupInterval
|
|
208
|
-
c := cache.New[MyStruct](cache.Memory, 5*time.Minute, 10*time.Minute)
|
|
209
|
-
|
|
210
|
-
// JSON file (persisted to .cache/<name>.json)
|
|
211
|
-
c := cache.New[MyStruct](cache.JSON, "my_cache_name")
|
|
212
|
-
|
|
213
|
-
// SQLite (persisted to .cache/<name>.db)
|
|
214
|
-
c := cache.New[MyStruct](cache.Sqlite, "my_cache_name")
|
|
215
|
-
|
|
216
|
-
// Redis (uses configured Redis instance)
|
|
217
|
-
c := cache.New[MyStruct](cache.Redis)
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
**Cache interface operations:**
|
|
221
|
-
```go
|
|
222
|
-
err := c.Set("key", value, 5*time.Minute) // 0 = no expiry
|
|
223
|
-
val, err := c.Get("key") // returns (T, error); error on miss
|
|
224
|
-
ok, err := c.Exists("key")
|
|
225
|
-
err = c.Del("key")
|
|
226
|
-
err = c.Expire("key", 2*time.Minute) // update TTL
|
|
227
|
-
n, err := c.Incr("counter") // atomic increment, returns new int64
|
|
228
|
-
err = c.Flush() // clear all keys
|
|
229
|
-
err = c.RPush("queue", value) // push to list tail
|
|
230
|
-
val, err = c.BRPop(timeout, "queue") // blocking pop from list tail
|
|
231
|
-
```
|
|
232
|
-
|
|
233
|
-
**Multi-level cache with singleflight** (prevents cache stampede):
|
|
234
|
-
```go
|
|
235
|
-
l1 := cache.New[MyStruct](cache.Memory, time.Minute, time.Minute)
|
|
236
|
-
l2 := cache.New[MyStruct](cache.Redis)
|
|
237
|
-
tool := cache.NewCacheTool[MyStruct](ctx, []cache.Cache[MyStruct]{l1, l2}, func(key string) (MyStruct, error) {
|
|
238
|
-
// loader: called only on full miss, result stored in all layers
|
|
239
|
-
return fetchFromDB(key)
|
|
240
|
-
})
|
|
241
|
-
val, err := tool.Get("key", 5*time.Minute)
|
|
242
|
-
tool.Set("key", val, 5*time.Minute)
|
|
243
|
-
tool.Delete("key")
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
**SQLite paged cache** — for time-series or queryable local storage with auto-indexed fields:
|
|
247
|
-
```go
|
|
248
|
-
// Struct tags: `idx:"field"` = single index, `idx_group:"group_name"` = compound index
|
|
249
|
-
type Stat struct {
|
|
250
|
-
At int64 `json:"at" idx:"at"`
|
|
251
|
-
Method string `json:"method" idx_group:"method_uri"`
|
|
252
|
-
URI string `json:"uri" idx_group:"method_uri"`
|
|
253
|
-
Count int64 `json:"count"`
|
|
254
|
-
}
|
|
255
|
-
pg, err := cache.NewSQLiteCachePage[Stat]("stat_cache")
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
## Scheduled Tasks (cronx)
|
|
259
|
-
|
|
260
|
-
`cronx` is a service registered automatically. Enable it by including `"CRON"` in service startup or via `bootstrap.StartUp()`.
|
|
261
|
-
|
|
262
|
-
```go
|
|
263
|
-
import "github.com/jom-io/gorig/cronx"
|
|
264
|
-
|
|
265
|
-
// Standard cron expression — with optional timeout context
|
|
266
|
-
cronx.AddCronTask("0 * * * * *", func(ctx context.Context) {
|
|
267
|
-
// ctx is canceled on timeout; check ctx.Done() for long tasks
|
|
268
|
-
}, 30*time.Second) // optional: task-level timeout
|
|
269
|
-
|
|
270
|
-
// Interval-based shorthand
|
|
271
|
-
cronx.AddEveryTask(5*time.Minute, func(ctx context.Context) {
|
|
272
|
-
// runs every 5 minutes
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
// One-shot delayed task (runs once after delay)
|
|
276
|
-
cronx.AddDelayTask(10*time.Second, func(ctx context.Context) {
|
|
277
|
-
// runs once 10s after registration
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
// One-shot at specific time
|
|
281
|
-
cronx.AddOnceTask(time.Now().Add(time.Hour), func(ctx context.Context) {
|
|
282
|
-
// runs once at the specified time, then removes itself
|
|
283
|
-
})
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
**Built-in behaviors:**
|
|
287
|
-
- Panic recovery: panics are caught, logged, and sent to DingTalk.
|
|
288
|
-
- Timeout: if timeout is set and exceeded, `ctx` is canceled and an error is logged.
|
|
289
|
-
- Duplicate deduplication: same spec+func combination is ignored with a warning.
|
|
290
|
-
- `AddTask(spec, func())` is deprecated — use `AddCronTask` for context support.
|
|
291
|
-
|
|
292
|
-
**Lifecycle** (if managing manually without bootstrap):
|
|
293
|
-
```go
|
|
294
|
-
go cronx.Startup("CRON", "")
|
|
295
|
-
// ...
|
|
296
|
-
cronx.Shutdown("CRON", context.Background())
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
## Message Broker (messagex)
|
|
300
|
-
|
|
301
|
-
In-process pub/sub (Local) or Redis-backed for cross-process messaging.
|
|
302
|
-
|
|
303
|
-
```go
|
|
304
|
-
import "github.com/jom-io/gorig/mid/messagex"
|
|
305
|
-
|
|
306
|
-
// Get broker instance
|
|
307
|
-
svc := messagex.Ins(messagex.Local) // in-memory
|
|
308
|
-
svc := messagex.Ins(messagex.Redis) // Redis-backed
|
|
309
|
-
|
|
310
|
-
// Or use package-level helpers (Local broker)
|
|
311
|
-
messagex.RegisterTopic(...)
|
|
312
|
-
messagex.PublishNewMsg(...)
|
|
313
|
-
messagex.UnSubscribe(...)
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
**Concurrent subscriber** (messages dispatched in parallel):
|
|
317
|
-
```go
|
|
318
|
-
subID, err := svc.RegisterTopic("order.created", func(msg *messagex.Message) *errors.Error {
|
|
319
|
-
name := msg.GetValue("name") // interface{}
|
|
320
|
-
age := msg.GetValueInt64("age") // int64
|
|
321
|
-
amt := msg.GetValueFloat64("amount") // float64
|
|
322
|
-
return nil
|
|
323
|
-
})
|
|
324
|
-
defer svc.UnRegisterTopic("order.created", subID)
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
**Sequential subscriber** (messages processed in publish order, one at a time):
|
|
328
|
-
```go
|
|
329
|
-
subID, err := svc.RegisterTopicSeq("order.created", func(msg *messagex.Message) *errors.Error {
|
|
330
|
-
// handler called serially; return error to trigger retry/DLQ
|
|
331
|
-
return nil
|
|
332
|
-
}, messagex.WithMaxRetry(3), messagex.WithRetryIntervals(500*time.Millisecond))
|
|
333
|
-
```
|
|
334
|
-
|
|
335
|
-
**Publish:**
|
|
336
|
-
```go
|
|
337
|
-
// payload can be any struct or map[string]any
|
|
338
|
-
svc.PublishNewMsg(ctx, "order.created", OrderCreatedEvent{UserID: uid, Amount: 99.9})
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
**Dead Letter Queue (sequential only):**
|
|
342
|
-
```go
|
|
343
|
-
// Replay up to 10 failed messages back into the topic queue
|
|
344
|
-
err := svc.ReplayDLQ("order.created", 10) // limit <= 0 = replay all
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
**Multiple subscribers** — all registered subscribers receive every published message independently.
|
|
348
|
-
|
|
349
|
-
## SSE — Server-Sent Events
|
|
350
|
-
|
|
351
|
-
Use `ssex` middleware and helpers for streaming responses:
|
|
352
|
-
|
|
353
|
-
```go
|
|
354
|
-
import "github.com/jom-io/gorig/httpx/ssex"
|
|
355
|
-
|
|
356
|
-
// Route registration — SSE endpoint must be GET
|
|
357
|
-
router.GET("/stream/events", ssex.Mid(), controller.StreamEvents)
|
|
358
|
-
|
|
359
|
-
// In controller — ssex.Mid() sets headers and enforces GET-only (returns 405 otherwise)
|
|
360
|
-
func (c *Controller) StreamEvents(ctx *gin.Context) {
|
|
361
|
-
err := ssex.SendOK(ctx, "update", map[string]interface{}{"value": 42})
|
|
362
|
-
if err != nil { return }
|
|
363
|
-
|
|
364
|
-
err = ssex.SendError(ctx, "update", "something went wrong")
|
|
365
|
-
if err != nil { return }
|
|
366
|
-
}
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
## Logging
|
|
370
|
-
|
|
371
|
-
Use `utils/logger` everywhere. Never use `fmt.Println` for runtime output.
|
|
372
|
-
|
|
373
|
-
```go
|
|
374
|
-
import (
|
|
375
|
-
"github.com/jom-io/gorig/utils/logger"
|
|
376
|
-
"go.uber.org/zap"
|
|
377
|
-
)
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
**Log levels:**
|
|
381
|
-
```go
|
|
382
|
-
logger.Debug(ctx, "cache miss", zap.String("key", key))
|
|
383
|
-
logger.Info(ctx, "order created", zap.Int64("id", id), zap.Float64("amount", amount))
|
|
384
|
-
logger.Warn(ctx, "retry attempt", zap.Int("attempt", n), zap.Error(err))
|
|
385
|
-
logger.Error(ctx, "db write failed", zap.Error(err))
|
|
386
|
-
logger.DPanic(ctx, "unexpected state") // panic in dev, error in prod
|
|
387
|
-
```
|
|
388
|
-
|
|
389
|
-
- All calls auto-inject `traceID` and `userID` from ctx — pass ctx from controller all the way down.
|
|
390
|
-
- Passing `nil` ctx is safe; traceID will be `"no traceid"`.
|
|
391
|
-
- `*gin.Context` is accepted directly as ctx.
|
|
392
|
-
|
|
393
|
-
**Create a standalone context with trace ID** (for background jobs / init):
|
|
394
|
-
```go
|
|
395
|
-
ctx := logger.NewCtx() // auto-generates xid trace ID
|
|
396
|
-
ctx := logger.NewCtx("my-trace-id") // custom trace ID
|
|
397
|
-
traceID := logger.GetTraceID(ctx)
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
**Structured fields** — always use zap typed constructors, never `zap.Any` for typed values:
|
|
401
|
-
```go
|
|
402
|
-
zap.String("field", val)
|
|
403
|
-
zap.Int64("id", id)
|
|
404
|
-
zap.Float64("amount", amt)
|
|
405
|
-
zap.Bool("ok", flag)
|
|
406
|
-
zap.Error(err) // for error values
|
|
407
|
-
zap.Any("data", obj) // fallback for unknown types
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
**Log files** are written to `.logs/commons/commons.jsonl` by default (lumberjack rotation).
|
|
411
|
-
Config keys in yaml: `logger.commons.root`, `logger.commons.level`, `logger.commons.file`.
|
|
412
|
-
|
|
413
|
-
### Scenario 6 — Logging across layers
|
|
414
|
-
|
|
415
|
-
```go
|
|
416
|
-
// controller.go — ctx carries traceID + userID from middleware
|
|
417
|
-
func (c *Controller) Create(ctx *gin.Context) {
|
|
418
|
-
defer apix.HandlePanic(ctx)
|
|
419
|
-
logger.Info(ctx, "create request received")
|
|
420
|
-
|
|
421
|
-
userID := apix.GetParamInt64(ctx, "user_id")
|
|
422
|
-
id, err := Create(ctx, userID, 99.9)
|
|
423
|
-
if err != nil {
|
|
424
|
-
logger.Error(ctx, "create failed", zap.Error(err))
|
|
425
|
-
apix.HandleData(ctx, nil, err)
|
|
426
|
-
return
|
|
427
|
-
}
|
|
428
|
-
logger.Info(ctx, "create success", zap.Int64("id", id))
|
|
429
|
-
apix.HandleData(ctx, id, nil)
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// service.go — forward ctx, never create a new one mid-request
|
|
433
|
-
func Create(ctx context.Context, userID int64, amount float64) (int64, *errors.Error) {
|
|
434
|
-
logger.Debug(ctx, "saving order", zap.Int64("user_id", userID))
|
|
435
|
-
d := model.D{UserID: userID, Amount: amount, Status: "pending"}
|
|
436
|
-
id, err := dx.On[model.D](ctx, &d).Save()
|
|
437
|
-
if err != nil {
|
|
438
|
-
logger.Error(ctx, "save order failed", zap.Int64("user_id", userID), zap.Error(err))
|
|
439
|
-
return 0, err
|
|
440
|
-
}
|
|
441
|
-
return id, nil
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// cronx / background — use logger.NewCtx() since there is no request ctx
|
|
445
|
-
func syncExpiredOrders(ctx context.Context) {
|
|
446
|
-
logger.Info(ctx, "sync started")
|
|
447
|
-
count, err := cancelExpired(ctx)
|
|
448
|
-
if err != nil {
|
|
449
|
-
logger.Error(ctx, "sync failed", zap.Error(err))
|
|
450
|
-
return
|
|
451
|
-
}
|
|
452
|
-
logger.Info(ctx, "sync done", zap.Int64("cancelled", count))
|
|
453
|
-
}
|
|
454
|
-
```
|
|
455
|
-
|
|
456
|
-
## Middleware
|
|
457
|
-
|
|
458
|
-
```go
|
|
459
|
-
import "github.com/jom-io/gorig/httpx"
|
|
460
|
-
|
|
461
|
-
// Panic recovery middleware — returns HTTP 500 with JSON error body
|
|
462
|
-
router.Use(httpx.Recovery())
|
|
463
|
-
```
|
|
464
|
-
|
|
465
|
-
## Execution Strategy
|
|
466
|
-
|
|
467
|
-
Implement directly using Read/Edit/Write tools.
|
|
468
|
-
Verify with `go build ./... && go test ./test/... -v` after every change.
|
|
469
|
-
|
|
470
|
-
## Maintain Documentation
|
|
471
|
-
When API changes exist, create or update `doc/<module>.md` using `~/.claude/skills/gorig-backend/assets/api-doc-template.md`.
|
|
472
|
-
When module behavior changes, update `domain/<module>/README.md` using `~/.claude/skills/gorig-backend/assets/module-readme-template.md`.
|
|
473
|
-
|
|
474
|
-
## Complete Scenario Examples
|
|
475
|
-
|
|
476
|
-
### Scenario 1 — CRUD module (HTTP API)
|
|
477
|
-
|
|
478
|
-
```
|
|
479
|
-
domain/order/
|
|
480
|
-
├── router.go
|
|
481
|
-
├── controller.go
|
|
482
|
-
├── service.go
|
|
483
|
-
└── model/
|
|
484
|
-
└── order.go
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
**model/order.go**
|
|
488
|
-
```go
|
|
489
|
-
package model
|
|
490
|
-
|
|
491
|
-
import "github.com/jom-io/gorig/domainx"
|
|
492
|
-
|
|
493
|
-
const Table = "order"
|
|
494
|
-
|
|
495
|
-
type D struct {
|
|
496
|
-
UserID int64 `bson:"user_id" json:"user_id"`
|
|
497
|
-
Status string `bson:"status" json:"status"`
|
|
498
|
-
Amount float64 `bson:"amount" json:"amount"`
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
func (d *D) DConfig() (domainx.ConType, string, string) {
|
|
502
|
-
return domainx.Mongo, "main", Table
|
|
503
|
-
}
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
**service.go**
|
|
507
|
-
```go
|
|
508
|
-
package order
|
|
509
|
-
|
|
510
|
-
import (
|
|
511
|
-
"context"
|
|
512
|
-
"github.com/jom-io/gorig/domainx"
|
|
513
|
-
"github.com/jom-io/gorig/domainx/dx"
|
|
514
|
-
"github.com/jom-io/gorig/utils/errors"
|
|
515
|
-
"yourproject/domain/order/model"
|
|
516
|
-
)
|
|
517
|
-
|
|
518
|
-
func init() {
|
|
519
|
-
domainx.AutoMigrate(
|
|
520
|
-
func() domainx.ConTable { return dx.On[model.D](context.Background()).Complex() },
|
|
521
|
-
domainx.CtIdx(domainx.Idx, "user_id", "status"),
|
|
522
|
-
)
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
func Create(ctx context.Context, userID int64, amount float64) (int64, *errors.Error) {
|
|
526
|
-
d := model.D{UserID: userID, Status: "pending", Amount: amount}
|
|
527
|
-
id, err := dx.On[model.D](ctx, &d).Save()
|
|
528
|
-
if err != nil {
|
|
529
|
-
return 0, err
|
|
530
|
-
}
|
|
531
|
-
return id, nil
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
func ListByUser(ctx context.Context, userID int64) ([]*model.D, *errors.Error) {
|
|
535
|
-
results, err := dx.On[model.D](ctx).Eq("user_id", userID).Sort("id").Find()
|
|
536
|
-
if err != nil {
|
|
537
|
-
return nil, err
|
|
538
|
-
}
|
|
539
|
-
return results.List(), nil
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
func Complete(ctx context.Context, id int64) *errors.Error {
|
|
543
|
-
return dx.On[model.D](ctx).WithID(id).Update("status", "done")
|
|
544
|
-
}
|
|
545
|
-
```
|
|
546
|
-
|
|
547
|
-
**controller.go**
|
|
548
|
-
```go
|
|
549
|
-
package order
|
|
550
|
-
|
|
551
|
-
import (
|
|
552
|
-
"github.com/gin-gonic/gin"
|
|
553
|
-
"github.com/jom-io/gorig/apix"
|
|
554
|
-
)
|
|
555
|
-
|
|
556
|
-
type Controller struct{}
|
|
557
|
-
|
|
558
|
-
func (c *Controller) Create(ctx *gin.Context) {
|
|
559
|
-
defer apix.HandlePanic(ctx)
|
|
560
|
-
userID := apix.GetParamInt64(ctx, "user_id")
|
|
561
|
-
amount := apix.GetParamFloat64(ctx, "amount")
|
|
562
|
-
id, err := Create(ctx, userID, amount)
|
|
563
|
-
apix.HandleData(ctx, id, err)
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
func (c *Controller) List(ctx *gin.Context) {
|
|
567
|
-
defer apix.HandlePanic(ctx)
|
|
568
|
-
userID := apix.GetParamInt64(ctx, "user_id")
|
|
569
|
-
list, err := ListByUser(ctx, userID)
|
|
570
|
-
apix.HandleData(ctx, list, err)
|
|
571
|
-
}
|
|
572
|
-
```
|
|
573
|
-
|
|
574
|
-
**router.go**
|
|
575
|
-
```go
|
|
576
|
-
package order
|
|
577
|
-
|
|
578
|
-
import "github.com/jom-io/gorig/httpx"
|
|
579
|
-
|
|
580
|
-
func init() {
|
|
581
|
-
c := &Controller{}
|
|
582
|
-
httpx.RegisterRouter(func(r httpx.Router) {
|
|
583
|
-
g := r.Group("/order")
|
|
584
|
-
g.POST("/create", c.Create)
|
|
585
|
-
g.GET("/list", c.List)
|
|
586
|
-
})
|
|
587
|
-
}
|
|
588
|
-
```
|
|
589
|
-
|
|
590
|
-
---
|
|
591
|
-
|
|
592
|
-
### Scenario 2 — Scheduled task
|
|
593
|
-
|
|
594
|
-
```go
|
|
595
|
-
package sync
|
|
596
|
-
|
|
597
|
-
import (
|
|
598
|
-
"context"
|
|
599
|
-
"github.com/jom-io/gorig/cronx"
|
|
600
|
-
"yourproject/domain/order"
|
|
601
|
-
)
|
|
602
|
-
|
|
603
|
-
func init() {
|
|
604
|
-
// Run every day at 02:00
|
|
605
|
-
cronx.AddCronTask("0 0 2 * * *", syncExpiredOrders, 5*time.Minute)
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
func syncExpiredOrders(ctx context.Context) {
|
|
609
|
-
// business logic; ctx is canceled after 5 min timeout
|
|
610
|
-
_ = order.CancelExpired(ctx)
|
|
611
|
-
}
|
|
612
|
-
```
|
|
613
|
-
|
|
614
|
-
---
|
|
615
|
-
|
|
616
|
-
### Scenario 3 — Event-driven with messagex
|
|
617
|
-
|
|
618
|
-
```go
|
|
619
|
-
// Publisher (in order service)
|
|
620
|
-
func Create(ctx context.Context, ...) (int64, *errors.Error) {
|
|
621
|
-
// ... save logic ...
|
|
622
|
-
messagex.PublishNewMsg(ctx, "order.created", map[string]any{
|
|
623
|
-
"order_id": id,
|
|
624
|
-
"user_id": userID,
|
|
625
|
-
})
|
|
626
|
-
return id, nil
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// Subscriber (in notify service init)
|
|
630
|
-
func init() {
|
|
631
|
-
messagex.RegisterTopic("order.created", func(msg *messagex.Message) *errors.Error {
|
|
632
|
-
orderID := msg.GetValueInt64("order_id")
|
|
633
|
-
return sendPushNotification(orderID)
|
|
634
|
-
})
|
|
635
|
-
}
|
|
636
|
-
```
|
|
637
|
-
|
|
638
|
-
---
|
|
639
|
-
|
|
640
|
-
### Scenario 4 — SSE streaming endpoint
|
|
641
|
-
|
|
642
|
-
```go
|
|
643
|
-
// router.go
|
|
644
|
-
g.GET("/stream/status", ssex.Mid(), c.StreamStatus)
|
|
645
|
-
|
|
646
|
-
// controller.go
|
|
647
|
-
func (c *Controller) StreamStatus(ctx *gin.Context) {
|
|
648
|
-
defer apix.HandlePanic(ctx)
|
|
649
|
-
orderID := apix.GetParamInt64(ctx, "order_id")
|
|
650
|
-
|
|
651
|
-
status, err := order.GetStatus(ctx, orderID)
|
|
652
|
-
if err != nil {
|
|
653
|
-
ssex.SendError(ctx, "status", err.Error())
|
|
654
|
-
return
|
|
655
|
-
}
|
|
656
|
-
ssex.SendOK(ctx, "status", map[string]any{"status": status})
|
|
657
|
-
}
|
|
658
|
-
```
|
|
659
|
-
|
|
660
|
-
---
|
|
661
|
-
|
|
662
|
-
### Scenario 5 — Cache-aside pattern
|
|
663
|
-
|
|
664
|
-
```go
|
|
665
|
-
var orderCache = cache.New[model.D](cache.Redis)
|
|
666
|
-
|
|
667
|
-
func GetByID(ctx context.Context, id int64) (*model.D, *errors.Error) {
|
|
668
|
-
key := fmt.Sprintf("order:%d", id)
|
|
669
|
-
|
|
670
|
-
// L1: Redis cache
|
|
671
|
-
if cached, err := orderCache.Get(key); err == nil {
|
|
672
|
-
return &cached, nil
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// L2: DB
|
|
676
|
-
result, err := dx.On[model.D](ctx).WithID(id).Get()
|
|
677
|
-
if err != nil || result.IsNil() {
|
|
678
|
-
return nil, err
|
|
679
|
-
}
|
|
680
|
-
_ = orderCache.Set(key, *result.Data, 5*time.Minute)
|
|
681
|
-
return result.Data, nil
|
|
682
|
-
}
|
|
683
|
-
```
|
|
684
|
-
|
|
685
|
-
---
|
|
686
|
-
|
|
687
|
-
## Go Test Convention
|
|
688
|
-
|
|
689
|
-
**All tests live in `test/`, `package test`. Never place test files inside domain packages.**
|
|
690
|
-
|
|
691
|
-
```
|
|
692
|
-
test/
|
|
693
|
-
├── init.go ← Shared DB init — DO NOT modify
|
|
694
|
-
├── _bin/local.yaml ← Test DB config — DO NOT modify
|
|
695
|
-
└── {feature}_test.go ← Add new test files here
|
|
696
|
-
```
|
|
697
|
-
|
|
698
|
-
`init.go` starts the DB connection before any test runs. Tests that bypass this package lose the connection.
|
|
699
|
-
|
|
700
|
-
**New test file boilerplate:**
|
|
701
|
-
|
|
702
|
-
```go
|
|
703
|
-
package test
|
|
704
|
-
|
|
705
|
-
import (
|
|
706
|
-
"context"
|
|
707
|
-
"testing"
|
|
708
|
-
)
|
|
709
|
-
|
|
710
|
-
func TestXxx_场景描述(t *testing.T) {
|
|
711
|
-
ctx := context.Background()
|
|
712
|
-
|
|
713
|
-
// arrange — hardcode inline, no external files
|
|
714
|
-
id, err := SomeModule.Create(ctx, 1, 99.9)
|
|
715
|
-
if err != nil {
|
|
716
|
-
t.Fatalf("Create failed: %v", err)
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
// act
|
|
720
|
-
result, err := SomeModule.GetByID(ctx, id)
|
|
721
|
-
|
|
722
|
-
// assert
|
|
723
|
-
if err != nil {
|
|
724
|
-
t.Fatalf("GetByID failed: %v", err)
|
|
725
|
-
}
|
|
726
|
-
if result == nil {
|
|
727
|
-
t.Fatal("expected result, got nil")
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
```
|
|
731
|
-
|
|
732
|
-
**Rules:**
|
|
733
|
-
- Functions under test must be **exported** (capitalized) — `package test` cannot access unexported symbols.
|
|
734
|
-
- Use standard `testing` only (testify is available but prefer stdlib assertions).
|
|
735
|
-
- Use `t.Fatalf` / `t.Errorf` for assertions.
|
|
736
|
-
- Hardcode test data inline; do not read external files.
|
|
737
|
-
|
|
738
|
-
**Run commands:**
|
|
739
|
-
|
|
740
|
-
```bash
|
|
741
|
-
go test ./test/... -v # all tests
|
|
742
|
-
go test ./test/... -run TestXxx -v # single test
|
|
743
|
-
```
|
|
744
|
-
|
|
745
|
-
## Validate and Deliver
|
|
746
|
-
Run `go build ./... && go test ./test/... -v` after every change.
|
|
747
|
-
If tests cannot run, explain the exact blocker and provide a minimal verification plan.
|
|
748
|
-
Deliver results in concise engineering format:
|
|
749
|
-
- Change summary.
|
|
750
|
-
- Key file paths.
|
|
751
|
-
- Test status and suggestions.
|
|
752
|
-
- Unknowns plus minimal assumptions.
|
|
753
|
-
|
|
754
|
-
## Provide Database Recommendation
|
|
755
|
-
When asked to choose storage, recommend based on:
|
|
756
|
-
- Existing dependencies and `dx` compatibility.
|
|
757
|
-
- Read/write pattern and query complexity.
|
|
758
|
-
- Transaction consistency requirements.
|
|
759
|
-
- Operational cost and team familiarity.
|
|
760
|
-
|
|
761
|
-
Present recommendation with clear rationale and trade-offs.
|
|
762
|
-
|
|
763
|
-
## Resources
|
|
764
|
-
- `~/.claude/skills/gorig-backend/references/onboarding-files.md`: Required code-reading checklist for gorig and news.
|
|
765
|
-
- `~/.claude/skills/gorig-backend/assets/api-doc-template.md`: API documentation template.
|
|
766
|
-
- `~/.claude/skills/gorig-backend/assets/module-readme-template.md`: Module README template.
|