package logic import ( "context" "crypto/subtle" "errors" "strings" "time" "spark-server/internal/module/user/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" "spark-server/internal/pkg/reqctx" ) // AuthenticateEmail verifies OTP (test01–test10@spark.com accept any code), // creates the user on first login, and upserts the login device. func (s *Service) AuthenticateEmail(ctx context.Context, email, verifyCode string, device *entity.LoginDevice) (*entity.User, error) { if s == nil || s.repo == nil || s.ids == nil { return nil, errs.ErrUnauthorized } email = entity.NormalizeEmail(email) if email == "" { return nil, errs.ErrUnauthorized } if err := s.checkVerifyCode(ctx, email, verifyCode); err != nil { return nil, err } u, err := s.getOrCreate(ctx, email) if err != nil { return nil, err } if err := s.ensureQRCode(ctx, u); err != nil { return nil, err } s.ensureDefaultAvatar(ctx, u) if u.Status != entity.StatusActive { return nil, errs.ErrForbidden } if err := s.touchDevice(ctx, u.ID, device); err != nil { return nil, err } if err := s.recordLogin(ctx, u.ID, device); err != nil { return nil, err } now := time.Now() if err := s.repo.TouchLogin(ctx, u.ID, now); err != nil { return nil, err } u.LastLoginAt = now u.LastActiveAt = now return u, nil } func (s *Service) checkVerifyCode(ctx context.Context, email, verifyCode string) error { if entity.IsTestEmail(email) { return nil } want, err := s.codes.Get(ctx, email) if err != nil { return err } if want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(verifyCode)) != 1 { return errs.ErrUnauthorized } _ = s.codes.Del(ctx, email) return nil } func (s *Service) getOrCreate(ctx context.Context, email string) (*entity.User, error) { u, err := s.repo.GetByEmail(ctx, email) if err == nil { return u, nil } if !errors.Is(err, errs.ErrNotFound) { return nil, err } return s.createWithEmail(ctx, email) } func (s *Service) createWithEmail(ctx context.Context, email string) (*entity.User, error) { id, err := s.ids.NextID() if err != nil { return nil, err } now := time.Now() qr, err := entity.NewQRCode() if err != nil { return nil, err } nick := nicknameFromEmail(email) u := &entity.User{ ID: id, SparkID: entity.DefaultSparkID(id), Nickname: nick, Avatar: entity.LetterAvatarDataURI(nick), Gender: entity.GenderUnknown, Status: entity.StatusActive, QRCode: qr, LastLoginAt: now, LastActiveAt: now, CreatedAt: now, UpdatedAt: now, } if err := s.repo.CreateWithEmail(ctx, u, email); err != nil { if errors.Is(err, errs.ErrDuplicate) { return s.repo.GetByEmail(ctx, email) } return nil, err } s.stampDefaultAvatar(ctx, u) return u, nil } func (s *Service) ensureQRCode(ctx context.Context, u *entity.User) error { if u == nil || entity.ValidQRCode(u.QRCode) { return nil } qr, err := entity.NewQRCode() if err != nil { return err } u.QRCode = qr return s.repo.UpdateQRCode(ctx, u.ID, u.QRCode) } func (s *Service) touchDevice(ctx context.Context, userID int64, device *entity.LoginDevice) error { if device == nil || device.DeviceID == 0 { return errs.ErrInvalidArgument } typ := entity.DeviceTypeOf(device) if !entity.ValidDeviceType(typ) { return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil)) } now := time.Now() ip := strings.TrimSpace(device.RealIP) if ip == "" { ip = reqctx.RealIP(ctx) } lang := strings.TrimSpace(device.Language) if lang == "" { lang = reqctx.Language(ctx) } return s.repo.UpsertDevice(ctx, &entity.Device{ UserID: userID, DeviceID: device.DeviceID, DeviceType: typ, DeviceName: device.DeviceName, AppVersion: device.AppVersion, Language: lang, LastIP: ip, LastLoginAt: now, CreatedAt: now, }) } func (s *Service) recordLogin(ctx context.Context, userID int64, device *entity.LoginDevice) error { if device == nil { return errs.ErrInvalidArgument } ip := strings.TrimSpace(device.RealIP) if ip == "" { ip = reqctx.RealIP(ctx) } lang := strings.TrimSpace(device.Language) if lang == "" { lang = reqctx.Language(ctx) } return s.repo.InsertLoginLog(ctx, &entity.LoginLog{ UserID: userID, DeviceID: device.DeviceID, DeviceType: entity.DeviceTypeOf(device), DeviceName: device.DeviceName, AppVersion: device.AppVersion, Language: lang, IP: ip, CreatedAt: time.Now(), }) } func nicknameFromEmail(email string) string { local, _, _ := strings.Cut(email, "@") local = strings.TrimSpace(local) if local == "" { return "user" } if len(local) > 64 { return local[:64] } return local }