2026-07-10 12:54:45 +00:00
|
|
|
|
package usecase
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"math/big"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"apps/backend/internal/module/member/domain"
|
|
|
|
|
|
tokenDomain "apps/backend/internal/module/token/domain"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// AccountService implements domain.AccountUseCase (stand-alone full member surface).
|
|
|
|
|
|
// AuthService is an alias so existing callers keep working.
|
|
|
|
|
|
type AccountService struct {
|
|
|
|
|
|
Repo domain.Repository
|
|
|
|
|
|
Issuer tokenDomain.Issuer
|
|
|
|
|
|
BcryptCost int
|
|
|
|
|
|
// OAuth optional config (empty = dev mock path)
|
|
|
|
|
|
GoogleClientID string
|
|
|
|
|
|
LineClientID string
|
|
|
|
|
|
LineClientSecret string
|
|
|
|
|
|
LineRedirectURI string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// AuthService kept as type alias for svc/logic compatibility.
|
|
|
|
|
|
type AuthService = AccountService
|
|
|
|
|
|
|
|
|
|
|
|
func NewAuthService(repo domain.Repository, issuer tokenDomain.Issuer, bcryptCost int) *AccountService {
|
|
|
|
|
|
return NewAccountService(repo, issuer, bcryptCost)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func NewAccountService(repo domain.Repository, issuer tokenDomain.Issuer, bcryptCost int) *AccountService {
|
|
|
|
|
|
if bcryptCost <= 0 {
|
|
|
|
|
|
bcryptCost = 10
|
|
|
|
|
|
}
|
|
|
|
|
|
return &AccountService{Repo: repo, Issuer: issuer, BcryptCost: bcryptCost}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- create / login ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) CreateUserAccount(ctx context.Context, req domain.CreateLoginUserRequest) (*domain.Member, *tokenDomain.TokenPair, error) {
|
|
|
|
|
|
platform := strings.ToLower(strings.TrimSpace(req.Platform))
|
|
|
|
|
|
if platform == "" {
|
|
|
|
|
|
platform = domain.PlatformPassword
|
|
|
|
|
|
}
|
|
|
|
|
|
loginID := strings.TrimSpace(req.LoginID)
|
|
|
|
|
|
if loginID == "" {
|
|
|
|
|
|
return nil, nil, domain.ErrBadPassword
|
|
|
|
|
|
}
|
|
|
|
|
|
if platform == domain.PlatformPassword {
|
|
|
|
|
|
loginID = strings.ToLower(loginID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// already bound?
|
|
|
|
|
|
if _, err := s.Repo.FindIdentity(ctx, loginID, platform); err == nil {
|
|
|
|
|
|
return nil, nil, domain.ErrIdentityTaken
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uid, err := s.Repo.NextUID(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
now := domain.NowNano()
|
|
|
|
|
|
display := strings.TrimSpace(req.DisplayName)
|
|
|
|
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
|
|
|
|
phone := strings.TrimSpace(req.Phone)
|
|
|
|
|
|
if platform == domain.PlatformPassword {
|
|
|
|
|
|
if req.AccountType == domain.AccountTypePhone || looksLikePhone(loginID) {
|
|
|
|
|
|
if phone == "" {
|
|
|
|
|
|
phone = loginID
|
|
|
|
|
|
}
|
|
|
|
|
|
req.AccountType = domain.AccountTypePhone
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if email == "" {
|
|
|
|
|
|
email = loginID
|
|
|
|
|
|
}
|
|
|
|
|
|
req.AccountType = domain.AccountTypeEmail
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if display == "" {
|
|
|
|
|
|
if email != "" {
|
|
|
|
|
|
display = strings.Split(email, "@")[0]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
display = loginID
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var passHash string
|
|
|
|
|
|
if platform == domain.PlatformPassword {
|
|
|
|
|
|
if req.Password == "" {
|
|
|
|
|
|
return nil, nil, domain.ErrWeakPassword
|
|
|
|
|
|
}
|
|
|
|
|
|
passHash, err = HashPassword(req.Password, s.BcryptCost)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
status := domain.StatusActive
|
|
|
|
|
|
if platform == domain.PlatformPassword {
|
|
|
|
|
|
status = domain.StatusUnverified
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
m := &domain.Member{
|
|
|
|
|
|
UID: uid, TenantID: "default", Email: email, Phone: phone,
|
|
|
|
|
|
DisplayName: display, Roles: []string{domain.RoleMember}, Status: status,
|
|
|
|
|
|
EmailVerified: false, InviteCode: NewInviteCode(), PasswordHash: passHash,
|
|
|
|
|
|
Timezone: "Asia/Taipei", PreferredLanguage: "zh-TW", Currency: "TWD",
|
|
|
|
|
|
CreatedAt: now, UpdatedAt: now,
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := s.Repo.CreateMember(ctx, m); err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
accType := req.AccountType
|
|
|
|
|
|
if accType == "" {
|
|
|
|
|
|
if platform == domain.PlatformPassword {
|
|
|
|
|
|
if phone != "" && email == "" {
|
|
|
|
|
|
accType = domain.AccountTypePhone
|
|
|
|
|
|
} else {
|
|
|
|
|
|
accType = domain.AccountTypeEmail
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
accType = domain.AccountTypePlatform
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ident := &domain.Identity{
|
|
|
|
|
|
UID: uid, LoginID: loginID, Platform: platform,
|
|
|
|
|
|
AccountType: accType, PasswordHash: passHash, CreatedAt: now, UpdatedAt: now,
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := s.Repo.CreateIdentity(ctx, ident); err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pair, err := s.issueSession(m)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return m, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return m, pair, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) LoginPassword(ctx context.Context, loginID, password, platform string) (*domain.Member, *tokenDomain.TokenPair, error) {
|
|
|
|
|
|
if platform == "" {
|
|
|
|
|
|
platform = domain.PlatformPassword
|
|
|
|
|
|
}
|
|
|
|
|
|
loginID = strings.TrimSpace(loginID)
|
|
|
|
|
|
if platform == domain.PlatformPassword {
|
|
|
|
|
|
loginID = strings.ToLower(loginID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Prefer identity; fallback member email for seed admin without identity row
|
|
|
|
|
|
ident, err := s.Repo.FindIdentity(ctx, loginID, platform)
|
|
|
|
|
|
var m *domain.Member
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case err == nil:
|
|
|
|
|
|
hash := ident.PasswordHash
|
|
|
|
|
|
if hash == "" || !CheckPasswordHash(password, hash) {
|
|
|
|
|
|
// also try member primary password
|
|
|
|
|
|
m, err = s.Repo.FindByUID(ctx, ident.UID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.PasswordHash == "" || !CheckPasswordHash(password, m.PasswordHash) {
|
|
|
|
|
|
return nil, nil, domain.ErrBadPassword
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
m, err = s.Repo.FindByUID(ctx, ident.UID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
case errors.Is(err, domain.ErrIdentityNotFound):
|
|
|
|
|
|
// legacy: email login without identity
|
|
|
|
|
|
m, err = s.Repo.FindByEmail(ctx, loginID)
|
|
|
|
|
|
if errors.Is(err, domain.ErrNotFound) {
|
|
|
|
|
|
return nil, nil, domain.ErrBadPassword
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err // DB/auth errors must not look like bad password
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.PasswordHash == "" || !CheckPasswordHash(password, m.PasswordHash) {
|
|
|
|
|
|
return nil, nil, domain.ErrBadPassword
|
|
|
|
|
|
}
|
|
|
|
|
|
// auto-heal identity for seed users
|
|
|
|
|
|
_ = s.Repo.CreateIdentity(ctx, &domain.Identity{
|
|
|
|
|
|
UID: m.UID, LoginID: m.Email, Platform: domain.PlatformPassword,
|
|
|
|
|
|
AccountType: domain.AccountTypeEmail, PasswordHash: m.PasswordHash,
|
|
|
|
|
|
CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
default:
|
|
|
|
|
|
// infrastructure failure (e.g. Mongo auth)
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.IsSuspended() {
|
|
|
|
|
|
return nil, nil, domain.ErrSuspended
|
|
|
|
|
|
}
|
|
|
|
|
|
pair, err := s.issueSession(m)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return m, pair, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Login — Harbor Desk email login shorthand
|
|
|
|
|
|
func (s *AccountService) Login(ctx context.Context, email, password string) (*domain.Member, *tokenDomain.TokenPair, error) {
|
|
|
|
|
|
return s.LoginPassword(ctx, email, password, domain.PlatformPassword)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) LoginOAuth(ctx context.Context, platform, subject, email, displayName string) (*domain.Member, *tokenDomain.TokenPair, error) {
|
|
|
|
|
|
platform = strings.ToLower(strings.TrimSpace(platform))
|
|
|
|
|
|
subject = strings.TrimSpace(subject)
|
|
|
|
|
|
if platform == "" || subject == "" {
|
|
|
|
|
|
return nil, nil, domain.ErrOAuthFailed
|
|
|
|
|
|
}
|
|
|
|
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
|
|
|
|
|
|
|
|
|
|
if ident, err := s.Repo.FindIdentity(ctx, subject, platform); err == nil {
|
|
|
|
|
|
m, err := s.Repo.FindByUID(ctx, ident.UID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.IsSuspended() {
|
|
|
|
|
|
return nil, nil, domain.ErrSuspended
|
|
|
|
|
|
}
|
|
|
|
|
|
pair, err := s.issueSession(m)
|
|
|
|
|
|
return m, pair, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// new member via oauth
|
|
|
|
|
|
req := domain.CreateLoginUserRequest{
|
|
|
|
|
|
LoginID: subject, Platform: platform, AccountType: domain.AccountTypePlatform,
|
|
|
|
|
|
DisplayName: displayName, Email: email,
|
|
|
|
|
|
}
|
|
|
|
|
|
m, pair, err := s.CreateUserAccount(ctx, req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
// oauth accounts treated as verified email when provider gives email
|
|
|
|
|
|
if email != "" {
|
|
|
|
|
|
now := domain.NowNano()
|
|
|
|
|
|
m.EmailVerified = true
|
|
|
|
|
|
m.EmailVerifiedAt = &now
|
|
|
|
|
|
m.Status = domain.StatusActive
|
|
|
|
|
|
m.UpdatedAt = now
|
|
|
|
|
|
_ = s.Repo.UpdateMember(ctx, m)
|
|
|
|
|
|
}
|
|
|
|
|
|
return m, pair, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- query ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) GetUIDByAccount(ctx context.Context, account, platform string) (int64, error) {
|
|
|
|
|
|
ident, err := s.Repo.FindIdentity(ctx, account, platform)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return ident.UID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) GetUserAccountInfo(ctx context.Context, account, platform string) (*domain.Identity, error) {
|
|
|
|
|
|
return s.Repo.FindIdentity(ctx, account, platform)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) GetUserInfo(ctx context.Context, uid int64) (*domain.Member, error) {
|
|
|
|
|
|
return s.Repo.FindByUID(ctx, uid)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) ListMember(ctx context.Context, page, pageSize int, query, status string) ([]*domain.Member, int64, error) {
|
|
|
|
|
|
return s.Repo.ListPage(ctx, page, pageSize, query, status)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) ListIdentities(ctx context.Context, uid int64) ([]*domain.Identity, error) {
|
|
|
|
|
|
return s.Repo.ListIdentitiesByUID(ctx, uid)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) FindLoginIDsByUID(ctx context.Context, uid int64) ([]*domain.Identity, error) {
|
|
|
|
|
|
return s.Repo.ListIdentitiesByUID(ctx, uid)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- update ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) UpdateUserToken(ctx context.Context, loginID, platform, newPassword string) error {
|
|
|
|
|
|
hash, err := HashPassword(newPassword, s.BcryptCost)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := s.Repo.UpdateIdentityPassword(ctx, loginID, platform, hash); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
// sync primary password on member if platform password identity
|
|
|
|
|
|
ident, err := s.Repo.FindIdentity(ctx, loginID, platform)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
m, err := s.Repo.FindByUID(ctx, ident.UID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
m.PasswordHash = hash
|
|
|
|
|
|
m.UpdatedAt = domain.NowNano()
|
|
|
|
|
|
_ = s.Repo.UpdateMember(ctx, m)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) UpdateUserInfo(ctx context.Context, uid int64, patch *domain.UpdateUserInfoPatch) (*domain.Member, error) {
|
|
|
|
|
|
m, err := s.Repo.FindByUID(ctx, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch == nil {
|
|
|
|
|
|
return m, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.DisplayName != nil {
|
|
|
|
|
|
m.DisplayName = *patch.DisplayName
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.Nickname != nil {
|
|
|
|
|
|
m.Nickname = *patch.Nickname
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.FullName != nil {
|
|
|
|
|
|
m.FullName = *patch.FullName
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.AvatarURL != nil {
|
|
|
|
|
|
m.AvatarURL = *patch.AvatarURL
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.Bio != nil {
|
|
|
|
|
|
m.Bio = *patch.Bio
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.Timezone != nil {
|
|
|
|
|
|
m.Timezone = *patch.Timezone
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.NotifyEmail != nil {
|
|
|
|
|
|
m.NotifyEmail = *patch.NotifyEmail
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.GenderCode != nil {
|
|
|
|
|
|
m.GenderCode = *patch.GenderCode
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.Birthdate != nil {
|
|
|
|
|
|
m.Birthdate = *patch.Birthdate
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.National != nil {
|
|
|
|
|
|
m.National = *patch.National
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.Address != nil {
|
|
|
|
|
|
m.Address = *patch.Address
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.PostCode != nil {
|
|
|
|
|
|
m.PostCode = *patch.PostCode
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.PreferredLanguage != nil {
|
|
|
|
|
|
m.PreferredLanguage = *patch.PreferredLanguage
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.Currency != nil {
|
|
|
|
|
|
m.Currency = *patch.Currency
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.Email != nil {
|
|
|
|
|
|
newEmail := strings.ToLower(strings.TrimSpace(*patch.Email))
|
|
|
|
|
|
if newEmail != "" && newEmail != m.Email {
|
|
|
|
|
|
m.Email = newEmail
|
|
|
|
|
|
m.EmailVerified = false
|
|
|
|
|
|
m.EmailVerifiedAt = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.Phone != nil {
|
|
|
|
|
|
m.Phone = strings.TrimSpace(*patch.Phone)
|
|
|
|
|
|
m.PhoneVerified = false
|
|
|
|
|
|
m.PhoneVerifiedAt = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if patch.NewPassword != nil && *patch.NewPassword != "" {
|
|
|
|
|
|
if patch.CurrentPassword == nil || !CheckPasswordHash(*patch.CurrentPassword, m.PasswordHash) {
|
|
|
|
|
|
return nil, domain.ErrBadPassword
|
|
|
|
|
|
}
|
|
|
|
|
|
hash, err := HashPassword(*patch.NewPassword, s.BcryptCost)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
m.PasswordHash = hash
|
|
|
|
|
|
// update platform identity password if exists
|
|
|
|
|
|
if m.Email != "" {
|
|
|
|
|
|
_ = s.Repo.UpdateIdentityPassword(ctx, m.Email, domain.PlatformPassword, hash)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
m.UpdatedAt = domain.NowNano()
|
|
|
|
|
|
if err := s.Repo.UpdateMember(ctx, m); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return m, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) UpdateStatus(ctx context.Context, uid int64, status string) (*domain.Member, error) {
|
|
|
|
|
|
m, err := s.Repo.FindByUID(ctx, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
m.Status = status
|
|
|
|
|
|
m.UpdatedAt = domain.NowNano()
|
|
|
|
|
|
if err := s.Repo.UpdateMember(ctx, m); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return m, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- session ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) Refresh(ctx context.Context, refreshToken string) (*tokenDomain.TokenPair, error) {
|
|
|
|
|
|
claims, err := s.Issuer.ParseRefresh(refreshToken)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
uid, ok := s.Repo.ConsumeRefresh(claims.JTI)
|
|
|
|
|
|
if !ok || uid != claims.UID {
|
|
|
|
|
|
return nil, tokenDomain.ErrInvalidToken
|
|
|
|
|
|
}
|
|
|
|
|
|
m, err := s.Repo.FindByUID(ctx, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.IsSuspended() {
|
|
|
|
|
|
return nil, domain.ErrSuspended
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.issueSession(m)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) Logout(refreshToken string) {
|
|
|
|
|
|
if refreshToken == "" {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if claims, err := s.Issuer.ParseRefresh(refreshToken); err == nil {
|
|
|
|
|
|
s.Repo.RevokeRefresh(claims.JTI)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) LogoutAll(ctx context.Context, uid int64) error {
|
|
|
|
|
|
return s.Repo.RevokeAllRefreshByUID(ctx, uid)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- binding ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) BindAccount(ctx context.Context, uid int64, loginID, platform, accountType, password string) (*domain.Identity, error) {
|
|
|
|
|
|
if _, err := s.Repo.FindByUID(ctx, uid); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
platform = strings.ToLower(strings.TrimSpace(platform))
|
|
|
|
|
|
if platform == "" {
|
|
|
|
|
|
platform = domain.PlatformPassword
|
|
|
|
|
|
}
|
|
|
|
|
|
loginID = strings.TrimSpace(loginID)
|
|
|
|
|
|
if platform == domain.PlatformPassword {
|
|
|
|
|
|
loginID = strings.ToLower(loginID)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := s.Repo.FindIdentity(ctx, loginID, platform); err == nil {
|
|
|
|
|
|
return nil, domain.ErrIdentityTaken
|
|
|
|
|
|
}
|
|
|
|
|
|
var hash string
|
|
|
|
|
|
if platform == domain.PlatformPassword && password != "" {
|
|
|
|
|
|
var err error
|
|
|
|
|
|
hash, err = HashPassword(password, s.BcryptCost)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if accountType == "" {
|
|
|
|
|
|
if platform == domain.PlatformPassword {
|
|
|
|
|
|
accountType = domain.AccountTypeEmail
|
|
|
|
|
|
} else {
|
|
|
|
|
|
accountType = domain.AccountTypePlatform
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
now := domain.NowNano()
|
|
|
|
|
|
ident := &domain.Identity{
|
|
|
|
|
|
UID: uid, LoginID: loginID, Platform: platform,
|
|
|
|
|
|
AccountType: accountType, PasswordHash: hash, CreatedAt: now, UpdatedAt: now,
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := s.Repo.CreateIdentity(ctx, ident); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return ident, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) UnbindAccount(ctx context.Context, uid int64, loginID, platform string) error {
|
|
|
|
|
|
ident, err := s.Repo.FindIdentity(ctx, loginID, platform)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if ident.UID != uid {
|
|
|
|
|
|
return domain.ErrIdentityNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
// refuse unbind last identity
|
|
|
|
|
|
list, err := s.Repo.ListIdentitiesByUID(ctx, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(list) <= 1 {
|
|
|
|
|
|
return domain.ErrAlreadyBound // reuse: cannot unbind last
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.Repo.DeleteIdentity(ctx, loginID, platform)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) BindVerifyEmail(ctx context.Context, uid int64, email string) error {
|
|
|
|
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
|
|
|
|
m, err := s.Repo.FindByUID(ctx, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
now := domain.NowNano()
|
|
|
|
|
|
m.Email = email
|
|
|
|
|
|
m.EmailVerified = true
|
|
|
|
|
|
m.EmailVerifiedAt = &now
|
|
|
|
|
|
if m.Status == domain.StatusUnverified {
|
|
|
|
|
|
m.Status = domain.StatusActive
|
|
|
|
|
|
}
|
|
|
|
|
|
m.UpdatedAt = now
|
|
|
|
|
|
if err := s.Repo.UpdateMember(ctx, m); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
// ensure identity
|
|
|
|
|
|
if _, err := s.Repo.FindIdentity(ctx, email, domain.PlatformPassword); err != nil {
|
|
|
|
|
|
_ = s.Repo.CreateIdentity(ctx, &domain.Identity{
|
|
|
|
|
|
UID: uid, LoginID: email, Platform: domain.PlatformPassword,
|
|
|
|
|
|
AccountType: domain.AccountTypeEmail, PasswordHash: m.PasswordHash,
|
|
|
|
|
|
CreatedAt: now, UpdatedAt: now,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) BindVerifyPhone(ctx context.Context, uid int64, phone string) error {
|
|
|
|
|
|
phone = strings.TrimSpace(phone)
|
|
|
|
|
|
m, err := s.Repo.FindByUID(ctx, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
now := domain.NowNano()
|
|
|
|
|
|
m.Phone = phone
|
|
|
|
|
|
m.PhoneVerified = true
|
|
|
|
|
|
m.PhoneVerifiedAt = &now
|
|
|
|
|
|
m.UpdatedAt = now
|
|
|
|
|
|
return s.Repo.UpdateMember(ctx, m)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- codes ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) GenerateCode(ctx context.Context, loginID, codeType string) (string, error) {
|
|
|
|
|
|
code := randomDigits(6)
|
|
|
|
|
|
s.Repo.SetCode(codeType, strings.ToLower(loginID), code, 10*time.Minute)
|
|
|
|
|
|
return code, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) VerifyCode(ctx context.Context, loginID, codeType, code string, consume bool) error {
|
|
|
|
|
|
key := strings.ToLower(loginID)
|
|
|
|
|
|
ok := false
|
|
|
|
|
|
if consume {
|
|
|
|
|
|
ok = s.Repo.CheckCode(codeType, key, code)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ok = s.Repo.PeekCode(codeType, key, code)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return domain.ErrInvalidCode
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) VerifyPlatformAuthResult(ctx context.Context, loginID, platform, password string) (bool, error) {
|
|
|
|
|
|
ident, err := s.Repo.FindIdentity(ctx, loginID, platform)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if ident.PasswordHash == "" {
|
|
|
|
|
|
return false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return CheckPasswordHash(password, ident.PasswordHash), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
// ---------- OAuth providers ----------
|
2026-07-10 12:54:45 +00:00
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) VerifyGoogleIDToken(ctx context.Context, idToken string) (subject, email, name string, err error) {
|
|
|
|
|
|
info, e := googleUserInfo(ctx, idToken)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return "", "", "", domain.ErrOAuthFailed
|
|
|
|
|
|
}
|
|
|
|
|
|
return info.ID, info.Email, info.Name, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) LineCodeToProfile(ctx context.Context, code, redirectURI string) (subject, displayName, email string, err error) {
|
|
|
|
|
|
if s.LineClientID == "" || s.LineClientSecret == "" {
|
|
|
|
|
|
return "", "", "", domain.ErrOAuthFailed
|
|
|
|
|
|
}
|
|
|
|
|
|
if redirectURI == "" {
|
|
|
|
|
|
redirectURI = s.LineRedirectURI
|
|
|
|
|
|
}
|
|
|
|
|
|
tok, e := lineExchangeCode(ctx, code, redirectURI, s.LineClientID, s.LineClientSecret)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return "", "", "", domain.ErrOAuthFailed
|
|
|
|
|
|
}
|
|
|
|
|
|
prof, e := lineProfile(ctx, tok)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return "", "", "", domain.ErrOAuthFailed
|
|
|
|
|
|
}
|
|
|
|
|
|
return prof.UserID, prof.DisplayName, "", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- Harbor Desk convenience ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) RegisterEmail(ctx context.Context, email, password, displayName string) (*domain.Member, *tokenDomain.TokenPair, error) {
|
|
|
|
|
|
return s.CreateUserAccount(ctx, domain.CreateLoginUserRequest{
|
|
|
|
|
|
LoginID: email, Platform: domain.PlatformPassword, AccountType: domain.AccountTypeEmail,
|
|
|
|
|
|
Password: password, DisplayName: displayName, Email: email,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Register — alias for logic layer
|
|
|
|
|
|
func (s *AccountService) Register(ctx context.Context, email, password, displayName string) (*domain.Member, *tokenDomain.TokenPair, error) {
|
|
|
|
|
|
return s.RegisterEmail(ctx, email, password, displayName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) Me(ctx context.Context, uid int64) (*domain.Member, error) {
|
|
|
|
|
|
m, err := s.Repo.FindByUID(ctx, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.IsSuspended() {
|
|
|
|
|
|
return nil, domain.ErrSuspended
|
|
|
|
|
|
}
|
|
|
|
|
|
return m, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) RequestPasswordReset(ctx context.Context, email string) (string, error) {
|
|
|
|
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
|
|
|
|
if email == "" || !strings.Contains(email, "@") {
|
|
|
|
|
|
return "", domain.ErrEmailNotRegistered
|
|
|
|
|
|
}
|
|
|
|
|
|
code := randomDigits(6)
|
|
|
|
|
|
// try identity or member — unknown email must error (product: 無此信箱不寄)
|
|
|
|
|
|
if _, err := s.Repo.FindByEmail(ctx, email); err == nil {
|
|
|
|
|
|
s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute)
|
|
|
|
|
|
return code, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := s.Repo.FindIdentity(ctx, email, domain.PlatformPassword); err == nil {
|
|
|
|
|
|
s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute)
|
|
|
|
|
|
return code, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return "", domain.ErrEmailNotRegistered
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) ResetPassword(ctx context.Context, email, code, newPassword string) error {
|
|
|
|
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
|
|
|
|
code = strings.TrimSpace(code)
|
|
|
|
|
|
// 1) 先驗密碼政策(失敗不消費驗證碼)
|
|
|
|
|
|
if err := ValidatePasswordPolicy(newPassword); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
// 2) Peek:輸錯/未過期都不刪(與信箱驗證同一套)
|
|
|
|
|
|
match := s.Repo.PeekCode(domain.CodeTypeForgetPassword, email, code) ||
|
|
|
|
|
|
s.Repo.PeekCode("reset", email, code)
|
|
|
|
|
|
if !match {
|
|
|
|
|
|
return domain.ErrInvalidCode
|
|
|
|
|
|
}
|
|
|
|
|
|
// 3) 業務先寫入;成功才消費碼
|
|
|
|
|
|
if err := s.UpdateUserToken(ctx, email, domain.PlatformPassword, newPassword); err != nil {
|
|
|
|
|
|
// seed / legacy member without identity row
|
|
|
|
|
|
hash, herr := HashPassword(newPassword, s.BcryptCost)
|
|
|
|
|
|
if herr != nil {
|
|
|
|
|
|
return herr
|
|
|
|
|
|
}
|
|
|
|
|
|
m, err := s.Repo.FindByEmail(ctx, email)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return domain.ErrNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
m.PasswordHash = hash
|
|
|
|
|
|
m.UpdatedAt = domain.NowNano()
|
|
|
|
|
|
if err := s.Repo.UpdateMember(ctx, m); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = s.Repo.CreateIdentity(ctx, &domain.Identity{
|
|
|
|
|
|
UID: m.UID, LoginID: email, Platform: domain.PlatformPassword,
|
|
|
|
|
|
AccountType: domain.AccountTypeEmail, PasswordHash: hash,
|
|
|
|
|
|
CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
// 4) 成功才刪碼(兩種 kind 都清)
|
|
|
|
|
|
_ = s.Repo.CheckCode(domain.CodeTypeForgetPassword, email, code)
|
|
|
|
|
|
_ = s.Repo.CheckCode("reset", email, code)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) SendEmailCode(ctx context.Context, uid int64) (string, error) {
|
|
|
|
|
|
m, err := s.Repo.FindByUID(ctx, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
code := randomDigits(6)
|
|
|
|
|
|
key := m.Email
|
|
|
|
|
|
if key == "" {
|
|
|
|
|
|
key = fmt.Sprintf("%d", uid)
|
|
|
|
|
|
}
|
|
|
|
|
|
s.Repo.SetCode(domain.CodeTypeEmail, strings.ToLower(key), code, 30*time.Minute)
|
|
|
|
|
|
// also store by uid for VerifyEmailCode
|
|
|
|
|
|
s.Repo.SetCode(domain.CodeTypeEmail, fmt.Sprintf("%d", uid), code, 30*time.Minute)
|
|
|
|
|
|
return code, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) VerifyEmailCode(ctx context.Context, uid int64, code string) (*domain.Member, error) {
|
|
|
|
|
|
m, err := s.Repo.FindByUID(ctx, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
uidKey := fmt.Sprintf("%d", uid)
|
|
|
|
|
|
emailKey := ""
|
|
|
|
|
|
if m.Email != "" {
|
|
|
|
|
|
emailKey = strings.ToLower(m.Email)
|
|
|
|
|
|
}
|
|
|
|
|
|
code = strings.TrimSpace(code)
|
|
|
|
|
|
// 與重設密碼同一套:Peek(輸錯不刪)→ 業務成功 → CheckCode 消費
|
|
|
|
|
|
match := s.Repo.PeekCode(domain.CodeTypeEmail, uidKey, code)
|
|
|
|
|
|
if !match && emailKey != "" {
|
|
|
|
|
|
match = s.Repo.PeekCode(domain.CodeTypeEmail, emailKey, code)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !match {
|
|
|
|
|
|
return nil, domain.ErrInvalidCode
|
|
|
|
|
|
}
|
|
|
|
|
|
now := domain.NowNano()
|
|
|
|
|
|
m.EmailVerified = true
|
|
|
|
|
|
m.EmailVerifiedAt = &now
|
|
|
|
|
|
if m.Status == domain.StatusUnverified {
|
|
|
|
|
|
m.Status = domain.StatusActive
|
|
|
|
|
|
}
|
|
|
|
|
|
m.UpdatedAt = now
|
|
|
|
|
|
if err := s.Repo.UpdateMember(ctx, m); err != nil {
|
|
|
|
|
|
// 業務失敗:碼仍有效,可重試
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
// 成功才消費兩把 key
|
|
|
|
|
|
_ = s.Repo.CheckCode(domain.CodeTypeEmail, uidKey, code)
|
|
|
|
|
|
if emailKey != "" {
|
|
|
|
|
|
_ = s.Repo.CheckCode(domain.CodeTypeEmail, emailKey, code)
|
|
|
|
|
|
}
|
|
|
|
|
|
return m, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// VerifyEmail — alias
|
|
|
|
|
|
func (s *AccountService) VerifyEmail(ctx context.Context, uid int64, code string) (*domain.Member, error) {
|
|
|
|
|
|
return s.VerifyEmailCode(ctx, uid, code)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- helpers ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (s *AccountService) issueSession(m *domain.Member) (*tokenDomain.TokenPair, error) {
|
|
|
|
|
|
pair, jti, exp, err := s.Issuer.Issue(m.UID, m.Roles)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
s.Repo.SaveRefresh(jti, m.UID, exp)
|
|
|
|
|
|
return pair, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func NewInviteCode() string {
|
|
|
|
|
|
var b [4]byte
|
|
|
|
|
|
_, _ = rand.Read(b[:])
|
|
|
|
|
|
return strings.ToUpper(hex.EncodeToString(b[:]))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func randomDigits(n int) string {
|
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
|
for i := 0; i < n; i++ {
|
|
|
|
|
|
v, _ := rand.Int(rand.Reader, big.NewInt(10))
|
|
|
|
|
|
b.WriteByte(byte('0' + v.Int64()))
|
|
|
|
|
|
}
|
|
|
|
|
|
return b.String()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func looksLikePhone(s string) bool {
|
|
|
|
|
|
if len(s) < 8 {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, c := range s {
|
|
|
|
|
|
if c >= '0' && c <= '9' || c == '+' {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|