feat: 迁移商务登录品牌和三步找回密码
This commit is contained in:
+2
-2
@@ -40,8 +40,8 @@ var (
|
|||||||
// Service identity reported to logs, health output, and the API documentation.
|
// Service identity reported to logs, health output, and the API documentation.
|
||||||
// A plain `go run` leaves the ldflags empty, so these are the fallbacks.
|
// A plain `go run` leaves the ldflags empty, so these are the fallbacks.
|
||||||
const (
|
const (
|
||||||
appName = "Kra Admin"
|
appName = "遨游龙翔游戏平台"
|
||||||
appDescription = "Kratos 管理后台服务"
|
appDescription = "渠道商务管理服务"
|
||||||
defaultVersion = "v0.0.0-dev"
|
defaultVersion = "v0.0.0-dev"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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("重置凭证未在事务中消费")
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6
@@ -94,8 +94,13 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
|||||||
bindingUsecase := business2.NewBindingUsecase(bindingRepo, accountUsecase, platformAccountRepo, platformPasswordVerifier)
|
bindingUsecase := business2.NewBindingUsecase(bindingRepo, accountUsecase, platformAccountRepo, platformPasswordVerifier)
|
||||||
bindingService := business3.NewBindingService(bindingUsecase)
|
bindingService := business3.NewBindingService(bindingUsecase)
|
||||||
handlerBusiness := handler.NewBusiness(userPreferenceService, accountService, bindingService)
|
handlerBusiness := handler.NewBusiness(userPreferenceService, accountService, bindingService)
|
||||||
|
passwordResetRepo := business.NewPasswordResetRepo(dataData)
|
||||||
runtimeconfigStore := data.NewIntegrationRuntime(dataData)
|
runtimeconfigStore := data.NewIntegrationRuntime(dataData)
|
||||||
smsSender := sms.NewSender(runtimeconfigStore)
|
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)
|
smsUsecase := integration.NewSMSUsecase(smsSender)
|
||||||
smsService := integration2.NewSMSService(smsUsecase)
|
smsService := integration2.NewSMSService(smsUsecase)
|
||||||
handlerSMS := handler.NewSMS(smsService)
|
handlerSMS := handler.NewSMS(smsService)
|
||||||
@@ -193,6 +198,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
|||||||
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
||||||
set := &handler.Set{
|
set := &handler.Set{
|
||||||
Business: handlerBusiness,
|
Business: handlerBusiness,
|
||||||
|
BusinessPassword: businessPassword,
|
||||||
SMS: handlerSMS,
|
SMS: handlerSMS,
|
||||||
Authority: authority,
|
Authority: authority,
|
||||||
Menu: menu,
|
Menu: menu,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@
|
|||||||
- 已落地商务列表、查询资料、新增子商务、部分编辑和审核状态,以及 Kra 风格 `BusinessList` 页面。层级及角色由服务端按原角色 ID 映射分配;账号、角色、偏好在同一事务创建。列表按平台与商务组合键统计直属一级渠道和已通过绑定。
|
- 已落地商务列表、查询资料、新增子商务、部分编辑和审核状态,以及 Kra 风格 `BusinessList` 页面。层级及角色由服务端按原角色 ID 映射分配;账号、角色、偏好在同一事务创建。列表按平台与商务组合键统计直属一级渠道和已通过绑定。
|
||||||
- 商务画像、旗下渠道明细、导出等功能仍在后续范围内;当前不将整个商务菜单标为完成。
|
- 商务画像、旗下渠道明细、导出等功能仍在后续范围内;当前不将整个商务菜单标为完成。
|
||||||
- 已接入绑定列表、指定商务已通过绑定详情、提交绑定及解绑四个接口,页面增加游戏 SDK 绑定页签与详情抽屉。绑定流程保持三级限制、平台验密、OA 待审核及驳回重提规则;新增键锁表 `sys_business_binding_locks`,串行处理同一平台商务的并发绑定。
|
- 已接入绑定列表、指定商务已通过绑定详情、提交绑定及解绑四个接口,页面增加游戏 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_`。真实连接凭据和盐不写进新仓库。完整平台库验密仍需部署配置后联调。
|
- 平台数据源通过 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 个变更源文件及额外找回密码控制器。此清单是范围索引,不代表已完成实现。
|
- 已生成 `source-inventory.json`:现有 SQL 的 63 个接口契约、279 个变更源文件及额外找回密码控制器。此清单是范围索引,不代表已完成实现。
|
||||||
- 已执行 `make generate`、相关 Go 测试、变更前端 ESLint 和生产构建。构建存在原 `arcdash` 依赖的 BigInt 浏览器目标警告。
|
- 已执行 `make generate`、相关 Go 测试、变更前端 ESLint 和生产构建。构建存在原 `arcdash` 依赖的 BigInt 浏览器目标警告。
|
||||||
@@ -56,4 +58,5 @@
|
|||||||
- 用户反馈实际商务列表失败后,定位为 sw7 缺少 `tab_promote`。已解耦基础列表与渠道统计:有绑定且渠道表缺失时返回 `promoteCount: null`,界面显示“未接入”,无绑定账号仍为真实零。新增通过真实 TCP 请求的 HTTP 回归测试,覆盖系统数据库及完整渠道表两种场景。
|
- 用户反馈实际商务列表失败后,定位为 sw7 缺少 `tab_promote`。已解耦基础列表与渠道统计:有绑定且渠道表缺失时返回 `promoteCount: null`,界面显示“未接入”,无绑定账号仍为真实零。新增通过真实 TCP 请求的 HTTP 回归测试,覆盖系统数据库及完整渠道表两种场景。
|
||||||
- 已通过正常验证码登录取得测试会话,并在修复后的独立 18000 实例连接同一 sw7 执行 HTTP 请求。列表总数 45,分页、账号筛选、资料和偏好查询全部成功。详见 `http-verification-2026-11-03.json`。8000 为用户 GoLand 旧二进制,需重新运行才加载源码修复。
|
- 已通过正常验证码登录取得测试会话,并在修复后的独立 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 测试;平台验密外部边界使用测试替身,密码算法另用固定向量验证。
|
- 绑定批次通过 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 回归全部通过,未发送真实短信或修改真实用户密码。
|
||||||
- 当前没有执行本轮真实数据库迁移验证。
|
- 当前没有执行本轮真实数据库迁移验证。
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"method": "真实 Chrome 渲染;找回密码接口使用受控响应,不发送真实短信",
|
||||||
|
"title": "遨游龙翔游戏平台",
|
||||||
|
"actions": [
|
||||||
|
"check",
|
||||||
|
"send-code",
|
||||||
|
"verify-code",
|
||||||
|
"reset"
|
||||||
|
],
|
||||||
|
"pageErrors": [],
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
@@ -35,3 +35,7 @@
|
|||||||
## 其他页面
|
## 其他页面
|
||||||
|
|
||||||
渠道列表、渠道激活、渠道统计、充值明细、注册明细、游戏数据、区服分析、排行榜、预警列表和预警规则,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。
|
渠道列表、渠道激活、渠道统计、充值明细、注册明细、游戏数据、区服分析、排行榜、预警列表和预警规则,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。
|
||||||
|
|
||||||
|
## 登录页
|
||||||
|
|
||||||
|
已从旧 `login.vue` 复用登录模板和样式,复制原品牌图片,适配 Kra 登录接口;保持记住账号而不存密码、验证码、三步找回密码和原文案。系统标题、侧栏标识及页脚已同步旧商务名称。`login-page-verification-2026-11-03.json` 记录 Chrome 的流程验证,短信接口为测试替身,不向真实手机发码。
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -11,4 +11,4 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ProviderSet is biz providers.
|
// 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)
|
||||||
|
|||||||
@@ -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] = ©
|
||||||
|
}
|
||||||
|
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("短信未发送却写入了可用验证码")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Migrations() []migration.Step {
|
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{})
|
return migration.CreateMissingTables(db, &bindingLockPO{})
|
||||||
}}, {ID: "202611030003_business_bindings", Migrate: func(db *gorm.DB) error {
|
}}, {ID: "202611030003_business_bindings", Migrate: func(db *gorm.DB) error {
|
||||||
return migration.CreateMissingTables(db, &bindingPO{})
|
return migration.CreateMissingTables(db, &bindingPO{})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
databusiness.NewAccountRepo,
|
databusiness.NewAccountRepo,
|
||||||
databusiness.NewBindingRepo,
|
databusiness.NewBindingRepo,
|
||||||
databusiness.NewPlatformAccountRepo,
|
databusiness.NewPlatformAccountRepo,
|
||||||
|
databusiness.NewPasswordResetRepo,
|
||||||
NewIntegrationRuntime,
|
NewIntegrationRuntime,
|
||||||
wire.Bind(new(datasystem.Provider), new(*Data)),
|
wire.Bind(new(datasystem.Provider), new(*Data)),
|
||||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
|||||||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if versions != 18 {
|
if versions != 19 {
|
||||||
t.Fatalf("migration versions = %d, want 18", versions)
|
t.Fatalf("migration versions = %d, want 19", versions)
|
||||||
}
|
}
|
||||||
var communicationRows []dataintegration.ConfigPO
|
var communicationRows []dataintegration.ConfigPO
|
||||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
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 键")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
|
|
||||||
var ProviderSet = wire.NewSet(
|
var ProviderSet = wire.NewSet(
|
||||||
gameplatform.NewPasswords,
|
gameplatform.NewPasswords,
|
||||||
|
gameplatform.NewPasswordSMS,
|
||||||
cache.New,
|
cache.New,
|
||||||
email.NewEmailRepo,
|
email.NewEmailRepo,
|
||||||
sms.NewSender,
|
sms.NewSender,
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
|||||||
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := catalog.MigrationSteps(); len(got) != 18 {
|
if got := catalog.MigrationSteps(); len(got) != 19 {
|
||||||
t.Fatalf("module migrations = %d, want 18", len(got))
|
t.Fatalf("module migrations = %d, want 19", len(got))
|
||||||
}
|
}
|
||||||
var menus, apis int
|
var menus, apis int
|
||||||
for _, definition := range catalog.Definitions {
|
for _, definition := range catalog.Definitions {
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ type routeValue struct {
|
|||||||
// Gin router files still bind handlers; tests keep those registrations aligned
|
// Gin router files still bind handlers; tests keep those registrations aligned
|
||||||
// with this catalog while public, audit and API metadata live only here.
|
// with this catalog while public, audit and API metadata live only here.
|
||||||
var routes = map[string]routeValue{
|
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/bindPlatform": {group: "商务管理", description: "查询游戏商务绑定"},
|
||||||
"GET /sw/business/:userId/bindPlatform": {group: "商务管理", description: "查询指定商务已通过的绑定"},
|
"GET /sw/business/:userId/bindPlatform": {group: "商务管理", description: "查询指定商务已通过的绑定"},
|
||||||
"POST /sw/business/bindPlatform": {group: "商务管理", description: "绑定游戏商务并提交审核", audit: true},
|
"POST /sw/business/bindPlatform": {group: "商务管理", description: "绑定游戏商务并提交审核", audit: true},
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
|||||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
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)
|
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)
|
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)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import "github.com/google/wire"
|
|||||||
// field so adding a handler only means adding its constructor here.
|
// field so adding a handler only means adding its constructor here.
|
||||||
var ProviderSet = wire.NewSet(
|
var ProviderSet = wire.NewSet(
|
||||||
NewBusiness,
|
NewBusiness,
|
||||||
|
NewBusinessPassword,
|
||||||
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
|
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
|
||||||
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
||||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
type Set struct {
|
type Set struct {
|
||||||
Business *Business
|
Business *Business
|
||||||
|
BusinessPassword *BusinessPassword
|
||||||
SMS *SMS
|
SMS *SMS
|
||||||
Authority *Authority
|
Authority *Authority
|
||||||
Menu *Menu
|
Menu *Menu
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ var sensitiveHeaders = map[string]struct{}{
|
|||||||
// both match "newpassword".
|
// both match "newpassword".
|
||||||
var sensitivePayloadKeys = map[string]struct{}{
|
var sensitivePayloadKeys = map[string]struct{}{
|
||||||
"password": {}, "newpassword": {}, "oldpassword": {}, "confirmpassword": {},
|
"password": {}, "newpassword": {}, "oldpassword": {}, "confirmpassword": {},
|
||||||
|
"smscode": {}, "verificationcode": {},
|
||||||
"passwd": {}, "pwd": {}, "token": {}, "accesstoken": {}, "refreshtoken": {},
|
"passwd": {}, "pwd": {}, "token": {}, "accesstoken": {}, "refreshtoken": {},
|
||||||
"secret": {}, "clientsecret": {}, "apikey": {}, "privatekey": {}, "idcard": {},
|
"secret": {}, "clientsecret": {}, "apikey": {}, "privatekey": {}, "idcard": {},
|
||||||
"key": {}, "signingkey": {}, "secretkey": {},
|
"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)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
|||||||
}
|
}
|
||||||
registrations := []func(){
|
registrations := []func(){
|
||||||
func() { RegisterBusiness(private, r.handlers.Business) },
|
func() { RegisterBusiness(private, r.handlers.Business) },
|
||||||
|
func() { RegisterBusinessPassword(public, r.handlers.BusinessPassword) },
|
||||||
func() { RegisterPublic(public, engine, r.handlers.Public) },
|
func() { RegisterPublic(public, engine, r.handlers.Public) },
|
||||||
func() { RegisterUser(private, r.handlers.User) },
|
func() { RegisterUser(private, r.handlers.User) },
|
||||||
func() { RegisterNavigation(private, r.handlers.Navigation) },
|
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)
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
businessservice.NewUserPreferenceService,
|
businessservice.NewUserPreferenceService,
|
||||||
businessservice.NewAccountService,
|
businessservice.NewAccountService,
|
||||||
businessservice.NewBindingService,
|
businessservice.NewBindingService,
|
||||||
|
businessservice.NewPasswordResetService,
|
||||||
systemservice.ProviderSet,
|
systemservice.ProviderSet,
|
||||||
paymentservice.ProviderSet,
|
paymentservice.ProviderSet,
|
||||||
integrationservice.ProviderSet,
|
integrationservice.ProviderSet,
|
||||||
|
|||||||
@@ -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});
|
||||||
+3
-3
@@ -5,11 +5,11 @@
|
|||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
content="Kra,管理后台,Vue,Kratos,Gin"
|
content="遨游龙翔游戏平台,渠道商务,管理后台"
|
||||||
name="keywords"
|
name="keywords"
|
||||||
/>
|
/>
|
||||||
<link rel="icon" href="/logo.svg" />
|
<link rel="icon" href="/business-logo.png" />
|
||||||
<title>Kra</title>
|
<title>遨游龙翔游戏平台</title>
|
||||||
<script>
|
<script>
|
||||||
;(() => {
|
;(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
import service from '@/utils/request'
|
||||||
|
|
||||||
|
const request = (action, data) => service({ url: `/sw/auth/forgot-password/${action}`, method: 'post', data, donNotShowLoading: true })
|
||||||
|
export const forgotPasswordCheck = (username) => request('check', { username })
|
||||||
|
export const forgotPasswordSendCode = (username) => request('send-code', { username })
|
||||||
|
export const forgotPasswordVerifyCode = (username, smsCode) => request('verify-code', { username, smsCode })
|
||||||
|
export const forgotPasswordReset = (data) => request('reset', data)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 773 KiB |
@@ -2,7 +2,7 @@
|
|||||||
<div
|
<div
|
||||||
class="kra-bottom-info flex flex-col md:flex-row gap-2 items-center text-sm text-slate-700 dark:text-slate-500 justify-center py-2"
|
class="kra-bottom-info flex flex-col md:flex-row gap-2 items-center text-sm text-slate-700 dark:text-slate-500 justify-center py-2"
|
||||||
>
|
>
|
||||||
<div class="text-center font-bold text-active">Kra</div>
|
<div class="text-center font-bold text-active">{{ $KRA.appName }}</div>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const darkLogoPath = '/logo.svg'
|
const darkLogoPath = '/business-logo.png'
|
||||||
const lightLogoPath = '/logo.svg'
|
const lightLogoPath = '/business-logo.png'
|
||||||
|
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
const { isDark } = storeToRefs(themeStore);
|
const { isDark } = storeToRefs(themeStore);
|
||||||
@@ -60,10 +60,10 @@ watchEffect(async (onCleanup) => {
|
|||||||
// 到这里就包没有提供两种 logo 了
|
// 到这里就包没有提供两种 logo 了
|
||||||
showTextPlaceholder.value = true;
|
showTextPlaceholder.value = true;
|
||||||
console.error(
|
console.error(
|
||||||
'错误: 在公共目录中找不到 logo.svg。'
|
'错误: 在公共目录中找不到 business-logo.png。'
|
||||||
);
|
);
|
||||||
console.warn(
|
console.warn(
|
||||||
'解决方案: 请在 public 目录中放置 logo.svg。'
|
'解决方案: 请在 public 目录中放置 business-logo.png。'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,15 +81,12 @@ function getSize() {
|
|||||||
<img v-if="!showTextPlaceholder && logoSrc" :src="logoSrc" :alt="$KRA.appName" class="object-contain"
|
<img v-if="!showTextPlaceholder && logoSrc" :src="logoSrc" :alt="$KRA.appName" class="object-contain"
|
||||||
:style="{
|
:style="{
|
||||||
...getSize()
|
...getSize()
|
||||||
}" :class="{
|
|
||||||
'filter invert-[90%] hue-rotate-180 brightness-110':
|
|
||||||
effectiveDark && logoSrc === '/logo.svg',
|
|
||||||
}" />
|
}" />
|
||||||
<div v-else-if="showTextPlaceholder"
|
<div v-else-if="showTextPlaceholder"
|
||||||
class="rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-gray-700 dark:text-gray-200 font-bold text-xs"
|
class="rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-gray-700 dark:text-gray-200 font-bold text-xs"
|
||||||
:style="{
|
:style="{
|
||||||
...getSize()
|
...getSize()
|
||||||
}">
|
}">
|
||||||
KRA
|
商务
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
const greenText = (text) => `\x1b[32m${text}\x1b[0m`
|
const greenText = (text) => `\x1b[32m${text}\x1b[0m`
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
appName: 'Kra',
|
appName: '遨游龙翔游戏平台',
|
||||||
showViteLogo: true,
|
showViteLogo: true,
|
||||||
keepAliveTabs: false,
|
keepAliveTabs: false,
|
||||||
logs: []
|
logs: []
|
||||||
|
|||||||
+956
-261
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user