2026-06-26 08:37:04 +00:00
|
|
|
package usecase
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-07-09 03:45:28 +00:00
|
|
|
"crypto/rand"
|
|
|
|
|
"fmt"
|
|
|
|
|
"math/big"
|
2026-06-26 08:37:04 +00:00
|
|
|
"strings"
|
2026-07-09 03:45:28 +00:00
|
|
|
"time"
|
2026-06-26 08:37:04 +00:00
|
|
|
|
2026-07-09 03:45:28 +00:00
|
|
|
"haixun-backend/internal/library/clock"
|
2026-06-26 08:37:04 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 14:02:41 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 14:02:41 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 03:45:28 +00:00
|
|
|
func (u *memberUseCase) VerifyEmail(ctx context.Context, tenantID, uid, verifyCode string) (*entity.Member, error) {
|
|
|
|
|
if tenantID == "" || uid == "" || strings.TrimSpace(verifyCode) == "" {
|
|
|
|
|
return nil, app.For(code.Auth).InputMissingRequired("tenant_id, uid, and code are required")
|
|
|
|
|
}
|
|
|
|
|
member, err := u.repo.FindByUID(ctx, tenantID, uid)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if member.Origin != "" && member.Origin != entity.OriginNative {
|
|
|
|
|
if member.EmailVerified {
|
|
|
|
|
return member, nil
|
|
|
|
|
}
|
|
|
|
|
return u.repo.UpdateProfile(ctx, tenantID, uid, domrepo.ProfileUpdate{
|
|
|
|
|
EmailVerified: boolPtr(true),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return u.repo.ConfirmEmailVerification(ctx, tenantID, uid, strings.TrimSpace(verifyCode))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (u *memberUseCase) ResendEmailVerification(ctx context.Context, tenantID, uid string, send func(email, code string) error) error {
|
|
|
|
|
if tenantID == "" || uid == "" {
|
|
|
|
|
return app.For(code.Auth).InputMissingRequired("tenant_id and uid are required")
|
|
|
|
|
}
|
|
|
|
|
if send == nil {
|
|
|
|
|
return app.For(code.Auth).SysNotImplemented("email sender is not configured")
|
|
|
|
|
}
|
|
|
|
|
member, err := u.repo.FindByUID(ctx, tenantID, uid)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if member.Origin != "" && member.Origin != entity.OriginNative {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if member.EmailVerified {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if member.EmailVerifyExpiresAt > 0 {
|
|
|
|
|
sentAt := member.EmailVerifyExpiresAt - int64(15*time.Minute)
|
|
|
|
|
if clock.NowUnixNano()-sentAt < int64(60*time.Second) {
|
|
|
|
|
return app.For(code.Auth).ResInvalidState("verification email was sent recently; please wait before resending")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
verifyCode, err := generateVerifyCode()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
expiresAt := clock.NowUnixNano() + int64(15*time.Minute)
|
|
|
|
|
if err := u.repo.SetEmailVerificationCode(ctx, tenantID, uid, verifyCode, expiresAt); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := send(member.Email, verifyCode); err != nil {
|
|
|
|
|
return app.For(code.Auth).SvcThirdParty("驗證碼已產生但寄信失敗,請確認 SMTP 或 Mailpit 是否啟動").WithCause(err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func boolPtr(value bool) *bool {
|
|
|
|
|
return &value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func generateVerifyCode() (string, error) {
|
|
|
|
|
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%06d", n.Int64()), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 14:02:41 +00:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
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")
|
|
|
|
|
}
|
2026-07-07 14:02:41 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
return u.repo.UpdateProfile(ctx, req.TenantID, req.UID, domrepo.ProfileUpdate{
|
2026-07-07 14:02:41 +00:00
|
|
|
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,
|
2026-06-26 08:37:04 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
}
|
2026-07-07 14:02:41 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|