@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,535 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: go-api-design
|
|
3
|
+
description: |
|
|
4
|
+
Go API 設計與版本管理:JSON Envelope、Request/Response 模式、API Versioning、
|
|
5
|
+
Pagination、Filter、Sort、Swagger 文件、棄用通知、HTTP 狀態碼最佳實務。
|
|
6
|
+
|
|
7
|
+
**適用場景**:設計 RESTful API、實作 API 版本控制、定義統一回應格式、分頁與篩選、
|
|
8
|
+
撰寫 OpenAPI Spec、棄用 API 版本、錯誤碼定義。
|
|
9
|
+
|
|
10
|
+
**關鍵字**:api design, rest api, json envelope, api versioning, pagination, swagger,
|
|
11
|
+
openapi, deprecation, http status code, response format, api best practices
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Go API 設計與版本管理規範
|
|
15
|
+
|
|
16
|
+
> **相關 Skills**:本規範建議搭配 `go-core`(錯誤處理)與 `go-observability`(日誌)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## JSON Envelope 模式
|
|
21
|
+
|
|
22
|
+
### 統一回應格式
|
|
23
|
+
|
|
24
|
+
```go
|
|
25
|
+
// 成功回應
|
|
26
|
+
type SuccessResponse struct {
|
|
27
|
+
Data interface{} `json:"data"`
|
|
28
|
+
Meta *Meta `json:"meta,omitempty"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Meta struct {
|
|
32
|
+
RequestID string `json:"request_id"`
|
|
33
|
+
Timestamp int64 `json:"timestamp"`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 錯誤回應
|
|
37
|
+
type ErrorResponse struct {
|
|
38
|
+
Error *APIError `json:"error"`
|
|
39
|
+
Meta *Meta `json:"meta,omitempty"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type APIError struct {
|
|
43
|
+
Code string `json:"code"` // 業務錯誤碼
|
|
44
|
+
Message string `json:"message"` // 使用者可讀訊息
|
|
45
|
+
Details string `json:"details,omitempty"` // 詳細錯誤(開發用)
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 回應範例
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
// 成功
|
|
53
|
+
{
|
|
54
|
+
"data": {
|
|
55
|
+
"id": "123",
|
|
56
|
+
"name": "John"
|
|
57
|
+
},
|
|
58
|
+
"meta": {
|
|
59
|
+
"request_id": "abc-123",
|
|
60
|
+
"timestamp": 1234567890
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 錯誤
|
|
65
|
+
{
|
|
66
|
+
"error": {
|
|
67
|
+
"code": "USER_NOT_FOUND",
|
|
68
|
+
"message": "User with ID 123 not found",
|
|
69
|
+
"details": "查詢資料庫時發現該使用者不存在"
|
|
70
|
+
},
|
|
71
|
+
"meta": {
|
|
72
|
+
"request_id": "abc-123",
|
|
73
|
+
"timestamp": 1234567890
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 輔助函式
|
|
79
|
+
|
|
80
|
+
```go
|
|
81
|
+
func RespondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
|
|
82
|
+
w.Header().Set("Content-Type", "application/json")
|
|
83
|
+
w.WriteHeader(statusCode)
|
|
84
|
+
|
|
85
|
+
resp := SuccessResponse{
|
|
86
|
+
Data: data,
|
|
87
|
+
Meta: &Meta{
|
|
88
|
+
RequestID: GetRequestID(r.Context()),
|
|
89
|
+
Timestamp: time.Now().Unix(),
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
json.NewEncoder(w).Encode(resp)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func RespondError(w http.ResponseWriter, statusCode int, code, message, details string) {
|
|
97
|
+
w.Header().Set("Content-Type", "application/json")
|
|
98
|
+
w.WriteHeader(statusCode)
|
|
99
|
+
|
|
100
|
+
resp := ErrorResponse{
|
|
101
|
+
Error: &APIError{
|
|
102
|
+
Code: code,
|
|
103
|
+
Message: message,
|
|
104
|
+
Details: details,
|
|
105
|
+
},
|
|
106
|
+
Meta: &Meta{
|
|
107
|
+
RequestID: GetRequestID(r.Context()),
|
|
108
|
+
Timestamp: time.Now().Unix(),
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
json.NewEncoder(w).Encode(resp)
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## API 版本管理
|
|
119
|
+
|
|
120
|
+
### 版本策略
|
|
121
|
+
|
|
122
|
+
**路徑版本**(推薦):
|
|
123
|
+
```
|
|
124
|
+
/api/v1/users
|
|
125
|
+
/api/v2/users
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Header 版本**(進階):
|
|
129
|
+
```
|
|
130
|
+
GET /api/users
|
|
131
|
+
Accept: application/vnd.myapp.v2+json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 實作範例
|
|
135
|
+
|
|
136
|
+
```go
|
|
137
|
+
func NewRouter() *mux.Router {
|
|
138
|
+
r := mux.NewRouter()
|
|
139
|
+
|
|
140
|
+
// V1 Routes
|
|
141
|
+
v1 := r.PathPrefix("/api/v1").Subrouter()
|
|
142
|
+
v1.HandleFunc("/users", v1.ListUsers).Methods("GET")
|
|
143
|
+
v1.HandleFunc("/users/{id}", v1.GetUser).Methods("GET")
|
|
144
|
+
|
|
145
|
+
// V2 Routes
|
|
146
|
+
v2 := r.PathPrefix("/api/v2").Subrouter()
|
|
147
|
+
v2.HandleFunc("/users", v2.ListUsers).Methods("GET")
|
|
148
|
+
v2.HandleFunc("/users/{id}", v2.GetUser).Methods("GET")
|
|
149
|
+
|
|
150
|
+
return r
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 棄用通知
|
|
155
|
+
|
|
156
|
+
**HTTP Header**:
|
|
157
|
+
```go
|
|
158
|
+
func DeprecatedMiddleware(deprecatedAt time.Time, sunsetAt time.Time) Middleware {
|
|
159
|
+
return func(next http.Handler) http.Handler {
|
|
160
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
161
|
+
// RFC 8594
|
|
162
|
+
w.Header().Set("Deprecation", deprecatedAt.Format(http.TimeFormat))
|
|
163
|
+
w.Header().Set("Sunset", sunsetAt.Format(http.TimeFormat))
|
|
164
|
+
w.Header().Set("Link", `</api/v2>; rel="successor-version"`)
|
|
165
|
+
|
|
166
|
+
next.ServeHTTP(w, r)
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**回應 Body 包含警告**:
|
|
173
|
+
```go
|
|
174
|
+
type SuccessResponse struct {
|
|
175
|
+
Data interface{} `json:"data"`
|
|
176
|
+
Meta *Meta `json:"meta,omitempty"`
|
|
177
|
+
Warnings []string `json:"warnings,omitempty"`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 使用範例
|
|
181
|
+
resp := SuccessResponse{
|
|
182
|
+
Data: users,
|
|
183
|
+
Warnings: []string{
|
|
184
|
+
"This API version is deprecated. Please migrate to /api/v2 before 2024-12-31",
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Pagination 與篩選
|
|
192
|
+
|
|
193
|
+
### Cursor-Based Pagination(推薦)
|
|
194
|
+
|
|
195
|
+
**適用**:大數據集、即時插入/刪除頻繁
|
|
196
|
+
|
|
197
|
+
```go
|
|
198
|
+
type PaginationRequest struct {
|
|
199
|
+
Cursor string `json:"cursor"`
|
|
200
|
+
Limit int `json:"limit"`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type PaginationResponse struct {
|
|
204
|
+
Items interface{} `json:"items"`
|
|
205
|
+
NextCursor string `json:"next_cursor,omitempty"`
|
|
206
|
+
HasMore bool `json:"has_more"`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
func (s *UserService) ListUsers(ctx context.Context, req PaginationRequest) (*PaginationResponse, error) {
|
|
210
|
+
if req.Limit <= 0 || req.Limit > 100 {
|
|
211
|
+
req.Limit = 20
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 解碼 Cursor(例如:Base64(ID))
|
|
215
|
+
var lastID int64
|
|
216
|
+
if req.Cursor != "" {
|
|
217
|
+
decoded, _ := base64.StdEncoding.DecodeString(req.Cursor)
|
|
218
|
+
lastID, _ = strconv.ParseInt(string(decoded), 10, 64)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 查詢(多拿 1 筆判斷是否有下一頁)
|
|
222
|
+
users, err := s.repo.FindUsers(ctx, lastID, req.Limit+1)
|
|
223
|
+
if err != nil {
|
|
224
|
+
return nil, err
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
hasMore := len(users) > req.Limit
|
|
228
|
+
if hasMore {
|
|
229
|
+
users = users[:req.Limit]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 產生下一頁 Cursor
|
|
233
|
+
var nextCursor string
|
|
234
|
+
if hasMore {
|
|
235
|
+
lastUser := users[len(users)-1]
|
|
236
|
+
nextCursor = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", lastUser.ID)))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return &PaginationResponse{
|
|
240
|
+
Items: users,
|
|
241
|
+
NextCursor: nextCursor,
|
|
242
|
+
HasMore: hasMore,
|
|
243
|
+
}, nil
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Offset-Based Pagination(簡單場景)
|
|
248
|
+
|
|
249
|
+
```go
|
|
250
|
+
type OffsetPaginationRequest struct {
|
|
251
|
+
Page int `json:"page"` // 從 1 開始
|
|
252
|
+
PageSize int `json:"page_size"`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
type OffsetPaginationResponse struct {
|
|
256
|
+
Items interface{} `json:"items"`
|
|
257
|
+
TotalCount int `json:"total_count"`
|
|
258
|
+
Page int `json:"page"`
|
|
259
|
+
PageSize int `json:"page_size"`
|
|
260
|
+
TotalPages int `json:"total_pages"`
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### 篩選與排序
|
|
265
|
+
|
|
266
|
+
```go
|
|
267
|
+
type ListUsersRequest struct {
|
|
268
|
+
// Pagination
|
|
269
|
+
Cursor string `json:"cursor"`
|
|
270
|
+
Limit int `json:"limit"`
|
|
271
|
+
|
|
272
|
+
// Filters
|
|
273
|
+
Status string `json:"status"` // e.g., "active", "inactive"
|
|
274
|
+
CreatedAt string `json:"created_at"` // e.g., "2024-01-01..2024-12-31"
|
|
275
|
+
|
|
276
|
+
// Sorting
|
|
277
|
+
SortBy string `json:"sort_by"` // e.g., "created_at", "name"
|
|
278
|
+
SortOrder string `json:"sort_order"` // "asc" or "desc"
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func ParseFilters(r *http.Request) (*ListUsersRequest, error) {
|
|
282
|
+
req := &ListUsersRequest{
|
|
283
|
+
Cursor: r.URL.Query().Get("cursor"),
|
|
284
|
+
Limit: parseIntDefault(r.URL.Query().Get("limit"), 20),
|
|
285
|
+
Status: r.URL.Query().Get("status"),
|
|
286
|
+
CreatedAt: r.URL.Query().Get("created_at"),
|
|
287
|
+
SortBy: r.URL.Query().Get("sort_by"),
|
|
288
|
+
SortOrder: r.URL.Query().Get("sort_order"),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Validation
|
|
292
|
+
if req.SortOrder != "" && req.SortOrder != "asc" && req.SortOrder != "desc" {
|
|
293
|
+
return nil, errors.New("invalid sort_order")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return req, nil
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Swagger / OpenAPI
|
|
303
|
+
|
|
304
|
+
### Annotation 範例(swaggo/swag)
|
|
305
|
+
|
|
306
|
+
```go
|
|
307
|
+
// ListUsers godoc
|
|
308
|
+
// @Summary 列出使用者
|
|
309
|
+
// @Description 支援分頁、篩選、排序
|
|
310
|
+
// @Tags users
|
|
311
|
+
// @Accept json
|
|
312
|
+
// @Produce json
|
|
313
|
+
// @Param cursor query string false "分頁 Cursor"
|
|
314
|
+
// @Param limit query int false "每頁筆數" default(20) maximum(100)
|
|
315
|
+
// @Param status query string false "狀態篩選" Enums(active, inactive)
|
|
316
|
+
// @Success 200 {object} PaginationResponse{items=[]User}
|
|
317
|
+
// @Failure 400 {object} ErrorResponse
|
|
318
|
+
// @Failure 500 {object} ErrorResponse
|
|
319
|
+
// @Router /api/v1/users [get]
|
|
320
|
+
// @Security BearerAuth
|
|
321
|
+
func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
|
322
|
+
// ...
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### 生成文件
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
# 安裝 swag
|
|
330
|
+
go install github.com/swaggo/swag/cmd/swag@latest
|
|
331
|
+
|
|
332
|
+
# 生成 docs/
|
|
333
|
+
swag init --generalInfo cmd/server/main.go
|
|
334
|
+
|
|
335
|
+
# 整合 Swagger UI
|
|
336
|
+
import _ "myapp/docs"
|
|
337
|
+
|
|
338
|
+
func main() {
|
|
339
|
+
r := mux.NewRouter()
|
|
340
|
+
|
|
341
|
+
// Swagger JSON
|
|
342
|
+
r.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler)
|
|
343
|
+
|
|
344
|
+
http.ListenAndServe(":8080", r)
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## HTTP 狀態碼最佳實務
|
|
351
|
+
|
|
352
|
+
### 常用狀態碼
|
|
353
|
+
|
|
354
|
+
| 狀態碼 | 意義 | 使用時機 |
|
|
355
|
+
|--------|------|----------|
|
|
356
|
+
| 200 OK | 成功 | GET、PUT、PATCH 成功 |
|
|
357
|
+
| 201 Created | 已建立 | POST 成功建立資源 |
|
|
358
|
+
| 204 No Content | 無內容 | DELETE 成功 |
|
|
359
|
+
| 400 Bad Request | 請求錯誤 | 參數驗證失敗 |
|
|
360
|
+
| 401 Unauthorized | 未授權 | Token 無效或缺少 |
|
|
361
|
+
| 403 Forbidden | 禁止存取 | 有權限但不允許操作 |
|
|
362
|
+
| 404 Not Found | 找不到 | 資源不存在 |
|
|
363
|
+
| 409 Conflict | 衝突 | 資源已存在、樂觀鎖衝突 |
|
|
364
|
+
| 422 Unprocessable Entity | 無法處理 | 業務邏輯驗證失敗 |
|
|
365
|
+
| 429 Too Many Requests | 請求過多 | Rate Limiting |
|
|
366
|
+
| 500 Internal Server Error | 伺服器錯誤 | 未預期的錯誤 |
|
|
367
|
+
| 503 Service Unavailable | 服務不可用 | 維護中、依賴服務失敗 |
|
|
368
|
+
|
|
369
|
+
### 錯誤碼設計
|
|
370
|
+
|
|
371
|
+
```go
|
|
372
|
+
// 錯誤碼常數
|
|
373
|
+
const (
|
|
374
|
+
// 4xx Client Errors
|
|
375
|
+
ErrCodeValidation = "VALIDATION_ERROR"
|
|
376
|
+
ErrCodeUnauthorized = "UNAUTHORIZED"
|
|
377
|
+
ErrCodeForbidden = "FORBIDDEN"
|
|
378
|
+
ErrCodeNotFound = "NOT_FOUND"
|
|
379
|
+
ErrCodeConflict = "CONFLICT"
|
|
380
|
+
ErrCodeRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
|
|
381
|
+
|
|
382
|
+
// 5xx Server Errors
|
|
383
|
+
ErrCodeInternal = "INTERNAL_ERROR"
|
|
384
|
+
ErrCodeDatabaseUnavailable = "DATABASE_UNAVAILABLE"
|
|
385
|
+
ErrCodeExternalService = "EXTERNAL_SERVICE_ERROR"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
// 自動映射 HTTP 狀態碼
|
|
389
|
+
func MapErrorToHTTPStatus(code string) int {
|
|
390
|
+
switch code {
|
|
391
|
+
case ErrCodeValidation:
|
|
392
|
+
return http.StatusBadRequest
|
|
393
|
+
case ErrCodeUnauthorized:
|
|
394
|
+
return http.StatusUnauthorized
|
|
395
|
+
case ErrCodeForbidden:
|
|
396
|
+
return http.StatusForbidden
|
|
397
|
+
case ErrCodeNotFound:
|
|
398
|
+
return http.StatusNotFound
|
|
399
|
+
case ErrCodeConflict:
|
|
400
|
+
return http.StatusConflict
|
|
401
|
+
case ErrCodeRateLimitExceeded:
|
|
402
|
+
return http.StatusTooManyRequests
|
|
403
|
+
default:
|
|
404
|
+
return http.StatusInternalServerError
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## Request/Response 模型
|
|
412
|
+
|
|
413
|
+
### DTO 設計原則
|
|
414
|
+
|
|
415
|
+
- **Input DTO**:用於接收請求、驗證
|
|
416
|
+
- **Output DTO**:用於回應、可包含計算欄位
|
|
417
|
+
- **Domain Model**:不應直接暴露
|
|
418
|
+
|
|
419
|
+
### 範例
|
|
420
|
+
|
|
421
|
+
```go
|
|
422
|
+
// Input DTO
|
|
423
|
+
type CreateUserRequest struct {
|
|
424
|
+
Email string `json:"email" validate:"required,email"`
|
|
425
|
+
Name string `json:"name" validate:"required,min=2,max=50"`
|
|
426
|
+
Password string `json:"password" validate:"required,min=8"`
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Output DTO
|
|
430
|
+
type UserResponse struct {
|
|
431
|
+
ID string `json:"id"`
|
|
432
|
+
Email string `json:"email"`
|
|
433
|
+
Name string `json:"name"`
|
|
434
|
+
CreatedAt time.Time `json:"created_at"`
|
|
435
|
+
UpdatedAt time.Time `json:"updated_at"`
|
|
436
|
+
// 不包含密碼!
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Domain Model
|
|
440
|
+
type User struct {
|
|
441
|
+
ID int64
|
|
442
|
+
Email string
|
|
443
|
+
Name string
|
|
444
|
+
PasswordHash string // 不應返回給前端
|
|
445
|
+
CreatedAt time.Time
|
|
446
|
+
UpdatedAt time.Time
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 轉換函式
|
|
450
|
+
func ToUserResponse(user *User) *UserResponse {
|
|
451
|
+
return &UserResponse{
|
|
452
|
+
ID: strconv.FormatInt(user.ID, 10),
|
|
453
|
+
Email: user.Email,
|
|
454
|
+
Name: user.Name,
|
|
455
|
+
CreatedAt: user.CreatedAt,
|
|
456
|
+
UpdatedAt: user.UpdatedAt,
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## Rate Limiting
|
|
464
|
+
|
|
465
|
+
### Token Bucket 實作
|
|
466
|
+
|
|
467
|
+
```go
|
|
468
|
+
import "golang.org/x/time/rate"
|
|
469
|
+
|
|
470
|
+
type RateLimiter struct {
|
|
471
|
+
limiter *rate.Limiter
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
func NewRateLimiter(requestsPerSecond int) *RateLimiter {
|
|
475
|
+
return &RateLimiter{
|
|
476
|
+
limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), requestsPerSecond),
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
|
|
481
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
482
|
+
if !rl.limiter.Allow() {
|
|
483
|
+
RespondError(w, http.StatusTooManyRequests,
|
|
484
|
+
ErrCodeRateLimitExceeded,
|
|
485
|
+
"Rate limit exceeded",
|
|
486
|
+
"請稍後再試",
|
|
487
|
+
)
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
next.ServeHTTP(w, r)
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## 檢查清單
|
|
499
|
+
|
|
500
|
+
**回應格式**
|
|
501
|
+
- [ ] 使用統一的 JSON Envelope(`data` / `error`)
|
|
502
|
+
- [ ] 包含 `meta`(request_id、timestamp)
|
|
503
|
+
- [ ] 錯誤回應包含業務錯誤碼(`code`)
|
|
504
|
+
- [ ] 訊息友善、可本地化
|
|
505
|
+
|
|
506
|
+
**版本管理**
|
|
507
|
+
- [ ] 使用路徑版本(`/api/v1`、`/api/v2`)
|
|
508
|
+
- [ ] 主版本號僅在 Breaking Changes 時遞增
|
|
509
|
+
- [ ] 提供棄用通知(`Deprecation` Header)
|
|
510
|
+
- [ ] 文件說明遷移路徑
|
|
511
|
+
|
|
512
|
+
**分頁與篩選**
|
|
513
|
+
- [ ] 大數據集使用 Cursor-Based Pagination
|
|
514
|
+
- [ ] Limit 設定上限(例如 100)
|
|
515
|
+
- [ ] 支援排序(`sort_by`、`sort_order`)
|
|
516
|
+
- [ ] 篩選參數驗證
|
|
517
|
+
|
|
518
|
+
**API 文件**
|
|
519
|
+
- [ ] 使用 Swagger/OpenAPI 3.0
|
|
520
|
+
- [ ] 每個 Endpoint 註解(Summary、Description、Tags)
|
|
521
|
+
- [ ] 包含請求/回應範例
|
|
522
|
+
- [ ] 文件與程式碼同步
|
|
523
|
+
|
|
524
|
+
**HTTP 狀態碼**
|
|
525
|
+
- [ ] 201 用於 POST 建立資源
|
|
526
|
+
- [ ] 204 用於 DELETE 成功
|
|
527
|
+
- [ ] 4xx 用於客戶端錯誤
|
|
528
|
+
- [ ] 5xx 用於伺服器錯誤
|
|
529
|
+
- [ ] 避免濫用 200 OK
|
|
530
|
+
|
|
531
|
+
**安全性**
|
|
532
|
+
- [ ] 敏感欄位不返回(密碼、Token)
|
|
533
|
+
- [ ] 使用 HTTPS
|
|
534
|
+
- [ ] 實作 Rate Limiting
|
|
535
|
+
- [ ] CORS 配置正確
|