spark-server/internal/module/user/logic/login.go
zhr ae9a71034d feat: implement Spark IM V3 modular monolith
Add HTTP /v1 MVC, TCP send path, groups with server-side member-avatar collage, object-storage upload, and friends/favorites around the architecture-v3 layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 19:52:11 +08:00

192 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}