feat: migrate workbench services

This commit is contained in:
richjohnrambo
2026-10-10 23:34:06 +08:00
parent c13fb72849
commit 81e3cf1d03
88 changed files with 7358 additions and 15333 deletions
+123
View File
@@ -0,0 +1,123 @@
package accounts
import (
"context"
"errors"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
"golang.org/x/crypto/bcrypt"
)
type profileRepository struct {
overview *store.MemberOverview
security *store.AccountSecurity
loginPasswordHash string
tradePasswordHash string
}
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
return nil, errors.New("not implemented")
}
func (r *profileRepository) AccountByMobileHash(context.Context, int64, string) (*store.Account, error) {
return nil, errors.New("not implemented")
}
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
func (r *profileRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) {
return r.overview, nil
}
func (r *profileRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) {
return &store.Avatar{AttachmentID: 12, URL: "https://cdn.example/avatar.png"}, nil
}
func (r *profileRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
return r.security, nil
}
func (r *profileRepository) UpdateLoginPassword(_ context.Context, _, _ int64, _, newHash string) error {
r.loginPasswordHash = newHash
return nil
}
func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _, newHash string) error {
r.tradePasswordHash = newHash
return nil
}
func TestProfileMapsMemberCategory(t *testing.T) {
repository := &profileRepository{overview: &store.MemberOverview{
MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2,
Currency: "CNY", WalletStatus: 1,
}}
service := &Service{repository: repository, ready: true}
profile, err := service.Profile(context.Background(), 0, 7)
if err != nil {
t.Fatal(err)
}
if profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" {
t.Fatalf("unexpected profile states: %+v", profile)
}
}
func TestChangePasswordVerifiesAndHashes(t *testing.T) {
currentHash, err := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash), MemberStatus: 1}}
service := &Service{repository: repository, ready: true}
if err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{
OldPassword: "old-secret", NewPassword: "new-secret",
}); err != nil {
t.Fatal(err)
}
if bcrypt.CompareHashAndPassword([]byte(repository.loginPasswordHash), []byte("new-secret")) != nil {
t.Fatal("new login password was not bcrypt hashed")
}
}
func TestChangePasswordRejectsWrongCurrentPassword(t *testing.T) {
currentHash, _ := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost)
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash)}}
service := &Service{repository: repository, ready: true}
err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{
OldPassword: "wrong-secret", NewPassword: "new-secret",
})
if !errors.Is(err, ErrCurrentPassword) {
t.Fatalf("error = %v, want %v", err, ErrCurrentPassword)
}
}
func TestSetTradePasswordRequiresSixDigits(t *testing.T) {
loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost)
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(loginHash)}}
service := &Service{repository: repository, ready: true}
for _, password := range []string{"", "12345", "1234567", "abcdef"} {
err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
LoginPassword: "login-secret", NewTradePassword: password,
})
if !errors.Is(err, ErrTradePassword) {
t.Errorf("password %q: error = %v, want %v", password, err, ErrTradePassword)
}
}
if err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
LoginPassword: "login-secret", NewTradePassword: "654321",
}); err != nil {
t.Fatal(err)
}
if bcrypt.CompareHashAndPassword([]byte(repository.tradePasswordHash), []byte("654321")) != nil {
t.Fatal("trade password was not bcrypt hashed")
}
}
func TestChangeTradePasswordRequiresOldPassword(t *testing.T) {
loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost)
tradeHash, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.MinCost)
repository := &profileRepository{security: &store.AccountSecurity{
PasswordHash: string(loginHash), TradePasswordHash: string(tradeHash),
}}
service := &Service{repository: repository, ready: true}
err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
LoginPassword: "login-secret", NewTradePassword: "654321",
})
if !errors.Is(err, ErrOldTradePasswordUnset) {
t.Fatalf("error = %v, want %v", err, ErrOldTradePasswordUnset)
}
}
+460
View File
@@ -0,0 +1,460 @@
package accounts
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"regexp"
"strconv"
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/store"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
var (
ErrUnavailable = errors.New("account service unavailable")
ErrUsernameInvalid = errors.New("username invalid")
ErrPhoneInvalid = errors.New("phone invalid")
ErrPasswordInvalid = errors.New("password invalid")
ErrReferralInvalid = errors.New("referral code invalid")
ErrPrivacyNotAccepted = errors.New("privacy policy not accepted")
ErrRateLimited = errors.New("login rate limited")
ErrCurrentPassword = errors.New("current password invalid")
ErrLoginPassword = errors.New("login password invalid")
ErrTradePassword = errors.New("trade password invalid")
ErrOldTradePassword = errors.New("old trade password invalid")
ErrOldTradePasswordUnset = errors.New("old trade password is required")
ErrAttachmentInvalid = errors.New("attachment invalid")
)
var (
usernamePattern = regexp.MustCompile(`^[a-z0-9]{1,10}$`)
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
referralPattern = regexp.MustCompile(`^[A-Za-z0-9]{1,32}$`)
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
)
type Repository interface {
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
AccountByMobileHash(context.Context, int64, string) (*store.Account, error)
RecordLogin(context.Context, int64, int64) error
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error)
UpdateLoginPassword(context.Context, int64, int64, string, string) error
UpdateTradePassword(context.Context, int64, int64, string, string) error
}
type RegisterInput struct {
Username string
Phone string
Password string
ReferralCode string
PrivacyAccepted bool
DeviceID string
}
type LoginInput struct {
Phone string
Password string
DeviceID string
}
type Result struct {
Account *store.Account
Token string
ExpiresAt time.Time
}
type Profile struct {
MemberID int64 `json:"member_id"`
MemberNo string `json:"member_no"`
Username string `json:"username"`
PhoneMask string `json:"phone_mask"`
AvatarURL string `json:"avatar_url"`
InviteCode string `json:"invite_code"`
Level ProfileLevel `json:"level"`
MemberStatus string `json:"member_status"`
MemberCategory string `json:"member_category"`
CreditScore int `json:"credit_score"`
Wallet ProfileWallet `json:"wallet"`
}
type ProfileLevel struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
IconURL string `json:"icon_url"`
CommissionRate int `json:"commission_rate"`
}
type ProfileWallet struct {
AvailableAmount int64 `json:"available_amount"`
FrozenAmount int64 `json:"frozen_amount"`
TotalIncome int64 `json:"total_income"`
TotalExpense int64 `json:"total_expense"`
Currency string `json:"currency"`
Status string `json:"status"`
Version int `json:"version"`
}
type AvatarResult struct {
AttachmentID int64 `json:"attachment_id"`
URL string `json:"avatar_url"`
}
type ChangePasswordInput struct {
OldPassword string
NewPassword string
}
type ChangeTradePasswordInput struct {
LoginPassword string
OldTradePassword string
NewTradePassword string
}
type Service struct {
repository Repository
redis *redis.Client
cfg config.AuthConfig
aead cipher.AEAD
lookupKey []byte
ready bool
}
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
service := &Service{repository: repository, redis: redisClient, cfg: cfg}
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey)
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
return service
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return service
}
aead, err := cipher.NewGCM(block)
if err != nil {
return service
}
service.aead = aead
service.lookupKey = lookupKey
service.ready = repository != nil && redisClient != nil
return service
}
func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, error) {
if !s.ready {
return nil, ErrUnavailable
}
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
input.Phone = strings.TrimSpace(input.Phone)
input.ReferralCode = strings.TrimSpace(input.ReferralCode)
if !usernamePattern.MatchString(input.Username) {
return nil, ErrUsernameInvalid
}
if !phonePattern.MatchString(input.Phone) {
return nil, ErrPhoneInvalid
}
if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 {
return nil, ErrPasswordInvalid
}
if input.ReferralCode != "" && !referralPattern.MatchString(input.ReferralCode) {
return nil, ErrReferralInvalid
}
if !input.PrivacyAccepted {
return nil, ErrPrivacyNotAccepted
}
normalizedPhone := "+86" + input.Phone
mobileCipher, err := s.encrypt(normalizedPhone)
if err != nil {
return nil, err
}
mobileHash := s.lookupHash(normalizedPhone)
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
memberNo, err := newPublicID("M", 21)
if err != nil {
return nil, err
}
inviteCode, err := newPublicID("", 8)
if err != nil {
return nil, err
}
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
TenantID: s.cfg.DefaultTenantID,
MemberNo: memberNo,
Username: input.Username,
MobileCipher: mobileCipher,
MobileHash: mobileHash,
MobileMask: maskPhone(input.Phone),
PasswordHash: string(passwordHash),
ReferralCode: input.ReferralCode,
InviteCode: inviteCode,
})
if err != nil {
if errors.Is(err, store.ErrReferralInvalid) {
return nil, ErrReferralInvalid
}
return nil, err
}
return s.issueSession(ctx, account, input.DeviceID)
}
func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error) {
if !s.ready {
return nil, ErrUnavailable
}
phone := strings.TrimSpace(input.Phone)
if !phonePattern.MatchString(phone) {
return nil, ErrPhoneInvalid
}
if input.Password == "" || len([]byte(input.Password)) > 72 {
return nil, store.ErrInvalidCredentials
}
mobileHash := s.lookupHash("+86" + phone)
rateKey := s.loginRateKey(mobileHash)
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
return nil, ErrRateLimited
}
account, err := s.repository.AccountByMobileHash(ctx, s.cfg.DefaultTenantID, mobileHash)
if err != nil {
if errors.Is(err, store.ErrInvalidCredentials) {
s.recordLoginFailure(ctx, rateKey)
}
return nil, err
}
if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(input.Password)) != nil {
s.recordLoginFailure(ctx, rateKey)
return nil, store.ErrInvalidCredentials
}
if err := s.repository.RecordLogin(ctx, account.TenantID, account.MemberID); err != nil {
return nil, err
}
result, err := s.issueSession(ctx, account, input.DeviceID)
if err == nil {
_ = s.redis.Del(ctx, rateKey).Err()
}
return result, err
}
func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profile, error) {
if !s.ready {
return nil, ErrUnavailable
}
row, err := s.repository.MemberOverview(ctx, tenantID, memberID)
if err != nil {
return nil, err
}
return &Profile{
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
PhoneMask: row.MobileMask, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName,
IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate},
MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory),
CreditScore: row.CreditScore,
Wallet: ProfileWallet{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount,
TotalIncome: row.TotalIncome, TotalExpense: row.TotalExpense, Currency: row.Currency,
Status: walletStatusName(row.WalletStatus), Version: row.WalletVersion},
}, nil
}
func (s *Service) ChangeAvatar(ctx context.Context, tenantID, memberID, attachmentID int64) (*AvatarResult, error) {
if !s.ready {
return nil, ErrUnavailable
}
if attachmentID <= 0 {
return nil, ErrAttachmentInvalid
}
avatar, err := s.repository.UpdateAvatar(ctx, tenantID, memberID, attachmentID)
if errors.Is(err, store.ErrAttachmentInvalid) {
return nil, ErrAttachmentInvalid
}
if err != nil {
return nil, err
}
return &AvatarResult{AttachmentID: avatar.AttachmentID, URL: avatar.URL}, nil
}
func (s *Service) ChangePassword(ctx context.Context, tenantID, memberID int64, input ChangePasswordInput) error {
if !s.ready {
return ErrUnavailable
}
if input.OldPassword == "" || len([]byte(input.OldPassword)) > 72 {
return ErrCurrentPassword
}
if len([]rune(input.NewPassword)) < 6 || len([]byte(input.NewPassword)) > 72 {
return ErrPasswordInvalid
}
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
if err != nil {
return err
}
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.OldPassword)) != nil {
return ErrCurrentPassword
}
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.NewPassword)) == nil {
return ErrPasswordInvalid
}
newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
return s.repository.UpdateLoginPassword(ctx, tenantID, memberID, security.PasswordHash, string(newHash))
}
func (s *Service) ChangeTradePassword(ctx context.Context, tenantID, memberID int64, input ChangeTradePasswordInput) error {
if !s.ready {
return ErrUnavailable
}
if input.LoginPassword == "" || len([]byte(input.LoginPassword)) > 72 {
return ErrLoginPassword
}
if !tradePasswordPattern.MatchString(input.NewTradePassword) {
return ErrTradePassword
}
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
if err != nil {
return err
}
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.LoginPassword)) != nil {
return ErrLoginPassword
}
if security.TradePasswordHash != "" {
if input.OldTradePassword == "" {
return ErrOldTradePasswordUnset
}
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.OldTradePassword)) != nil {
return ErrOldTradePassword
}
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.NewTradePassword)) == nil {
return ErrTradePassword
}
} else if input.OldTradePassword != "" {
return ErrOldTradePassword
}
newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewTradePassword), bcrypt.DefaultCost)
if err != nil {
return err
}
return s.repository.UpdateTradePassword(ctx, tenantID, memberID, security.TradePasswordHash, string(newHash))
}
func (s *Service) issueSession(ctx context.Context, account *store.Account, deviceID string) (*Result, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
token := base64.RawURLEncoding.EncodeToString(raw)
expiresAt := time.Now().Add(time.Duration(s.cfg.SessionTTLSeconds) * time.Second).UTC().Round(time.Millisecond)
payload, err := json.Marshal(map[string]any{
"member_id": account.MemberID,
"tenant_id": account.TenantID,
"device_id": strings.TrimSpace(deviceID),
"state": "active",
"issued_at": time.Now().UTC().Format(time.RFC3339Nano),
"expires_at": expiresAt.Format(time.RFC3339),
})
if err != nil {
return nil, err
}
if err := s.redis.Set(ctx, sessionKey(s.cfg.SessionKeyPattern, raw), payload, time.Until(expiresAt)).Err(); err != nil {
return nil, err
}
return &Result{Account: account, Token: token, ExpiresAt: expiresAt}, nil
}
func (s *Service) encrypt(value string) (string, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
sealed := s.aead.Seal(nil, nonce, []byte(value), nil)
return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil
}
func (s *Service) lookupHash(value string) string {
mac := hmac.New(sha256.New, s.lookupKey)
_, _ = mac.Write([]byte(value))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *Service) loginRateKey(mobileHash string) string {
return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + mobileHash
}
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
pipe := s.redis.TxPipeline()
pipe.Incr(ctx, key)
pipe.Expire(ctx, key, 15*time.Minute)
_, _ = pipe.Exec(ctx)
}
func decodeKey(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
return decoded, nil
}
return base64.RawStdEncoding.DecodeString(value)
}
func sessionKey(pattern string, rawToken []byte) string {
digest := sha256.Sum256(rawToken)
return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1)
}
func maskPhone(phone string) string {
if len(phone) != 11 {
return ""
}
return phone[:3] + "****" + phone[7:]
}
func newPublicID(prefix string, length int) (string, error) {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
raw := make([]byte, length-len(prefix))
if _, err := rand.Read(raw); err != nil {
return "", err
}
result := make([]byte, len(raw))
for i, value := range raw {
result[i] = alphabet[int(value)%len(alphabet)]
}
return prefix + string(result), nil
}
func memberStatusName(status int) string {
if status == 1 {
return "active"
}
return "disabled"
}
func memberCategoryName(category int) string {
if category == 2 {
return "fake"
}
return "real"
}
func walletStatusName(status int) string {
if status == 1 {
return "active"
}
return "disabled"
}
+100
View File
@@ -0,0 +1,100 @@
package accounts
import (
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"errors"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
)
type fakeRepository struct{}
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) AccountByMobileHash(context.Context, int64, string) (*store.Account, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
return errors.New("should not be called")
}
func (fakeRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) UpdateLoginPassword(context.Context, int64, int64, string, string) error {
return errors.New("should not be called")
}
func (fakeRepository) UpdateTradePassword(context.Context, int64, int64, string, string) error {
return errors.New("should not be called")
}
func TestRegisterValidation(t *testing.T) {
s := &Service{repository: fakeRepository{}, ready: true}
tests := []struct {
name string
input RegisterInput
want error
}{
{name: "username", input: RegisterInput{Username: "中文", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrUsernameInvalid},
{name: "phone", input: RegisterInput{Username: "alice", Phone: "12800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrPhoneInvalid},
{name: "password", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "123", PrivacyAccepted: true}, want: ErrPasswordInvalid},
{name: "referral", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "bad!", PrivacyAccepted: true}, want: ErrReferralInvalid},
{name: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1"}, want: ErrPrivacyNotAccepted},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := s.Register(context.Background(), test.input)
if !errors.Is(err, test.want) {
t.Fatalf("Register() error = %v, want %v", err, test.want)
}
})
}
}
func TestMobileEncryptionRoundTrip(t *testing.T) {
key := make([]byte, 32)
block, err := aes.NewCipher(key)
if err != nil {
t.Fatal(err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
s := &Service{aead: aead, lookupKey: []byte("01234567890123456789012345678901")}
encrypted, err := s.encrypt("+8613800138000")
if err != nil {
t.Fatal(err)
}
raw, err := base64.RawStdEncoding.DecodeString(encrypted)
if err != nil {
t.Fatal(err)
}
plain, err := aead.Open(nil, raw[:aead.NonceSize()], raw[aead.NonceSize():], nil)
if err != nil || string(plain) != "+8613800138000" {
t.Fatalf("decrypt = %q, err=%v", plain, err)
}
if len(s.lookupHash("+8613800138000")) != 64 {
t.Fatal("mobile lookup hash must be hex sha256")
}
}
func TestLoginRequiresMainlandPhone(t *testing.T) {
s := &Service{repository: fakeRepository{}, ready: true}
for _, phone := range []string{"", "alice01", "12800138000", "+8613800138000"} {
_, err := s.Login(context.Background(), LoginInput{Phone: phone, Password: "secret1"})
if !errors.Is(err, ErrPhoneInvalid) {
t.Errorf("Login(phone=%q) error = %v, want %v", phone, err, ErrPhoneInvalid)
}
}
}