- handler 层: device/log/webhook/user handler 使用 pagination.DefaultPageSize/MaxPageSize - service 层: device/login_log/operation_log service 使用 pagination.DefaultPageSize - repository 层: user repository 使用 pagination.DefaultPageSize/MaxPageSize - 消除 8 处硬编码的 20/100 分页魔法数字
422 lines
13 KiB
Go
422 lines
13 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/user-management-system/internal/domain"
|
||
"github.com/user-management-system/internal/pagination"
|
||
apierrors "github.com/user-management-system/internal/pkg/errors"
|
||
"github.com/user-management-system/internal/repository"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type deviceRepository interface {
|
||
Create(ctx context.Context, device *domain.Device) error
|
||
Update(ctx context.Context, device *domain.Device) error
|
||
Delete(ctx context.Context, id int64) error
|
||
GetByID(ctx context.Context, id int64) (*domain.Device, error)
|
||
GetByDeviceID(ctx context.Context, userID int64, deviceID string) (*domain.Device, error)
|
||
Exists(ctx context.Context, userID int64, deviceID string) (bool, error)
|
||
ListByUserID(ctx context.Context, userID int64, offset, limit int) ([]*domain.Device, int64, error)
|
||
ListByStatus(ctx context.Context, status domain.DeviceStatus, offset, limit int) ([]*domain.Device, int64, error)
|
||
UpdateStatus(ctx context.Context, id int64, status domain.DeviceStatus) error
|
||
UpdateLastActiveTime(ctx context.Context, id int64) error
|
||
TrustDevice(ctx context.Context, id int64, expiresAt *time.Time) error
|
||
UntrustDevice(ctx context.Context, id int64) error
|
||
DeleteAllByUserIDExcept(ctx context.Context, userID int64, exceptDeviceID int64) error
|
||
GetTrustedDevices(ctx context.Context, userID int64) ([]*domain.Device, error)
|
||
CountTrustedDevices(ctx context.Context, userID int64) (int64, error)
|
||
ListAll(ctx context.Context, params *repository.ListDevicesParams) ([]*domain.Device, int64, error)
|
||
ListAllCursor(ctx context.Context, params *repository.ListDevicesParams, limit int, cursor *pagination.Cursor) ([]*domain.Device, bool, error)
|
||
}
|
||
|
||
type deviceUserRepository interface {
|
||
GetByID(ctx context.Context, id int64) (*domain.User, error)
|
||
}
|
||
|
||
type DeviceService struct {
|
||
deviceRepo deviceRepository
|
||
userRepo deviceUserRepository
|
||
}
|
||
|
||
func NewDeviceService(deviceRepo deviceRepository, userRepo deviceUserRepository) *DeviceService {
|
||
return &DeviceService{
|
||
deviceRepo: deviceRepo,
|
||
userRepo: userRepo,
|
||
}
|
||
}
|
||
|
||
type CreateDeviceRequest struct {
|
||
DeviceID string `json:"device_id" binding:"required"`
|
||
DeviceName string `json:"device_name"`
|
||
DeviceType int `json:"device_type"`
|
||
DeviceOS string `json:"device_os"`
|
||
DeviceBrowser string `json:"device_browser"`
|
||
IP string `json:"ip"`
|
||
Location string `json:"location"`
|
||
}
|
||
|
||
type UpdateDeviceRequest struct {
|
||
DeviceName string `json:"device_name"`
|
||
DeviceType int `json:"device_type"`
|
||
DeviceOS string `json:"device_os"`
|
||
DeviceBrowser string `json:"device_browser"`
|
||
IP string `json:"ip"`
|
||
Location string `json:"location"`
|
||
Status int `json:"status"`
|
||
}
|
||
|
||
func (s *DeviceService) CreateDevice(ctx context.Context, userID int64, req *CreateDeviceRequest) (*domain.Device, error) {
|
||
if _, err := s.userRepo.GetByID(ctx, userID); err != nil {
|
||
return nil, errors.New("user not found")
|
||
}
|
||
|
||
exists, err := s.deviceRepo.Exists(ctx, userID, req.DeviceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if exists {
|
||
device, err := s.deviceRepo.GetByDeviceID(ctx, userID, req.DeviceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
device.LastActiveTime = time.Now()
|
||
return device, s.deviceRepo.Update(ctx, device)
|
||
}
|
||
|
||
device := &domain.Device{
|
||
UserID: userID,
|
||
DeviceID: req.DeviceID,
|
||
DeviceName: req.DeviceName,
|
||
DeviceType: domain.DeviceType(req.DeviceType),
|
||
DeviceOS: req.DeviceOS,
|
||
DeviceBrowser: req.DeviceBrowser,
|
||
IP: req.IP,
|
||
Location: req.Location,
|
||
Status: domain.DeviceStatusActive,
|
||
}
|
||
|
||
if err := s.deviceRepo.Create(ctx, device); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return device, nil
|
||
}
|
||
|
||
func isDeviceNotFoundError(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return true
|
||
}
|
||
|
||
lowerErr := strings.ToLower(strings.TrimSpace(err.Error()))
|
||
return strings.Contains(lowerErr, "record not found") ||
|
||
strings.Contains(lowerErr, "device not found") ||
|
||
strings.Contains(lowerErr, "not found")
|
||
}
|
||
|
||
func (s *DeviceService) getDeviceByID(ctx context.Context, deviceID int64) (*domain.Device, error) {
|
||
device, err := s.deviceRepo.GetByID(ctx, deviceID)
|
||
if err != nil {
|
||
if isDeviceNotFoundError(err) {
|
||
return nil, apierrors.NotFound("device_not_found", "device not found").WithCause(err)
|
||
}
|
||
return nil, err
|
||
}
|
||
return device, nil
|
||
}
|
||
|
||
func (s *DeviceService) getAuthorizedDevice(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) (*domain.Device, error) {
|
||
device, err := s.getDeviceByID(ctx, deviceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !isAdmin && device.UserID != actorUserID {
|
||
return nil, apierrors.Forbidden("device_forbidden", "permission denied")
|
||
}
|
||
return device, nil
|
||
}
|
||
|
||
func (s *DeviceService) persistDeviceUpdate(ctx context.Context, device *domain.Device, req *UpdateDeviceRequest) (*domain.Device, error) {
|
||
if req == nil {
|
||
return device, nil
|
||
}
|
||
|
||
if req.DeviceName != "" {
|
||
device.DeviceName = req.DeviceName
|
||
}
|
||
if req.DeviceType >= 0 {
|
||
device.DeviceType = domain.DeviceType(req.DeviceType)
|
||
}
|
||
if req.DeviceOS != "" {
|
||
device.DeviceOS = req.DeviceOS
|
||
}
|
||
if req.DeviceBrowser != "" {
|
||
device.DeviceBrowser = req.DeviceBrowser
|
||
}
|
||
if req.IP != "" {
|
||
device.IP = req.IP
|
||
}
|
||
if req.Location != "" {
|
||
device.Location = req.Location
|
||
}
|
||
if req.Status >= 0 {
|
||
device.Status = domain.DeviceStatus(req.Status)
|
||
}
|
||
|
||
if err := s.deviceRepo.Update(ctx, device); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return device, nil
|
||
}
|
||
|
||
// maxTrustedDevicesPerUser 每个用户最大信任设备数量(P2 安全增强)
|
||
const maxTrustedDevicesPerUser = 10
|
||
|
||
func (s *DeviceService) trustDeviceRecord(ctx context.Context, device *domain.Device, trustDuration time.Duration) error {
|
||
// P2 安全增强:检查信任设备数量上限
|
||
trustedCount, err := s.deviceRepo.CountTrustedDevices(ctx, device.UserID)
|
||
if err != nil {
|
||
return fmt.Errorf("count trusted devices failed: %w", err)
|
||
}
|
||
if trustedCount >= maxTrustedDevicesPerUser {
|
||
return fmt.Errorf("trusted device limit reached (max %d), please untrust an existing device first", maxTrustedDevicesPerUser)
|
||
}
|
||
|
||
var trustExpiresAt *time.Time
|
||
if trustDuration > 0 {
|
||
expiresAt := time.Now().Add(trustDuration)
|
||
trustExpiresAt = &expiresAt
|
||
}
|
||
return s.deviceRepo.TrustDevice(ctx, device.ID, trustExpiresAt)
|
||
}
|
||
|
||
func (s *DeviceService) UpdateDevice(ctx context.Context, deviceID int64, req *UpdateDeviceRequest) (*domain.Device, error) {
|
||
device, err := s.getDeviceByID(ctx, deviceID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.persistDeviceUpdate(ctx, device, req)
|
||
}
|
||
|
||
func (s *DeviceService) DeleteDevice(ctx context.Context, deviceID int64) error {
|
||
device, err := s.getDeviceByID(ctx, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.deviceRepo.Delete(ctx, device.ID)
|
||
}
|
||
|
||
func (s *DeviceService) GetDevice(ctx context.Context, deviceID int64) (*domain.Device, error) {
|
||
return s.getDeviceByID(ctx, deviceID)
|
||
}
|
||
|
||
func (s *DeviceService) GetUserDevices(ctx context.Context, userID int64, page, pageSize int) ([]*domain.Device, int64, error) {
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = pagination.DefaultPageSize
|
||
}
|
||
|
||
offset := (page - 1) * pageSize
|
||
return s.deviceRepo.ListByUserID(ctx, userID, offset, pageSize)
|
||
}
|
||
|
||
func (s *DeviceService) GetDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) (*domain.Device, error) {
|
||
return s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||
}
|
||
|
||
func (s *DeviceService) UpdateDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, req *UpdateDeviceRequest) (*domain.Device, error) {
|
||
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.persistDeviceUpdate(ctx, device, req)
|
||
}
|
||
|
||
func (s *DeviceService) DeleteDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) error {
|
||
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.deviceRepo.Delete(ctx, device.ID)
|
||
}
|
||
|
||
func (s *DeviceService) UpdateDeviceStatus(ctx context.Context, deviceID int64, status domain.DeviceStatus) error {
|
||
device, err := s.getDeviceByID(ctx, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.deviceRepo.UpdateStatus(ctx, device.ID, status)
|
||
}
|
||
|
||
func (s *DeviceService) UpdateDeviceStatusForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, status domain.DeviceStatus) error {
|
||
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.deviceRepo.UpdateStatus(ctx, device.ID, status)
|
||
}
|
||
|
||
func (s *DeviceService) UpdateLastActiveTime(ctx context.Context, deviceID int64) error {
|
||
return s.deviceRepo.UpdateLastActiveTime(ctx, deviceID)
|
||
}
|
||
|
||
func (s *DeviceService) GetActiveDevices(ctx context.Context, page, pageSize int) ([]*domain.Device, int64, error) {
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = pagination.DefaultPageSize
|
||
}
|
||
|
||
offset := (page - 1) * pageSize
|
||
return s.deviceRepo.ListByStatus(ctx, domain.DeviceStatusActive, offset, pageSize)
|
||
}
|
||
|
||
func (s *DeviceService) TrustDevice(ctx context.Context, deviceID int64, trustDuration time.Duration) error {
|
||
device, err := s.getDeviceByID(ctx, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.trustDeviceRecord(ctx, device, trustDuration)
|
||
}
|
||
|
||
func (s *DeviceService) TrustDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, trustDuration time.Duration) error {
|
||
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.trustDeviceRecord(ctx, device, trustDuration)
|
||
}
|
||
|
||
func (s *DeviceService) TrustDeviceByDeviceID(ctx context.Context, userID int64, deviceID string, trustDuration time.Duration) error {
|
||
device, err := s.deviceRepo.GetByDeviceID(ctx, userID, deviceID)
|
||
if err != nil {
|
||
if isDeviceNotFoundError(err) {
|
||
return apierrors.NotFound("device_not_found", "device not found").WithCause(err)
|
||
}
|
||
return err
|
||
}
|
||
return s.trustDeviceRecord(ctx, device, trustDuration)
|
||
}
|
||
|
||
func (s *DeviceService) UntrustDevice(ctx context.Context, deviceID int64) error {
|
||
device, err := s.getDeviceByID(ctx, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.deviceRepo.UntrustDevice(ctx, device.ID)
|
||
}
|
||
|
||
func (s *DeviceService) UntrustDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) error {
|
||
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.deviceRepo.UntrustDevice(ctx, device.ID)
|
||
}
|
||
|
||
func (s *DeviceService) LogoutAllOtherDevices(ctx context.Context, userID int64, currentDeviceID int64) error {
|
||
return s.deviceRepo.DeleteAllByUserIDExcept(ctx, userID, currentDeviceID)
|
||
}
|
||
|
||
func (s *DeviceService) GetTrustedDevices(ctx context.Context, userID int64) ([]*domain.Device, error) {
|
||
return s.deviceRepo.GetTrustedDevices(ctx, userID)
|
||
}
|
||
|
||
type GetAllDevicesRequest struct {
|
||
Page int `form:"page"`
|
||
PageSize int `form:"page_size"`
|
||
UserID int64 `form:"user_id"`
|
||
Status *int `form:"status"`
|
||
IsTrusted *bool `form:"is_trusted"`
|
||
Keyword string `form:"keyword"`
|
||
Cursor string `form:"cursor"`
|
||
Size int `form:"size"`
|
||
}
|
||
|
||
func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesRequest) ([]*domain.Device, int64, error) {
|
||
if req.Page <= 0 {
|
||
req.Page = 1
|
||
}
|
||
if req.PageSize <= 0 {
|
||
req.PageSize = 20
|
||
}
|
||
if req.PageSize > 100 {
|
||
req.PageSize = 100
|
||
}
|
||
|
||
offset := (req.Page - 1) * req.PageSize
|
||
params := &repository.ListDevicesParams{
|
||
UserID: req.UserID,
|
||
Keyword: req.Keyword,
|
||
Offset: offset,
|
||
Limit: req.PageSize,
|
||
}
|
||
|
||
if req.Status != nil && (*req.Status == 0 || *req.Status == 1) {
|
||
status := domain.DeviceStatus(*req.Status)
|
||
params.Status = &status
|
||
}
|
||
if req.IsTrusted != nil {
|
||
params.IsTrusted = req.IsTrusted
|
||
}
|
||
|
||
return s.deviceRepo.ListAll(ctx, params)
|
||
}
|
||
|
||
func (s *DeviceService) GetAllDevicesCursor(ctx context.Context, req *GetAllDevicesRequest) (*CursorResult, error) {
|
||
size := pagination.ClampPageSize(req.Size)
|
||
if req.PageSize > 0 && req.Cursor == "" {
|
||
size = pagination.ClampPageSize(req.PageSize)
|
||
}
|
||
|
||
cursor, err := pagination.Decode(req.Cursor)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("invalid cursor: %w", err)
|
||
}
|
||
|
||
params := &repository.ListDevicesParams{
|
||
UserID: req.UserID,
|
||
Keyword: req.Keyword,
|
||
}
|
||
if req.Status != nil && (*req.Status == 0 || *req.Status == 1) {
|
||
status := domain.DeviceStatus(*req.Status)
|
||
params.Status = &status
|
||
}
|
||
if req.IsTrusted != nil {
|
||
params.IsTrusted = req.IsTrusted
|
||
}
|
||
|
||
devices, hasMore, err := s.deviceRepo.ListAllCursor(ctx, params, size, cursor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
nextCursor := ""
|
||
if len(devices) > 0 {
|
||
last := devices[len(devices)-1]
|
||
nextCursor = pagination.BuildNextCursor(last.ID, last.LastActiveTime)
|
||
}
|
||
|
||
return &CursorResult{
|
||
Items: devices,
|
||
NextCursor: nextCursor,
|
||
HasMore: hasMore,
|
||
PageSize: size,
|
||
}, nil
|
||
}
|
||
|
||
func (s *DeviceService) GetDeviceByDeviceID(ctx context.Context, userID int64, deviceID string) (*domain.Device, error) {
|
||
return s.deviceRepo.GetByDeviceID(ctx, userID, deviceID)
|
||
}
|