From 4ccbbfbf1b9ab70e635fa72cd8460002e4e3dc78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=AD=E5=B7=9E=E6=98=8E=E5=A9=B3=E7=A7=91=E6=8A=80?= Date: Wed, 8 Jul 2026 19:36:43 +0800 Subject: [PATCH] fix full example login password flow --- CHANGELOG.md | 2 +- examples/README.md | 12 ++- examples/full/main.go | 62 ++++++++++++++-- examples/full/main_test.go | 148 +++++++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 examples/full/main_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e9025b8..b38abe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **M4 database 全局置换资源释放修复**:`SetDefaultManager` / `SetDefaultRedisManager` 替换全局默认 manager 时会关闭旧 DB/Redis manager,避免包级默认资源反复置换后连接池泄漏;App 初始化改用 `SwapDefaultManager` / `SwapDefaultRedisManager` 暂存旧资源,保证失败回滚仍能恢复旧默认资源。 - **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。 - **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost;`ValidateStruct(nil)` 直接返回 nil。 -- **M16 测试工具与脚手架边界修复**:`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache` 与 `MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码。 +- **M16 测试工具、脚手架与示例闭环修复**:`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache` 与 `MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码;`examples/full` 启动时初始化 `alice/secret`,登录校验 bcrypt 哈希,创建用户也保存哈希,避免示例首次运行无法登录或传播不验密/明文密码模式。 - **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。 - **M13 cron handler panic 未 recover 崩进程**(cron/cron.go):`RunTask` 与 `checkAndRun` 调度 goroutine 统一经新增 `executeTask(t)` 边界 `recover`,panic 转为 error(含 `debug.Stack` 调用栈)记入 `task.LastError` 并向上返回,不再终止进程。外侧 `defer wg.Done()`/`running` 守卫释放不受影响(recover 在边界内完成)。顺带修复 `RunTask` 手动路径此前只更 `LastRun/RunCount`、不记 `LastError` 的子问题(现与调度路径一致)。 diff --git a/examples/README.md b/examples/README.md index 90ac76c..4cf6dcf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,7 +24,7 @@ go run ./examples/minimal go run ./examples/full ``` -启动后自动迁移 user 表。接口: +启动后自动迁移 user 表,并初始化示例用户 `alice/secret`(密码以 bcrypt 哈希保存)。接口: | 方法 | 路径 | 说明 | 认证 | |---|---|---|---| @@ -39,4 +39,12 @@ curl -X POST http://localhost:8082/api/v1/login \ -d '{"username":"alice","password":"secret"}' ``` -> 示例代码为演示用途,密码明文存储、未做参数校验,生产环境请使用 bcrypt 哈希密码并配合 `validation` 包校验入参。 +创建用户示例: +```bash +curl -X POST http://localhost:8082/api/v1/users \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"username":"bob","password":"s3cret"}' +``` + +> 示例代码为演示用途,已演示 bcrypt 密码哈希与登录校验;生产环境仍需配合 `validation` 包完善入参校验、密码强度策略与用户注册流程。 diff --git a/examples/full/main.go b/examples/full/main.go index e58f25c..0341163 100644 --- a/examples/full/main.go +++ b/examples/full/main.go @@ -10,12 +10,16 @@ // GET /api/v1/users/:id (需 Authorization: Bearer ) // POST /api/v1/users (创建用户) // +// 示例会在启动时初始化 alice/secret,密码以 bcrypt 哈希保存。 +// // 运行: // // go run ./examples/full package main import ( + "context" + "errors" "fmt" "os" @@ -26,6 +30,7 @@ import ( "github.com/EthanCodeCraft/xlgo-core/repository" "github.com/EthanCodeCraft/xlgo-core/response" "github.com/EthanCodeCraft/xlgo-core/router" + "github.com/EthanCodeCraft/xlgo-core/validation" "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -34,7 +39,7 @@ import ( type User struct { gorm.Model Username string `gorm:"uniqueIndex;size:64" json:"username"` - Password string `gorm:"size:128" json:"-"` // 实际项目应存 bcrypt 哈希 + Password string `gorm:"size:128" json:"-"` // bcrypt 哈希,永不返回给客户端 UserType string `gorm:"size:32" json:"user_type"` } @@ -46,6 +51,12 @@ func main() { xlgo.WithModels(&User{}), xlgo.WithMiddlewares(middleware.Logger(), middleware.CORS()), xlgo.WithModules(router.ModuleFunc(registerRoutes)), + xlgo.WithHook(xlgo.Hook{ + Name: "seed-example-user", + OnInit: func(*xlgo.App) error { + return ensureExampleUser(context.Background()) + }, + }), ) // 初始化 user repository(App.Init 之后 master DB 才可用,这里在 registerRoutes 里延迟拿) @@ -82,10 +93,13 @@ func login(c *gin.Context) { return } - // 示例简化:查询用户,不校验密码哈希 u, err := userRepo.FindOne(c.Request.Context(), "username = ?", req.Username) if err != nil { - response.Fail(c, "用户不存在") + response.Fail(c, "用户名或密码错误") + return + } + if !validation.CheckPassword(u.Password, req.Password) { + response.Fail(c, "用户名或密码错误") return } @@ -114,15 +128,51 @@ func getUser(c *gin.Context) { } func createUser(c *gin.Context) { - var u User - if err := c.ShouldBindJSON(&u); err != nil { + var req struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + UserType string `json:"user_type"` + } + if err := c.ShouldBindJSON(&req); err != nil { response.Fail(c, "参数错误") return } - u.UserType = "user" + + hash, err := validation.HashPassword(req.Password) + if err != nil { + response.ServerError(c, "密码加密失败") + return + } + u := User{ + Username: req.Username, + Password: hash, + UserType: req.UserType, + } + if u.UserType == "" { + u.UserType = "user" + } if err := userRepo.Create(c.Request.Context(), &u); err != nil { response.Fail(c, "创建失败: "+err.Error()) return } response.Success(c, u) } + +func ensureExampleUser(ctx context.Context) error { + _, err := userRepo.FindOne(ctx, "username = ?", "alice") + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + hash, err := validation.HashPassword("secret") + if err != nil { + return err + } + return userRepo.Create(ctx, &User{ + Username: "alice", + Password: hash, + UserType: "user", + }) +} diff --git a/examples/full/main_test.go b/examples/full/main_test.go new file mode 100644 index 0000000..87a5e2b --- /dev/null +++ b/examples/full/main_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + "github.com/EthanCodeCraft/xlgo-core/database" + "github.com/EthanCodeCraft/xlgo-core/validation" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func setupExampleRouter(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + + database.RegisterDialect(database.DialectSpec{ + Name: "sqlite_full_example_test", + Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) }, + DSN: func(c *config.DatabaseConfig) string { + return c.CustomDSN + }, + }) + + cfg := &config.Config{ + Database: config.DatabaseConfig{ + Driver: "sqlite_full_example_test", + CustomDSN: filepath.Join(t.TempDir(), "full-example.db"), + }, + JWT: config.JWTConfig{ + Secret: "test-secret-for-full-example-at-least-32-bytes", + Expire: time.Hour, + RefreshExpire: 24 * time.Hour, + Issuer: "xlgo", + Algorithm: "HS256", + }, + } + if err := config.Set(cfg); err != nil { + t.Fatalf("设置测试配置失败: %v", err) + } + + mgr := database.NewManager(cfg) + if err := mgr.InitDB(cfg); err != nil { + t.Fatalf("初始化测试数据库失败: %v", err) + } + db := mgr.Master() + if err := db.AutoMigrate(&User{}); err != nil { + t.Fatalf("迁移示例用户表失败: %v", err) + } + prev := database.SwapDefaultManager(mgr) + t.Cleanup(func() { + current := database.SwapDefaultManager(prev) + if current != nil && current != prev { + _ = current.Close() + } + }) + + gin.SetMode(gin.TestMode) + r := gin.New() + registerRoutes(r.Group("")) + if err := ensureExampleUser(context.Background()); err != nil { + t.Fatalf("初始化示例用户失败: %v", err) + } + return r, db +} + +func postJSON(t *testing.T, r http.Handler, path string, body string, token string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("解析响应 JSON 失败: %v, body=%s", err, rec.Body.String()) + } + return rec.Code, payload +} + +func tokenFromResponse(t *testing.T, payload map[string]any) string { + t.Helper() + data, ok := payload["data"].(map[string]any) + if !ok { + t.Fatalf("响应缺少 data 对象: %#v", payload) + } + token, ok := data["token"].(string) + if !ok || strings.TrimSpace(token) == "" { + t.Fatalf("响应缺少 token: %#v", payload) + } + return token +} + +func TestFullExampleLoginAndPasswordFlow(t *testing.T) { + r, db := setupExampleRouter(t) + + var seeded User + if err := db.Where("username = ?", "alice").First(&seeded).Error; err != nil { + t.Fatalf("示例用户 alice 应在启动时初始化: %v", err) + } + if seeded.Password == "" || seeded.Password == "secret" { + t.Fatal("示例用户密码必须保存为 bcrypt 哈希,不能保存明文") + } + if !validation.CheckPassword(seeded.Password, "secret") { + t.Fatal("示例用户 alice/secret 应能通过密码校验") + } + + status, wrong := postJSON(t, r, "/api/v1/login", `{"username":"alice","password":"wrong"}`, "") + if status != http.StatusOK { + t.Fatalf("业务失败模式下 HTTP 状态应为 200,got %d", status) + } + if code, _ := wrong["code"].(float64); code == 0 { + t.Fatalf("错误密码应返回业务失败: %#v", wrong) + } + + _, loginPayload := postJSON(t, r, "/api/v1/login", `{"username":"alice","password":"secret"}`, "") + token := tokenFromResponse(t, loginPayload) + + _, createPayload := postJSON(t, r, "/api/v1/users", `{"username":"bob","password":"s3cret"}`, token) + if code, _ := createPayload["code"].(float64); code != 0 { + t.Fatalf("带 token 创建用户应成功: %#v", createPayload) + } + + var bob User + if err := db.Where("username = ?", "bob").First(&bob).Error; err != nil { + t.Fatalf("创建后的用户应写入数据库: %v", err) + } + if bob.Password == "" || bob.Password == "s3cret" { + t.Fatal("创建用户时必须保存 bcrypt 哈希,不能保存明文") + } + if !validation.CheckPassword(bob.Password, "s3cret") { + t.Fatal("创建用户的密码哈希应能通过校验") + } + + _, bobLogin := postJSON(t, r, "/api/v1/login", `{"username":"bob","password":"s3cret"}`, "") + _ = tokenFromResponse(t, bobLogin) +}