moicle 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/assets/architecture/go-backend.md +930 -108
- package/assets/commands/brainstorm.md +1 -0
- package/assets/skills/api-integration/SKILL.md +883 -0
- package/assets/skills/architect-review/SKILL.md +473 -0
- package/assets/skills/deprecation/SKILL.md +923 -0
- package/assets/skills/documentation/SKILL.md +1333 -0
- package/assets/skills/fix-pr-comment/SKILL.md +283 -0
- package/assets/skills/go-module/SKILL.md +77 -0
- package/assets/skills/incident-response/SKILL.md +946 -0
- package/assets/skills/onboarding/SKILL.md +607 -0
- package/assets/skills/pr-review/SKILL.md +620 -0
- package/assets/skills/refactor/SKILL.md +756 -0
- package/assets/skills/spike/SKILL.md +535 -0
- package/assets/skills/tdd/SKILL.md +828 -0
- package/bin/cli.js +2 -1
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +20 -2
- package/dist/commands/install.js.map +1 -1
- package/dist/utils/symlink.d.ts +1 -0
- package/dist/utils/symlink.d.ts.map +1 -1
- package/dist/utils/symlink.js +1 -0
- package/dist/utils/symlink.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,38 +1,76 @@
|
|
|
1
|
-
# Go Backend
|
|
1
|
+
# Go Backend Architecture
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Production-grade Clean Architecture with Modular Design
|
|
4
4
|
|
|
5
5
|
## Project Structure
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
{project}/
|
|
9
9
|
├── cmd/
|
|
10
|
-
│
|
|
11
|
-
│
|
|
10
|
+
│ ├── api/
|
|
11
|
+
│ │ ├── main.go # Entry point (HTTP + gRPC + SSE)
|
|
12
|
+
│ │ └── router.go # Route definitions & middleware
|
|
13
|
+
│ └── background/
|
|
14
|
+
│ ├── main.go # CLI entry (Cobra)
|
|
15
|
+
│ ├── commands/ # CLI subcommands
|
|
16
|
+
│ │ ├── consumer.go # Task consumer
|
|
17
|
+
│ │ ├── scheduler.go # Task scheduler
|
|
18
|
+
│ │ └── sync.go # Sync command
|
|
19
|
+
│ └── consumers/ # Task handlers (Asynq)
|
|
20
|
+
│ ├── notification.go
|
|
21
|
+
│ └── cleanup.go
|
|
12
22
|
├── internal/
|
|
13
23
|
│ ├── config/
|
|
14
|
-
│ │ └── config.go
|
|
15
|
-
│ ├──
|
|
16
|
-
│ │ ├──
|
|
17
|
-
│ │
|
|
18
|
-
│ ├──
|
|
19
|
-
│ │ ├──
|
|
20
|
-
│ │
|
|
21
|
-
│
|
|
22
|
-
│
|
|
23
|
-
│ │ └──
|
|
24
|
-
│
|
|
25
|
-
│
|
|
26
|
-
│
|
|
27
|
-
│ └──
|
|
28
|
-
│
|
|
29
|
-
|
|
30
|
-
│ ├──
|
|
31
|
-
│
|
|
32
|
-
│ └──
|
|
33
|
-
├──
|
|
34
|
-
├──
|
|
35
|
-
├──
|
|
24
|
+
│ │ └── config.go # App configuration
|
|
25
|
+
│ ├── middleware/
|
|
26
|
+
│ │ ├── auth.go # Auth middleware + caching
|
|
27
|
+
│ │ ├── admin_auth.go # Admin auth
|
|
28
|
+
│ │ ├── cors.go # CORS
|
|
29
|
+
│ │ ├── logger.go # Request logging
|
|
30
|
+
│ │ ├── recovery.go # Panic recovery
|
|
31
|
+
│ │ └── api_key.go # API key validation
|
|
32
|
+
│ ├── grpc/ # gRPC services (optional)
|
|
33
|
+
│ │ └── account_service.go
|
|
34
|
+
│ └── modules/ # Feature modules
|
|
35
|
+
│ ├── auth/
|
|
36
|
+
│ │ ├── controllers/
|
|
37
|
+
│ │ │ └── auth_controller.go
|
|
38
|
+
│ │ ├── usecases/
|
|
39
|
+
│ │ │ └── auth_usecase.go
|
|
40
|
+
│ │ ├── dtos/
|
|
41
|
+
│ │ │ └── auth_dto.go
|
|
42
|
+
│ │ └── init.go # Module init & routes
|
|
43
|
+
│ ├── user/
|
|
44
|
+
│ │ ├── controllers/
|
|
45
|
+
│ │ ├── usecases/
|
|
46
|
+
│ │ ├── dtos/
|
|
47
|
+
│ │ └── init.go
|
|
48
|
+
│ └── {feature}/ # Other modules...
|
|
49
|
+
│ ├── controllers/
|
|
50
|
+
│ ├── usecases/
|
|
51
|
+
│ ├── dtos/
|
|
52
|
+
│ ├── validators/ # (optional) Chain validators
|
|
53
|
+
│ └── init.go
|
|
54
|
+
├── pkg/
|
|
55
|
+
│ ├── database/
|
|
56
|
+
│ │ ├── database.go # GORM connection
|
|
57
|
+
│ │ ├── user.go # User model
|
|
58
|
+
│ │ └── {entity}.go # Other models
|
|
59
|
+
│ ├── enums/ # Enumeration types
|
|
60
|
+
│ ├── events/ # Event definitions
|
|
61
|
+
│ ├── queue/ # Task queue (Asynq)
|
|
62
|
+
│ │ ├── tasks.go # Task definitions
|
|
63
|
+
│ │ └── client.go # Queue client
|
|
64
|
+
│ ├── redis/ # Redis client
|
|
65
|
+
│ ├── sse/ # Server-Sent Events
|
|
66
|
+
│ │ └── hub.go # SSE Hub
|
|
67
|
+
│ ├── response/ # HTTP response helpers
|
|
68
|
+
│ ├── logger/ # Structured logging
|
|
69
|
+
│ ├── storage/ # File storage (S3/R2)
|
|
70
|
+
│ └── utils/ # Utility functions
|
|
71
|
+
├── migrations/ # SQL migrations
|
|
72
|
+
├── chart/ # Kubernetes Helm charts (optional)
|
|
73
|
+
├── .env.example
|
|
36
74
|
├── go.mod
|
|
37
75
|
├── Makefile
|
|
38
76
|
├── Dockerfile
|
|
@@ -42,136 +80,893 @@
|
|
|
42
80
|
## Architecture Pattern
|
|
43
81
|
|
|
44
82
|
```
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
Request
|
|
48
|
-
Binding
|
|
83
|
+
Controller → Usecase → Database (GORM)
|
|
84
|
+
↓ ↓
|
|
85
|
+
Request Business
|
|
86
|
+
Binding Logic
|
|
87
|
+
↓ ↓
|
|
88
|
+
DTOs Models
|
|
49
89
|
```
|
|
50
90
|
|
|
51
|
-
**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
91
|
+
**Layer Responsibilities:**
|
|
92
|
+
|
|
93
|
+
| Layer | Responsibility |
|
|
94
|
+
|-------|----------------|
|
|
95
|
+
| Controller | HTTP handling, request/response, validation |
|
|
96
|
+
| Usecase | Business logic, orchestration |
|
|
97
|
+
| DTO | Data transfer objects, API contracts |
|
|
98
|
+
| Model | Database entities (pkg/database) |
|
|
99
|
+
|
|
100
|
+
## Module Structure
|
|
101
|
+
|
|
102
|
+
Each feature is an independent module:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
internal/modules/{feature}/
|
|
106
|
+
├── controllers/
|
|
107
|
+
│ └── {feature}_controller.go # HTTP handlers
|
|
108
|
+
├── usecases/
|
|
109
|
+
│ └── {feature}_usecase.go # Business logic
|
|
110
|
+
├── dtos/
|
|
111
|
+
│ └── {feature}_dto.go # Request/Response DTOs
|
|
112
|
+
├── validators/ # (optional)
|
|
113
|
+
│ └── {feature}_validator.go # Chain validators
|
|
114
|
+
└── init.go # Module init & route registration
|
|
115
|
+
```
|
|
57
116
|
|
|
58
117
|
## Key Files
|
|
59
118
|
|
|
60
|
-
###
|
|
119
|
+
### cmd/api/main.go
|
|
61
120
|
```go
|
|
62
|
-
package
|
|
121
|
+
package main
|
|
63
122
|
|
|
64
|
-
import
|
|
123
|
+
import (
|
|
124
|
+
"context"
|
|
125
|
+
"log"
|
|
126
|
+
"net/http"
|
|
127
|
+
"os"
|
|
128
|
+
"os/signal"
|
|
129
|
+
"syscall"
|
|
130
|
+
"time"
|
|
65
131
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
132
|
+
"myapp/internal/config"
|
|
133
|
+
"myapp/pkg/database"
|
|
134
|
+
"myapp/pkg/redis"
|
|
135
|
+
"myapp/pkg/queue"
|
|
136
|
+
"myapp/pkg/sse"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
func main() {
|
|
140
|
+
cfg := config.Load()
|
|
141
|
+
|
|
142
|
+
// Initialize dependencies
|
|
143
|
+
db := database.Connect(cfg.Database)
|
|
144
|
+
redisClient := redis.NewClient(cfg.Redis)
|
|
145
|
+
queueClient := queue.NewClient(redisClient)
|
|
146
|
+
sseHub := sse.NewHub(redisClient)
|
|
147
|
+
|
|
148
|
+
// Start SSE Hub
|
|
149
|
+
go sseHub.Run()
|
|
150
|
+
|
|
151
|
+
// Setup router
|
|
152
|
+
router := SetupRouter(db, redisClient, queueClient, sseHub, cfg)
|
|
153
|
+
|
|
154
|
+
// HTTP Server
|
|
155
|
+
server := &http.Server{
|
|
156
|
+
Addr: ":" + cfg.Port,
|
|
157
|
+
Handler: router,
|
|
158
|
+
ReadTimeout: 15 * time.Second,
|
|
159
|
+
WriteTimeout: 0, // Disabled for SSE
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Graceful shutdown
|
|
163
|
+
go func() {
|
|
164
|
+
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
165
|
+
log.Fatalf("Server error: %v", err)
|
|
166
|
+
}
|
|
167
|
+
}()
|
|
168
|
+
|
|
169
|
+
quit := make(chan os.Signal, 1)
|
|
170
|
+
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
171
|
+
<-quit
|
|
172
|
+
|
|
173
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
174
|
+
defer cancel()
|
|
175
|
+
|
|
176
|
+
if err := server.Shutdown(ctx); err != nil {
|
|
177
|
+
log.Fatalf("Shutdown error: %v", err)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### cmd/api/router.go
|
|
183
|
+
```go
|
|
184
|
+
package main
|
|
185
|
+
|
|
186
|
+
import (
|
|
187
|
+
"github.com/gin-gonic/gin"
|
|
188
|
+
"gorm.io/gorm"
|
|
189
|
+
|
|
190
|
+
"myapp/internal/config"
|
|
191
|
+
"myapp/internal/middleware"
|
|
192
|
+
"myapp/internal/modules/auth"
|
|
193
|
+
"myapp/internal/modules/user"
|
|
194
|
+
"myapp/pkg/queue"
|
|
195
|
+
"myapp/pkg/redis"
|
|
196
|
+
"myapp/pkg/sse"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
func SetupRouter(
|
|
200
|
+
db *gorm.DB,
|
|
201
|
+
redisClient *redis.Client,
|
|
202
|
+
queueClient *queue.Client,
|
|
203
|
+
sseHub *sse.Hub,
|
|
204
|
+
cfg *config.Config,
|
|
205
|
+
) *gin.Engine {
|
|
206
|
+
r := gin.New()
|
|
207
|
+
|
|
208
|
+
// Global middleware
|
|
209
|
+
r.Use(middleware.Recovery())
|
|
210
|
+
r.Use(middleware.Logger())
|
|
211
|
+
r.Use(middleware.CORS(cfg.AllowedOrigins))
|
|
212
|
+
|
|
213
|
+
// Health check
|
|
214
|
+
r.GET("/health", func(c *gin.Context) {
|
|
215
|
+
c.JSON(200, gin.H{"status": "ok"})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Auth middleware
|
|
219
|
+
authMiddleware := middleware.NewAuthMiddleware(db, redisClient, cfg.Firebase)
|
|
220
|
+
|
|
221
|
+
// Initialize modules
|
|
222
|
+
auth.Init(r, db, redisClient, authMiddleware)
|
|
223
|
+
user.Init(r, db, authMiddleware)
|
|
224
|
+
|
|
225
|
+
return r
|
|
71
226
|
}
|
|
72
227
|
```
|
|
73
228
|
|
|
74
|
-
### internal/
|
|
229
|
+
### internal/modules/auth/init.go
|
|
75
230
|
```go
|
|
76
|
-
package
|
|
231
|
+
package auth
|
|
77
232
|
|
|
78
233
|
import (
|
|
79
|
-
"
|
|
234
|
+
"github.com/gin-gonic/gin"
|
|
80
235
|
"gorm.io/gorm"
|
|
236
|
+
|
|
237
|
+
"myapp/internal/middleware"
|
|
238
|
+
"myapp/internal/modules/auth/controllers"
|
|
239
|
+
"myapp/internal/modules/auth/usecases"
|
|
240
|
+
"myapp/pkg/redis"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
func Init(
|
|
244
|
+
r *gin.Engine,
|
|
245
|
+
db *gorm.DB,
|
|
246
|
+
redisClient *redis.Client,
|
|
247
|
+
authMiddleware *middleware.AuthMiddleware,
|
|
248
|
+
) {
|
|
249
|
+
// Initialize usecase
|
|
250
|
+
usecase := usecases.NewAuthUsecase(db, redisClient)
|
|
251
|
+
|
|
252
|
+
// Initialize controller
|
|
253
|
+
controller := controllers.NewAuthController(usecase)
|
|
254
|
+
|
|
255
|
+
// Register routes
|
|
256
|
+
authGroup := r.Group("/auth")
|
|
257
|
+
{
|
|
258
|
+
authGroup.POST("/verify", controller.VerifyToken)
|
|
259
|
+
|
|
260
|
+
// Protected routes
|
|
261
|
+
protected := authGroup.Group("")
|
|
262
|
+
protected.Use(authMiddleware.Authenticate())
|
|
263
|
+
{
|
|
264
|
+
protected.GET("/me", controller.GetMe)
|
|
265
|
+
protected.PUT("/me", controller.UpdateMe)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### internal/modules/auth/controllers/auth_controller.go
|
|
272
|
+
```go
|
|
273
|
+
package controllers
|
|
274
|
+
|
|
275
|
+
import (
|
|
276
|
+
"net/http"
|
|
277
|
+
|
|
278
|
+
"github.com/gin-gonic/gin"
|
|
279
|
+
|
|
280
|
+
"myapp/internal/modules/auth/dtos"
|
|
281
|
+
"myapp/internal/modules/auth/usecases"
|
|
282
|
+
"myapp/pkg/response"
|
|
81
283
|
)
|
|
82
284
|
|
|
83
|
-
type
|
|
84
|
-
|
|
285
|
+
type AuthController struct {
|
|
286
|
+
usecase *usecases.AuthUsecase
|
|
85
287
|
}
|
|
86
288
|
|
|
87
|
-
func
|
|
88
|
-
return &
|
|
289
|
+
func NewAuthController(usecase *usecases.AuthUsecase) *AuthController {
|
|
290
|
+
return &AuthController{usecase: usecase}
|
|
89
291
|
}
|
|
90
292
|
|
|
91
|
-
func (
|
|
92
|
-
var
|
|
93
|
-
if err :=
|
|
293
|
+
func (c *AuthController) VerifyToken(ctx *gin.Context) {
|
|
294
|
+
var req dtos.VerifyTokenRequest
|
|
295
|
+
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
296
|
+
response.Error(ctx, http.StatusBadRequest, "Invalid request")
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
user, err := c.usecase.VerifyToken(ctx, req.Token)
|
|
301
|
+
if err != nil {
|
|
302
|
+
response.Error(ctx, http.StatusUnauthorized, "Invalid token")
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
response.Success(ctx, dtos.ToUserResponse(user))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
func (c *AuthController) GetMe(ctx *gin.Context) {
|
|
310
|
+
userID := ctx.GetString("user_id")
|
|
311
|
+
|
|
312
|
+
user, err := c.usecase.GetByID(ctx, userID)
|
|
313
|
+
if err != nil {
|
|
314
|
+
response.Error(ctx, http.StatusNotFound, "User not found")
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
response.Success(ctx, dtos.ToUserResponse(user))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
func (c *AuthController) UpdateMe(ctx *gin.Context) {
|
|
322
|
+
userID := ctx.GetString("user_id")
|
|
323
|
+
|
|
324
|
+
var req dtos.UpdateUserRequest
|
|
325
|
+
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
326
|
+
response.Error(ctx, http.StatusBadRequest, "Invalid request")
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
user, err := c.usecase.Update(ctx, userID, &req)
|
|
331
|
+
if err != nil {
|
|
332
|
+
response.Error(ctx, http.StatusInternalServerError, err.Error())
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
response.Success(ctx, dtos.ToUserResponse(user))
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### internal/modules/auth/usecases/auth_usecase.go
|
|
341
|
+
```go
|
|
342
|
+
package usecases
|
|
343
|
+
|
|
344
|
+
import (
|
|
345
|
+
"context"
|
|
346
|
+
"errors"
|
|
347
|
+
|
|
348
|
+
"gorm.io/gorm"
|
|
349
|
+
|
|
350
|
+
"myapp/internal/modules/auth/dtos"
|
|
351
|
+
"myapp/pkg/database"
|
|
352
|
+
"myapp/pkg/firebase"
|
|
353
|
+
"myapp/pkg/redis"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
type AuthUsecase struct {
|
|
357
|
+
db *gorm.DB
|
|
358
|
+
redisClient *redis.Client
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
func NewAuthUsecase(db *gorm.DB, redisClient *redis.Client) *AuthUsecase {
|
|
362
|
+
return &AuthUsecase{
|
|
363
|
+
db: db,
|
|
364
|
+
redisClient: redisClient,
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
func (u *AuthUsecase) VerifyToken(ctx context.Context, token string) (*database.User, error) {
|
|
369
|
+
// Verify Firebase token
|
|
370
|
+
firebaseToken, err := firebase.VerifyToken(ctx, token)
|
|
371
|
+
if err != nil {
|
|
94
372
|
return nil, err
|
|
95
373
|
}
|
|
374
|
+
|
|
375
|
+
// Find or create user
|
|
376
|
+
var user database.User
|
|
377
|
+
result := u.db.Where("firebase_uid = ?", firebaseToken.UID).First(&user)
|
|
378
|
+
|
|
379
|
+
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
|
380
|
+
// Create new user
|
|
381
|
+
user = database.User{
|
|
382
|
+
FirebaseUID: firebaseToken.UID,
|
|
383
|
+
Email: firebaseToken.Claims["email"].(string),
|
|
384
|
+
}
|
|
385
|
+
if err := u.db.Create(&user).Error; err != nil {
|
|
386
|
+
return nil, err
|
|
387
|
+
}
|
|
388
|
+
} else if result.Error != nil {
|
|
389
|
+
return nil, result.Error
|
|
390
|
+
}
|
|
391
|
+
|
|
96
392
|
return &user, nil
|
|
97
393
|
}
|
|
98
394
|
|
|
99
|
-
func (
|
|
100
|
-
var
|
|
101
|
-
if err :=
|
|
395
|
+
func (u *AuthUsecase) GetByID(ctx context.Context, id string) (*database.User, error) {
|
|
396
|
+
var user database.User
|
|
397
|
+
if err := u.db.First(&user, "id = ?", id).Error; err != nil {
|
|
102
398
|
return nil, err
|
|
103
399
|
}
|
|
104
|
-
return
|
|
400
|
+
return &user, nil
|
|
105
401
|
}
|
|
106
402
|
|
|
107
|
-
func (
|
|
108
|
-
|
|
403
|
+
func (u *AuthUsecase) Update(ctx context.Context, id string, req *dtos.UpdateUserRequest) (*database.User, error) {
|
|
404
|
+
var user database.User
|
|
405
|
+
if err := u.db.First(&user, "id = ?", id).Error; err != nil {
|
|
406
|
+
return nil, err
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
updates := map[string]interface{}{}
|
|
410
|
+
if req.Name != nil {
|
|
411
|
+
updates["name"] = *req.Name
|
|
412
|
+
}
|
|
413
|
+
if req.Phone != nil {
|
|
414
|
+
updates["phone"] = *req.Phone
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if err := u.db.Model(&user).Updates(updates).Error; err != nil {
|
|
418
|
+
return nil, err
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return &user, nil
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### internal/modules/auth/dtos/auth_dto.go
|
|
426
|
+
```go
|
|
427
|
+
package dtos
|
|
428
|
+
|
|
429
|
+
import "myapp/pkg/database"
|
|
430
|
+
|
|
431
|
+
type VerifyTokenRequest struct {
|
|
432
|
+
Token string `json:"token" binding:"required"`
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
type UpdateUserRequest struct {
|
|
436
|
+
Name *string `json:"name"`
|
|
437
|
+
Phone *string `json:"phone"`
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
type UserResponse struct {
|
|
441
|
+
ID string `json:"id"`
|
|
442
|
+
Email string `json:"email"`
|
|
443
|
+
Name *string `json:"name"`
|
|
444
|
+
Phone *string `json:"phone"`
|
|
445
|
+
AvatarURL *string `json:"avatar_url"`
|
|
446
|
+
CreatedAt string `json:"created_at"`
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
func ToUserResponse(user *database.User) *UserResponse {
|
|
450
|
+
return &UserResponse{
|
|
451
|
+
ID: user.ID,
|
|
452
|
+
Email: user.Email,
|
|
453
|
+
Name: user.Name,
|
|
454
|
+
Phone: user.Phone,
|
|
455
|
+
AvatarURL: user.AvatarURL,
|
|
456
|
+
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
457
|
+
}
|
|
109
458
|
}
|
|
110
459
|
```
|
|
111
460
|
|
|
112
|
-
### internal/
|
|
461
|
+
### internal/middleware/auth.go
|
|
113
462
|
```go
|
|
114
|
-
package
|
|
463
|
+
package middleware
|
|
115
464
|
|
|
116
465
|
import (
|
|
117
466
|
"net/http"
|
|
118
|
-
"
|
|
467
|
+
"strings"
|
|
468
|
+
"time"
|
|
469
|
+
|
|
119
470
|
"github.com/gin-gonic/gin"
|
|
471
|
+
"gorm.io/gorm"
|
|
472
|
+
|
|
473
|
+
"myapp/pkg/database"
|
|
474
|
+
"myapp/pkg/firebase"
|
|
475
|
+
"myapp/pkg/redis"
|
|
120
476
|
)
|
|
121
477
|
|
|
122
|
-
type
|
|
123
|
-
|
|
478
|
+
type AuthMiddleware struct {
|
|
479
|
+
db *gorm.DB
|
|
480
|
+
redisClient *redis.Client
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
func NewAuthMiddleware(db *gorm.DB, redisClient *redis.Client) *AuthMiddleware {
|
|
484
|
+
return &AuthMiddleware{
|
|
485
|
+
db: db,
|
|
486
|
+
redisClient: redisClient,
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
func (m *AuthMiddleware) Authenticate() gin.HandlerFunc {
|
|
491
|
+
return func(c *gin.Context) {
|
|
492
|
+
authHeader := c.GetHeader("Authorization")
|
|
493
|
+
if authHeader == "" {
|
|
494
|
+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing authorization header"})
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
499
|
+
|
|
500
|
+
// Check Redis cache first
|
|
501
|
+
cacheKey := "auth:" + token[:32]
|
|
502
|
+
if cached, err := m.redisClient.Get(c, cacheKey); err == nil {
|
|
503
|
+
c.Set("user_id", cached)
|
|
504
|
+
c.Next()
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Verify Firebase token
|
|
509
|
+
firebaseToken, err := firebase.VerifyToken(c, token)
|
|
510
|
+
if err != nil {
|
|
511
|
+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Get user from database
|
|
516
|
+
var user database.User
|
|
517
|
+
if err := m.db.Where("firebase_uid = ?", firebaseToken.UID).First(&user).Error; err != nil {
|
|
518
|
+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Cache for 1 hour
|
|
523
|
+
m.redisClient.Set(c, cacheKey, user.ID, time.Hour)
|
|
524
|
+
|
|
525
|
+
c.Set("user_id", user.ID)
|
|
526
|
+
c.Set("user", &user)
|
|
527
|
+
c.Next()
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
func (m *AuthMiddleware) Optional() gin.HandlerFunc {
|
|
532
|
+
return func(c *gin.Context) {
|
|
533
|
+
authHeader := c.GetHeader("Authorization")
|
|
534
|
+
if authHeader == "" {
|
|
535
|
+
c.Next()
|
|
536
|
+
return
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
540
|
+
|
|
541
|
+
firebaseToken, err := firebase.VerifyToken(c, token)
|
|
542
|
+
if err != nil {
|
|
543
|
+
c.Next()
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
var user database.User
|
|
548
|
+
if err := m.db.Where("firebase_uid = ?", firebaseToken.UID).First(&user).Error; err == nil {
|
|
549
|
+
c.Set("user_id", user.ID)
|
|
550
|
+
c.Set("user", &user)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
c.Next()
|
|
554
|
+
}
|
|
124
555
|
}
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### pkg/database/database.go
|
|
559
|
+
```go
|
|
560
|
+
package database
|
|
561
|
+
|
|
562
|
+
import (
|
|
563
|
+
"fmt"
|
|
564
|
+
"log"
|
|
125
565
|
|
|
126
|
-
|
|
127
|
-
|
|
566
|
+
"gorm.io/driver/mysql"
|
|
567
|
+
"gorm.io/gorm"
|
|
568
|
+
"gorm.io/gorm/logger"
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
type Config struct {
|
|
572
|
+
Host string
|
|
573
|
+
Port string
|
|
574
|
+
User string
|
|
575
|
+
Password string
|
|
576
|
+
Name string
|
|
128
577
|
}
|
|
129
578
|
|
|
130
|
-
func (
|
|
131
|
-
|
|
579
|
+
func Connect(cfg Config) *gorm.DB {
|
|
580
|
+
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=UTC",
|
|
581
|
+
cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Name)
|
|
582
|
+
|
|
583
|
+
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
|
584
|
+
Logger: logger.Default.LogMode(logger.Info),
|
|
585
|
+
})
|
|
132
586
|
if err != nil {
|
|
133
|
-
|
|
134
|
-
|
|
587
|
+
log.Fatalf("Failed to connect to database: %v", err)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
sqlDB, _ := db.DB()
|
|
591
|
+
sqlDB.SetMaxOpenConns(100)
|
|
592
|
+
sqlDB.SetMaxIdleConns(10)
|
|
593
|
+
|
|
594
|
+
// Auto-migrate models
|
|
595
|
+
db.AutoMigrate(
|
|
596
|
+
&User{},
|
|
597
|
+
// Add other models here
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
return db
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### pkg/database/user.go
|
|
605
|
+
```go
|
|
606
|
+
package database
|
|
607
|
+
|
|
608
|
+
import (
|
|
609
|
+
"time"
|
|
610
|
+
|
|
611
|
+
"github.com/google/uuid"
|
|
612
|
+
"gorm.io/gorm"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
type User struct {
|
|
616
|
+
ID string `gorm:"type:char(36);primaryKey" json:"id"`
|
|
617
|
+
FirebaseUID string `gorm:"type:varchar(128);uniqueIndex" json:"-"`
|
|
618
|
+
Email string `gorm:"type:varchar(255);uniqueIndex" json:"email"`
|
|
619
|
+
Name *string `gorm:"type:varchar(255)" json:"name"`
|
|
620
|
+
Phone *string `gorm:"type:varchar(20)" json:"phone"`
|
|
621
|
+
AvatarURL *string `gorm:"type:varchar(500)" json:"avatar_url"`
|
|
622
|
+
Status string `gorm:"type:varchar(20);default:active" json:"status"`
|
|
623
|
+
CreatedAt time.Time `json:"created_at"`
|
|
624
|
+
UpdatedAt time.Time `json:"updated_at"`
|
|
625
|
+
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
func (u *User) BeforeCreate(tx *gorm.DB) error {
|
|
629
|
+
if u.ID == "" {
|
|
630
|
+
u.ID = uuid.New().String()
|
|
135
631
|
}
|
|
136
|
-
|
|
632
|
+
return nil
|
|
633
|
+
}
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
### pkg/response/response.go
|
|
637
|
+
```go
|
|
638
|
+
package response
|
|
639
|
+
|
|
640
|
+
import "github.com/gin-gonic/gin"
|
|
641
|
+
|
|
642
|
+
type Response struct {
|
|
643
|
+
Success bool `json:"success"`
|
|
644
|
+
Data interface{} `json:"data,omitempty"`
|
|
645
|
+
Error string `json:"error,omitempty"`
|
|
646
|
+
Meta *Meta `json:"meta,omitempty"`
|
|
137
647
|
}
|
|
138
648
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
649
|
+
type Meta struct {
|
|
650
|
+
Page int `json:"page"`
|
|
651
|
+
PerPage int `json:"per_page"`
|
|
652
|
+
Total int64 `json:"total"`
|
|
653
|
+
TotalPages int `json:"total_pages"`
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
func Success(c *gin.Context, data interface{}) {
|
|
657
|
+
c.JSON(200, Response{
|
|
658
|
+
Success: true,
|
|
659
|
+
Data: data,
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
func SuccessWithMeta(c *gin.Context, data interface{}, meta *Meta) {
|
|
664
|
+
c.JSON(200, Response{
|
|
665
|
+
Success: true,
|
|
666
|
+
Data: data,
|
|
667
|
+
Meta: meta,
|
|
668
|
+
})
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
func Error(c *gin.Context, status int, message string) {
|
|
672
|
+
c.JSON(status, Response{
|
|
673
|
+
Success: false,
|
|
674
|
+
Error: message,
|
|
675
|
+
})
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
func Created(c *gin.Context, data interface{}) {
|
|
679
|
+
c.JSON(201, Response{
|
|
680
|
+
Success: true,
|
|
681
|
+
Data: data,
|
|
682
|
+
})
|
|
683
|
+
}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
### pkg/queue/tasks.go (Asynq)
|
|
687
|
+
```go
|
|
688
|
+
package queue
|
|
689
|
+
|
|
690
|
+
import (
|
|
691
|
+
"encoding/json"
|
|
692
|
+
|
|
693
|
+
"github.com/hibiken/asynq"
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
const (
|
|
697
|
+
TaskSendNotification = "notification:send"
|
|
698
|
+
TaskSyncData = "sync:data"
|
|
699
|
+
TaskCleanup = "cleanup:expired"
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
type NotificationPayload struct {
|
|
703
|
+
UserID string `json:"user_id"`
|
|
704
|
+
Title string `json:"title"`
|
|
705
|
+
Body string `json:"body"`
|
|
706
|
+
Data map[string]string `json:"data"`
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
func NewSendNotificationTask(payload *NotificationPayload) (*asynq.Task, error) {
|
|
710
|
+
data, err := json.Marshal(payload)
|
|
142
711
|
if err != nil {
|
|
143
|
-
|
|
144
|
-
return
|
|
712
|
+
return nil, err
|
|
145
713
|
}
|
|
146
|
-
|
|
714
|
+
return asynq.NewTask(TaskSendNotification, data), nil
|
|
147
715
|
}
|
|
148
716
|
```
|
|
149
717
|
|
|
150
|
-
###
|
|
718
|
+
### pkg/sse/hub.go (Server-Sent Events)
|
|
719
|
+
```go
|
|
720
|
+
package sse
|
|
721
|
+
|
|
722
|
+
import (
|
|
723
|
+
"context"
|
|
724
|
+
"encoding/json"
|
|
725
|
+
"sync"
|
|
726
|
+
|
|
727
|
+
"myapp/pkg/redis"
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
type Client struct {
|
|
731
|
+
UserID string
|
|
732
|
+
Channel chan []byte
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
type Hub struct {
|
|
736
|
+
clients map[string]map[*Client]bool
|
|
737
|
+
register chan *Client
|
|
738
|
+
unregister chan *Client
|
|
739
|
+
broadcast chan *Message
|
|
740
|
+
mutex sync.RWMutex
|
|
741
|
+
redisClient *redis.Client
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
type Message struct {
|
|
745
|
+
UserID string `json:"user_id,omitempty"`
|
|
746
|
+
Type string `json:"type"`
|
|
747
|
+
Data interface{} `json:"data"`
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
func NewHub(redisClient *redis.Client) *Hub {
|
|
751
|
+
return &Hub{
|
|
752
|
+
clients: make(map[string]map[*Client]bool),
|
|
753
|
+
register: make(chan *Client),
|
|
754
|
+
unregister: make(chan *Client),
|
|
755
|
+
broadcast: make(chan *Message, 256),
|
|
756
|
+
redisClient: redisClient,
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
func (h *Hub) Run() {
|
|
761
|
+
// Subscribe to Redis for cross-instance communication
|
|
762
|
+
go h.subscribeRedis()
|
|
763
|
+
|
|
764
|
+
for {
|
|
765
|
+
select {
|
|
766
|
+
case client := <-h.register:
|
|
767
|
+
h.mutex.Lock()
|
|
768
|
+
if h.clients[client.UserID] == nil {
|
|
769
|
+
h.clients[client.UserID] = make(map[*Client]bool)
|
|
770
|
+
}
|
|
771
|
+
h.clients[client.UserID][client] = true
|
|
772
|
+
h.mutex.Unlock()
|
|
773
|
+
|
|
774
|
+
case client := <-h.unregister:
|
|
775
|
+
h.mutex.Lock()
|
|
776
|
+
if clients, ok := h.clients[client.UserID]; ok {
|
|
777
|
+
delete(clients, client)
|
|
778
|
+
close(client.Channel)
|
|
779
|
+
if len(clients) == 0 {
|
|
780
|
+
delete(h.clients, client.UserID)
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
h.mutex.Unlock()
|
|
784
|
+
|
|
785
|
+
case message := <-h.broadcast:
|
|
786
|
+
h.broadcastLocal(message)
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
func (h *Hub) BroadcastUser(userID string, msgType string, data interface{}) {
|
|
792
|
+
msg := &Message{
|
|
793
|
+
UserID: userID,
|
|
794
|
+
Type: msgType,
|
|
795
|
+
Data: data,
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Publish to Redis for cross-instance
|
|
799
|
+
payload, _ := json.Marshal(msg)
|
|
800
|
+
h.redisClient.Publish(context.Background(), "sse:broadcast", payload)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
func (h *Hub) broadcastLocal(msg *Message) {
|
|
804
|
+
h.mutex.RLock()
|
|
805
|
+
defer h.mutex.RUnlock()
|
|
806
|
+
|
|
807
|
+
payload, _ := json.Marshal(msg)
|
|
808
|
+
|
|
809
|
+
if msg.UserID != "" {
|
|
810
|
+
if clients, ok := h.clients[msg.UserID]; ok {
|
|
811
|
+
for client := range clients {
|
|
812
|
+
select {
|
|
813
|
+
case client.Channel <- payload:
|
|
814
|
+
default:
|
|
815
|
+
close(client.Channel)
|
|
816
|
+
delete(clients, client)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
func (h *Hub) subscribeRedis() {
|
|
824
|
+
pubsub := h.redisClient.Subscribe(context.Background(), "sse:broadcast")
|
|
825
|
+
ch := pubsub.Channel()
|
|
826
|
+
|
|
827
|
+
for msg := range ch {
|
|
828
|
+
var message Message
|
|
829
|
+
if err := json.Unmarshal([]byte(msg.Payload), &message); err == nil {
|
|
830
|
+
h.broadcast <- &message
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
func (h *Hub) Register(client *Client) {
|
|
836
|
+
h.register <- client
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
func (h *Hub) Unregister(client *Client) {
|
|
840
|
+
h.unregister <- client
|
|
841
|
+
}
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
### cmd/background/main.go (Cobra CLI)
|
|
151
845
|
```go
|
|
152
846
|
package main
|
|
153
847
|
|
|
154
848
|
import (
|
|
849
|
+
"log"
|
|
850
|
+
|
|
851
|
+
"github.com/spf13/cobra"
|
|
852
|
+
|
|
853
|
+
"myapp/cmd/background/commands"
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
func main() {
|
|
857
|
+
rootCmd := &cobra.Command{
|
|
858
|
+
Use: "worker",
|
|
859
|
+
Short: "Background worker CLI",
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
rootCmd.AddCommand(commands.ConsumerCmd())
|
|
863
|
+
rootCmd.AddCommand(commands.SchedulerCmd())
|
|
864
|
+
rootCmd.AddCommand(commands.SyncCmd())
|
|
865
|
+
|
|
866
|
+
if err := rootCmd.Execute(); err != nil {
|
|
867
|
+
log.Fatal(err)
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
### cmd/background/commands/consumer.go
|
|
873
|
+
```go
|
|
874
|
+
package commands
|
|
875
|
+
|
|
876
|
+
import (
|
|
877
|
+
"log"
|
|
878
|
+
|
|
879
|
+
"github.com/hibiken/asynq"
|
|
880
|
+
"github.com/spf13/cobra"
|
|
881
|
+
|
|
882
|
+
"myapp/cmd/background/consumers"
|
|
155
883
|
"myapp/internal/config"
|
|
156
|
-
"myapp/internal/handlers"
|
|
157
|
-
"myapp/internal/services"
|
|
158
884
|
"myapp/pkg/database"
|
|
885
|
+
"myapp/pkg/queue"
|
|
159
886
|
)
|
|
160
887
|
|
|
161
|
-
func
|
|
162
|
-
|
|
163
|
-
|
|
888
|
+
func ConsumerCmd() *cobra.Command {
|
|
889
|
+
return &cobra.Command{
|
|
890
|
+
Use: "consumer",
|
|
891
|
+
Short: "Start task consumer",
|
|
892
|
+
Run: func(cmd *cobra.Command, args []string) {
|
|
893
|
+
cfg := config.Load()
|
|
894
|
+
db := database.Connect(cfg.Database)
|
|
164
895
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
896
|
+
srv := asynq.NewServer(
|
|
897
|
+
asynq.RedisClientOpt{Addr: cfg.Redis.Addr},
|
|
898
|
+
asynq.Config{
|
|
899
|
+
Concurrency: 10,
|
|
900
|
+
},
|
|
901
|
+
)
|
|
168
902
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
r.GET("/users/:id", userHandler.GetByID)
|
|
903
|
+
mux := asynq.NewServeMux()
|
|
904
|
+
mux.HandleFunc(queue.TaskSendNotification, consumers.HandleSendNotification(db))
|
|
905
|
+
mux.HandleFunc(queue.TaskCleanup, consumers.HandleCleanup(db))
|
|
173
906
|
|
|
174
|
-
|
|
907
|
+
if err := srv.Run(mux); err != nil {
|
|
908
|
+
log.Fatalf("Failed to run server: %v", err)
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
## Validator Chain Pattern (Optional)
|
|
916
|
+
|
|
917
|
+
For complex validations:
|
|
918
|
+
|
|
919
|
+
```go
|
|
920
|
+
package validators
|
|
921
|
+
|
|
922
|
+
import (
|
|
923
|
+
"context"
|
|
924
|
+
"errors"
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
type Validator interface {
|
|
928
|
+
Validate(ctx context.Context, data interface{}) error
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
type ValidatorChain struct {
|
|
932
|
+
validators []Validator
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
func NewValidatorChain() *ValidatorChain {
|
|
936
|
+
return &ValidatorChain{
|
|
937
|
+
validators: make([]Validator, 0),
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
func (c *ValidatorChain) Add(v Validator) *ValidatorChain {
|
|
942
|
+
c.validators = append(c.validators, v)
|
|
943
|
+
return c
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
func (c *ValidatorChain) Validate(ctx context.Context, data interface{}) error {
|
|
947
|
+
for _, v := range c.validators {
|
|
948
|
+
if err := v.Validate(ctx, data); err != nil {
|
|
949
|
+
return err
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return nil
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Example validator
|
|
956
|
+
type AmountValidator struct {
|
|
957
|
+
MinAmount int64
|
|
958
|
+
MaxAmount int64
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
func (v *AmountValidator) Validate(ctx context.Context, data interface{}) error {
|
|
962
|
+
amount := data.(int64)
|
|
963
|
+
if amount < v.MinAmount {
|
|
964
|
+
return errors.New("amount too small")
|
|
965
|
+
}
|
|
966
|
+
if amount > v.MaxAmount {
|
|
967
|
+
return errors.New("amount too large")
|
|
968
|
+
}
|
|
969
|
+
return nil
|
|
175
970
|
}
|
|
176
971
|
```
|
|
177
972
|
|
|
@@ -179,39 +974,66 @@ func main() {
|
|
|
179
974
|
|
|
180
975
|
| Item | Convention | Example |
|
|
181
976
|
|------|------------|---------|
|
|
182
|
-
| Package | lowercase, short | `
|
|
183
|
-
|
|
|
184
|
-
| File | snake_case | `
|
|
185
|
-
|
|
|
186
|
-
|
|
|
977
|
+
| Package | lowercase, short | `auth`, `user` |
|
|
978
|
+
| Module folder | snake_case | `bank_account/` |
|
|
979
|
+
| File | snake_case | `auth_controller.go` |
|
|
980
|
+
| Struct | PascalCase | `AuthController` |
|
|
981
|
+
| Interface | PascalCase | `Validator` |
|
|
982
|
+
| Function | PascalCase | `GetByID`, `Create` |
|
|
983
|
+
| Private func | camelCase | `validateToken` |
|
|
984
|
+
| Constants | PascalCase | `TaskSendNotification` |
|
|
985
|
+
| Enums | PascalCase | `StatusActive` |
|
|
187
986
|
|
|
188
987
|
## Makefile
|
|
189
988
|
|
|
190
989
|
```makefile
|
|
191
|
-
.PHONY: run build test
|
|
990
|
+
.PHONY: dev run build test migrate
|
|
991
|
+
|
|
992
|
+
dev:
|
|
993
|
+
air
|
|
192
994
|
|
|
193
995
|
run:
|
|
194
996
|
go run cmd/api/main.go
|
|
195
997
|
|
|
196
998
|
build:
|
|
197
999
|
go build -o bin/api cmd/api/main.go
|
|
1000
|
+
go build -o bin/worker cmd/background/main.go
|
|
198
1001
|
|
|
199
1002
|
test:
|
|
200
1003
|
go test -v ./...
|
|
201
1004
|
|
|
202
1005
|
migrate:
|
|
203
1006
|
go run cmd/migrate/main.go
|
|
1007
|
+
|
|
1008
|
+
worker-consumer:
|
|
1009
|
+
go run cmd/background/main.go consumer
|
|
1010
|
+
|
|
1011
|
+
worker-scheduler:
|
|
1012
|
+
go run cmd/background/main.go scheduler
|
|
204
1013
|
```
|
|
205
1014
|
|
|
206
|
-
##
|
|
1015
|
+
## Tech Stack
|
|
1016
|
+
|
|
1017
|
+
| Component | Technology |
|
|
1018
|
+
|-----------|------------|
|
|
1019
|
+
| Framework | Gin |
|
|
1020
|
+
| ORM | GORM |
|
|
1021
|
+
| Database | MySQL/PostgreSQL |
|
|
1022
|
+
| Cache | Redis |
|
|
1023
|
+
| Queue | Asynq |
|
|
1024
|
+
| Auth | Firebase Auth |
|
|
1025
|
+
| CLI | Cobra |
|
|
1026
|
+
| gRPC | google.golang.org/grpc |
|
|
1027
|
+
| Storage | AWS S3 / Cloudflare R2 |
|
|
207
1028
|
|
|
208
|
-
|
|
209
|
-
- Small to medium APIs
|
|
210
|
-
- CRUD operations
|
|
211
|
-
- Simple business logic
|
|
1029
|
+
## When to Use What
|
|
212
1030
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
1031
|
+
| Scenario | Solution |
|
|
1032
|
+
|----------|----------|
|
|
1033
|
+
| Simple CRUD | Controller → Usecase → Model |
|
|
1034
|
+
| Complex validation | Validator Chain pattern |
|
|
1035
|
+
| Background jobs | Asynq task queue |
|
|
1036
|
+
| Real-time events | SSE Hub + Redis pub/sub |
|
|
1037
|
+
| Inter-service | gRPC |
|
|
1038
|
+
| File upload | Storage package (S3/R2) |
|
|
1039
|
+
| Push notifications | FCM via queue |
|