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.
@@ -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 - minimal structure
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 - do not add these
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 - always in service.go init()
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 - pass bare field names:
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 domainx.UseComplex[model.D](domainx.Mongo, global.DB, model.Table) },
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
- result, err := dx.On[model.D](ctx).
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 i := range result {
87
- if result[i] != nil && result[i].Data != nil {
88
- // use result[i].Data
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
- // Get single record
93
- item, err := dx.On[model.D](ctx).Eq("agent_id", agentID).Sort("id", true).Get()
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
- // Save (create or update)
96
- _, err := dx.On[model.D](ctx, &d).Save()
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
- // Update single field
99
- err := dx.On[model.D](ctx).WithID(id).Update("field", value)
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
- For small changes (< ~20 lines, 1-2 files): implement directly.
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 `go/test/`, `package test`. Never place test files inside domain packages.**
689
+ **All tests live in `test/`, `package test`. Never place test files inside domain packages.**
118
690
 
119
- ```text
120
- go/test/
121
- |-- init.go <- Shared MongoDB init - DO NOT modify
122
- |-- _bin/local.yaml <- Test DB config - DO NOT modify
123
- `-- {feature}_test.go <- Add new test files here
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 MongoDB before any test runs. Tests that bypass this package lose the DB connection.
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
- // arrange
142
- input := ...
711
+ ctx := context.Background()
143
712
 
144
- // act - only exported functions are accessible from package test
145
- got := {module}.ExportedFunction(input)
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 got != want {
149
- t.Fatalf("ExportedFunction() = %v, want %v", got, want)
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) - `package test` cannot access unexported symbols.
156
- - Use standard `testing` only. No testify or other assertion libraries.
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
  ```