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, } }