feat: 迁移商务登录品牌和三步找回密码

This commit is contained in:
Yvan
2026-11-03 19:07:30 +08:00
parent 317c253ba9
commit 8048dfb514
39 changed files with 2190 additions and 283 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase)
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase)
+239
View File
@@ -0,0 +1,239 @@
package business
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"github.com/go-kratos/kratos/v3/errors"
"golang.org/x/crypto/bcrypt"
"math/big"
"regexp"
"strings"
"time"
"unicode/utf16"
)
var (
ErrResetAccount = errors.BadRequest("SW_RESET_ACCOUNT", "账号不存在或未注册")
ErrResetPhone = errors.BadRequest("SW_RESET_PHONE", "该账号未绑定手机号,请联系管理员重置")
ErrResetCode = errors.BadRequest("SW_RESET_CODE", "验证码错误")
ErrResetToken = errors.BadRequest("SW_RESET_TOKEN", "重置凭证已失效,请重新找回密码")
ErrResetLength = errors.BadRequest("SW_RESET_LENGTH", "密码长度需为 6-15 位")
ErrResetMismatch = errors.BadRequest("SW_RESET_MISMATCH", "两次输入的密码不一致")
ErrResetSMS = errors.BadRequest("SW_RESET_SMS", "验证码短信发送失败")
ErrResetRate = errors.TooManyRequests("SW_RESET_RATE", "验证码发送过于频繁,请稍后重试")
)
type ResetUser struct {
ID uint
Username, Phone string
Enabled bool
Status int
}
type ResetCode struct {
UserID uint
CodeHash string
ExpiresAt, SendAfter *time.Time
}
type ResetToken struct {
Hash string
UserID uint
ExpiresAt, CreatedAt time.Time
Failures int
}
type PasswordResetRepo interface {
UserByName(context.Context, string) (*ResetUser, error)
UserByID(context.Context, uint) (*ResetUser, error)
WithCodeLock(context.Context, uint, func(context.Context) error) error
Code(context.Context, uint) (*ResetCode, error)
SaveCode(context.Context, *ResetCode) error
CreateToken(context.Context, *ResetToken) error
WithTokenLock(context.Context, string, func(context.Context) error) error
Token(context.Context, string) (*ResetToken, error)
SaveToken(context.Context, *ResetToken) error
DeleteToken(context.Context, string) error
UpdatePassword(context.Context, uint, string) error
}
type PasswordResetSMS interface {
SendCode(context.Context, string, string) error
}
type PasswordResetUsecase struct {
repo PasswordResetRepo
sms PasswordResetSMS
}
func NewPasswordResetUsecase(repo PasswordResetRepo, sms PasswordResetSMS) *PasswordResetUsecase {
return &PasswordResetUsecase{repo: repo, sms: sms}
}
func secretHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
var resetPhonePattern = regexp.MustCompile(`^\d{11}$`)
var resetCodePattern = regexp.MustCompile(`^\d{6}$`)
func MaskPhone(phone string) string {
if !resetPhonePattern.MatchString(phone) {
return "****"
}
return phone[:3] + "****" + phone[7:]
}
func (uc *PasswordResetUsecase) user(ctx context.Context, username string) (*ResetUser, error) {
u, err := uc.repo.UserByName(ctx, username)
if err != nil {
return nil, err
}
if u == nil || !u.Enabled || u.Status != 0 {
return nil, ErrResetAccount
}
if strings.TrimSpace(u.Phone) == "" {
return nil, ErrResetPhone
}
return u, nil
}
func (uc *PasswordResetUsecase) Check(ctx context.Context, username string) (string, error) {
u, err := uc.user(ctx, username)
if err != nil {
return "", err
}
return MaskPhone(u.Phone), nil
}
func (uc *PasswordResetUsecase) SendCode(ctx context.Context, username string) (string, error) {
u, err := uc.user(ctx, username)
if err != nil {
return "", err
}
err = uc.repo.WithCodeLock(ctx, u.ID, func(txctx context.Context) error {
state, err := uc.repo.Code(txctx, u.ID)
if err != nil {
return err
}
now := time.Now()
if state.SendAfter != nil && state.SendAfter.After(now) {
return ErrResetRate
}
next := now.Add(time.Minute)
state.SendAfter = &next
return uc.repo.SaveCode(txctx, state)
})
if err != nil {
return "", err
}
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", ErrResetSMS
}
code := fmt.Sprintf("%06d", n.Int64())
if err := uc.sms.SendCode(ctx, u.Phone, code); err != nil {
return "", ErrResetSMS
}
err = uc.repo.WithCodeLock(ctx, u.ID, func(txctx context.Context) error {
state, err := uc.repo.Code(txctx, u.ID)
if err != nil {
return err
}
expires := time.Now().Add(2 * time.Minute)
state.CodeHash, state.ExpiresAt = secretHash(fmt.Sprintf("%d:%s", u.ID, code)), &expires
return uc.repo.SaveCode(txctx, state)
})
if err != nil {
return "", err
}
return MaskPhone(u.Phone), nil
}
func (uc *PasswordResetUsecase) VerifyCode(ctx context.Context, username, code string) (string, error) {
u, err := uc.user(ctx, username)
if err != nil {
return "", err
}
if !resetCodePattern.MatchString(code) {
return "", ErrResetCode
}
var token string
err = uc.repo.WithCodeLock(ctx, u.ID, func(txctx context.Context) error {
state, err := uc.repo.Code(txctx, u.ID)
if err != nil {
return err
}
if state.ExpiresAt == nil || !state.ExpiresAt.After(time.Now()) || state.CodeHash != secretHash(fmt.Sprintf("%d:%s", u.ID, code)) {
return ErrResetCode
}
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return ErrStorage
}
token = hex.EncodeToString(bytes)
now := time.Now()
if err := uc.repo.CreateToken(txctx, &ResetToken{Hash: secretHash(token), UserID: u.ID, CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute)}); err != nil {
return err
}
state.CodeHash, state.ExpiresAt = "", nil
return uc.repo.SaveCode(txctx, state)
})
if err != nil {
return "", err
}
return token, nil
}
func validateResetPassword(password, confirm string) error {
length := len(utf16.Encode([]rune(password)))
if strings.TrimSpace(password) == "" || length < 6 || length > 15 {
return ErrResetLength
}
if password != confirm {
return ErrResetMismatch
}
return nil
}
func (uc *PasswordResetUsecase) Reset(ctx context.Context, token, password, confirm string) error {
key := secretHash(token)
var outcome error
err := uc.repo.WithTokenLock(ctx, key, func(txctx context.Context) error {
state, err := uc.repo.Token(txctx, key)
if err != nil {
return err
}
if state == nil || !state.ExpiresAt.After(time.Now()) {
outcome = ErrResetToken
return uc.repo.DeleteToken(txctx, key)
}
if invalid := validateResetPassword(password, confirm); invalid != nil {
state.Failures++
outcome = invalid
if state.Failures >= 5 {
return uc.repo.DeleteToken(txctx, key)
}
return uc.repo.SaveToken(txctx, state)
}
u, err := uc.repo.UserByID(txctx, state.UserID)
if err != nil {
return err
}
if u == nil || !u.Enabled || u.Status != 0 {
outcome = ErrResetAccount
return uc.repo.DeleteToken(txctx, key)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return ErrStorage
}
if err := uc.repo.UpdatePassword(txctx, u.ID, string(hash)); err != nil {
return err
}
return uc.repo.DeleteToken(txctx, key)
})
// 校验失败次数和凭证失效也要提交;不能因为返回业务提示而回滚这些状态。
if err != nil {
return err
}
return outcome
}
@@ -0,0 +1,215 @@
package business
import (
"context"
"errors"
"golang.org/x/crypto/bcrypt"
"sync"
"testing"
"time"
)
type resetRepoFake struct {
PasswordResetRepo
mu sync.Mutex
user *ResetUser
code *ResetCode
tokens map[string]*ResetToken
password string
}
func (r *resetRepoFake) UserByName(context.Context, string) (*ResetUser, error) {
if r.user == nil {
return nil, nil
}
u := *r.user
return &u, nil
}
func (r *resetRepoFake) UserByID(context.Context, uint) (*ResetUser, error) {
return r.UserByName(context.Background(), "")
}
func (r *resetRepoFake) transaction(ctx context.Context, fn func(context.Context) error) error {
r.mu.Lock()
defer r.mu.Unlock()
beforeCode := r.code
beforeTokens := map[string]*ResetToken{}
for key, value := range r.tokens {
copy := *value
beforeTokens[key] = &copy
}
beforePassword := r.password
err := fn(ctx)
if err != nil {
r.code, r.tokens, r.password = beforeCode, beforeTokens, beforePassword
}
return err
}
func (r *resetRepoFake) WithCodeLock(ctx context.Context, id uint, fn func(context.Context) error) error {
return r.transaction(ctx, func(ctx context.Context) error {
if r.code == nil {
r.code = &ResetCode{UserID: id}
}
return fn(ctx)
})
}
func (r *resetRepoFake) WithTokenLock(ctx context.Context, _ string, fn func(context.Context) error) error {
return r.transaction(ctx, fn)
}
func (r *resetRepoFake) Code(context.Context, uint) (*ResetCode, error) {
copy := *r.code
return &copy, nil
}
func (r *resetRepoFake) SaveCode(_ context.Context, c *ResetCode) error {
copy := *c
r.code = &copy
return nil
}
func (r *resetRepoFake) CreateToken(_ context.Context, t *ResetToken) error {
copy := *t
r.tokens[t.Hash] = &copy
return nil
}
func (r *resetRepoFake) Token(_ context.Context, key string) (*ResetToken, error) {
if t := r.tokens[key]; t != nil {
copy := *t
return &copy, nil
}
return nil, nil
}
func (r *resetRepoFake) SaveToken(_ context.Context, t *ResetToken) error {
copy := *t
r.tokens[t.Hash] = &copy
return nil
}
func (r *resetRepoFake) DeleteToken(_ context.Context, key string) error {
delete(r.tokens, key)
return nil
}
func (r *resetRepoFake) UpdatePassword(_ context.Context, _ uint, hash string) error {
r.password = hash
return nil
}
type resetSMSFake struct {
code string
calls int
err error
}
func (s *resetSMSFake) SendCode(_ context.Context, _ string, code string) error {
s.code = code
s.calls++
return s.err
}
func resetFixture() (*PasswordResetUsecase, *resetRepoFake, *resetSMSFake) {
r := &resetRepoFake{user: &ResetUser{ID: 7, Username: "business", Phone: "13800138000", Enabled: true}, tokens: map[string]*ResetToken{}}
s := &resetSMSFake{}
return NewPasswordResetUsecase(r, s), r, s
}
func TestPasswordResetConsumesOnlyCorrectCodeAndOneToken(t *testing.T) {
uc, repo, sms := resetFixture()
ctx := context.Background()
phone, err := uc.SendCode(ctx, "business")
if err != nil || phone != "138****8000" {
t.Fatalf("发码失败: %s %v", phone, err)
}
if _, err := uc.SendCode(ctx, "business"); err != ErrResetRate || sms.calls != 1 {
t.Fatal("未执行 60 秒发码限制")
}
wrong := "000000"
if sms.code == wrong {
wrong = "111111"
}
if _, err := uc.VerifyCode(ctx, "business", wrong); err != ErrResetCode {
t.Fatal("错误验证码被接受")
}
token, err := uc.VerifyCode(ctx, "business", sms.code)
if err != nil {
t.Fatal(err)
}
if repo.code.CodeHash != "" || len(token) != 64 {
t.Fatal("匹配验证码未消费或未签发凭证")
}
if _, err := uc.VerifyCode(ctx, "business", sms.code); err != ErrResetCode {
t.Fatal("验证码可重放")
}
if err := uc.Reset(ctx, token, "newpass1", "newpass1"); err != nil {
t.Fatal(err)
}
if bcrypt.CompareHashAndPassword([]byte(repo.password), []byte("newpass1")) != nil {
t.Fatal("未保存新密码 hash")
}
if err := uc.Reset(ctx, token, "newpass2", "newpass2"); err != ErrResetToken {
t.Fatal("重置凭证可重放")
}
}
func TestPasswordResetFailuresCommitAndRevokeAfterFive(t *testing.T) {
uc, repo, sms := resetFixture()
ctx := context.Background()
uc.SendCode(ctx, "business")
token, err := uc.VerifyCode(ctx, "business", sms.code)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
if err := uc.Reset(ctx, token, "short", "short"); err != ErrResetLength {
t.Fatal(err)
}
}
if len(repo.tokens) != 0 {
t.Fatal("业务校验失败后计数被回滚,凭证未吊销")
}
if err := uc.Reset(ctx, token, "newpass1", "newpass1"); err != ErrResetToken {
t.Fatal("失败达到五次后仍允许重置")
}
}
func TestPasswordResetRechecksStatusAndExpiration(t *testing.T) {
uc, repo, sms := resetFixture()
ctx := context.Background()
uc.SendCode(ctx, "business")
token, err := uc.VerifyCode(ctx, "business", sms.code)
if err != nil {
t.Fatal(err)
}
repo.user.Status = 3
if err := uc.Reset(ctx, token, "newpass1", "newpass1"); err != ErrResetAccount || repo.password != "" || len(repo.tokens) != 0 {
t.Fatal("发码后停用或待审核的账号仍可重置")
}
repo.tokens[secretHash("expired")] = &ResetToken{Hash: secretHash("expired"), UserID: 7, ExpiresAt: time.Now().Add(-time.Second)}
if err := uc.Reset(ctx, "expired", "newpass1", "newpass1"); err != ErrResetToken {
t.Fatal("过期凭证仍可用")
}
if MaskPhone("not-a-phone") != "****" {
t.Fatal("非标准手机号泄露明文")
}
}
func TestPasswordResetConcurrentVerificationIssuesOneToken(t *testing.T) {
uc, repo, sms := resetFixture()
ctx := context.Background()
if _, err := uc.SendCode(ctx, "business"); err != nil {
t.Fatal(err)
}
results := make(chan error, 2)
for i := 0; i < 2; i++ {
go func() { _, err := uc.VerifyCode(ctx, "business", sms.code); results <- err }()
}
a, b := <-results, <-results
if !((a == nil && b == ErrResetCode) || (b == nil && a == ErrResetCode)) || len(repo.tokens) != 1 {
t.Fatalf("并发消费不原子: %v %v", a, b)
}
}
func TestFailedSMSDoesNotIssueUsableCode(t *testing.T) {
uc, repo, sms := resetFixture()
sms.err = errors.New("provider failed")
if _, err := uc.SendCode(context.Background(), "business"); err != ErrResetSMS {
t.Fatal(err)
}
if repo.code.CodeHash != "" || repo.code.ExpiresAt != nil {
t.Fatal("短信未发送却写入了可用验证码")
}
}
+3 -1
View File
@@ -6,7 +6,9 @@ import (
)
func Migrations() []migration.Step {
return []migration.Step{{ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
return []migration.Step{{ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &bindingLockPO{})
}}, {ID: "202611030003_business_bindings", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &bindingPO{})
+128
View File
@@ -0,0 +1,128 @@
package business
import (
"context"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"kra/internal/biz/business"
"time"
)
type resetCodePO struct {
UserID uint `gorm:"primaryKey;autoIncrement:false"`
CodeHash string `gorm:"size:64"`
ExpiresAt, SendAfter *time.Time
UpdatedAt time.Time
}
func (resetCodePO) TableName() string { return "sys_business_password_codes" }
type resetTokenPO struct {
Hash string `gorm:"column:token_hash;primaryKey;size:64"`
UserID uint `gorm:"index"`
ExpiresAt time.Time `gorm:"index"`
CreatedAt time.Time
Failures int
}
func (resetTokenPO) TableName() string { return "sys_business_password_resets" }
type passwordResetRepo struct{ data Provider }
func NewPasswordResetRepo(data Provider) business.PasswordResetRepo {
return &passwordResetRepo{data: data}
}
type resetTxKey struct{}
func (r *passwordResetRepo) db(ctx context.Context) *gorm.DB {
if tx, ok := ctx.Value(resetTxKey{}).(*gorm.DB); ok {
return tx.WithContext(ctx)
}
return r.data.DB().WithContext(ctx)
}
func resetUser(po accountPO) *business.ResetUser {
return &business.ResetUser{ID: po.ID, Username: po.Username, Phone: po.Phone, Enabled: po.Enable == 1, Status: po.BusinessStatus}
}
func (r *passwordResetRepo) UserByName(ctx context.Context, name string) (*business.ResetUser, error) {
var po accountPO
err := r.db(ctx).Select("id,username,phone,enable,business_status").Where("username = ?", name).First(&po).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, business.ErrStorage
}
return resetUser(po), nil
}
func (r *passwordResetRepo) UserByID(ctx context.Context, id uint) (*business.ResetUser, error) {
var po accountPO
err := r.db(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Select("id,username,phone,enable,business_status").First(&po, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, business.ErrStorage
}
return resetUser(po), nil
}
func (r *passwordResetRepo) WithCodeLock(ctx context.Context, id uint, fn func(context.Context) error) error {
err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
po := resetCodePO{UserID: id}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&po).Error; err != nil {
return err
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&po, id).Error; err != nil {
return err
}
return fn(context.WithValue(ctx, resetTxKey{}, tx))
})
return storageError(err)
}
func (r *passwordResetRepo) Code(ctx context.Context, id uint) (*business.ResetCode, error) {
var po resetCodePO
if err := r.db(ctx).First(&po, id).Error; err != nil {
return nil, storageError(err)
}
return &business.ResetCode{UserID: po.UserID, CodeHash: po.CodeHash, ExpiresAt: po.ExpiresAt, SendAfter: po.SendAfter}, nil
}
func (r *passwordResetRepo) SaveCode(ctx context.Context, c *business.ResetCode) error {
return storageError(r.db(ctx).Model(&resetCodePO{}).Where("user_id = ?", c.UserID).Updates(map[string]any{"code_hash": c.CodeHash, "expires_at": c.ExpiresAt, "send_after": c.SendAfter}).Error)
}
func (r *passwordResetRepo) CreateToken(ctx context.Context, t *business.ResetToken) error {
if err := r.db(ctx).Where("expires_at < ?", t.CreatedAt).Delete(&resetTokenPO{}).Error; err != nil {
return storageError(err)
}
return storageError(r.db(ctx).Create(&resetTokenPO{Hash: t.Hash, UserID: t.UserID, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt, Failures: t.Failures}).Error)
}
func (r *passwordResetRepo) WithTokenLock(ctx context.Context, key string, fn func(context.Context) error) error {
return storageError(r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { return fn(context.WithValue(ctx, resetTxKey{}, tx)) }))
}
func (r *passwordResetRepo) Token(ctx context.Context, key string) (*business.ResetToken, error) {
var po resetTokenPO
err := r.db(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&po, "token_hash = ?", key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, storageError(err)
}
return &business.ResetToken{Hash: po.Hash, UserID: po.UserID, ExpiresAt: po.ExpiresAt, CreatedAt: po.CreatedAt, Failures: po.Failures}, nil
}
func (r *passwordResetRepo) SaveToken(ctx context.Context, t *business.ResetToken) error {
return storageError(r.db(ctx).Model(&resetTokenPO{}).Where("token_hash = ?", t.Hash).Update("failures", t.Failures).Error)
}
func (r *passwordResetRepo) DeleteToken(ctx context.Context, key string) error {
return storageError(r.db(ctx).Where("token_hash = ?", key).Delete(&resetTokenPO{}).Error)
}
func (r *passwordResetRepo) UpdatePassword(ctx context.Context, id uint, hash string) error {
result := r.db(ctx).Model(&accountPO{}).Where("id = ?", id).Updates(map[string]any{"password": hash, "password_updated_at": time.Now(), "must_change_password": false})
if result.Error != nil {
return storageError(result.Error)
}
if result.RowsAffected == 0 {
return business.ErrResetToken
}
return nil
}
+1
View File
@@ -32,6 +32,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewAccountRepo,
databusiness.NewBindingRepo,
databusiness.NewPlatformAccountRepo,
databusiness.NewPasswordResetRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
+2 -2
View File
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 18 {
t.Fatalf("migration versions = %d, want 18", versions)
if versions != 19 {
t.Fatalf("migration versions = %d, want 19", versions)
}
var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
@@ -0,0 +1,38 @@
package gameplatform
import (
"context"
"kra/internal/biz/business"
"kra/internal/biz/integration"
"os"
"regexp"
)
type passwordSMS struct {
uc *integration.SMSUsecase
provider string
}
func NewPasswordSMS(sender integration.SMSSender) business.PasswordResetSMS {
provider := os.Getenv("SW_PASSWORD_RESET_SMS_PROVIDER")
if provider == "" {
provider = "tencent"
}
return &passwordSMS{uc: integration.NewSMSUsecase(sender), provider: provider}
}
var localMobile = regexp.MustCompile(`^\d{11}$`)
func (s *passwordSMS) SendCode(ctx context.Context, phone, code string) error {
if localMobile.MatchString(phone) {
phone = "+86" + phone
}
message := &integration.SMSMessage{Provider: s.provider, Phone: phone}
if s.provider == "aliyun" {
message.NamedParams = map[string]string{"code": code}
} else {
message.Params = []string{code}
}
_, err := s.uc.Send(ctx, message)
return err
}
@@ -0,0 +1,35 @@
package gameplatform
import (
"context"
"kra/internal/biz/integration"
"testing"
)
type resetSMSSender struct{ message *integration.SMSMessage }
func (s *resetSMSSender) Send(_ context.Context, m *integration.SMSMessage) (*integration.SMSResult, error) {
s.message = m
return &integration.SMSResult{}, nil
}
func TestPasswordSMSUsesConfiguredProviderAndTemplateShape(t *testing.T) {
for _, provider := range []string{"tencent", "aliyun"} {
t.Run(provider, func(t *testing.T) {
t.Setenv("SW_PASSWORD_RESET_SMS_PROVIDER", provider)
sender := &resetSMSSender{}
if err := NewPasswordSMS(sender).SendCode(context.Background(), "13800138000", "123456"); err != nil {
t.Fatal(err)
}
m := sender.message
if m.Phone != "+8613800138000" || m.Provider != provider {
t.Fatalf("手机号或提供商不正确: %+v", m)
}
if provider == "tencent" && (len(m.Params) != 1 || m.Params[0] != "123456") {
t.Fatal("腾讯云参数未按模板顺序传递")
}
if provider == "aliyun" && m.NamedParams["code"] != "123456" {
t.Fatal("阿里云参数未使用 code 键")
}
})
}
}
+1
View File
@@ -24,6 +24,7 @@ import (
var ProviderSet = wire.NewSet(
gameplatform.NewPasswords,
gameplatform.NewPasswordSMS,
cache.New,
email.NewEmailRepo,
sms.NewSender,
+2 -2
View File
@@ -25,8 +25,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
}
}
if got := catalog.MigrationSteps(); len(got) != 18 {
t.Fatalf("module migrations = %d, want 18", len(got))
if got := catalog.MigrationSteps(); len(got) != 19 {
t.Fatalf("module migrations = %d, want 19", len(got))
}
var menus, apis int
for _, definition := range catalog.Definitions {
+4
View File
@@ -41,6 +41,10 @@ type routeValue struct {
// Gin router files still bind handlers; tests keep those registrations aligned
// with this catalog while public, audit and API metadata live only here.
var routes = map[string]routeValue{
"POST /sw/auth/forgot-password/check": {group: "商务登录", description: "校验找回密码账号", public: true},
"POST /sw/auth/forgot-password/send-code": {group: "商务登录", description: "发送找回密码验证码", public: true},
"POST /sw/auth/forgot-password/verify-code": {group: "商务登录", description: "验证短信并签发重置凭证", public: true},
"POST /sw/auth/forgot-password/reset": {group: "商务登录", description: "重置商务密码", public: true},
"GET /sw/business/bindPlatform": {group: "商务管理", description: "查询游戏商务绑定"},
"GET /sw/business/:userId/bindPlatform": {group: "商务管理", description: "查询指定商务已通过的绑定"},
"POST /sw/business/bindPlatform": {group: "商务管理", description: "绑定游戏商务并提交审核", audit: true},
+1 -1
View File
@@ -98,7 +98,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":207`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":211`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
@@ -0,0 +1,67 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/go-kratos/kratos/v3/errors"
"kra/internal/service/business"
"kra/internal/service/dto"
)
type BusinessPassword struct {
service *business.PasswordResetService
}
func NewBusinessPassword(service *business.PasswordResetService) *BusinessPassword {
return &BusinessPassword{service: service}
}
func (h *BusinessPassword) Check(c *gin.Context) {
var req dto.PasswordResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, "参数格式错误")
return
}
value, err := h.service.Check(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, value)
}
func (h *BusinessPassword) SendCode(c *gin.Context) {
var req dto.PasswordResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, "参数格式错误")
return
}
value, err := h.service.SendCode(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, value)
}
func (h *BusinessPassword) VerifyCode(c *gin.Context) {
var req dto.PasswordResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, "参数格式错误")
return
}
value, err := h.service.VerifyCode(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, value)
}
func (h *BusinessPassword) Reset(c *gin.Context) {
var req dto.PasswordResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, "参数格式错误")
return
}
if err := h.service.Reset(c.Request.Context(), &req); err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OK(c)
}
+1
View File
@@ -6,6 +6,7 @@ import "github.com/google/wire"
// field so adding a handler only means adding its constructor here.
var ProviderSet = wire.NewSet(
NewBusiness,
NewBusinessPassword,
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
+1
View File
@@ -2,6 +2,7 @@ package handler
type Set struct {
Business *Business
BusinessPassword *BusinessPassword
SMS *SMS
Authority *Authority
Menu *Menu
+1
View File
@@ -17,6 +17,7 @@ var sensitiveHeaders = map[string]struct{}{
// both match "newpassword".
var sensitivePayloadKeys = map[string]struct{}{
"password": {}, "newpassword": {}, "oldpassword": {}, "confirmpassword": {},
"smscode": {}, "verificationcode": {},
"passwd": {}, "pwd": {}, "token": {}, "accesstoken": {}, "refreshtoken": {},
"secret": {}, "clientsecret": {}, "apikey": {}, "privatekey": {}, "idcard": {},
"key": {}, "signingkey": {}, "secretkey": {},
@@ -0,0 +1,14 @@
package router
import (
"github.com/gin-gonic/gin"
"kra/internal/server/handler"
)
func RegisterBusinessPassword(public *gin.RouterGroup, h *handler.BusinessPassword) {
group := public.Group("/sw/auth/forgot-password")
group.POST("/check", h.Check)
group.POST("/send-code", h.SendCode)
group.POST("/verify-code", h.VerifyCode)
group.POST("/reset", h.Reset)
}
+1
View File
@@ -21,6 +21,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
}
registrations := []func(){
func() { RegisterBusiness(private, r.handlers.Business) },
func() { RegisterBusinessPassword(public, r.handlers.BusinessPassword) },
func() { RegisterPublic(public, engine, r.handlers.Public) },
func() { RegisterUser(private, r.handlers.User) },
func() { RegisterNavigation(private, r.handlers.Navigation) },
@@ -0,0 +1,72 @@
package business
import (
"context"
"kra/internal/biz/business"
"kra/internal/service/dto"
"strings"
"unicode/utf8"
)
type PasswordResetUsecase interface {
Check(context.Context, string) (string, error)
SendCode(context.Context, string) (string, error)
VerifyCode(context.Context, string, string) (string, error)
Reset(context.Context, string, string, string) error
}
type PasswordResetService struct{ uc PasswordResetUsecase }
func NewPasswordResetService(uc *business.PasswordResetUsecase) *PasswordResetService {
return &PasswordResetService{uc: uc}
}
func resetUsername(req *dto.PasswordResetRequest) (string, error) {
if req == nil || strings.TrimSpace(req.Username) == "" || utf8.RuneCountInString(req.Username) > 30 {
return "", business.ErrAccountInput
}
return strings.TrimSpace(req.Username), nil
}
func (s *PasswordResetService) Check(ctx context.Context, req *dto.PasswordResetRequest) (*dto.PasswordResetPhoneResponse, error) {
name, err := resetUsername(req)
if err != nil {
return nil, err
}
phone, err := s.uc.Check(ctx, name)
if err != nil {
return nil, err
}
return &dto.PasswordResetPhoneResponse{MaskedPhone: phone}, nil
}
func (s *PasswordResetService) SendCode(ctx context.Context, req *dto.PasswordResetRequest) (*dto.PasswordResetPhoneResponse, error) {
name, err := resetUsername(req)
if err != nil {
return nil, err
}
phone, err := s.uc.SendCode(ctx, name)
if err != nil {
return nil, err
}
return &dto.PasswordResetPhoneResponse{MaskedPhone: phone}, nil
}
func (s *PasswordResetService) VerifyCode(ctx context.Context, req *dto.PasswordResetRequest) (*dto.PasswordResetTokenResponse, error) {
name, err := resetUsername(req)
if err != nil {
return nil, err
}
if len(req.SMSCode) != 6 {
return nil, business.ErrResetCode
}
token, err := s.uc.VerifyCode(ctx, name, req.SMSCode)
if err != nil {
return nil, err
}
return &dto.PasswordResetTokenResponse{ResetToken: token}, nil
}
func (s *PasswordResetService) Reset(ctx context.Context, req *dto.PasswordResetRequest) error {
if req == nil || strings.TrimSpace(req.ResetToken) == "" || strings.TrimSpace(req.NewPassword) == "" || strings.TrimSpace(req.ConfirmPassword) == "" {
return business.ErrAccountInput
}
if len(req.ResetToken) != 64 {
return business.ErrResetToken
}
return s.uc.Reset(ctx, req.ResetToken, req.NewPassword, req.ConfirmPassword)
}
+15
View File
@@ -0,0 +1,15 @@
package dto
type PasswordResetRequest struct {
Username string `json:"username"`
SMSCode string `json:"smsCode"`
ResetToken string `json:"resetToken"`
NewPassword string `json:"newPassword"`
ConfirmPassword string `json:"confirmPassword"`
}
type PasswordResetPhoneResponse struct {
MaskedPhone string `json:"maskedPhone"`
}
type PasswordResetTokenResponse struct {
ResetToken string `json:"resetToken"`
}
+1
View File
@@ -15,6 +15,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewUserPreferenceService,
businessservice.NewAccountService,
businessservice.NewBindingService,
businessservice.NewPasswordResetService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,