diff --git a/cmd/main.go b/cmd/main.go index ffaa4f3..4778ea2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -40,8 +40,8 @@ var ( // Service identity reported to logs, health output, and the API documentation. // A plain `go run` leaves the ldflags empty, so these are the fallbacks. const ( - appName = "Kra Admin" - appDescription = "Kratos 管理后台服务" + appName = "遨游龙翔游戏平台" + appDescription = "渠道商务管理服务" defaultVersion = "v0.0.0-dev" ) diff --git a/cmd/password_reset_http_test.go b/cmd/password_reset_http_test.go new file mode 100644 index 0000000..3efcd0c --- /dev/null +++ b/cmd/password_reset_http_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "encoding/json" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + businessbiz "kra/internal/biz/business" + businessdata "kra/internal/data/business" + systemdata "kra/internal/data/system" + "kra/internal/server/handler" + "kra/internal/server/router" + businessservice "kra/internal/service/business" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +type resetHTTPSMS struct { + code string + calls int +} + +func (s *resetHTTPSMS) SendCode(_ context.Context, _ string, code string) error { + s.code = code + s.calls++ + return nil +} + +func TestPasswordResetAnonymousHTTPFlow(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "reset.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + for _, step := range append(systemdata.Migrations(), businessdata.Migrations()...) { + if err := step.Migrate(db); err != nil { + t.Fatal(err) + } + } + before := time.Now().Add(-time.Hour) + if err := db.Table("sys_users").Create(map[string]any{"id": 7, "uuid": "reset-test", "username": "recover", "phone": "13800138000", "enable": 1, "business_status": 0, "password": "old-hash", "password_updated_at": before}).Error; err != nil { + t.Fatal(err) + } + sms := &resetHTTPSMS{} + service := businessservice.NewPasswordResetService(businessbiz.NewPasswordResetUsecase(businessdata.NewPasswordResetRepo(businessHTTPData{db}), sms)) + engine := gin.New() + router.RegisterBusinessPassword(engine.Group(""), handler.NewBusinessPassword(service)) + server := httptest.NewServer(engine) + defer server.Close() + call := func(action string, payload map[string]any, expected int) map[string]any { + t.Helper() + encoded, _ := json.Marshal(payload) + response, err := http.Post(server.URL+"/sw/auth/forgot-password/"+action, "application/json", strings.NewReader(string(encoded))) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var body map[string]any + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if response.StatusCode != 200 || body["code"] != float64(expected) { + t.Fatalf("%s 结果不正确: %+v", action, body) + } + data, _ := body["data"].(map[string]any) + return data + } + phone := call("check", map[string]any{"username": "recover"}, 0) + if phone["maskedPhone"] != "138****8000" { + t.Fatal("接口手机号未脱敏") + } + call("send-code", map[string]any{"username": "recover"}, 0) + call("send-code", map[string]any{"username": "recover"}, 7) + if sms.calls != 1 { + t.Fatal("HTTP 发码限流无效") + } + wrong := "000000" + if sms.code == wrong { + wrong = "111111" + } + call("verify-code", map[string]any{"username": "recover", "smsCode": wrong}, 7) + verified := call("verify-code", map[string]any{"username": "recover", "smsCode": sms.code}, 0) + token, ok := verified["resetToken"].(string) + if !ok || len(token) != 64 { + t.Fatal("未返回一次性重置凭证") + } + call("verify-code", map[string]any{"username": "recover", "smsCode": sms.code}, 7) + call("reset", map[string]any{"resetToken": token, "newPassword": "abc123", "confirmPassword": "abc123"}, 0) + var user struct { + Password string + PasswordUpdatedAt time.Time + } + if err := db.Table("sys_users").Where("id = ?", 7).Take(&user).Error; err != nil { + t.Fatal(err) + } + if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte("abc123")) != nil || !user.PasswordUpdatedAt.After(before) { + t.Fatal("新密码或会话失效版本未保存") + } + call("reset", map[string]any{"resetToken": token, "newPassword": "abc124", "confirmPassword": "abc124"}, 7) + var count int64 + if err := db.Table("sys_business_password_resets").Count(&count).Error; err != nil || count != 0 { + t.Fatal("重置凭证未在事务中消费") + } +} diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 889ce1e..5cebe7d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -94,8 +94,13 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg bindingUsecase := business2.NewBindingUsecase(bindingRepo, accountUsecase, platformAccountRepo, platformPasswordVerifier) bindingService := business3.NewBindingService(bindingUsecase) handlerBusiness := handler.NewBusiness(userPreferenceService, accountService, bindingService) + passwordResetRepo := business.NewPasswordResetRepo(dataData) runtimeconfigStore := data.NewIntegrationRuntime(dataData) smsSender := sms.NewSender(runtimeconfigStore) + passwordResetSMS := gameplatform.NewPasswordSMS(smsSender) + passwordResetUsecase := business2.NewPasswordResetUsecase(passwordResetRepo, passwordResetSMS) + passwordResetService := business3.NewPasswordResetService(passwordResetUsecase) + businessPassword := handler.NewBusinessPassword(passwordResetService) smsUsecase := integration.NewSMSUsecase(smsSender) smsService := integration2.NewSMSService(smsUsecase) handlerSMS := handler.NewSMS(smsService) @@ -193,6 +198,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg integrationConfig := handler.NewIntegrationConfig(integrationConfigService) set := &handler.Set{ Business: handlerBusiness, + BusinessPassword: businessPassword, SMS: handlerSMS, Authority: authority, Menu: menu, diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md index 1cfeede..b38e876 100644 --- a/docs/sw6-to-kra/implementation.md +++ b/docs/sw6-to-kra/implementation.md @@ -49,6 +49,8 @@ - 已落地商务列表、查询资料、新增子商务、部分编辑和审核状态,以及 Kra 风格 `BusinessList` 页面。层级及角色由服务端按原角色 ID 映射分配;账号、角色、偏好在同一事务创建。列表按平台与商务组合键统计直属一级渠道和已通过绑定。 - 商务画像、旗下渠道明细、导出等功能仍在后续范围内;当前不将整个商务菜单标为完成。 - 已接入绑定列表、指定商务已通过绑定详情、提交绑定及解绑四个接口,页面增加游戏 SDK 绑定页签与详情抽屉。绑定流程保持三级限制、平台验密、OA 待审核及驳回重提规则;新增键锁表 `sys_business_binding_locks`,串行处理同一平台商务的并发绑定。 +- 已恢复旧登录页的图片、文案、布局及记住账号功能,系统名称统一为“遨游龙翔游戏平台”。找回密码四个匿名接口位于 `/sw/auth/forgot-password/`,保留账号/手机号检查、60 秒发码限制、两分钟验证码、十分钟凭证、6–15 位密码及失败五次吊销等规则。 +- 找回密码采用同库事务保存验证码摘要和一次性凭证,并原子执行改密/消费凭证;新增 `sys_business_password_codes`、`sys_business_password_resets`。更新密码版本使旧 JWT 失效。短信复用 Kra 集成配置,`SW_PASSWORD_RESET_SMS_PROVIDER` 默认 `tencent`,也支持 `aliyun`;模板和通道凭据在对应集成配置中维护。 - 平台数据源通过 Kra 的 `data.database_list` 复用连接,别名为 `lx` / `hw` / `yq`。旧密码算法支持三种格式,兼容盐通过环境变量 `SW_SDK_AUTH_CODE`、`SW_SDK_MIGRATE_AUTH_CODE` 提供,取值应与旧 `XiguPasswordUtils` 对应常量一致;`SW_SDK_TABLE_PREFIX` 缺省为 `sys_`。真实连接凭据和盐不写进新仓库。完整平台库验密仍需部署配置后联调。 - 已生成 `source-inventory.json`:现有 SQL 的 63 个接口契约、279 个变更源文件及额外找回密码控制器。此清单是范围索引,不代表已完成实现。 - 已执行 `make generate`、相关 Go 测试、变更前端 ESLint 和生产构建。构建存在原 `arcdash` 依赖的 BigInt 浏览器目标警告。 @@ -56,4 +58,5 @@ - 用户反馈实际商务列表失败后,定位为 sw7 缺少 `tab_promote`。已解耦基础列表与渠道统计:有绑定且渠道表缺失时返回 `promoteCount: null`,界面显示“未接入”,无绑定账号仍为真实零。新增通过真实 TCP 请求的 HTTP 回归测试,覆盖系统数据库及完整渠道表两种场景。 - 已通过正常验证码登录取得测试会话,并在修复后的独立 18000 实例连接同一 sw7 执行 HTTP 请求。列表总数 45,分页、账号筛选、资料和偏好查询全部成功。详见 `http-verification-2026-11-03.json`。8000 为用户 GoLand 旧二进制,需重新运行才加载源码修复。 - 绑定批次通过 13 项真实 HTTP 检查:商务 45 条、绑定 58 条、平台与审核筛选、详情、偏好查询和写请求校验,结果见 `http-business-bindings-2026-11-03.json`;`scripts/sw_http_smoke.py` 可复用且不记录令牌。真实库不制造测试商务或发起实际平台绑定。新增、修改、偏好关闭、提交绑定及解绑的正向流程已用独立 SQLite 数据库通过实际 TCP HTTP 测试;平台验密外部边界使用测试替身,密码算法另用固定向量验证。 +- 登录/找回密码批次 `go test ./...` 全量通过,前端 ESLint 和生产构建通过。Chrome 已检查桌面/移动登录页及三步找回流程(短信接口受控替身);完整找回密码链路已通过匿名 TCP HTTP + SQLite 测试,覆盖错误验证码后重试、原子消费、改密版本更新和凭证重放拒绝。实际运行实例四个匿名接口校验与既有 13 项商务 HTTP 回归全部通过,未发送真实短信或修改真实用户密码。 - 当前没有执行本轮真实数据库迁移验证。 diff --git a/docs/sw6-to-kra/login-page-verification-2026-11-03.json b/docs/sw6-to-kra/login-page-verification-2026-11-03.json new file mode 100644 index 0000000..f767a3e --- /dev/null +++ b/docs/sw6-to-kra/login-page-verification-2026-11-03.json @@ -0,0 +1,12 @@ +{ + "method": "真实 Chrome 渲染;找回密码接口使用受控响应,不发送真实短信", + "title": "遨游龙翔游戏平台", + "actions": [ + "check", + "send-code", + "verify-code", + "reset" + ], + "pageErrors": [], + "passed": true +} \ No newline at end of file diff --git a/docs/sw6-to-kra/page-parity.md b/docs/sw6-to-kra/page-parity.md index 79679ee..99584e1 100644 --- a/docs/sw6-to-kra/page-parity.md +++ b/docs/sw6-to-kra/page-parity.md @@ -35,3 +35,7 @@ ## 其他页面 渠道列表、渠道激活、渠道统计、充值明细、注册明细、游戏数据、区服分析、排行榜、预警列表和预警规则,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。 + +## 登录页 + +已从旧 `login.vue` 复用登录模板和样式,复制原品牌图片,适配 Kra 登录接口;保持记住账号而不存密码、验证码、三步找回密码和原文案。系统标题、侧栏标识及页脚已同步旧商务名称。`login-page-verification-2026-11-03.json` 记录 Chrome 的流程验证,短信接口为测试替身,不向真实手机发码。 diff --git a/docs/sw6-to-kra/password-reset-http-2026-11-03.json b/docs/sw6-to-kra/password-reset-http-2026-11-03.json new file mode 100644 index 0000000..6ba01e9 --- /dev/null +++ b/docs/sw6-to-kra/password-reset-http-2026-11-03.json @@ -0,0 +1,38 @@ +{ + "scope": "运行实例匿名接口校验;不发送短信、不修改真实用户密码", + "base_url": "http://127.0.0.1:18000", + "checks": [ + { + "action": "check", + "http": 200, + "code": 7, + "msg": "账号不存在或未注册", + "elapsed_ms": 3097, + "passed": true + }, + { + "action": "send-code", + "http": 200, + "code": 7, + "msg": "商务账号参数不正确", + "elapsed_ms": 314, + "passed": true + }, + { + "action": "verify-code", + "http": 200, + "code": 7, + "msg": "商务账号参数不正确", + "elapsed_ms": 85, + "passed": true + }, + { + "action": "reset", + "http": 200, + "code": 7, + "msg": "商务账号参数不正确", + "elapsed_ms": 224, + "passed": true + } + ] +} \ No newline at end of file diff --git a/internal/biz/biz.go b/internal/biz/biz.go index 626555e..95da708 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -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) diff --git a/internal/biz/business/password_reset.go b/internal/biz/business/password_reset.go new file mode 100644 index 0000000..cfabf97 --- /dev/null +++ b/internal/biz/business/password_reset.go @@ -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 +} diff --git a/internal/biz/business/password_reset_test.go b/internal/biz/business/password_reset_test.go new file mode 100644 index 0000000..282de36 --- /dev/null +++ b/internal/biz/business/password_reset_test.go @@ -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] = © + } + 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 ©, nil +} +func (r *resetRepoFake) SaveCode(_ context.Context, c *ResetCode) error { + copy := *c + r.code = © + return nil +} +func (r *resetRepoFake) CreateToken(_ context.Context, t *ResetToken) error { + copy := *t + r.tokens[t.Hash] = © + return nil +} +func (r *resetRepoFake) Token(_ context.Context, key string) (*ResetToken, error) { + if t := r.tokens[key]; t != nil { + copy := *t + return ©, nil + } + return nil, nil +} +func (r *resetRepoFake) SaveToken(_ context.Context, t *ResetToken) error { + copy := *t + r.tokens[t.Hash] = © + 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("短信未发送却写入了可用验证码") + } +} diff --git a/internal/data/business/migrations.go b/internal/data/business/migrations.go index ff0d0b2..0156d79 100644 --- a/internal/data/business/migrations.go +++ b/internal/data/business/migrations.go @@ -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{}) diff --git a/internal/data/business/password_reset.go b/internal/data/business/password_reset.go new file mode 100644 index 0000000..25aa645 --- /dev/null +++ b/internal/data/business/password_reset.go @@ -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 +} diff --git a/internal/data/data.go b/internal/data/data.go index 92a7c99..bb4157b 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -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)), diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index 998e037..0fd5a3b 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -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 { diff --git a/internal/integration/gameplatform/password_sms.go b/internal/integration/gameplatform/password_sms.go new file mode 100644 index 0000000..47461dc --- /dev/null +++ b/internal/integration/gameplatform/password_sms.go @@ -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 +} diff --git a/internal/integration/gameplatform/password_sms_test.go b/internal/integration/gameplatform/password_sms_test.go new file mode 100644 index 0000000..0b770c8 --- /dev/null +++ b/internal/integration/gameplatform/password_sms_test.go @@ -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 键") + } + }) + } +} diff --git a/internal/integration/provider.go b/internal/integration/provider.go index 5d5a331..27bfa38 100644 --- a/internal/integration/provider.go +++ b/internal/integration/provider.go @@ -24,6 +24,7 @@ import ( var ProviderSet = wire.NewSet( gameplatform.NewPasswords, + gameplatform.NewPasswordSMS, cache.New, email.NewEmailRepo, sms.NewSender, diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index a0fd945..bba3a7f 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -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 { diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index be93d45..2fdeae6 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -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}, diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index e9e8ae1..3f43632 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -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) } } diff --git a/internal/server/handler/business_password.go b/internal/server/handler/business_password.go new file mode 100644 index 0000000..ad9fb20 --- /dev/null +++ b/internal/server/handler/business_password.go @@ -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) +} diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go index ba3116c..0dea7af 100644 --- a/internal/server/handler/provider.go +++ b/internal/server/handler/provider.go @@ -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, diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go index 373b023..a72fb1a 100644 --- a/internal/server/handler/set.go +++ b/internal/server/handler/set.go @@ -2,6 +2,7 @@ package handler type Set struct { Business *Business + BusinessPassword *BusinessPassword SMS *SMS Authority *Authority Menu *Menu diff --git a/internal/server/middleware/redact.go b/internal/server/middleware/redact.go index b5d5708..dbe4b57 100644 --- a/internal/server/middleware/redact.go +++ b/internal/server/middleware/redact.go @@ -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": {}, diff --git a/internal/server/router/business_password.go b/internal/server/router/business_password.go new file mode 100644 index 0000000..325176a --- /dev/null +++ b/internal/server/router/business_password.go @@ -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) +} diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go index 3779207..0f3503a 100644 --- a/internal/server/router/routes.go +++ b/internal/server/router/routes.go @@ -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) }, diff --git a/internal/service/business/password_reset.go b/internal/service/business/password_reset.go new file mode 100644 index 0000000..ef3fed0 --- /dev/null +++ b/internal/service/business/password_reset.go @@ -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) +} diff --git a/internal/service/dto/business_password.go b/internal/service/dto/business_password.go new file mode 100644 index 0000000..eea766c --- /dev/null +++ b/internal/service/dto/business_password.go @@ -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"` +} diff --git a/internal/service/service.go b/internal/service/service.go index 6b8fc6b..a766a85 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -15,6 +15,7 @@ var ProviderSet = wire.NewSet( businessservice.NewUserPreferenceService, businessservice.NewAccountService, businessservice.NewBindingService, + businessservice.NewPasswordResetService, systemservice.ProviderSet, paymentservice.ProviderSet, integrationservice.ProviderSet, diff --git a/scripts/sw_login_smoke.cjs b/scripts/sw_login_smoke.cjs new file mode 100644 index 0000000..8e00256 --- /dev/null +++ b/scripts/sw_login_smoke.cjs @@ -0,0 +1,52 @@ +// 验证旧商务登录外观和三步找回密码;短信接口始终使用测试响应。 +const fs=require('node:fs/promises'); +const path=require('node:path'); +const {chromium}=require(process.env.SW_PLAYWRIGHT_MODULE||'playwright'); +(async()=>{ + const browser=await chromium.launch({channel:'chrome',headless:true}); + const calls=[]; + try { + const context=await browser.newContext({viewport:{width:1440,height:1000}}); + const page=await context.newPage(); + const errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.route('http://127.0.0.1:8080/api/**',async route=>{ + const request=route.request();const uri=new URL(request.url());const apiPath=uri.pathname.slice(4); + if(apiPath.startsWith('/sw/auth/forgot-password/')){ + const action=apiPath.split('/').pop();const payload=request.postDataJSON();calls.push({action,payload}); + const data=action==='verify-code'?{resetToken:'a'.repeat(64)}:action==='reset'?{}:{maskedPhone:'138****8000'}; + await route.fulfill({json:{code:0,msg:'成功',data}});return; + } + if(apiPath==='/base/captcha'){ + const response=await route.fetch({url:'http://127.0.0.1:18000/base/captcha'});const body=await response.json();body.data.openCaptcha=true;await route.fulfill({response,json:body});return; + } + await route.continue(); + }); + await page.goto('http://127.0.0.1:8080/#/login'); + await page.getByText('欢迎登录',{exact:true}).waitFor(); + await page.getByPlaceholder('请输入验证码',{exact:true}).waitFor(); + if(await page.title()!=='遨游龙翔游戏平台')throw new Error('页面标题没有使用旧商务名称'); + if(!await page.getByAltText('遨游龙翔游戏平台',{exact:true}).isVisible())throw new Error('旧品牌图片未显示'); + await page.screenshot({path:path.join(process.env.TEMP,'sw-kra-login-desktop.png'),fullPage:true,animations:'disabled'}); + await page.getByRole('button',{name:'忘记密码?'}).click(); + const dialog=page.getByRole('dialog',{name:'找回密码'}); + await dialog.getByPlaceholder('请输入您注册的商务账号').fill('demo_biz'); + await dialog.getByRole('button',{name:'下一步',exact:true}).click(); + await dialog.getByText('138****8000',{exact:true}).waitFor(); + await dialog.getByPlaceholder('请输入 6 位验证码').fill('123456'); + await dialog.getByRole('button',{name:'下一步',exact:true}).click(); + await dialog.getByPlaceholder('6-15 位字符').fill('abc123'); + await dialog.getByPlaceholder('请再次输入新密码').fill('abc123'); + await dialog.getByRole('button',{name:'完成',exact:true}).click(); + await dialog.waitFor({state:'hidden'}); + const actions=calls.map(c=>c.action); + if(JSON.stringify(actions)!==JSON.stringify(['check','send-code','verify-code','reset']))throw new Error('三步流程请求顺序不正确'); + const reset=calls[3].payload; + if(reset.resetToken!=='a'.repeat(64)||reset.newPassword!=='abc123'||reset.confirmPassword!=='abc123')throw new Error('重置请求参数未对齐'); + if(errors.length)throw new Error('页面运行时错误:'+errors.join(';')); + await page.setViewportSize({width:390,height:844}); + await page.screenshot({path:path.join(process.env.TEMP,'sw-kra-login-mobile.png'),fullPage:true,animations:'disabled'}); + const report={method:'真实 Chrome 渲染;找回密码接口使用受控响应,不发送真实短信',title:await page.title(),actions,pageErrors:errors,passed:true}; + await fs.writeFile('docs/sw6-to-kra/login-page-verification-2026-11-03.json',JSON.stringify(report,null,2)); + console.log(JSON.stringify(report)); + }finally{await browser.close();} +})().catch(e=>{console.error(e.message);process.exitCode=1}); diff --git a/web/index.html b/web/index.html index e6eecc3..26e7c49 100644 --- a/web/index.html +++ b/web/index.html @@ -5,11 +5,11 @@ - - Kra + + 遨游龙翔游戏平台 - diff --git a/web/src/view/login/useBusinessLogin.js b/web/src/view/login/useBusinessLogin.js new file mode 100644 index 0000000..fedb248 --- /dev/null +++ b/web/src/view/login/useBusinessLogin.js @@ -0,0 +1,143 @@ +import { ref, onMounted, onBeforeUnmount } from 'vue' +import { ElMessage } from 'element-plus' +import { useUserStore } from '@/pinia/modules/user' +import { captcha } from '@/api/user' +import { forgotPasswordCheck, forgotPasswordSendCode, forgotPasswordVerifyCode, forgotPasswordReset } from '@/api/businessPassword' + +export function useBusinessLogin() { + const title = '遨游龙翔游戏平台' + const currentYear = new Date().getFullYear() + const featureList = [ + { text: '实时渠道充值与注册趋势看板', icon: 'trend' }, + { text: '多级商务层级与游戏 SDK 绑定管理', icon: 'user' }, + { text: '智能预警规则与风险监控', icon: 'shield' } + ] + const userStore = useUserStore() + const accountKey = 'biz_login_account' + let saved = window.localStorage.getItem(accountKey) || '' + try { const parsed = JSON.parse(saved); if (typeof parsed === 'string') saved = parsed; else if (parsed && typeof parsed === 'object') saved = parsed.username || '' } catch { /* 兼容旧版直接保存账号字符串 */ } + const loginForm = ref({ username: saved, password: '', code: '', rememberMe: Boolean(saved) }) + const loginRef = ref() + const loading = ref(false) + const passwordReadonly = ref(true) + const captchaEnabled = ref(false) + const captchaId = ref('') + const captchaLength = ref(6) + const codeUrl = ref('') + const loginRules = { + username: [{ required: true, message: '请输入商务账号', trigger: 'blur' }], + password: [{ required: true, message: '请输入登录密码', trigger: 'blur' }], + code: [{ validator: (_rule, value, callback) => { + if (captchaEnabled.value && !new RegExp(`^\\d{${captchaLength.value}}$`).test(value || '')) callback(new Error('请输入正确的验证码')) + else callback() + }, trigger: 'blur' }] + } + const getCode = async () => { + const res = await captcha() + if (res.code !== 0) return + captchaEnabled.value = Boolean(res.data.openCaptcha) + captchaId.value = res.data.captchaId + captchaLength.value = Number(res.data.captchaLength) || 6 + codeUrl.value = res.data.picPath + loginForm.value.code = '' + } + const valid = async (form) => Boolean(await form.value?.validate().catch(() => false)) + const handleLogin = async () => { + if (loading.value || !await valid(loginRef)) return + loading.value = true + try { + const success = await userStore.LoginIn({ username: loginForm.value.username.trim(), password: loginForm.value.password, captcha: loginForm.value.code, captchaId: captchaId.value }) + if (success) { + if (loginForm.value.rememberMe) window.localStorage.setItem(accountKey, loginForm.value.username.trim()) + else window.localStorage.removeItem(accountKey) + } else await getCode() + } finally { loading.value = false } + } + + const forgotPasswordVisible = ref(false) + const forgotPasswordStep = ref('account') + const forgotAccountRef = ref() + const forgotSmsRef = ref() + const forgotResetRef = ref() + const forgotPasswordForm = ref({ username: '', smsCode: '', newPassword: '', confirmPassword: '' }) + const forgotAccountError = ref('') + const forgotMaskedPhone = ref('') + const forgotResetToken = ref('') + const forgotSubmitting = ref(false) + const smsCountdown = ref(0) + let smsTimer + const forgotAccountRules = { username: [{ required: true, message: '请输入您注册的商务账号', trigger: 'blur' }] } + const forgotSmsRules = { smsCode: [{ required: true, message: '请输入短信验证码', trigger: 'blur' }, { pattern: /^\d{6}$/, message: '请输入 6 位数字验证码', trigger: 'blur' }] } + const forgotResetRules = { + newPassword: [{ required: true, message: '请输入新密码', trigger: 'blur' }, { min: 6, max: 15, message: '密码长度为 6-15 位字符', trigger: 'blur' }], + confirmPassword: [{ required: true, message: '请再次输入新密码', trigger: 'blur' }, { validator: (_rule, value, callback) => { + if (value !== forgotPasswordForm.value.newPassword) callback(new Error('两次输入的密码不一致')) + else callback() + }, trigger: 'blur' }] + } + const clearSmsTimer = () => { clearInterval(smsTimer); smsTimer = undefined; smsCountdown.value = 0 } + const handleForgotPasswordClosed = () => { + clearSmsTimer() + forgotPasswordStep.value = 'account' + forgotPasswordForm.value = { username: '', smsCode: '', newPassword: '', confirmPassword: '' } + forgotResetToken.value = '' + forgotMaskedPhone.value = '' + forgotAccountError.value = '' + forgotSubmitting.value = false + } + const handleOpenForgotPassword = () => { + handleForgotPasswordClosed() + forgotPasswordForm.value.username = loginForm.value.username + forgotPasswordVisible.value = true + } + const requestSendCode = async () => { + const res = await forgotPasswordSendCode(forgotPasswordForm.value.username.trim()) + if (res.code !== 0) return false + forgotMaskedPhone.value = res.data.maskedPhone + clearSmsTimer() + const deadline = Date.now() + 60000 + smsCountdown.value = 60 + smsTimer = setInterval(() => { smsCountdown.value = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); if (!smsCountdown.value) clearSmsTimer() }, 1000) + ElMessage.success(`验证码已发送至 ${forgotMaskedPhone.value}`) + return true + } + const handleForgotAccountNext = async () => { + if (forgotSubmitting.value || !await valid(forgotAccountRef)) return + forgotSubmitting.value = true + try { + const res = await forgotPasswordCheck(forgotPasswordForm.value.username.trim()) + if (res.code !== 0) { forgotAccountError.value = res.msg; return } + forgotMaskedPhone.value = res.data.maskedPhone + if (await requestSendCode()) forgotPasswordStep.value = 'sms' + } finally { forgotSubmitting.value = false } + } + const handleSendSmsCode = async () => { + if (forgotSubmitting.value || smsCountdown.value > 0) return + forgotSubmitting.value = true + try { await requestSendCode() } finally { forgotSubmitting.value = false } + } + const handleForgotSmsNext = async () => { + if (forgotSubmitting.value || !await valid(forgotSmsRef)) return + forgotSubmitting.value = true + try { + const res = await forgotPasswordVerifyCode(forgotPasswordForm.value.username.trim(), forgotPasswordForm.value.smsCode) + if (res.code === 0) { forgotResetToken.value = res.data.resetToken; forgotPasswordStep.value = 'reset' } + } finally { forgotSubmitting.value = false } + } + const handleForgotResetSubmit = async () => { + if (forgotSubmitting.value || !await valid(forgotResetRef)) return + forgotSubmitting.value = true + try { + const res = await forgotPasswordReset({ resetToken: forgotResetToken.value, newPassword: forgotPasswordForm.value.newPassword, confirmPassword: forgotPasswordForm.value.confirmPassword }) + if (res.code === 0) { ElMessage.success('密码重置成功,请使用新密码登录'); loginForm.value.username = forgotPasswordForm.value.username; loginForm.value.password = ''; forgotPasswordVisible.value = false } + } finally { forgotSubmitting.value = false } + } + onMounted(getCode) + onBeforeUnmount(clearSmsTimer) + return { + title, currentYear, featureList, loginRef, loginForm, loginRules, loading, passwordReadonly, captchaEnabled, codeUrl, getCode, handleLogin, + forgotPasswordVisible, forgotPasswordStep, forgotAccountRef, forgotSmsRef, forgotResetRef, forgotPasswordForm, forgotAccountError, forgotMaskedPhone, + forgotSubmitting, smsCountdown, forgotAccountRules, forgotSmsRules, forgotResetRules, handleOpenForgotPassword, handleForgotPasswordClosed, + handleForgotAccountNext, handleSendSmsCode, handleForgotSmsNext, handleForgotResetSubmit + } +}