diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 8e4eff5..111e284 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -6,7 +6,9 @@ import ( "log" "gen2d/internal/config" + "gen2d/internal/db" "gen2d/internal/handler" + "gen2d/internal/model" "github.com/gin-gonic/gin" ) @@ -16,11 +18,29 @@ func main() { gin.SetMode(cfg.Mode) + // 初始化 SQLite 数据库 + if err := db.Init(cfg.DSN, &model.User{}); err != nil { + log.Fatalf("db init failed: %v", err) + } + + // 初始化 AuthService,注入 JWT 配置 + handler.InitAuthService(cfg.JWTSecret, cfg.JWTExpire) + r := gin.New() r.Use(gin.Recovery()) // panic 恢复中间件,防止服务因未捕获异常宕机 // API v1 路由组 - r.GET("/api/v1/health", handler.Health) // 健康检查 + v1 := r.Group("/api/v1") + { + v1.GET("/health", handler.Health) // 健康检查 + } + + // Auth 路由组 + auth := r.Group("/auth") + { + auth.POST("/register", handler.Register) // 用户注册 + auth.POST("/login", handler.Login) // 用户登录 + } addr := fmt.Sprintf(":%d", cfg.Port) log.Printf("gen2d backend starting on %s", addr) diff --git a/backend/go.mod b/backend/go.mod index a9befe9..35002f7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,9 @@ go 1.26.3 require ( github.com/cloudwego/eino v0.8.13 github.com/gin-gonic/gin v1.12.0 + golang.org/x/crypto v0.48.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 ) require ( @@ -23,13 +26,17 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/goph/emperror v0.17.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/nikolalohinski/gonja v1.5.3 // indirect @@ -45,7 +52,6 @@ require ( github.com/yargevad/filepathx v1.0.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0cab42f..90a9615 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -48,6 +48,8 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -59,6 +61,10 @@ github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -83,6 +89,8 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -179,3 +187,7 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6254b87..7d954a3 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -8,9 +8,12 @@ import ( // Config 应用全局配置,优先读取环境变量,未设置时使用默认值。 type Config struct { - Port int // HTTP 监听端口,默认 8080,环境变量 GEN2D_PORT + Port int // HTTP 监听端口,默认 8080,环境变量 GEN2D_PORT Mode string // Gin 运行模式 (debug/release),环境变量 GEN2D_MODE MaxFileSize int64 // 上传文件大小上限(字节),默认 10MB + DSN string // SQLite 数据库路径,默认 data/gen2d.db,环境变量 GEN2D_DSN + JWTSecret string // JWT 签名密钥,环境变量 GEN2D_JWT_SECRET + JWTExpire int64 // JWT 过期时间(秒),默认 7200 } // Load 从环境变量加载配置并返回。 @@ -19,6 +22,9 @@ func Load() *Config { Port: 8080, Mode: "debug", MaxFileSize: 10 << 20, // 10MB + DSN: "data/gen2d.db", + JWTSecret: "gen2d-dev-secret", + JWTExpire: 7200, } if port := os.Getenv("GEN2D_PORT"); port != "" { @@ -31,5 +37,19 @@ func Load() *Config { cfg.Mode = mode } + if dsn := os.Getenv("GEN2D_DSN"); dsn != "" { + cfg.DSN = dsn + } + + if secret := os.Getenv("GEN2D_JWT_SECRET"); secret != "" { + cfg.JWTSecret = secret + } + + if expire := os.Getenv("GEN2D_JWT_EXPIRE"); expire != "" { + if e, err := strconv.ParseInt(expire, 10, 64); err == nil { + cfg.JWTExpire = e + } + } + return cfg } diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..0fdbf7b --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,20 @@ +package db + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// DB 全局数据库实例,Init 后可用。 +var DB *gorm.DB + +// Init 初始化 SQLite 数据库连接并执行自动迁移。 +func Init(dsn string, models ...any) error { + var err error + DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + return err + } + + return DB.AutoMigrate(models...) +} diff --git a/backend/internal/handler/login.go b/backend/internal/handler/login.go index c193012..e839664 100644 --- a/backend/internal/handler/login.go +++ b/backend/internal/handler/login.go @@ -10,18 +10,18 @@ import ( // LoginRequest 登录请求参数。 type LoginRequest struct { - Username string `json:"username" binding:"required"` // 用户名 - Password string `json:"password" binding:"required"` // 密码 + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` } // LoginResponse 登录成功返回的凭证及用户信息。 type LoginResponse struct { - Token string `json:"token"` // 访问令牌 - ExpiresIn int64 `json:"expiresIn"` // 过期时间(秒) - User model.User `json:"user"` // 用户信息 + Token string `json:"token"` + ExpiresIn int64 `json:"expiresIn"` + User model.User `json:"user"` } -// Login 用户登录接口,校验用户名/密码并返回访问令牌。 +// Login 用户登录接口,校验用户名/密码并返回 JWT 令牌。 func Login(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -29,10 +29,15 @@ func Login(c *gin.Context) { return } - // TODO: 调用 service 层校验用户凭证并签发 token + token, expiresIn, user, err := authSvc.Login(c.Request.Context(), req.Username, req.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, model.Fail(http.StatusUnauthorized, err.Error())) + return + } + c.JSON(http.StatusOK, model.OK(LoginResponse{ - Token: "placeholder-token", - ExpiresIn: 7200, - User: model.User{Username: req.Username}, + Token: token, + ExpiresIn: expiresIn, + User: *user, })) } diff --git a/backend/internal/handler/register.go b/backend/internal/handler/register.go index 756bdd0..bca2108 100644 --- a/backend/internal/handler/register.go +++ b/backend/internal/handler/register.go @@ -4,15 +4,23 @@ import ( "net/http" "gen2d/internal/model" + "gen2d/internal/service" "github.com/gin-gonic/gin" ) +var authSvc *service.AuthService + +// InitAuthService 由 main 在启动时调用,注入 JWT 配置。 +func InitAuthService(jwtSecret string, jwtExpire int64) { + authSvc = service.NewAuthService(jwtSecret, jwtExpire) +} + // RegisterRequest 注册请求参数。 type RegisterRequest struct { - Username string `json:"username" binding:"required,min=3,max=32"` // 用户名 - Password string `json:"password" binding:"required,min=6,max=64"` // 密码 - Email string `json:"email" binding:"omitempty,email"` // 邮箱(可选) + Username string `json:"username" binding:"required,min=3,max=32"` + Password string `json:"password" binding:"required,min=6,max=64"` + Email string `json:"email" binding:"omitempty,email"` } // RegisterResponse 注册成功返回的用户信息。 @@ -29,9 +37,14 @@ func Register(c *gin.Context) { return } - // TODO: 调用 service 层创建用户 + user, err := authSvc.Register(c.Request.Context(), req.Username, req.Password, req.Email) + if err != nil { + c.JSON(http.StatusConflict, model.Fail(http.StatusConflict, err.Error())) + return + } + c.JSON(http.StatusCreated, model.OK(RegisterResponse{ - ID: 0, - Username: req.Username, + ID: user.ID, + Username: user.Username, })) } diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index f5a4c06..43bc250 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -4,10 +4,10 @@ import "time" // User 用户实体。 type User struct { - ID uint `json:"id"` - Username string `json:"username"` - Password string `json:"-"` // 密码哈希,JSON 序列化时忽略 - Email string `json:"email,omitempty"` + ID uint `json:"id" gorm:"primaryKey"` + Username string `json:"username" gorm:"uniqueIndex;size:32"` + Password string `json:"-"` + Email string `json:"email,omitempty" gorm:"size:128"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go new file mode 100644 index 0000000..4f96abe --- /dev/null +++ b/backend/internal/service/auth.go @@ -0,0 +1,79 @@ +package service + +import ( + "context" + "errors" + "time" + + "gen2d/internal/db" + "gen2d/internal/model" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// AuthService 用户认证服务。 +type AuthService struct { + jwtSecret []byte + jwtExpire time.Duration +} + +// NewAuthService 创建 AuthService 实例。 +func NewAuthService(jwtSecret string, jwtExpire int64) *AuthService { + return &AuthService{ + jwtSecret: []byte(jwtSecret), + jwtExpire: time.Duration(jwtExpire) * time.Second, + } +} + +// Register 注册新用户,密码使用 bcrypt 加密存储。 +func (s *AuthService) Register(ctx context.Context, username, password, email string) (*model.User, error) { + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + user := model.User{ + Username: username, + Password: string(hashed), + Email: email, + } + + if err := db.DB.WithContext(ctx).Create(&user).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, errors.New("用户名已存在") + } + return nil, err + } + + return &user, nil +} + +// Login 校验用户名密码,成功返回 JWT token 及用户信息。 +func (s *AuthService) Login(ctx context.Context, username, password string) (string, int64, *model.User, error) { + var user model.User + if err := db.DB.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", 0, nil, errors.New("用户名或密码错误") + } + return "", 0, nil, err + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + return "", 0, nil, errors.New("用户名或密码错误") + } + + expiresAt := time.Now().Add(s.jwtExpire) + claims := jwt.MapClaims{ + "sub": user.ID, + "exp": expiresAt.Unix(), + "iat": time.Now().Unix(), + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret) + if err != nil { + return "", 0, nil, err + } + + return token, int64(s.jwtExpire.Seconds()), &user, nil +}