refactor: remove GORM FK associations, handle relations in business layer (v0.6.1)
Some checks are pending
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Waiting to run
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Waiting to run
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Waiting to run
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Waiting to run
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Waiting to run

- Remove Role association field from User model
- Remove User association field from Token model
- controller/user.go: query Role separately after loading User
- controller/token.go: query User and Role with separate DB calls
- handler/admin.go: introduce userResp type, build role info manually;
  batch-load roles in AdminListUsers to avoid N+1

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
loveuer
2026-02-28 01:56:56 -08:00
parent ef6347a8b4
commit 62e8acf757
5 changed files with 110 additions and 40 deletions

View File

@@ -65,13 +65,7 @@ func (tm *tokenManager) Delete(userID uint, tokenID uint) error {
// Verify looks up a DB API token and returns a Session if valid.
func (tm *tokenManager) Verify(rawToken string) (*model.Session, error) {
var t model.Token
err := db.Default.Session().
Where("token = ?", rawToken).
Preload("User").
Preload("User.Role").
First(&t).Error
if err != nil {
if err := db.Default.Session().Where("token = ?", rawToken).First(&t).Error; err != nil {
return nil, errors.New("无效的 API Token")
}
@@ -79,16 +73,30 @@ func (tm *tokenManager) Verify(rawToken string) (*model.Session, error) {
return nil, errors.New("API Token 已过期")
}
var user model.User
if err := db.Default.Session().First(&user, t.UserID).Error; err != nil {
return nil, errors.New("Token 关联用户不存在")
}
if !user.Active {
return nil, errors.New("账号已被禁用")
}
var role model.Role
if err := db.Default.Session().First(&role, user.RoleID).Error; err != nil {
return nil, errors.New("账号角色异常")
}
// Update last_used_at asynchronously
now := time.Now()
go db.Default.Session().Model(&t).Update("last_used_at", now) //nolint:errcheck
session := &model.Session{
UserID: t.User.ID,
Username: t.User.Username,
Role: t.User.Role.Name,
RoleLabel: t.User.Role.Label,
Permissions: t.User.Role.PermissionList(),
UserID: user.ID,
Username: user.Username,
Role: role.Name,
RoleLabel: role.Label,
Permissions: role.PermissionList(),
LoginAt: now.Unix(),
Token: rawToken,
}

View File

@@ -89,7 +89,6 @@ func (um *userManager) Login(username, password string) (*model.Session, error)
user := new(model.User)
if err := db.Default.Session().
Where("username = ? AND active = ?", username, true).
Preload("Role").
First(user).Error; err != nil {
return nil, errors.New("账号或密码错误")
}
@@ -98,12 +97,17 @@ func (um *userManager) Login(username, password string) (*model.Session, error)
return nil, errors.New("账号或密码错误")
}
var role model.Role
if err := db.Default.Session().First(&role, user.RoleID).Error; err != nil {
return nil, errors.New("账号角色异常,请联系管理员")
}
session := &model.Session{
UserID: user.ID,
Username: user.Username,
Role: user.Role.Name,
RoleLabel: user.Role.Label,
Permissions: user.Role.PermissionList(),
Role: role.Name,
RoleLabel: role.Label,
Permissions: role.PermissionList(),
LoginAt: now.Unix(),
Token: tool.RandomString(32),
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"strings"
"time"
"github.com/loveuer/nf"
"github.com/loveuer/nf/nft/log"
@@ -12,14 +13,65 @@ import (
"github.com/spf13/cast"
)
// userResp is the JSON response shape for a user including role info,
// built manually at the business layer instead of relying on GORM associations.
type userResp struct {
ID uint `json:"id"`
Username string `json:"username"`
RoleID uint `json:"role_id"`
Role model.Role `json:"role"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toUserResp(u model.User, r model.Role) userResp {
return userResp{
ID: u.ID,
Username: u.Username,
RoleID: u.RoleID,
Role: r,
Active: u.Active,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
func AdminListUsers() nf.HandlerFunc {
return func(c *nf.Ctx) error {
var users []model.User
if err := db.Default.Session().Preload("Role").Find(&users).Error; err != nil {
if err := db.Default.Session().Find(&users).Error; err != nil {
log.Error("handler.AdminListUsers: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": users})
// Collect unique role IDs and query them in one shot
roleIDSet := make(map[uint]struct{})
for _, u := range users {
roleIDSet[u.RoleID] = struct{}{}
}
roleIDs := make([]uint, 0, len(roleIDSet))
for id := range roleIDSet {
roleIDs = append(roleIDs, id)
}
var roles []model.Role
if err := db.Default.Session().Where("id IN ?", roleIDs).Find(&roles).Error; err != nil {
log.Error("handler.AdminListUsers: query roles: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
}
roleMap := make(map[uint]model.Role, len(roles))
for _, r := range roles {
roleMap[r.ID] = r
}
resp := make([]userResp, 0, len(users))
for _, u := range users {
resp = append(resp, toUserResp(u, roleMap[u.RoleID]))
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": resp})
}
}
@@ -57,6 +109,11 @@ func AdminCreateUser() nf.HandlerFunc {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名已存在"})
}
var role model.Role
if err := db.Default.Session().First(&role, req.RoleID).Error; err != nil {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
}
user := &model.User{
Username: req.Username,
Password: tool.NewPassword(req.Password),
@@ -69,11 +126,7 @@ func AdminCreateUser() nf.HandlerFunc {
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "创建用户失败"})
}
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
log.Error("handler.AdminCreateUser: preload role: %s", err.Error())
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
return c.Status(http.StatusOK).JSON(map[string]any{"data": toUserResp(*user, role)})
}
}
@@ -97,11 +150,16 @@ func AdminUpdateUser() nf.HandlerFunc {
session := c.Locals("user").(*model.Session)
user := new(model.User)
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
var user model.User
if err := db.Default.Session().First(&user, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
}
var currentRole model.Role
if err := db.Default.Session().First(&currentRole, user.RoleID).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询角色失败"})
}
updates := map[string]any{}
if req.RoleID != nil && *req.RoleID != user.RoleID {
@@ -110,7 +168,7 @@ func AdminUpdateUser() nf.HandlerFunc {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
}
// If demoting from admin, ensure at least one other active admin remains
if user.Role.Name == model.RoleAdmin && newRole.Name != model.RoleAdmin {
if currentRole.Name == model.RoleAdmin && newRole.Name != model.RoleAdmin {
var adminCount int64
db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
@@ -120,13 +178,14 @@ func AdminUpdateUser() nf.HandlerFunc {
}
}
updates["role_id"] = *req.RoleID
currentRole = newRole
}
if req.Active != nil && *req.Active != user.Active {
if user.ID == session.UserID && !*req.Active {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能禁用自己的账号"})
}
if user.Role.Name == model.RoleAdmin && !*req.Active {
if currentRole.Name == model.RoleAdmin && !*req.Active {
var adminCount int64
db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
@@ -149,16 +208,12 @@ func AdminUpdateUser() nf.HandlerFunc {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "没有需要更新的字段"})
}
if err := db.Default.Session().Model(user).Updates(updates).Error; err != nil {
if err := db.Default.Session().Model(&user).Updates(updates).Error; err != nil {
log.Error("handler.AdminUpdateUser: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "更新失败"})
}
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
log.Error("handler.AdminUpdateUser: preload: %s", err.Error())
}
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
return c.Status(http.StatusOK).JSON(map[string]any{"data": toUserResp(user, currentRole)})
}
}
@@ -174,13 +229,18 @@ func AdminDeleteUser() nf.HandlerFunc {
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能删除自己的账号"})
}
user := new(model.User)
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
var user model.User
if err := db.Default.Session().First(&user, id).Error; err != nil {
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
}
// Prevent deleting the last admin
if user.Role.Name == model.RoleAdmin {
// Prevent deleting the last admin: check via role name
var userRole model.Role
if err := db.Default.Session().First(&userRole, user.RoleID).Error; err != nil {
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询角色失败"})
}
if userRole.Name == model.RoleAdmin {
var adminCount int64
db.Default.Session().Model(&model.User{}).
Where("role_id = ? AND id != ?", user.RoleID, id).
@@ -190,7 +250,7 @@ func AdminDeleteUser() nf.HandlerFunc {
}
}
if err := db.Default.Session().Delete(user).Error; err != nil {
if err := db.Default.Session().Delete(&user).Error; err != nil {
log.Error("handler.AdminDeleteUser: %s", err.Error())
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "删除失败"})
}

View File

@@ -7,7 +7,6 @@ import "time"
type Token struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"-"`
Name string `gorm:"not null" json:"name"`
Token string `gorm:"uniqueIndex;not null" json:"-"`
CreatedAt time.Time `json:"created_at"`

View File

@@ -8,7 +8,6 @@ type User struct {
Username string `gorm:"uniqueIndex;not null" json:"username"`
Password string `gorm:"not null" json:"-"`
RoleID uint `gorm:"not null" json:"role_id"`
Role Role `gorm:"foreignKey:RoleID" json:"role"`
Active bool `gorm:"default:true" json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`