package logic import ( "context" "errors" "strings" "time" "spark-server/internal/module/friend/entity" userentity "spark-server/internal/module/user/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" "spark-server/internal/pkg/reqctx" "spark-server/internal/pkg/validate" ) // ApplyInput starts a friend request. Exactly one of uid / email / spark_id / qrcode. type ApplyInput struct { UID int64 `json:"uid" validate:"omitempty,gt=0" label:"field.uid"` Email string `json:"email" validate:"omitempty,email" label:"field.email" errmsg:"email:validate.login.email_invalid"` SparkID string `json:"spark_id" validate:"omitempty,min=6,max=32" label:"field.spark_id" errmsg:"min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"` QRCode string `json:"qrcode" validate:"omitempty,max=64" label:"field.qrcode"` Reason string `json:"reason" validate:"omitempty,max=128" label:"field.reason" errmsg:"max:validate.friend.reason_invalid"` Remark string `json:"remark" validate:"omitempty,max=64" label:"field.remark" errmsg:"max:validate.friend.remark_invalid"` } // ApplyResult is returned after applying or auto-accepting a reverse request. type ApplyResult struct { Status entity.RequestStatus `json:"status"` RequestID int64 `json:"request_id,omitempty"` UID int64 `json:"uid"` } func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) (*ApplyResult, error) { if !s.ready() || fromUserID <= 0 { return nil, errs.ErrUnauthorized } if in == nil { return nil, errs.ErrInvalidArgument } if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { return nil, err } target, source, err := s.resolveTarget(ctx, in) if err != nil { return nil, err } if err := s.canApply(ctx, fromUserID, target.ID); err != nil { return nil, err } if reverse, err := s.repo.GetRequestByPair(ctx, target.ID, fromUserID); err == nil && reverse.Status == entity.RequestPending { if err := s.becomeFriends(ctx, reverse, fromUserID, strings.TrimSpace(in.Remark)); err != nil { return nil, err } return &ApplyResult{Status: entity.RequestAccepted, UID: target.ID}, nil } else if err != nil && !errors.Is(err, errs.ErrNotFound) { return nil, err } req, err := s.upsertPending(ctx, fromUserID, target.ID, source, in) if err != nil { return nil, err } return &ApplyResult{Status: entity.RequestPending, RequestID: req.ID, UID: target.ID}, nil } func (s *Service) resolveTarget(ctx context.Context, in *ApplyInput) (*userentity.User, entity.Source, error) { lang := reqctx.Lang(ctx) n := 0 if in.UID > 0 { n++ } if strings.TrimSpace(in.Email) != "" { n++ } if strings.TrimSpace(in.SparkID) != "" { n++ } if strings.TrimSpace(in.QRCode) != "" { n++ } if n != 1 { return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyFriendTargetRequired, nil)) } switch { case in.UID > 0: u, err := s.activeUser(ctx, in.UID) return u, entity.SourceSparkID, err case strings.TrimSpace(in.Email) != "": u, err := s.users.GetByEmail(ctx, userentity.NormalizeEmail(in.Email)) return s.ensureActive(ctx, u, err, entity.SourceEmail) case strings.TrimSpace(in.SparkID) != "": u, err := s.users.GetBySparkID(ctx, strings.TrimSpace(in.SparkID)) return s.ensureActive(ctx, u, err, entity.SourceSparkID) default: raw := strings.TrimSpace(in.QRCode) if !userentity.ValidQRCode(raw) { return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyFriendQRInvalid, nil)) } u, err := s.users.GetByQRCode(ctx, raw) return s.ensureActive(ctx, u, err, entity.SourceQRCode) } } func (s *Service) ensureActive(_ context.Context, u *userentity.User, err error, source entity.Source) (*userentity.User, entity.Source, error) { if err != nil { if errors.Is(err, errs.ErrNotFound) { return nil, 0, errs.ErrNotFound } return nil, 0, err } if u == nil || u.Status != userentity.StatusActive { return nil, 0, errs.ErrNotFound } return u, source, nil } func (s *Service) activeUser(ctx context.Context, id int64) (*userentity.User, error) { u, err := s.users.Get(ctx, id) if err != nil { return nil, err } if u == nil || u.Status != userentity.StatusActive { return nil, errs.ErrNotFound } return u, nil } func (s *Service) canApply(ctx context.Context, from, to int64) error { lang := reqctx.Lang(ctx) if entity.SameUser(from, to) { return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyFriendSelf, nil)) } blockedMe, err := s.repo.IsBlocked(ctx, from, to) if err != nil { return err } if blockedMe { return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyFriendBlocked, nil)) } blockedBy, err := s.repo.IsBlocked(ctx, to, from) if err != nil { return err } if blockedBy { return errs.ErrNotFound } ok, err := s.AreFriends(ctx, from, to) if err != nil { return err } if ok { return errs.ErrDuplicate.WithMsg(i18n.T(lang, i18n.KeyFriendAlready, nil)) } return nil } func (s *Service) upsertPending(ctx context.Context, from, to int64, source entity.Source, in *ApplyInput) (*entity.Request, error) { now := time.Now() req := &entity.Request{ FromUser: from, ToUser: to, Source: source, Reason: strings.TrimSpace(in.Reason), Remark: strings.TrimSpace(in.Remark), Status: entity.RequestPending, CreatedAt: now, UpdatedAt: now, } existing, err := s.repo.GetRequestByPair(ctx, from, to) switch { case err == nil: req.ID = existing.ID req.CreatedAt = existing.CreatedAt if req.Remark == "" { req.Remark = existing.Remark } case errors.Is(err, errs.ErrNotFound): id, err := s.ids.NextID() if err != nil { return nil, err } req.ID = id default: return nil, err } if err := s.repo.UpsertRequest(ctx, req); err != nil { return nil, err } return req, nil }