package usecase import ( "context" "strings" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" authusecase "haixun-backend/internal/model/auth/domain/usecase" "haixun-backend/internal/model/member/domain/entity" domrepo "haixun-backend/internal/model/member/domain/repository" domusecase "haixun-backend/internal/model/member/domain/usecase" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) type memberUseCase struct { repo domrepo.Repository tokens authusecase.TokenUseCase } func NewUseCase(repo domrepo.Repository, tokens authusecase.TokenUseCase) domusecase.UseCase { return &memberUseCase{repo: repo, tokens: tokens} } func (u *memberUseCase) Register(ctx context.Context, req domusecase.RegisterRequest) (*entity.Member, *domusecase.AuthToken, error) { tenantID := strings.TrimSpace(req.TenantID) email := normalizeEmail(req.Email) if tenantID == "" || email == "" || req.Password == "" { return nil, nil, app.For(code.Member).InputMissingRequired("tenant_id, email, and password are required") } if len(req.Password) < 8 { return nil, nil, app.For(code.Member).InputInvalidFormat("password must be at least 8 characters") } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { return nil, nil, app.For(code.Member).SysInternal("hash password failed").WithCause(err) } member, err := u.repo.Create(ctx, &entity.Member{ TenantID: tenantID, UID: uuid.NewString(), Email: email, DisplayName: strings.TrimSpace(req.DisplayName), Language: strings.TrimSpace(req.Language), Status: entity.StatusOpen, Origin: entity.OriginNative, PasswordHash: string(hash), Roles: []string{"user"}, }) if err != nil { return nil, nil, err } token, err := u.issue(ctx, member) return member, token, err } func (u *memberUseCase) Login(ctx context.Context, req domusecase.LoginRequest) (*entity.Member, *domusecase.AuthToken, error) { tenantID := strings.TrimSpace(req.TenantID) email := normalizeEmail(req.Email) if tenantID == "" || email == "" || req.Password == "" { return nil, nil, app.For(code.Auth).InputMissingRequired("tenant_id, email, and password are required") } member, err := u.repo.FindByEmail(ctx, tenantID, email) if err != nil { return nil, nil, app.For(code.Auth).AuthUnauthorized("invalid email or password").WithCause(err) } if member.Status != entity.StatusOpen { return nil, nil, app.For(code.Auth).AuthForbidden("member is not active") } if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte(req.Password)); err != nil { return nil, nil, app.For(code.Auth).AuthUnauthorized("invalid email or password") } token, err := u.issue(ctx, member) return member, token, err } func (u *memberUseCase) ListMembers(ctx context.Context, req domusecase.ListMembersRequest) (*domusecase.ListMembersResult, error) { if strings.TrimSpace(req.TenantID) == "" { return nil, app.For(code.Member).InputMissingRequired("tenant_id is required") } items, total, page, pageSize, err := u.repo.List(ctx, strings.TrimSpace(req.TenantID), req.Page, req.PageSize) if err != nil { return nil, err } totalPages := int64(0) if pageSize > 0 { totalPages = (total + pageSize - 1) / pageSize } return &domusecase.ListMembersResult{List: items, Total: total, Page: page, PageSize: pageSize, TotalPages: totalPages}, nil } func (u *memberUseCase) GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) { if tenantID == "" || uid == "" { return nil, app.For(code.Member).InputMissingRequired("tenant_id and uid are required") } return u.repo.FindByUID(ctx, tenantID, uid) } func (u *memberUseCase) SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error { if tenantID == "" || uid == "" { return app.For(code.Member).InputMissingRequired("tenant_id and uid are required") } return u.repo.SetActiveThreadsAccountID(ctx, tenantID, uid, accountID) } func (u *memberUseCase) SetRoles(ctx context.Context, tenantID, uid string, roles []string) error { if tenantID == "" || uid == "" { return app.For(code.Member).InputMissingRequired("tenant_id and uid are required") } normalized := normalizeRoles(roles) if len(normalized) == 0 { return app.For(code.Member).InputInvalidFormat("roles must include user or admin") } for _, role := range normalized { if role != "user" && role != "admin" { return app.For(code.Member).InputInvalidFormat("roles only support user or admin") } } return u.repo.SetRoles(ctx, tenantID, uid, normalized) } func (u *memberUseCase) SetEmailVerificationCode(ctx context.Context, tenantID, uid, verifyCode string, expiresAt int64) error { if tenantID == "" || uid == "" || strings.TrimSpace(verifyCode) == "" || expiresAt <= 0 { return app.For(code.Member).InputMissingRequired("tenant_id, uid, code, and expires_at are required") } return u.repo.SetEmailVerificationCode(ctx, tenantID, uid, strings.TrimSpace(verifyCode), expiresAt) } func (u *memberUseCase) UpdatePassword(ctx context.Context, tenantID, uid, password string) error { if tenantID == "" || uid == "" || password == "" { return app.For(code.Member).InputMissingRequired("tenant_id, uid, and password are required") } if len(password) < 8 { return app.For(code.Member).InputInvalidFormat("password must be at least 8 characters") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return app.For(code.Member).SysInternal("hash password failed").WithCause(err) } return u.repo.UpdatePassword(ctx, tenantID, uid, string(hash)) } func (u *memberUseCase) UpdateProfile(ctx context.Context, req domusecase.UpdateProfileRequest) (*entity.Member, error) { if req.TenantID == "" || req.UID == "" { return nil, app.For(code.Member).InputMissingRequired("tenant_id and uid are required") } var status *entity.Status if req.Status != nil { next := entity.Status(strings.TrimSpace(*req.Status)) if next != entity.StatusOpen && next != entity.StatusSuspended && next != entity.StatusDeleted { return nil, app.For(code.Member).InputInvalidFormat("status only supports open, suspended, or deleted") } status = &next } return u.repo.UpdateProfile(ctx, req.TenantID, req.UID, domrepo.ProfileUpdate{ DisplayName: req.DisplayName, Avatar: req.Avatar, Language: req.Language, Currency: req.Currency, Phone: req.Phone, EmailVerified: req.EmailVerified, Status: status, BusinessEmail: req.BusinessEmail, BusinessEmailVerified: req.BusinessEmailVerified, BusinessPhone: req.BusinessPhone, BusinessPhoneVerified: req.BusinessPhoneVerified, }) } func (u *memberUseCase) issue(ctx context.Context, member *entity.Member) (*domusecase.AuthToken, error) { if u.tokens == nil { return nil, app.For(code.Auth).SysNotImplemented("token usecase is not configured") } pair, err := u.tokens.IssuePair(ctx, authusecase.IssuePairRequest{ TenantID: member.TenantID, UID: member.UID, }) if err != nil { return nil, err } return &domusecase.AuthToken{ AccessToken: pair.AccessToken, RefreshToken: pair.RefreshToken, ExpiresIn: pair.ExpiresIn, UID: member.UID, TokenType: pair.TokenType, }, nil } func normalizeEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } func normalizeRoles(roles []string) []string { seen := make(map[string]bool, len(roles)) out := make([]string, 0, len(roles)) for _, role := range roles { role = strings.ToLower(strings.TrimSpace(role)) if role == "" || seen[role] { continue } seen[role] = true out = append(out, role) } return out }