@vincent119/go-copilot-rules 1.0.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/.agent_agy/INSTALLATION.md +786 -0
- package/.agent_agy/README.md +243 -0
- package/.agent_agy/SKILLS_INDEX.md +516 -0
- package/.agent_agy/rules/go-core.copilot-instructions.md +251 -0
- package/.agent_agy/skills/go-api-design/SKILL.md +535 -0
- package/.agent_agy/skills/go-ci-tooling/SKILL.md +533 -0
- package/.agent_agy/skills/go-configuration/SKILL.md +609 -0
- package/.agent_agy/skills/go-database/SKILL.md +412 -0
- package/.agent_agy/skills/go-ddd/SKILL.md +374 -0
- package/.agent_agy/skills/go-dependency-injection/SKILL.md +546 -0
- package/.agent_agy/skills/go-domain-events/SKILL.md +525 -0
- package/.agent_agy/skills/go-examples/SKILL.md +690 -0
- package/.agent_agy/skills/go-graceful-shutdown/SKILL.md +708 -0
- package/.agent_agy/skills/go-grpc/SKILL.md +484 -0
- package/.agent_agy/skills/go-http-advanced/SKILL.md +494 -0
- package/.agent_agy/skills/go-observability/SKILL.md +684 -0
- package/.agent_agy/skills/go-testing-advanced/SKILL.md +573 -0
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/cli/install.js +344 -0
- package/package.json +47 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: go-examples
|
|
3
|
+
description: |
|
|
4
|
+
Go 實作範例庫:完整的 HTTP Client、Repository Pattern、Use Case、Handler、
|
|
5
|
+
Service 實作範例,涵蓋常見場景的最佳實務程式碼。
|
|
6
|
+
|
|
7
|
+
**適用場景**:參考完整實作範例、學習最佳實務、快速啟動新專案、程式碼審查參考、
|
|
8
|
+
架構設計模板、HTTP、gRPC、Database 整合範例。
|
|
9
|
+
|
|
10
|
+
**關鍵字**:examples, code examples, http client example, repository pattern,
|
|
11
|
+
use case example, handler example, service example, best practices, template
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Go 實作範例庫
|
|
15
|
+
|
|
16
|
+
> **注意**:本 Skill 提供完整的實作範例,展示最佳實務與常見模式
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## HTTP Client 範例
|
|
21
|
+
|
|
22
|
+
### 基礎 HTTP Client
|
|
23
|
+
|
|
24
|
+
```go
|
|
25
|
+
package httpclient
|
|
26
|
+
|
|
27
|
+
import (
|
|
28
|
+
"context"
|
|
29
|
+
"encoding/json"
|
|
30
|
+
"fmt"
|
|
31
|
+
"io"
|
|
32
|
+
"net/http"
|
|
33
|
+
"time"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
type Client struct {
|
|
37
|
+
baseURL string
|
|
38
|
+
httpClient *http.Client
|
|
39
|
+
apiKey string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func NewClient(baseURL string, apiKey string) *Client {
|
|
43
|
+
return &Client{
|
|
44
|
+
baseURL: baseURL,
|
|
45
|
+
apiKey: apiKey,
|
|
46
|
+
httpClient: &http.Client{
|
|
47
|
+
Timeout: 30 * time.Second,
|
|
48
|
+
Transport: &http.Transport{
|
|
49
|
+
MaxIdleConns: 100,
|
|
50
|
+
MaxIdleConnsPerHost: 10,
|
|
51
|
+
IdleConnTimeout: 90 * time.Second,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func (c *Client) GetUser(ctx context.Context, userID string) (*User, error) {
|
|
58
|
+
url := fmt.Sprintf("%s/users/%s", c.baseURL, userID)
|
|
59
|
+
|
|
60
|
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
61
|
+
if err != nil {
|
|
62
|
+
return nil, fmt.Errorf("create request: %w", err)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
66
|
+
req.Header.Set("Accept", "application/json")
|
|
67
|
+
|
|
68
|
+
resp, err := c.httpClient.Do(req)
|
|
69
|
+
if err != nil {
|
|
70
|
+
return nil, fmt.Errorf("do request: %w", err)
|
|
71
|
+
}
|
|
72
|
+
defer resp.Body.Close()
|
|
73
|
+
|
|
74
|
+
if resp.StatusCode != http.StatusOK {
|
|
75
|
+
body, _ := io.ReadAll(resp.Body)
|
|
76
|
+
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
var user User
|
|
80
|
+
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
|
81
|
+
return nil, fmt.Errorf("decode response: %w", err)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return &user, nil
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func (c *Client) CreateUser(ctx context.Context, req *CreateUserRequest) (*User, error) {
|
|
88
|
+
url := fmt.Sprintf("%s/users", c.baseURL)
|
|
89
|
+
|
|
90
|
+
body, err := json.Marshal(req)
|
|
91
|
+
if err != nil {
|
|
92
|
+
return nil, fmt.Errorf("marshal request: %w", err)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
96
|
+
if err != nil {
|
|
97
|
+
return nil, fmt.Errorf("create request: %w", err)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
101
|
+
httpReq.Header.Set("Content-Type", "application/json")
|
|
102
|
+
httpReq.Header.Set("Accept", "application/json")
|
|
103
|
+
|
|
104
|
+
resp, err := c.httpClient.Do(httpReq)
|
|
105
|
+
if err != nil {
|
|
106
|
+
return nil, fmt.Errorf("do request: %w", err)
|
|
107
|
+
}
|
|
108
|
+
defer resp.Body.Close()
|
|
109
|
+
|
|
110
|
+
if resp.StatusCode != http.StatusCreated {
|
|
111
|
+
body, _ := io.ReadAll(resp.Body)
|
|
112
|
+
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
var user User
|
|
116
|
+
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
|
117
|
+
return nil, fmt.Errorf("decode response: %w", err)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return &user, nil
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Repository Pattern 範例
|
|
127
|
+
|
|
128
|
+
### Interface 定義
|
|
129
|
+
|
|
130
|
+
```go
|
|
131
|
+
package repository
|
|
132
|
+
|
|
133
|
+
import "context"
|
|
134
|
+
|
|
135
|
+
type UserRepository interface {
|
|
136
|
+
Create(ctx context.Context, user *User) error
|
|
137
|
+
Update(ctx context.Context, user *User) error
|
|
138
|
+
Delete(ctx context.Context, id int64) error
|
|
139
|
+
FindByID(ctx context.Context, id int64) (*User, error)
|
|
140
|
+
FindByEmail(ctx context.Context, email string) (*User, error)
|
|
141
|
+
List(ctx context.Context, filters ListFilters) ([]*User, error)
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### PostgreSQL 實作
|
|
146
|
+
|
|
147
|
+
```go
|
|
148
|
+
package repository
|
|
149
|
+
|
|
150
|
+
import (
|
|
151
|
+
"context"
|
|
152
|
+
"database/sql"
|
|
153
|
+
"errors"
|
|
154
|
+
"fmt"
|
|
155
|
+
"time"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
type postgresUserRepository struct {
|
|
159
|
+
db *sql.DB
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func NewPostgresUserRepository(db *sql.DB) UserRepository {
|
|
163
|
+
return &postgresUserRepository{db: db}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func (r *postgresUserRepository) Create(ctx context.Context, user *User) error {
|
|
167
|
+
query := `
|
|
168
|
+
INSERT INTO users (email, name, password_hash, created_at, updated_at)
|
|
169
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
170
|
+
RETURNING id
|
|
171
|
+
`
|
|
172
|
+
|
|
173
|
+
now := time.Now()
|
|
174
|
+
err := r.db.QueryRowContext(
|
|
175
|
+
ctx, query,
|
|
176
|
+
user.Email, user.Name, user.PasswordHash, now, now,
|
|
177
|
+
).Scan(&user.ID)
|
|
178
|
+
|
|
179
|
+
if err != nil {
|
|
180
|
+
return fmt.Errorf("insert user: %w", err)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
user.CreatedAt = now
|
|
184
|
+
user.UpdatedAt = now
|
|
185
|
+
|
|
186
|
+
return nil
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func (r *postgresUserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
|
|
190
|
+
query := `
|
|
191
|
+
SELECT id, email, name, password_hash, created_at, updated_at
|
|
192
|
+
FROM users
|
|
193
|
+
WHERE id = $1
|
|
194
|
+
`
|
|
195
|
+
|
|
196
|
+
var user User
|
|
197
|
+
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
|
198
|
+
&user.ID, &user.Email, &user.Name, &user.PasswordHash,
|
|
199
|
+
&user.CreatedAt, &user.UpdatedAt,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if err != nil {
|
|
203
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
204
|
+
return nil, ErrUserNotFound
|
|
205
|
+
}
|
|
206
|
+
return nil, fmt.Errorf("query user: %w", err)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return &user, nil
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func (r *postgresUserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
|
|
213
|
+
query := `
|
|
214
|
+
SELECT id, email, name, password_hash, created_at, updated_at
|
|
215
|
+
FROM users
|
|
216
|
+
WHERE email = $1
|
|
217
|
+
`
|
|
218
|
+
|
|
219
|
+
var user User
|
|
220
|
+
err := r.db.QueryRowContext(ctx, query, email).Scan(
|
|
221
|
+
&user.ID, &user.Email, &user.Name, &user.PasswordHash,
|
|
222
|
+
&user.CreatedAt, &user.UpdatedAt,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if err != nil {
|
|
226
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
227
|
+
return nil, ErrUserNotFound
|
|
228
|
+
}
|
|
229
|
+
return nil, fmt.Errorf("query user: %w", err)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return &user, nil
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func (r *postgresUserRepository) Update(ctx context.Context, user *User) error {
|
|
236
|
+
query := `
|
|
237
|
+
UPDATE users
|
|
238
|
+
SET name = $1, updated_at = $2
|
|
239
|
+
WHERE id = $3
|
|
240
|
+
`
|
|
241
|
+
|
|
242
|
+
now := time.Now()
|
|
243
|
+
result, err := r.db.ExecContext(ctx, query, user.Name, now, user.ID)
|
|
244
|
+
if err != nil {
|
|
245
|
+
return fmt.Errorf("update user: %w", err)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
rowsAffected, err := result.RowsAffected()
|
|
249
|
+
if err != nil {
|
|
250
|
+
return fmt.Errorf("get rows affected: %w", err)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if rowsAffected == 0 {
|
|
254
|
+
return ErrUserNotFound
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
user.UpdatedAt = now
|
|
258
|
+
return nil
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func (r *postgresUserRepository) Delete(ctx context.Context, id int64) error {
|
|
262
|
+
query := `DELETE FROM users WHERE id = $1`
|
|
263
|
+
|
|
264
|
+
result, err := r.db.ExecContext(ctx, query, id)
|
|
265
|
+
if err != nil {
|
|
266
|
+
return fmt.Errorf("delete user: %w", err)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
rowsAffected, err := result.RowsAffected()
|
|
270
|
+
if err != nil {
|
|
271
|
+
return fmt.Errorf("get rows affected: %w", err)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if rowsAffected == 0 {
|
|
275
|
+
return ErrUserNotFound
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return nil
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func (r *postgresUserRepository) List(ctx context.Context, filters ListFilters) ([]*User, error) {
|
|
282
|
+
query := `
|
|
283
|
+
SELECT id, email, name, password_hash, created_at, updated_at
|
|
284
|
+
FROM users
|
|
285
|
+
WHERE 1=1
|
|
286
|
+
`
|
|
287
|
+
args := []interface{}{}
|
|
288
|
+
argPos := 1
|
|
289
|
+
|
|
290
|
+
if filters.Email != "" {
|
|
291
|
+
query += fmt.Sprintf(" AND email LIKE $%d", argPos)
|
|
292
|
+
args = append(args, "%"+filters.Email+"%")
|
|
293
|
+
argPos++
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
query += " ORDER BY created_at DESC"
|
|
297
|
+
|
|
298
|
+
if filters.Limit > 0 {
|
|
299
|
+
query += fmt.Sprintf(" LIMIT $%d", argPos)
|
|
300
|
+
args = append(args, filters.Limit)
|
|
301
|
+
argPos++
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if filters.Offset > 0 {
|
|
305
|
+
query += fmt.Sprintf(" OFFSET $%d", argPos)
|
|
306
|
+
args = append(args, filters.Offset)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
rows, err := r.db.QueryContext(ctx, query, args...)
|
|
310
|
+
if err != nil {
|
|
311
|
+
return nil, fmt.Errorf("query users: %w", err)
|
|
312
|
+
}
|
|
313
|
+
defer rows.Close()
|
|
314
|
+
|
|
315
|
+
var users []*User
|
|
316
|
+
for rows.Next() {
|
|
317
|
+
var user User
|
|
318
|
+
if err := rows.Scan(
|
|
319
|
+
&user.ID, &user.Email, &user.Name, &user.PasswordHash,
|
|
320
|
+
&user.CreatedAt, &user.UpdatedAt,
|
|
321
|
+
); err != nil {
|
|
322
|
+
return nil, fmt.Errorf("scan user: %w", err)
|
|
323
|
+
}
|
|
324
|
+
users = append(users, &user)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if err := rows.Err(); err != nil {
|
|
328
|
+
return nil, fmt.Errorf("rows error: %w", err)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return users, nil
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## Use Case 範例
|
|
338
|
+
|
|
339
|
+
### 建立使用者 Use Case
|
|
340
|
+
|
|
341
|
+
```go
|
|
342
|
+
package usecase
|
|
343
|
+
|
|
344
|
+
import (
|
|
345
|
+
"context"
|
|
346
|
+
"errors"
|
|
347
|
+
"fmt"
|
|
348
|
+
"go.uber.org/zap"
|
|
349
|
+
"golang.org/x/crypto/bcrypt"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
type CreateUserUseCase struct {
|
|
353
|
+
userRepo UserRepository
|
|
354
|
+
eventBus EventBus
|
|
355
|
+
logger *zap.Logger
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
func NewCreateUserUseCase(
|
|
359
|
+
userRepo UserRepository,
|
|
360
|
+
eventBus EventBus,
|
|
361
|
+
logger *zap.Logger,
|
|
362
|
+
) *CreateUserUseCase {
|
|
363
|
+
return &CreateUserUseCase{
|
|
364
|
+
userRepo: userRepo,
|
|
365
|
+
eventBus: eventBus,
|
|
366
|
+
logger: logger,
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
func (uc *CreateUserUseCase) Execute(ctx context.Context, req *CreateUserRequest) (*User, error) {
|
|
371
|
+
// 1. 驗證請求
|
|
372
|
+
if err := req.Validate(); err != nil {
|
|
373
|
+
return nil, fmt.Errorf("validate request: %w", err)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 2. 檢查 Email 是否已存在
|
|
377
|
+
existingUser, err := uc.userRepo.FindByEmail(ctx, req.Email)
|
|
378
|
+
if err != nil && !errors.Is(err, ErrUserNotFound) {
|
|
379
|
+
return nil, fmt.Errorf("check existing user: %w", err)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if existingUser != nil {
|
|
383
|
+
return nil, ErrEmailAlreadyExists
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 3. Hash 密碼
|
|
387
|
+
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
388
|
+
if err != nil {
|
|
389
|
+
return nil, fmt.Errorf("hash password: %w", err)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 4. 建立使用者
|
|
393
|
+
user := &User{
|
|
394
|
+
Email: req.Email,
|
|
395
|
+
Name: req.Name,
|
|
396
|
+
PasswordHash: string(passwordHash),
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if err := uc.userRepo.Create(ctx, user); err != nil {
|
|
400
|
+
return nil, fmt.Errorf("create user: %w", err)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 5. 發布 Domain Event
|
|
404
|
+
event := NewUserCreated(user.ID, user.Email, user.Name)
|
|
405
|
+
if err := uc.eventBus.Publish(ctx, event); err != nil {
|
|
406
|
+
uc.logger.Error("failed to publish event",
|
|
407
|
+
zap.Error(err),
|
|
408
|
+
zap.Int64("user_id", user.ID),
|
|
409
|
+
)
|
|
410
|
+
// 不要因為事件發布失敗而回滾整個操作
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
uc.logger.Info("user created",
|
|
414
|
+
zap.Int64("user_id", user.ID),
|
|
415
|
+
zap.String("email", user.Email),
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
return user, nil
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
## HTTP Handler 範例
|
|
425
|
+
|
|
426
|
+
### RESTful API Handler
|
|
427
|
+
|
|
428
|
+
```go
|
|
429
|
+
package handler
|
|
430
|
+
|
|
431
|
+
import (
|
|
432
|
+
"encoding/json"
|
|
433
|
+
"errors"
|
|
434
|
+
"net/http"
|
|
435
|
+
"strconv"
|
|
436
|
+
"go.uber.org/zap"
|
|
437
|
+
"github.com/gorilla/mux"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
type UserHandler struct {
|
|
441
|
+
createUserUC *CreateUserUseCase
|
|
442
|
+
getUserUC *GetUserUseCase
|
|
443
|
+
listUsersUC *ListUsersUseCase
|
|
444
|
+
logger *zap.Logger
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
func NewUserHandler(
|
|
448
|
+
createUserUC *CreateUserUseCase,
|
|
449
|
+
getUserUC *GetUserUseCase,
|
|
450
|
+
listUsersUC *ListUsersUseCase,
|
|
451
|
+
logger *zap.Logger,
|
|
452
|
+
) *UserHandler {
|
|
453
|
+
return &UserHandler{
|
|
454
|
+
createUserUC: createUserUC,
|
|
455
|
+
getUserUC: getUserUC,
|
|
456
|
+
listUsersUC: listUsersUC,
|
|
457
|
+
logger: logger,
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
func (h *UserHandler) RegisterRoutes(r *mux.Router) {
|
|
462
|
+
r.HandleFunc("/users", h.CreateUser).Methods(http.MethodPost)
|
|
463
|
+
r.HandleFunc("/users/{id}", h.GetUser).Methods(http.MethodGet)
|
|
464
|
+
r.HandleFunc("/users", h.ListUsers).Methods(http.MethodGet)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|
468
|
+
var req CreateUserRequest
|
|
469
|
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
470
|
+
h.respondError(w, http.StatusBadRequest, "INVALID_REQUEST", "Invalid JSON", err.Error())
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
user, err := h.createUserUC.Execute(r.Context(), &req)
|
|
475
|
+
if err != nil {
|
|
476
|
+
h.handleUseCaseError(w, err)
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
h.respondJSON(w, http.StatusCreated, ToUserResponse(user))
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
|
|
484
|
+
vars := mux.Vars(r)
|
|
485
|
+
idStr := vars["id"]
|
|
486
|
+
|
|
487
|
+
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
488
|
+
if err != nil {
|
|
489
|
+
h.respondError(w, http.StatusBadRequest, "INVALID_ID", "Invalid user ID", "")
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
user, err := h.getUserUC.Execute(r.Context(), id)
|
|
494
|
+
if err != nil {
|
|
495
|
+
h.handleUseCaseError(w, err)
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
h.respondJSON(w, http.StatusOK, ToUserResponse(user))
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
|
503
|
+
email := r.URL.Query().Get("email")
|
|
504
|
+
limit := parseIntDefault(r.URL.Query().Get("limit"), 20)
|
|
505
|
+
offset := parseIntDefault(r.URL.Query().Get("offset"), 0)
|
|
506
|
+
|
|
507
|
+
filters := ListFilters{
|
|
508
|
+
Email: email,
|
|
509
|
+
Limit: limit,
|
|
510
|
+
Offset: offset,
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
users, err := h.listUsersUC.Execute(r.Context(), filters)
|
|
514
|
+
if err != nil {
|
|
515
|
+
h.handleUseCaseError(w, err)
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
response := make([]*UserResponse, len(users))
|
|
520
|
+
for i, user := range users {
|
|
521
|
+
response[i] = ToUserResponse(user)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
h.respondJSON(w, http.StatusOK, response)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
func (h *UserHandler) handleUseCaseError(w http.ResponseWriter, err error) {
|
|
528
|
+
switch {
|
|
529
|
+
case errors.Is(err, ErrUserNotFound):
|
|
530
|
+
h.respondError(w, http.StatusNotFound, "USER_NOT_FOUND", "User not found", "")
|
|
531
|
+
case errors.Is(err, ErrEmailAlreadyExists):
|
|
532
|
+
h.respondError(w, http.StatusConflict, "EMAIL_EXISTS", "Email already exists", "")
|
|
533
|
+
case errors.Is(err, ErrValidation):
|
|
534
|
+
h.respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error(), "")
|
|
535
|
+
default:
|
|
536
|
+
h.logger.Error("unexpected error", zap.Error(err))
|
|
537
|
+
h.respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "")
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
func (h *UserHandler) respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
|
542
|
+
w.Header().Set("Content-Type", "application/json")
|
|
543
|
+
w.WriteHeader(status)
|
|
544
|
+
json.NewEncoder(w).Encode(SuccessResponse{Data: data})
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
func (h *UserHandler) respondError(w http.ResponseWriter, status int, code, message, details string) {
|
|
548
|
+
w.Header().Set("Content-Type", "application/json")
|
|
549
|
+
w.WriteHeader(status)
|
|
550
|
+
json.NewEncoder(w).Encode(ErrorResponse{
|
|
551
|
+
Error: &APIError{
|
|
552
|
+
Code: code,
|
|
553
|
+
Message: message,
|
|
554
|
+
Details: details,
|
|
555
|
+
},
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
562
|
+
## Service 完整範例
|
|
563
|
+
|
|
564
|
+
### 主程式(main.go)
|
|
565
|
+
|
|
566
|
+
```go
|
|
567
|
+
package main
|
|
568
|
+
|
|
569
|
+
import (
|
|
570
|
+
"context"
|
|
571
|
+
"database/sql"
|
|
572
|
+
"fmt"
|
|
573
|
+
"net/http"
|
|
574
|
+
"os"
|
|
575
|
+
"os/signal"
|
|
576
|
+
"syscall"
|
|
577
|
+
"time"
|
|
578
|
+
|
|
579
|
+
"go.uber.org/zap"
|
|
580
|
+
_ "github.com/lib/pq"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
func main() {
|
|
584
|
+
// 1. 初始化 Logger
|
|
585
|
+
logger, _ := zap.NewProduction()
|
|
586
|
+
defer logger.Sync()
|
|
587
|
+
|
|
588
|
+
// 2. 載入設定
|
|
589
|
+
cfg, err := config.Load()
|
|
590
|
+
if err != nil {
|
|
591
|
+
logger.Fatal("failed to load config", zap.Error(err))
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 3. 連線資料庫
|
|
595
|
+
db, err := sql.Open("postgres", cfg.Database.DSN())
|
|
596
|
+
if err != nil {
|
|
597
|
+
logger.Fatal("failed to connect database", zap.Error(err))
|
|
598
|
+
}
|
|
599
|
+
defer db.Close()
|
|
600
|
+
|
|
601
|
+
if err := db.Ping(); err != nil {
|
|
602
|
+
logger.Fatal("failed to ping database", zap.Error(err))
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// 4. 初始化 Repository
|
|
606
|
+
userRepo := repository.NewPostgresUserRepository(db)
|
|
607
|
+
|
|
608
|
+
// 5. 初始化 Event Bus
|
|
609
|
+
eventBus := eventbus.NewEventBus()
|
|
610
|
+
|
|
611
|
+
// 6. 初始化 Use Cases
|
|
612
|
+
createUserUC := usecase.NewCreateUserUseCase(userRepo, eventBus, logger)
|
|
613
|
+
getUserUC := usecase.NewGetUserUseCase(userRepo, logger)
|
|
614
|
+
listUsersUC := usecase.NewListUsersUseCase(userRepo, logger)
|
|
615
|
+
|
|
616
|
+
// 7. 初始化 Handler
|
|
617
|
+
userHandler := handler.NewUserHandler(createUserUC, getUserUC, listUsersUC, logger)
|
|
618
|
+
|
|
619
|
+
// 8. 建立 Router
|
|
620
|
+
router := mux.NewRouter()
|
|
621
|
+
userHandler.RegisterRoutes(router.PathPrefix("/api/v1").Subrouter())
|
|
622
|
+
|
|
623
|
+
// 9. 建立 HTTP Server
|
|
624
|
+
srv := &http.Server{
|
|
625
|
+
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
|
626
|
+
Handler: router,
|
|
627
|
+
ReadTimeout: 15 * time.Second,
|
|
628
|
+
WriteTimeout: 15 * time.Second,
|
|
629
|
+
IdleTimeout: 60 * time.Second,
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// 10. 啟動 Server(Goroutine)
|
|
633
|
+
go func() {
|
|
634
|
+
logger.Info("starting server", zap.Int("port", cfg.Server.Port))
|
|
635
|
+
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
636
|
+
logger.Fatal("server failed", zap.Error(err))
|
|
637
|
+
}
|
|
638
|
+
}()
|
|
639
|
+
|
|
640
|
+
// 11. 優雅關機
|
|
641
|
+
quit := make(chan os.Signal, 1)
|
|
642
|
+
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
643
|
+
<-quit
|
|
644
|
+
|
|
645
|
+
logger.Info("shutting down server...")
|
|
646
|
+
|
|
647
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
648
|
+
defer cancel()
|
|
649
|
+
|
|
650
|
+
if err := srv.Shutdown(ctx); err != nil {
|
|
651
|
+
logger.Fatal("server forced to shutdown", zap.Error(err))
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
logger.Info("server exited")
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
---
|
|
659
|
+
|
|
660
|
+
## 檢查清單
|
|
661
|
+
|
|
662
|
+
**HTTP Client**
|
|
663
|
+
- [ ] 重用 `http.Client`
|
|
664
|
+
- [ ] 設定逾時與 Transport 配置
|
|
665
|
+
- [ ] 處理錯誤狀態碼
|
|
666
|
+
- [ ] 嚴格 `defer resp.Body.Close()`
|
|
667
|
+
|
|
668
|
+
**Repository**
|
|
669
|
+
- [ ] Interface 定義在 Domain/Use Case 層
|
|
670
|
+
- [ ] 實作在 Infrastructure 層
|
|
671
|
+
- [ ] 錯誤處理(區分 NotFound 與其他錯誤)
|
|
672
|
+
- [ ] 使用 Context 傳遞
|
|
673
|
+
|
|
674
|
+
**Use Case**
|
|
675
|
+
- [ ] 包含業務邏輯驗證
|
|
676
|
+
- [ ] 依賴 Repository Interface
|
|
677
|
+
- [ ] 記錄日誌(成功與失敗)
|
|
678
|
+
- [ ] 發布 Domain Events
|
|
679
|
+
|
|
680
|
+
**Handler**
|
|
681
|
+
- [ ] 統一錯誤處理(`handleUseCaseError`)
|
|
682
|
+
- [ ] 使用 JSON Envelope 回應
|
|
683
|
+
- [ ] 參數驗證與類型轉換
|
|
684
|
+
- [ ] 正確的 HTTP 狀態碼
|
|
685
|
+
|
|
686
|
+
**Service**
|
|
687
|
+
- [ ] 依賴注入(避免全域變數)
|
|
688
|
+
- [ ] 優雅關機(Graceful Shutdown)
|
|
689
|
+
- [ ] 完整的錯誤處理與日誌
|
|
690
|
+
- [ ] 配置外部化(環境變數)
|