gorig-cli 1.0.23 → 1.0.24
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/package.json +1 -1
- package/templates/skills/claude/gorig-backend/SKILL.md +630 -53
- package/templates/skills/claude/gorig-backend/references/onboarding-files.md +18 -12
- package/templates/skills/codex/gorig-backend/SKILL.md +624 -47
- package/templates/skills/codex/gorig-backend/references/onboarding-files.md +18 -12
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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, 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.
|
|
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
4
|
---
|
|
5
5
|
|
|
6
6
|
# Gorig Backend
|
|
@@ -40,11 +40,10 @@ Follow these hard constraints:
|
|
|
40
40
|
- Use snake_case for storage fields and CamelCase for model fields.
|
|
41
41
|
- Keep module layout under `domain/<module>/{router.go, controller.go, service.go, model/*.go}`.
|
|
42
42
|
- Put shared logic in `domain/common`.
|
|
43
|
-
- When a route should bypass the built-in 429 debounce middleware, add it in `router.go` with `httpx.DebouceAw(...)` before `httpx.RegisterRouter(...)`. Example: `httpx.DebouceAw("/api/catalog/markets", "/api/catalog/agents")`.
|
|
44
43
|
|
|
45
44
|
## Data Access Patterns
|
|
46
45
|
|
|
47
|
-
### Model layer
|
|
46
|
+
### Model layer — minimal structure
|
|
48
47
|
The model `D` struct only needs fields and `DConfig()`. Do NOT add `M` type alias or `NewM()` factory:
|
|
49
48
|
```go
|
|
50
49
|
// CORRECT
|
|
@@ -56,74 +55,647 @@ func (d *D) DConfig() (domainx.ConType, string, string) {
|
|
|
56
55
|
return domainx.Mongo, global.QuantSageDB, Table
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
// WRONG
|
|
58
|
+
// WRONG — do not add these
|
|
60
59
|
type M = domainx.Complex[D]
|
|
61
60
|
func NewM() *M { return domainx.UseComplex[D](...) }
|
|
62
61
|
```
|
|
63
62
|
|
|
64
|
-
### Index registration
|
|
63
|
+
### Index registration — always in service.go init()
|
|
65
64
|
Indexes are registered in `service.go` via `domainx.AutoMigrate`, never in the model file.
|
|
66
|
-
Fields are automatically prefixed with `data.` by the framework
|
|
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
67
|
```go
|
|
68
68
|
func init() {
|
|
69
69
|
domainx.AutoMigrate(
|
|
70
|
-
func() domainx.ConTable { return
|
|
70
|
+
func() domainx.ConTable { return dx.On[model.D](context.Background()).Complex() },
|
|
71
71
|
domainx.CtIdx(domainx.Idx, "field_a", "field_b", "field_c"), // compound index
|
|
72
72
|
domainx.CtIdx(domainx.Unique, "unique_field"), // unique index
|
|
73
|
+
domainx.CtIdx(domainx.Spatial2D, "lat", "lng"), // 2D spatial index
|
|
73
74
|
)
|
|
74
75
|
}
|
|
75
76
|
```
|
|
76
77
|
|
|
77
|
-
### dx query API
|
|
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
|
|
78
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
|
+
|
|
79
141
|
// Find multiple records
|
|
80
|
-
|
|
81
|
-
Eq("agent_id", agentID).
|
|
82
|
-
Gte("date", startDate).
|
|
83
|
-
Sort("id", true). // true=desc, false=asc
|
|
84
|
-
Find() // returns domainx.ComplexList[D] = []*domainx.Complex[D]
|
|
142
|
+
results, err := dx.On[model.D](ctx).Eq("status", "active").Sort("id").Find()
|
|
85
143
|
if err != nil { return nil, err }
|
|
86
|
-
for
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
89
427
|
}
|
|
428
|
+
logger.Info(ctx, "create success", zap.Int64("id", id))
|
|
429
|
+
apix.HandleData(ctx, id, nil)
|
|
90
430
|
}
|
|
91
431
|
|
|
92
|
-
//
|
|
93
|
-
|
|
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
|
+
}
|
|
94
443
|
|
|
95
|
-
//
|
|
96
|
-
|
|
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
|
|
97
457
|
|
|
98
|
-
|
|
99
|
-
|
|
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())
|
|
100
463
|
```
|
|
101
464
|
|
|
102
465
|
## Execution Strategy
|
|
103
466
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
For larger or pattern-repeatable changes (3+ files, bulk updates): delegate only when it helps Codex move work in parallel.
|
|
107
|
-
- Keep the critical path local when the next step depends on the result.
|
|
108
|
-
- Give delegated workers disjoint file ownership and keep `gorig-backend` conventions in scope.
|
|
109
|
-
- Verify with `go build ./...` and `go test ./...` after delegated work lands.
|
|
467
|
+
Implement directly using Read/Edit/Write tools.
|
|
468
|
+
Verify with `go build ./... && go test ./test/... -v` after every change.
|
|
110
469
|
|
|
111
470
|
## Maintain Documentation
|
|
112
471
|
When API changes exist, create or update `doc/<module>.md` using `~/.codex/skills/gorig-backend/assets/api-doc-template.md`.
|
|
113
472
|
When module behavior changes, update `domain/<module>/README.md` using `~/.codex/skills/gorig-backend/assets/module-readme-template.md`.
|
|
114
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
|
+
|
|
115
687
|
## Go Test Convention
|
|
116
688
|
|
|
117
|
-
**All tests live in `
|
|
689
|
+
**All tests live in `test/`, `package test`. Never place test files inside domain packages.**
|
|
118
690
|
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
124
696
|
```
|
|
125
697
|
|
|
126
|
-
`init.go` starts
|
|
698
|
+
`init.go` starts the DB connection before any test runs. Tests that bypass this package lose the connection.
|
|
127
699
|
|
|
128
700
|
**New test file boilerplate:**
|
|
129
701
|
|
|
@@ -131,36 +703,41 @@ go/test/
|
|
|
131
703
|
package test
|
|
132
704
|
|
|
133
705
|
import (
|
|
706
|
+
"context"
|
|
134
707
|
"testing"
|
|
135
|
-
|
|
136
|
-
"quantsage/domain/{module}" // import target domain
|
|
137
|
-
{module}model "quantsage/domain/{module}/model" // if model types needed
|
|
138
708
|
)
|
|
139
709
|
|
|
140
710
|
func TestXxx_场景描述(t *testing.T) {
|
|
141
|
-
|
|
142
|
-
input := ...
|
|
711
|
+
ctx := context.Background()
|
|
143
712
|
|
|
144
|
-
//
|
|
145
|
-
|
|
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)
|
|
146
721
|
|
|
147
722
|
// assert
|
|
148
|
-
if
|
|
149
|
-
t.Fatalf("
|
|
723
|
+
if err != nil {
|
|
724
|
+
t.Fatalf("GetByID failed: %v", err)
|
|
725
|
+
}
|
|
726
|
+
if result == nil {
|
|
727
|
+
t.Fatal("expected result, got nil")
|
|
150
728
|
}
|
|
151
729
|
}
|
|
152
730
|
```
|
|
153
731
|
|
|
154
732
|
**Rules:**
|
|
155
|
-
- Functions under test must be **exported** (capitalized)
|
|
156
|
-
- Use standard `testing` only
|
|
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).
|
|
157
735
|
- Use `t.Fatalf` / `t.Errorf` for assertions.
|
|
158
736
|
- Hardcode test data inline; do not read external files.
|
|
159
737
|
|
|
160
738
|
**Run commands:**
|
|
161
739
|
|
|
162
740
|
```bash
|
|
163
|
-
cd go
|
|
164
741
|
go test ./test/... -v # all tests
|
|
165
742
|
go test ./test/... -run TestXxx -v # single test
|
|
166
743
|
```
|