first commit
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user