Files
zt-closet-manage-backend/internal/service/admin.go
T
2026-09-11 01:47:20 +08:00

380 lines
13 KiB
Go

package service
import (
"context"
"errors"
"log/slog"
"strings"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/redisdao"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/lzcwyjwt"
"golang.org/x/crypto/bcrypt"
)
// AdminService 管理员账号业务服务。
type AdminService struct {
adminDao *mysqldao.AdminDao
}
// NewAdminService 创建管理员服务。
func NewAdminService() *AdminService {
return &AdminService{
adminDao: mysqldao.NewAdminDao(),
}
}
// Login 管理员登录:账号密码校验 → 签发双 token → 会话入库 → 返回管理员信息。
func (s *AdminService) Login(ctx context.Context, request *dto.AdminLoginRequest) (*dto.AdminLoginResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.AdminLogin")
defer span.End()
admin, err := s.adminDao.AdminGetByUsername(ctx, request.Username)
if err != nil {
if isNotFound(err) {
// 账号不存在与密码错误统一返回同一错误,避免账号枚举攻击。
return nil, errcode.AdminLoginFailed
}
return nil, errcode.DatabaseError
}
if admin.Status != AdminStatusEnabled {
return nil, errcode.AdminDisabled
}
if cerr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(request.Password)); cerr != nil {
return nil, errcode.AdminLoginFailed
}
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(admin.ID)
if terr != nil {
return nil, errcode.UnauthorizedTokenGenerate
}
if aerr := redisdao.AuthTokenSet(ctx, admin.ID, accessToken, refreshToken); aerr != nil {
// Redis 故障降级放行(与鉴权中间件 fail-open 策略一致)。
slog.WarnContext(ctx, "set admin auth token failed, allow by fallback", "error", aerr)
}
// best-effort 更新最近登录时间。
_ = s.adminDao.AdminUpdate(ctx, admin.ID, map[string]any{"last_login_time": nowUnix()})
return &dto.AdminLoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
AdminInfo: buildAdminInfo(admin),
}, nil
}
// RefreshToken 使用 refresh token 刷新访问令牌(需携带旧 access token 精确吊销旧会话)。
func (s *AdminService) RefreshToken(ctx context.Context, request *dto.AdminRefreshTokenRequest) (*dto.AdminRefreshTokenResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.AdminRefreshToken")
defer span.End()
claims, err := lzcwyjwt.ParseRefreshToken(request.RefreshToken)
if err != nil {
if errors.Is(err, lzcwyjwt.ErrTokenExpired) {
return nil, errcode.UnauthorizedTokenTimeout
}
return nil, errcode.UnauthorizedTokenError
}
// 校验 refresh token 仍在有效会话中。
uid, aerr := redisdao.AuthAdminIDByRefresh(ctx, request.RefreshToken)
if aerr != nil {
if isCacheMiss(aerr) {
return nil, errcode.UnauthorizedTokenError
}
return nil, errcode.CacheError
}
if uid != claims.AdminID {
return nil, errcode.UnauthorizedTokenError
}
// 管理员状态回查(禁用后 refresh 也立即失效)。
admin, dbErr := s.adminDao.AdminGetByID(ctx, claims.AdminID)
if dbErr != nil {
return nil, errcode.AdminNotFound
}
if admin.Status != AdminStatusEnabled {
return nil, errcode.AdminDisabled
}
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(claims.AdminID)
if terr != nil {
return nil, errcode.UnauthorizedTokenGenerate
}
// 失效旧会话:吊销旧 access + 旧 refresh,防止旧 token 泄露后被继续使用。
if request.AccessToken == "" {
return nil, errcode.InvalidParams.WithDetails("缺少旧 access token")
}
oldUID, oerr := redisdao.AuthAdminIDByAccess(ctx, request.AccessToken)
if oerr != nil && !isCacheMiss(oerr) {
return nil, errcode.CacheError
}
if oerr == nil && oldUID != claims.AdminID {
return nil, errcode.UnauthorizedTokenError
}
_ = redisdao.AuthTokenDel(ctx, claims.AdminID, request.AccessToken, request.RefreshToken)
if serr := redisdao.AuthTokenSet(ctx, claims.AdminID, accessToken, refreshToken); serr != nil {
return nil, errcode.CacheError
}
return &dto.AdminRefreshTokenResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
}, nil
}
// Logout 登出:吊销当前会话(access + refresh)。
func (s *AdminService) Logout(ctx context.Context, accessToken, refreshToken string) *errcode.Error {
ctx, span := global.Tracer.Start(ctx, "svc.AdminLogout")
defer span.End()
adminID, e := requireAdminID(ctx)
if e != nil {
return e
}
if err := redisdao.AuthTokenDel(ctx, adminID, accessToken, refreshToken); err != nil {
slog.WarnContext(ctx, "admin logout delete token failed", "error", err)
}
return nil
}
// ChangePassword 修改自己密码(校验原密码,新密码 bcrypt 入库)。
func (s *AdminService) ChangePassword(ctx context.Context, adminID int64, request *dto.AdminChangePasswordRequest) *errcode.Error {
ctx, span := global.Tracer.Start(ctx, "svc.AdminChangePassword")
defer span.End()
admin, err := s.adminDao.AdminGetByID(ctx, adminID)
if err != nil {
if isNotFound(err) {
return errcode.AdminNotFound
}
return errcode.DatabaseError
}
if cerr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(request.OldPassword)); cerr != nil {
return errcode.AdminOldPasswordWrong
}
hash, herr := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
if herr != nil {
return errcode.AdminPasswordInvalid
}
if err := s.adminDao.AdminUpdate(ctx, adminID, map[string]any{"password_hash": string(hash)}); err != nil {
return errcode.DatabaseError
}
// 改密后失效全部会话,强制重新登录。
if derr := redisdao.AuthAdminTokensDelAll(ctx, adminID); derr != nil {
slog.WarnContext(ctx, "invalidate admin tokens failed after password change", "error", derr)
}
return nil
}
// List 分页查询管理员列表(仅超级管理员)。
func (s *AdminService) List(ctx context.Context, request *dto.AdminListRequest) (*dto.AdminListResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.AdminList")
defer span.End()
page, pageSize := normalizePage(request.Page, request.PageSize)
list, total, err := s.adminDao.AdminList(ctx, request.Status, page, pageSize)
if err != nil {
return nil, errcode.DatabaseError
}
items := make([]dto.AdminInfo, 0, len(list))
for i := range list {
items = append(items, buildAdminInfo(&list[i]))
}
return &dto.AdminListResponse{
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
List: items,
}, nil
}
// Create 创建管理员账号(仅超级管理员)。
func (s *AdminService) Create(ctx context.Context, request *dto.AdminCreateRequest) (*dto.AdminInfo, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.AdminCreate")
defer span.End()
username := strings.TrimSpace(request.Username)
if username == "" {
return nil, errcode.InvalidParams.WithDetails("账号不能为空")
}
// 用户名唯一性校验(含软删,防历史账号占用)。
if _, err := s.adminDao.AdminGetByUsername(ctx, username); err == nil {
return nil, errcode.AdminUsernameExists
} else if !isNotFound(err) {
return nil, errcode.DatabaseError
}
hash, herr := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
if herr != nil {
return nil, errcode.AdminPasswordInvalid
}
admin := &mysqlmodel.AdminUser{
Username: username,
PasswordHash: string(hash),
Nickname: strings.TrimSpace(request.Nickname),
Role: request.Role,
Status: AdminStatusEnabled,
}
if admin.Nickname == "" {
admin.Nickname = username
}
if err := s.adminDao.AdminCreate(ctx, admin); err != nil {
return nil, errcode.DatabaseError
}
info := buildAdminInfo(admin)
return &info, nil
}
// Update 更新管理员账号(仅超级管理员;不能操作自己以外的超级管理员)。
func (s *AdminService) Update(ctx context.Context, adminID, targetID int64, request *dto.AdminUpdateRequest) *errcode.Error {
ctx, span := global.Tracer.Start(ctx, "svc.AdminUpdate")
defer span.End()
target, err := s.adminDao.AdminGetByID(ctx, targetID)
if err != nil {
if isNotFound(err) {
return errcode.AdminNotFound
}
return errcode.DatabaseError
}
// 保护规则:超级管理员之间不能互操作;目标为超级管理员且非本人时拒绝。
if target.Role == AdminRoleSuper && targetID != adminID {
return errcode.AdminCannotModifySuper
}
// 不能禁用自己。
if adminID == targetID && request.Status == AdminStatusDisabled {
return errcode.AdminCannotDisableSelf
}
updates := map[string]any{}
if strings.TrimSpace(request.Nickname) != "" {
updates["nickname"] = strings.TrimSpace(request.Nickname)
}
if request.Role != 0 && request.Role != target.Role {
updates["role"] = request.Role
}
if request.Status != 0 && request.Status != target.Status {
updates["status"] = request.Status
}
if request.Password != "" {
hash, herr := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
if herr != nil {
return errcode.AdminPasswordInvalid
}
updates["password_hash"] = string(hash)
}
if len(updates) > 0 {
if err := s.adminDao.AdminUpdate(ctx, targetID, updates); err != nil {
return errcode.DatabaseError
}
// 禁用/改密后失效目标账号全部会话,立即生效。
if request.Status == AdminStatusDisabled || request.Password != "" {
if derr := redisdao.AuthAdminTokensDelAll(ctx, targetID); derr != nil {
slog.WarnContext(ctx, "invalidate admin tokens failed", "admin_id", targetID, "error", derr)
}
}
}
return nil
}
// Delete 删除管理员账号(仅超级管理员;不能删除自己与其他超级管理员)。
func (s *AdminService) Delete(ctx context.Context, adminID, targetID int64) *errcode.Error {
ctx, span := global.Tracer.Start(ctx, "svc.AdminDelete")
defer span.End()
if adminID == targetID {
return errcode.AdminCannotDeleteSelf
}
target, err := s.adminDao.AdminGetByID(ctx, targetID)
if err != nil {
if isNotFound(err) {
return errcode.AdminNotFound
}
return errcode.DatabaseError
}
if target.Role == AdminRoleSuper {
return errcode.AdminCannotModifySuper
}
if err := s.adminDao.AdminSoftDelete(ctx, targetID); err != nil {
return errcode.DatabaseError
}
if derr := redisdao.AuthAdminTokensDelAll(ctx, targetID); derr != nil {
slog.WarnContext(ctx, "invalidate admin tokens failed", "admin_id", targetID, "error", derr)
}
return nil
}
// EnsureInitialSuperAdmin 幂等创建初始超级管理员(配置 Admin.InitialUsername/InitialPassword)。
// 已存在则跳过;账号被软删则视为不存在重新创建。
func EnsureInitialSuperAdmin(ctx context.Context) {
if global.AdminSetting == nil ||
strings.TrimSpace(global.AdminSetting.InitialUsername) == "" ||
global.AdminSetting.InitialPassword == "" {
slog.WarnContext(ctx, "admin initial account not configured, skip seed")
return
}
username := strings.TrimSpace(global.AdminSetting.InitialUsername)
svc := NewAdminService()
// 已存在(含被禁用)则跳过,避免覆盖运营修改后的账号。
if admin, err := svc.adminDao.AdminGetByUsername(ctx, username); err == nil {
if admin.DeletedAt == 0 {
slog.InfoContext(ctx, "initial admin already exists, skip seed", "username", username)
return
}
} else if !isNotFound(err) {
slog.ErrorContext(ctx, "check initial admin failed", "error", err)
return
}
hash, herr := bcrypt.GenerateFromPassword([]byte(global.AdminSetting.InitialPassword), bcrypt.DefaultCost)
if herr != nil {
slog.ErrorContext(ctx, "hash initial admin password failed", "error", herr)
return
}
admin := &mysqlmodel.AdminUser{
Username: username,
PasswordHash: string(hash),
Nickname: username,
Role: AdminRoleSuper,
Status: AdminStatusEnabled,
}
if err := svc.adminDao.AdminCreate(ctx, admin); err != nil {
slog.ErrorContext(ctx, "create initial admin failed", "error", err)
return
}
slog.InfoContext(ctx, "initial admin created", "username", username)
}
// buildAdminInfo 将管理员模型映射为响应信息(密码哈希不出网)。
func buildAdminInfo(a *mysqlmodel.AdminUser) dto.AdminInfo {
return dto.AdminInfo{
ID: a.ID,
Username: a.Username,
Nickname: a.Nickname,
Role: a.Role,
Status: a.Status,
LastLoginTime: a.LastLoginTime,
CreatedAt: a.CreatedAt,
}
}