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

801 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
// ---------- OAuth providers (real when configured; else ErrOAuthFailed) ----------
func (s *AccountService) VerifyGoogleIDToken(ctx context.Context, idToken string) (subject, email, name string, err error) {
// Dev/mock: accept "mock:<subject>:<email>" when no Google client configured
if strings.HasPrefix(idToken, "mock:") {
parts := strings.SplitN(idToken, ":", 3)
if len(parts) >= 3 {
return parts[1], parts[2], parts[1], nil
}
if len(parts) == 2 {
return parts[1], parts[1] + "@oauth.local", parts[1], nil
}
}
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 strings.HasPrefix(code, "mock:") {
sub := strings.TrimPrefix(code, "mock:")
return sub, "line " + sub, "", nil
}
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
}