implement user authentication and database initialization, add models for user, comment, label, and OIDC configuration

This commit is contained in:
2025-07-22 06:18:23 +08:00
parent 99a3f80e12
commit d1a040617f
23 changed files with 602 additions and 19 deletions

View File

@ -3,6 +3,8 @@ package v1
import (
"context"
"github.com/cloudwego/hertz/pkg/app"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/pkg/resps"
)
type userType struct{}
@ -10,11 +12,13 @@ type userType struct{}
var User = new(userType)
func (u *userType) Login(ctx context.Context, c *app.RequestContext) {
// TODO: Impl
var userLoginReq dto.UserLoginReq
if err := c.BindAndValidate(&userLoginReq); err != nil {
resps.BadRequest(c, resps.ErrParamInvalid)
}
}
func (u *userType) Register(ctx context.Context, c *app.RequestContext) {
// TODO: Impl
}
func (u *userType) Logout(ctx context.Context, c *app.RequestContext) {

7
internal/dto/dto.go Normal file
View File

@ -0,0 +1,7 @@
package dto
type BaseResp struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
}

33
internal/dto/user.go Normal file
View File

@ -0,0 +1,33 @@
package dto
type UserDto struct {
Username string `json:"username"` // 用户名
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"` // 头像URL
Email string `json:"email"` // 邮箱
Gender string `json:"gender"`
Role string `json:"role"`
}
type UserLoginReq struct {
Username string `json:"username"` // username or email
Password string `json:"password"`
}
type UserLoginResp struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
User UserDto `json:"user"`
}
type UserRegisterReq struct {
Username string `json:"username"` // 用户名
Nickname string `json:"nickname"` // 昵称
Password string `json:"password"` // 密码
Email string `json:"email"` // 邮箱
}
type UserRegisterResp struct {
Token string `json:"token"` // 访问令牌
RefreshToken string `json:"refresh_token"` // 刷新令牌
User UserDto `json:"user"` // 用户信息
}

14
internal/model/comment.go Normal file
View File

@ -0,0 +1,14 @@
package model
import "gorm.io/gorm"
type Comment struct {
gorm.Model
UserID uint `gorm:"index"` // 评论的用户ID
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
TargetID uint `gorm:"index"` // 目标ID
TargetType string `gorm:"index"` // 目标类型,如 "post", "page"
ReplyID uint `gorm:"index"` // 回复的评论ID
Content string `gorm:"type:text"` // 评论内容
Depth int `gorm:"default:0"` // 评论的层级深度
}

1
internal/model/file.go Normal file
View File

@ -0,0 +1 @@
package model

10
internal/model/label.go Normal file
View File

@ -0,0 +1,10 @@
package model
import "gorm.io/gorm"
type Label struct {
gorm.Model
Key string `gorm:"uniqueIndex"` // 标签键,唯一标识
Value string `gorm:"type:text"` // 标签值,描述标签的内容
Color string `gorm:"type:text"` // 前端可用颜色代码
}

View File

@ -0,0 +1,89 @@
package model
import (
"fmt"
"gorm.io/gorm"
"resty.dev/v3"
"time"
)
type OidcConfig struct {
gorm.Model
Name string `gorm:"uniqueIndex"`
ClientID string `gorm:"column:client_id"` // 客户端ID
ClientSecret string `gorm:"column:client_secret"` // 客户端密钥
DisplayName string `gorm:"column:display_name"` // 显示名称,例如:轻雪通行证
GroupsClaim *string `gorm:"default:groups"` // 组声明,默认为:"groups"
Icon *string `gorm:"column:icon"` // 图标url为空则使用内置默认图标
OidcDiscoveryUrl string `gorm:"column:oidc_discovery_url"` // OpenID自动发现URL例如 https://pass.liteyuki.icu/.well-known/openid-configuration
Enabled bool `gorm:"column:enabled;default:true"` // 是否启用
// 以下字段为自动获取字段,每次更新配置时自动填充
Issuer string
AuthorizationEndpoint string
TokenEndpoint string
UserInfoEndpoint string
JwksUri string
}
type oidcDiscoveryResp struct {
Issuer string `json:"issuer" validate:"required"`
AuthorizationEndpoint string `json:"authorization_endpoint" validate:"required"`
TokenEndpoint string `json:"token_endpoint" validate:"required"`
UserInfoEndpoint string `json:"userinfo_endpoint" validate:"required"`
JwksUri string `json:"jwks_uri" validate:"required"`
// 可选字段
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
ScopesSupported []string `json:"scopes_supported,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported,omitempty"`
GrantTypesSupported []string `json:"grant_types_supported,omitempty"`
SubjectTypesSupported []string `json:"subject_types_supported,omitempty"`
IdTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"`
ClaimsSupported []string `json:"claims_supported,omitempty"`
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
}
func updateOidcConfigFromUrl(url string) (*oidcDiscoveryResp, error) {
client := resty.New()
client.SetTimeout(10 * time.Second) // 设置超时时间
var discovery oidcDiscoveryResp
resp, err := client.R().
SetHeader("Accept", "application/json").
SetResult(&discovery).
Get(url)
if err != nil {
return nil, fmt.Errorf("请求OIDC发现端点失败: %w", err)
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("请求OIDC发现端点失败状态码: %d", resp.StatusCode())
}
// 验证必要字段
if discovery.Issuer == "" ||
discovery.AuthorizationEndpoint == "" ||
discovery.TokenEndpoint == "" ||
discovery.UserInfoEndpoint == "" ||
discovery.JwksUri == "" {
return nil, fmt.Errorf("OIDC发现端点响应缺少必要字段")
}
return &discovery, nil
}
func (o *OidcConfig) BeforeSave(tx *gorm.DB) (err error) {
// 设置默认值
if o.GroupsClaim == nil {
defaultGroupsClaim := "groups"
o.GroupsClaim = &defaultGroupsClaim
}
// 只有在创建新记录或更新 OidcDiscoveryUrl 字段时才更新端点信息
if tx.Statement.Changed("OidcDiscoveryUrl") {
discoveryResp, err := updateOidcConfigFromUrl(o.OidcDiscoveryUrl)
if err != nil {
return fmt.Errorf("更新OIDC配置失败: %w", err)
}
o.Issuer = discoveryResp.Issuer
o.AuthorizationEndpoint = discoveryResp.AuthorizationEndpoint
o.TokenEndpoint = discoveryResp.TokenEndpoint
o.UserInfoEndpoint = discoveryResp.UserInfoEndpoint
o.JwksUri = discoveryResp.JwksUri
}
return nil
}

1
internal/model/page.go Normal file
View File

@ -0,0 +1 @@
package model

12
internal/model/post.go Normal file
View File

@ -0,0 +1,12 @@
package model
import "gorm.io/gorm"
type Post struct {
gorm.Model
UserID uint `gorm:"index"` // 发布者的用户ID
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
Title string `gorm:"type:text;not null"` // 帖子标题
Content string `gorm:"type:text;not null"` // 帖子内容
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签
}

15
internal/model/user.go Normal file
View File

@ -0,0 +1,15 @@
package model
import "gorm.io/gorm"
type User struct {
gorm.Model
Username string `gorm:"unique;index"` // 用户名,唯一
Nickname string
AvatarUrl string
Email string `gorm:"unique;index"`
Gender string
Role string `gorm:"default:'user'"`
Password string // 密码,存储加密后的值
}

133
internal/repo/init.go Normal file
View File

@ -0,0 +1,133 @@
package repo
import (
"errors"
"fmt"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/pkg/utils"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"os"
"path/filepath"
)
var db *gorm.DB
func GetDB() *gorm.DB {
return db
}
// DBConfig 数据库配置结构体
type DBConfig struct {
Driver string // 数据库驱动类型,例如 "sqlite" 或 "postgres" Database driver type, e.g., "sqlite" or "postgres"
Path string // SQLite 路径 SQLite path
Host string // PostgreSQL 主机名 PostgreSQL hostname
Port int // PostgreSQL 端口 PostgreSQL port
User string // PostgreSQL 用户名 PostgreSQL username
Password string // PostgreSQL 密码 PostgreSQL password
DBName string // PostgreSQL 数据库名 PostgreSQL database name
SSLMode string // PostgreSQL SSL 模式 PostgreSQL SSL mode
}
// loadDBConfig 从配置文件加载数据库配置
func loadDBConfig() DBConfig {
return DBConfig{
Driver: utils.Env.Get("database.driver", "sqlite"),
Path: utils.Env.Get("database.path", "./data/data.db"),
Host: utils.Env.Get("database.host", "postgres"),
Port: utils.Env.GetenvAsInt("database.port", 5432),
User: utils.Env.Get("database.user", "spage"),
Password: utils.Env.Get("database.password", "spage"),
DBName: utils.Env.Get("database.dbname", "spage"),
SSLMode: utils.Env.Get("database.sslmode", "disable"),
}
}
// InitDatabase 手动初始化数据库连接
func InitDatabase() error {
dbConfig := loadDBConfig()
// 创建通用的 GORM 配置
gormConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
var err error
switch dbConfig.Driver {
case "postgres":
if db, err = initPostgres(dbConfig, gormConfig); err != nil {
return fmt.Errorf("postgres initialization failed: %w", err)
}
logrus.Infoln("postgres initialization succeeded", dbConfig)
case "sqlite":
if db, err = initSQLite(dbConfig.Path, gormConfig); err != nil {
return fmt.Errorf("sqlite initialization failed: %w", err)
}
logrus.Infoln("sqlite initialization succeeded", dbConfig)
default:
return errors.New("unsupported database driver, only sqlite and postgres are supported")
}
return nil
// TODO: impl
//// 迁移模型
//if err = models.Migrate(db); err != nil {
// logrus.Error("Failed to migrate models:", err)
// return err
//}
//// 执行初始化数据
//// 创建管理员账户
//hashedPassword, err := utils.Password.HashPassword(config.AdminPassword, config.JwtSecret)
//if err != nil {
// logrus.Error("Failed to hash password:", err)
// return err
//}
//user := &models.User{
// Name: config.AdminUsername,
// Password: &hashedPassword,
// Role: constants.GlobalRoleAdmin,
//}
//if err = User.UpdateSystemAdmin(user); err != nil {
// logrus.Error("Failed to update admin user:", err)
// return err
//}
//return nil
}
// initPostgres 初始化PostgreSQL连接
func initPostgres(config DBConfig, gormConfig *gorm.Config) (db *gorm.DB, err error) {
if config.Host == "" || config.User == "" || config.Password == "" || config.DBName == "" {
err = errors.New("PostgreSQL configuration is incomplete: host, user, password, and dbname are required")
}
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
config.Host, config.Port, config.User, config.Password, config.DBName, config.SSLMode)
db, err = gorm.Open(postgres.Open(dsn), gormConfig)
return
}
// initSQLite 初始化 SQLite 连接
func initSQLite(path string, gormConfig *gorm.Config) (*gorm.DB, error) {
if path == "" {
path = "./data/data.db"
}
// 创建 SQLite 数据库文件的目录
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
return nil, fmt.Errorf("failed to create directory for SQLite database: %w", err)
}
db, err := gorm.Open(sqlite.Open(path), gormConfig)
return db, err
}
func migrate() error {
return GetDB().AutoMigrate(
&model.Comment{},
&model.Label{},
&model.Post{},
&model.User{})
}

45
internal/repo/user.go Normal file
View File

@ -0,0 +1,45 @@
package repo
import "github.com/snowykami/neo-blog/internal/model"
type userRepo struct{}
var User = &userRepo{}
func (user *userRepo) GetByUsername(username string) (*model.User, error) {
var userModel model.User
if err := GetDB().Where("username = ?", username).First(&userModel).Error; err != nil {
return nil, err
}
return &userModel, nil
}
func (user *userRepo) GetByEmail(email string) (*model.User, error) {
var userModel model.User
if err := GetDB().Where("email = ?", email).First(&userModel).Error; err != nil {
return nil, err
}
return &userModel, nil
}
func (user *userRepo) GetByUsernameOrEmail(usernameOrEmail string) (*model.User, error) {
var userModel model.User
if err := GetDB().Where("username = ? OR email = ?", usernameOrEmail, usernameOrEmail).First(&userModel).Error; err != nil {
return nil, err
}
return &userModel, nil
}
func (user *userRepo) Create(userModel *model.User) error {
if err := GetDB().Create(userModel).Error; err != nil {
return err
}
return nil
}
func (user *userRepo) Update(userModel *model.User) error {
if err := GetDB().Updates(userModel).Error; err != nil {
return err
}
return nil
}

View File

@ -11,7 +11,7 @@ import (
var h *server.Hertz
func Run() error {
mode := utils.Getenv("MODE", constant.ModeProd) // dev | prod
mode := utils.Env.Get("MODE", constant.ModeProd) // dev | prod
switch mode {
case constant.ModeProd:
h.Spin()
@ -25,8 +25,8 @@ func Run() error {
func init() {
h = server.New(
server.WithHostPorts(":"+utils.Getenv("PORT", "8888")),
server.WithMaxRequestBodySize(utils.GetenvAsInt("MAX_REQUEST_BODY_SIZE", 1048576000)), // 1000MiB
server.WithHostPorts(":"+utils.Env.Get("PORT", "8888")),
server.WithMaxRequestBodySize(utils.Env.GetenvAsInt("MAX_REQUEST_BODY_SIZE", 1048576000)), // 1000MiB
)
apiv1.RegisterRoutes(h)
}

32
internal/service/user.go Normal file
View File

@ -0,0 +1,32 @@
package service
import (
"errors"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/resps"
"github.com/snowykami/neo-blog/pkg/utils"
)
type UserService interface {
UserLogin(dto *dto.UserLoginReq) (*dto.UserLoginResp, error)
UserRegister(dto *dto.UserRegisterReq) (*dto.UserRegisterResp, error)
}
type userService struct{}
func NewUserService() UserService {
return &userService{}
}
func (s *userService) UserLogin(dto *dto.UserLoginReq) (*dto.UserLoginResp, error) {
user, err := repo.User.GetByUsernameOrEmail(dto.Username)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New(resps.ErrNotFound)
}
utils.Password.VerifyPassword(dto.Password, user.Password, utils.Env.Get(constant.EnvVarPasswordSalt, "default_salt"))
}