thread-master/apps/backend/internal/module/member/usecase/account.go

814 lines
23 KiB
Go
Raw Permalink Normal View History

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)
2026-07-15 15:23:59 +00:00
GoogleClientID string
LineClientID string
LineClientSecret string
LineRedirectURI string
2026-07-10 12:54:45 +00:00
}
// 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 {
2026-07-15 15:23:59 +00:00
return err
2026-07-10 12:54:45 +00:00
}
m, err := s.Repo.FindByUID(ctx, ident.UID)
if err != nil {
2026-07-15 15:23:59 +00:00
return err
2026-07-10 12:54:45 +00:00
}
m.PasswordHash = hash
m.UpdatedAt = domain.NowNano()
2026-07-15 15:23:59 +00:00
if err := s.Repo.UpdateMember(ctx, m); err != nil {
return err
}
return s.Repo.RevokeAllRefreshByUID(ctx, ident.UID)
2026-07-10 12:54:45 +00:00
}
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
}
2026-07-15 15:23:59 +00:00
passwordChanged := patch.NewPassword != nil && *patch.NewPassword != ""
if passwordChanged {
2026-07-10 12:54:45 +00:00
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
}
2026-07-15 15:23:59 +00:00
if passwordChanged {
if err := s.Repo.RevokeAllRefreshByUID(ctx, uid); err != nil {
return nil, err
}
}
2026-07-10 12:54:45 +00:00
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)
2026-07-15 15:23:59 +00:00
if !s.Repo.SetCode(codeType, strings.ToLower(loginID), code, 10*time.Minute) {
return "", domain.ErrCodeRateLimited
}
2026-07-10 12:54:45 +00:00
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) {
2026-07-15 15:23:59 +00:00
if strings.TrimSpace(s.GoogleClientID) == "" {
return "", "", "", domain.ErrOAuthFailed
}
info, e := googleIDTokenInfo(ctx, idToken, s.GoogleClientID)
2026-07-10 12:54:45 +00:00
if e != nil {
return "", "", "", domain.ErrOAuthFailed
}
2026-07-15 15:23:59 +00:00
return info.Subject, info.Email, info.Name, nil
2026-07-10 12:54:45 +00:00
}
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 {
2026-07-15 15:23:59 +00:00
if !s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute) {
return "", domain.ErrCodeRateLimited
}
2026-07-10 12:54:45 +00:00
return code, nil
}
if _, err := s.Repo.FindIdentity(ctx, email, domain.PlatformPassword); err == nil {
2026-07-15 15:23:59 +00:00
if !s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute) {
return "", domain.ErrCodeRateLimited
}
2026-07-10 12:54:45 +00:00
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 {
2026-07-15 15:23:59 +00:00
if !errors.Is(err, domain.ErrIdentityNotFound) {
return err
}
2026-07-10 12:54:45 +00:00
// 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(),
})
2026-07-15 15:23:59 +00:00
if err := s.Repo.RevokeAllRefreshByUID(ctx, m.UID); err != nil {
return err
}
2026-07-10 12:54:45 +00:00
}
// 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)
}
2026-07-15 15:23:59 +00:00
if !s.Repo.SetCode(domain.CodeTypeEmail, strings.ToLower(key), code, 30*time.Minute) {
return "", domain.ErrCodeRateLimited
}
2026-07-10 12:54:45 +00:00
// also store by uid for VerifyEmailCode
2026-07-15 15:23:59 +00:00
if key != fmt.Sprintf("%d", uid) {
_ = s.Repo.SetCode(domain.CodeTypeEmail, fmt.Sprintf("%d", uid), code, 30*time.Minute)
}
2026-07-10 12:54:45 +00:00
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
}