package services import ( "errors" "fmt" "math/rand" "regexp" "strings" "sync" "time" "server/models" "server/pkg/passwordutil" authsvc "server/services/auth" "github.com/google/uuid" ) // 统一认证中心自助账户能力:注册、找回密码、验证码。 // // 与旧版 password_reset.go(按「租户名+账号」定位老表)不同,这里直接操作 // 统一身份层 yz_auth_identity:手机号即全局账号,一处改密全平台生效。 var mobileRegexp = regexp.MustCompile(`^1[3-9]\d{9}$`) // identityCodeItem 短信验证码条目 type identityCodeItem struct { Code string ExpiredAt time.Time IssuedAt time.Time } var identityCodeStore sync.Map // key: purpose|phone func identityCodeKey(purpose, phone string) string { return strings.TrimSpace(purpose) + "|" + strings.TrimSpace(phone) } // IsMobile 校验手机号格式 func IsMobile(phone string) bool { return mobileRegexp.MatchString(strings.TrimSpace(phone)) } // findIdentityByMobile 按手机号查身份(含软删过滤) func findIdentityByMobile(phone string) *models.AuthIdentity { if !IsMobile(phone) { return nil } var m models.AuthIdentity if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("mobile", strings.TrimSpace(phone)). Filter("delete_time__isnull", true). One(&m); err != nil { return nil } return &m } // SendIdentityCode 发送自助账户短信验证码。 // - purpose=register:手机号必须未注册 // - purpose=reset:手机号必须已注册 // // 60 秒服务端冷却,防短信轰炸;验证码 5 分钟有效。 func SendIdentityCode(purpose, phone string) error { phone = strings.TrimSpace(phone) if !IsMobile(phone) { return errors.New("请输入正确的手机号") } if purpose != "register" && purpose != "reset" { return errors.New("参数错误") } existed := findIdentityByMobile(phone) if purpose == "register" && existed != nil { return errors.New("该手机号已注册,请直接登录或找回密码") } if purpose == "reset" && existed == nil { // 不暴露「手机号是否存在」会牺牲可用性;注册页已明示手机号唯一,这里直接提示 return errors.New("该手机号尚未注册") } // 服务端 60 秒冷却 if v, ok := identityCodeStore.Load(identityCodeKey(purpose, phone)); ok { if item, ok := v.(identityCodeItem); ok && time.Since(item.IssuedAt) < 60*time.Second { return errors.New("验证码发送过于频繁,请稍后再试") } } // 短信网关配置校验前置,避免生成验证码后发送失败造成困惑 if _, _, err := getDefaultSystemSMSConfig(); err != nil { return errors.New("短信服务未配置,请联系平台管理员") } code := fmt.Sprintf("%06d", rand.Intn(1000000)) //nolint:gosec // 验证码场景可接受 content := "验证码:" + code + ",5分钟内有效。为保护账号安全,请勿泄露给他人。" if err := enqueueSMSTaskForLogin(0, phone, content, code); err != nil { return errors.New("短信发送失败,请稍后重试") } now := time.Now() identityCodeStore.Store(identityCodeKey(purpose, phone), identityCodeItem{ Code: code, ExpiredAt: now.Add(5 * time.Minute), IssuedAt: now, }) return nil } // VerifyIdentityCode 校验自助账户短信验证码,验证通过后立即销毁(一次性) func VerifyIdentityCode(purpose, phone, code string) error { phone = strings.TrimSpace(phone) code = strings.TrimSpace(code) if code == "" { return errors.New("请输入验证码") } key := identityCodeKey(purpose, phone) v, ok := identityCodeStore.Load(key) if !ok { return errors.New("请先获取验证码") } item, ok := v.(identityCodeItem) if !ok { return errors.New("验证码状态异常,请重新获取") } if time.Now().After(item.ExpiredAt) { identityCodeStore.Delete(key) return errors.New("验证码已过期,请重新获取") } if item.Code != code { return errors.New("验证码错误") } identityCodeStore.Delete(key) return nil } // RegisterIdentityInput 自助注册入参 type RegisterIdentityInput struct { Phone string Code string Password string Confirm string Nickname string TenantCode string // 选填:填了则注册后直接加入该企业(默认「租户用户」角色) } // RegisterIdentityResult 注册结果 type RegisterIdentityResult struct { IdentityID uint64 JoinedTid uint64 // 成功加入的企业;0 表示未加入 Warning string // 加入企业失败的提示(身份已创建成功) } // RegisterIdentity 自助注册:创建统一身份,可选加入企业。 // // 统一认证原则:手机号即全局账号,密码在身份层一份;加入企业只是建绑定, // 企业内角色默认「租户用户」,后续由企业管理员调整。 func RegisterIdentity(in RegisterIdentityInput) (*RegisterIdentityResult, error) { phone := strings.TrimSpace(in.Phone) if !IsMobile(phone) { return nil, errors.New("请输入正确的手机号") } if err := VerifyIdentityCode("register", phone, in.Code); err != nil { return nil, err } if len(strings.TrimSpace(in.Password)) < 6 { return nil, errors.New("密码长度不能少于6个字符") } if in.Password != in.Confirm { return nil, errors.New("两次输入的密码不一致") } // 防并发重复注册 if findIdentityByMobile(phone) != nil { return nil, errors.New("该手机号已注册,请直接登录") } hashed, err := passwordutil.Hash(strings.TrimSpace(in.Password)) if err != nil { return nil, errors.New("密码处理失败") } nickname := strings.TrimSpace(in.Nickname) if nickname == "" { nickname = "用户" + phone[len(phone)-4:] } // 选填企业编码:先校验企业存在且启用、配额未满,失败直接拒绝(身份尚未创建) tenantCode := strings.TrimSpace(in.TenantCode) var tenant *models.SystemTenant if tenantCode != "" { t := new(models.SystemTenant) err := models.Orm.QueryTable(new(models.SystemTenant)). Filter("tenant_code", tenantCode). Filter("status", 1). Filter("delete_time__isnull", true). One(t) if err != nil { return nil, errors.New("企业编码不存在,请确认后重试") } if check, err := CheckTenantUserQuota(t.ID, 0); err == nil && !check.Allowed { return nil, fmt.Errorf("该企业用户数已达上限(%d/%d),请联系企业管理员", check.Used, check.Quota) } tenant = t } identity := &models.AuthIdentity{ UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""), PasswordAlgo: passwordutil.AlgoOf(hashed), Status: models.AuthIdentityStatusEnabled, } identity.Mobile = &phone identity.Nickname = &nickname identity.PasswordHash = &hashed id, err := models.Orm.Insert(identity) if err != nil { return nil, errors.New("注册失败,请稍后重试") } identity.ID = uint64(id) result := &RegisterIdentityResult{IdentityID: identity.ID} // 加入企业:建绑定(老表 + 认证中心),角色默认「租户用户」 if tenant != nil { warn := joinTenantAfterRegister(identity, tenant, nickname, phone, hashed) if warn != "" { result.Warning = warn } else { result.JoinedTid = tenant.ID } } return result, nil } // joinTenantAfterRegister 注册后加入企业;返回警告信息(空串表示成功)。 // 身份已创建成功,加入失败仅降级为警告,不回滚注册。 func joinTenantAfterRegister(identity *models.AuthIdentity, tenant *models.SystemTenant, nickname, phone, passwordHash string) string { // 企业内账号默认用手机号;若已被本企业其他身份占用则放弃自动加入 var dup models.SystemTenantUser if err := models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("tid", tenant.ID). Filter("account", phone). Filter("delete_time__isnull", true). One(&dup); err == nil && dup.Uid != identity.ID { return "注册成功,但企业内账号已被占用,请联系企业管理员邀请你加入" } phoneStr := phone nameStr := nickname hashStr := passwordHash if _, err := BindTenantUser( tenant.ID, identity.ID, &phoneStr, &nameStr, &phoneStr, nil, nil, nil, &hashStr, 1, 1, nil, ); err != nil { return "注册成功,但加入企业失败,请联系企业管理员邀请你加入" } // 默认「租户用户」角色;角色 ID 一并写入认证中心绑定 groupID := uint64(0) if roleID, err := models.GetTenantUserRole(); err == nil && roleID > 0 { groupID = roleID _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("tid", tenant.ID). Filter("uid", identity.ID). Update(map[string]interface{}{"group_id": roleID}) } // 双写认证中心绑定 if err := authsvc.SyncTenantUser(authsvc.SyncTenantUserInput{ Tid: tenant.ID, Account: phoneStr, Name: nameStr, Phone: phoneStr, PasswordHash: passwordHash, GroupID: groupID, Status: 1, IsDefault: 1, }); err != nil { return "注册成功,企业绑定已建立,但认证中心同步失败,请联系管理员" } return "" } // ResetIdentityPasswordByMobile 自助找回密码:验证码通过后重置身份层密码。 // 密码属于身份层,重置后该用户在所有企业的登录密码同步更新。 func ResetIdentityPasswordByMobile(phone, code, newPassword, confirm string) error { phone = strings.TrimSpace(phone) if !IsMobile(phone) { return errors.New("请输入正确的手机号") } if err := VerifyIdentityCode("reset", phone, code); err != nil { return err } if len(strings.TrimSpace(newPassword)) < 6 { return errors.New("密码长度不能少于6个字符") } if newPassword != confirm { return errors.New("两次输入的密码不一致") } identity := findIdentityByMobile(phone) if identity == nil { return errors.New("该手机号尚未注册") } hashed, err := passwordutil.Hash(strings.TrimSpace(newPassword)) if err != nil { return errors.New("密码处理失败") } _, err = models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", identity.ID). Update(map[string]interface{}{ "password_hash": hashed, "password_algo": passwordutil.AlgoOf(hashed), "fail_count": 0, }) if err != nil { return errors.New("密码重置失败,请稍后重试") } // 老表镜像同步:把新密码写到该身份在各企业的 yz_system_tenant_user 行, // 保持「老表密码 = 身份层密码」的双写口径(登录以认证中心为准) mirrorPasswordToTenantRows(identity.ID, hashed) return nil } // mirrorPasswordToTenantRows 把身份层密码镜像到该身份所有企业绑定的老表行 func mirrorPasswordToTenantRows(identityID uint64, passwordHash string) { var binds []models.AuthTenantUser if _, err := models.Orm.QueryTable(new(models.AuthTenantUser)). Filter("identity_id", identityID). Filter("delete_time__isnull", true). All(&binds); err != nil { return } for _, b := range binds { _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("tid", b.Tid). Filter("uid", identityID). Update(map[string]interface{}{"password": passwordHash}) } }