package services import ( "crypto/rand" "encoding/hex" "errors" "fmt" "regexp" "strings" "time" "server/models" "server/pkg/passwordutil" authsvc "server/services/auth" "github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/core/logs" "github.com/google/uuid" ) // ============================================================= // 两步式自助注册(统一认证中心): // 第一步 核验身份:手机号(短信验证码)或 账号(服务号验证码), // 核验通过即创建统一身份 yz_auth_identity,并签发一次性 // register_token(10 分钟有效),token 携带身份与微信 openid。 // 第二步 企业归属(三选一,可跳过): // - 绑定企业:创建 join 申请 → 目标企业管理员在 backend 审批; // - 创建企业:提交企业资料(营业执照)→ 平台在 platform 审批, // 通过后自动建租户并把申请人绑定为租户管理员; // - 暂不加入:只完成身份注册。 // ============================================================= // registerTokenTTL register_token 有效期(覆盖第一步核验到第二步企业归属的完整操作: // 填企业资料 + 上传营业执照可能较久,给 2 小时;会话已落库,服务重启也不会丢) const registerTokenTTL = 2 * time.Hour // registerTokenItem 一次性注册令牌: // 第一步核验通过时只保存「待注册信息」,身份尚未创建; // 第二步归属操作调用 CompleteRegister 时才真正建号并写回 IdentityID。 // 这样令牌过期(或用户放弃)时不会有「已占用账号但没完成注册」的残留身份。 type registerTokenItem struct { IdentityID uint64 // 第二步完成建号后写回;0 = 尚未建号 Account string // 账号注册 / 小程序核验的账号名 Mobile string // 手机号注册的手机号 Nickname string PasswordHash string PasswordAlgo string OpenID string // 核验扫码人的微信 openid(公众号 / 小程序,用于注册后绑定微信登录) UnionID string // 小程序核验时的 unionid(同主体多端互通,可为空) Provider string // models.IdPWechat(公众号) / models.IdPWechatMini(小程序) ExpiredAt time.Time } // RegisterPending 第一步核验结果(尚未落库为身份) type RegisterPending struct { Account string Mobile string Nickname string PasswordHash string PasswordAlgo string OpenID string UnionID string Provider string } // IssuePendingRegisterToken 保存第一步核验结果并签发待注册令牌(此时尚未创建身份)。 // 会话落库(yz_auth_register_session):服务重启 / 多副本部署都不会中途失效。 func IssuePendingRegisterToken(p RegisterPending) string { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("rt%d%d", time.Now().UnixNano(), time.Now().UnixNano()) } token := hex.EncodeToString(b) now := time.Now() row := &models.AuthRegisterSession{ Token: token, Account: strings.TrimSpace(p.Account), Mobile: strings.TrimSpace(p.Mobile), Nickname: strings.TrimSpace(p.Nickname), PasswordHash: p.PasswordHash, PasswordAlgo: p.PasswordAlgo, OpenID: strings.TrimSpace(p.OpenID), UnionID: strings.TrimSpace(p.UnionID), Provider: p.Provider, Status: models.RegisterSessionPending, ExpireAt: now.Add(registerTokenTTL), CreateTime: now, UpdateTime: &now, } if _, err := models.Orm.Insert(row); err != nil { logs.Error("[register] 保存注册会话失败: %v", err) return "" } return token } // ConsumeRegisterToken 领取注册令牌(一次性,过期作废) func ConsumeRegisterToken(token string) (identityID uint64, openid string, err error) { identityID, openid, err = PeekRegisterToken(token) if err != nil { return 0, "", err } _, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)). Filter("token", strings.TrimSpace(token)). Delete() return identityID, openid, nil } // PeekRegisterToken 查看注册令牌(不消费);第二步先 Peek 校验, // 业务成功后再 Consume,避免申请失败导致令牌作废无法重试。 func PeekRegisterToken(token string) (identityID uint64, openid string, err error) { identityID, openid, _, _, err = PeekRegisterTokenThird(token) return identityID, openid, err } // PeekRegisterTokenThird 同 PeekRegisterToken,额外返回 unionid 与第三方来源 // (公众号 / 小程序),供第二步按来源绑定对应 provider。 func PeekRegisterTokenThird(token string) (identityID uint64, openid, unionid, provider string, err error) { token = strings.TrimSpace(token) if token == "" { return 0, "", "", "", errors.New("注册会话已失效,请重新注册") } var row models.AuthRegisterSession if err = models.Orm.QueryTable(new(models.AuthRegisterSession)). Filter("token", token).One(&row); err != nil { return 0, "", "", "", errors.New("注册会话已失效,请重新注册") } if time.Now().After(row.ExpireAt) { // 令牌过期:若期间已建号但没走完归属,回滚,避免留下占用账号的半成品 rollbackRegisterIdentity(row.IdentityID) _, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)).Filter("token", token).Delete() return 0, "", "", "", errors.New("注册会话已过期,请重新注册") } return row.IdentityID, row.OpenID, row.UnionID, row.Provider, nil } // ---------------- 第一步:核验并创建身份 ---------------- // RegisterVerifyInput 第一步核验入参 type RegisterVerifyInput struct { Mode string // phone / account / mini Phone string // mode=phone:手机号 Account string // mode=account|mini:自定义账号 Code string // 验证码(短信 或 服务号下发);mode=mini 不使用 Password string Confirm string OpenID string // 核验得到的微信 openid(公众号 或 小程序) UnionID string // mode=mini:小程序 unionid(可为空) Provider string // 微信来源:models.IdPWechat / models.IdPWechatMini } // validateAccountName 账号名规则:4-32 位,字母开头,字母/数字/下划线/中划线 func validateAccountName(account string) error { if len(account) < 4 || len(account) > 32 { return errors.New("账号长度需为 4-32 个字符") } c := account[0] if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') { return errors.New("账号需以字母开头") } for i := 0; i < len(account); i++ { c := account[i] if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { return errors.New("账号仅支持字母、数字、下划线和中划线") } } return nil } // findIdentityByAccountName 按全局账号列查身份 func findIdentityByAccountName(account string) *models.AuthIdentity { var m models.AuthIdentity if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("account", account). Filter("delete_time__isnull", true). One(&m); err != nil { return nil } return &m } // VerifyAndIssueRegisterToken 第一步:校验验证码与密码、检查账号唯一性,签发待注册令牌。 // 注意:此处【不创建身份】——身份推迟到第二步归属操作(CompleteRegister)才落库, // 避免「核验通过但第二步超时未完成」留下占用账号却无法使用的半成品账号。 // 调用方保证:mode=phone 已发短信验证码;mode=account 已核销服务号验证码并传入 openid; // mode=mini 已核销小程序注册会话并传入小程序 openid(Provider=wechat_mini)。 func VerifyAndIssueRegisterToken(in RegisterVerifyInput) (string, error) { in.Mode = strings.TrimSpace(in.Mode) if in.Mode != "phone" && in.Mode != "account" && in.Mode != "mini" { return "", errors.New("请选择注册方式") } if len(strings.TrimSpace(in.Password)) < 6 { return "", errors.New("密码长度不能少于6个字符") } if in.Password != in.Confirm { return "", errors.New("两次输入的密码不一致") } pending := RegisterPending{ OpenID: strings.TrimSpace(in.OpenID), UnionID: strings.TrimSpace(in.UnionID), Provider: in.Provider, } switch in.Mode { case "phone": phone := strings.TrimSpace(in.Phone) if !IsMobile(phone) { return "", errors.New("请输入正确的手机号") } if err := VerifyIdentityCode("register", phone, in.Code); err != nil { return "", err } if exist := findIdentityByMobile(phone); exist != nil { // 孤儿身份(注册未完成、从未登录、无企业归属)自动回收,账号/手机号可重新注册 if !isOrphanIdentity(exist) { return "", errors.New("该手机号已注册,请直接登录") } discardOrphanIdentity(exist.ID) } pending.Mobile = phone pending.Nickname = "用户" + phone[len(phone)-4:] case "account", "mini": account := strings.TrimSpace(in.Account) if err := validateAccountName(account); err != nil { return "", err } if exist := findIdentityByAccountName(account); exist != nil { // 同上:回收「核验通过但没走完第二步」留下的孤儿身份 if !isOrphanIdentity(exist) { return "", errors.New("该账号已被注册,请直接登录,或换一个账号名") } discardOrphanIdentity(exist.ID) } if in.Mode == "mini" && strings.TrimSpace(in.OpenID) == "" { return "", errors.New("微信核验已失效,请重新扫码确认") } pending.Account = account pending.Nickname = account } hashed, err := passwordutil.Hash(strings.TrimSpace(in.Password)) if err != nil { return "", errors.New("密码处理失败") } pending.PasswordHash = hashed pending.PasswordAlgo = passwordutil.AlgoOf(hashed) token := IssuePendingRegisterToken(pending) if token == "" { return "", errors.New("注册会话创建失败,请稍后重试") } return token, nil } // CompleteRegister 第二步归属操作开始时真正创建身份: // 取出第一步核验结果 → 复核唯一性 → 建号 → 绑定微信(若有)→ 写回令牌。 // 令牌此时不消费(业务失败可重试);令牌过期时由 rollbackRegisterIdentity 清理半成品身份。 func CompleteRegister(token string) (uint64, error) { tk := strings.TrimSpace(token) var row models.AuthRegisterSession if err := models.Orm.QueryTable(new(models.AuthRegisterSession)). Filter("token", tk).One(&row); err != nil { return 0, errors.New("注册会话已失效,请重新注册") } if time.Now().After(row.ExpireAt) { rollbackRegisterIdentity(row.IdentityID) _, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)).Filter("token", tk).Delete() return 0, errors.New("注册会话已过期,请重新注册") } if row.IdentityID != 0 { return row.IdentityID, nil // 已建号(重试场景),直接复用 } // 建号前复核:核验后到这一步之间可能被他人抢注;孤儿身份仍可回收 if row.Account != "" { if exist := findIdentityByAccountName(row.Account); exist != nil { if !isOrphanIdentity(exist) { return 0, errors.New("该账号已被注册,请直接登录,或换一个账号名") } discardOrphanIdentity(exist.ID) } } if row.Mobile != "" { if exist := findIdentityByMobile(row.Mobile); exist != nil { if !isOrphanIdentity(exist) { return 0, errors.New("该手机号已注册,请直接登录") } discardOrphanIdentity(exist.ID) } } identity := &models.AuthIdentity{ UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""), PasswordAlgo: row.PasswordAlgo, PasswordHash: &row.PasswordHash, Status: models.AuthIdentityStatusEnabled, } if row.Account != "" { account := row.Account identity.Account = &account } if row.Mobile != "" { mobile := row.Mobile identity.Mobile = &mobile } if row.Nickname != "" { nick := row.Nickname identity.Nickname = &nick } id, err := models.Orm.Insert(identity) if err != nil { return 0, errors.New("注册失败,请稍后重试") } identity.ID = uint64(id) BindRegisterThird(identity.ID, row.OpenID, row.UnionID, row.Provider) now := time.Now() _, _ = models.Orm.QueryTable(new(models.AuthRegisterSession)). Filter("id", row.ID). Update(map[string]interface{}{"identity_id": identity.ID, "update_time": now}) return identity.ID, nil } // isOrphanIdentity 判定「注册未完成」的孤儿身份:从未登录成功、没有任何企业归属、 // 也没有待审批的加入/创建企业申请。这类身份占用账号名(或手机号)却无法使用, // 回收后同名账号可正常重新注册。 func isOrphanIdentity(m *models.AuthIdentity) bool { if m == nil || m.ID == 0 { return false } if m.LastLoginAt != nil { return false } var tu models.AuthTenantUser if err := models.Orm.QueryTable(new(models.AuthTenantUser)). Filter("identity_id", m.ID). Filter("delete_time__isnull", true). One(&tu); err == nil { return false } var ta models.AuthTenantApply if err := models.Orm.QueryTable(new(models.AuthTenantApply)). Filter("identity_id", m.ID). Filter("delete_time__isnull", true). One(&ta); err == nil { return false } return true } // discardOrphanIdentity 物理删除孤儿身份及其第三方绑定。 // 用物理删而非软删:account/mobile 上有唯一索引,软删行仍占着索引会导致同名无法重新注册。 // 调用方必须先用 isOrphanIdentity 确认该身份没有任何关联数据。 func discardOrphanIdentity(identityID uint64) { if identityID == 0 { return } _, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("identity_id", identityID). Delete() _, _ = models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", identityID). Delete() } // rollbackRegisterIdentity 令牌过期 / 放弃注册时清理已建但未完成归属的身份, // 防止占用账号名却没有企业归属的「半成品账号」(同名账号可正常重新注册)。 func rollbackRegisterIdentity(identityID uint64) { if identityID == 0 { return } var m models.AuthIdentity if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", identityID).One(&m); err != nil { return } // 已产生企业归属或申请(业务已成功)时绝不能删 if !isOrphanIdentity(&m) { return } discardOrphanIdentity(identityID) } // ---------------- 第二步:企业归属 ---------------- // TenantSearchItem 企业搜索结果 type TenantSearchItem struct { Tid uint64 `json:"tid"` TenantName string `json:"tenant_name"` TenantCode string `json:"tenant_code"` } // SearchTenantsForRegister 注册页搜索启用中的企业(按名称/编码模糊,最多 10 条) func SearchTenantsForRegister(keyword string) ([]TenantSearchItem, error) { keyword = strings.TrimSpace(keyword) if keyword == "" { return []TenantSearchItem{}, nil } var rows []models.SystemTenant _, err := models.Orm.QueryTable(new(models.SystemTenant)). Filter("status", 1). Filter("delete_time__isnull", true). Filter("tenant_name__icontains", keyword). Limit(10). All(&rows, "ID", "TenantName", "TenantCode") if err != nil { return nil, err } if len(rows) < 10 { var byCode []models.SystemTenant _, _ = models.Orm.QueryTable(new(models.SystemTenant)). Filter("status", 1). Filter("delete_time__isnull", true). Filter("tenant_code__icontains", keyword). Limit(10). All(&byCode, "ID", "TenantName", "TenantCode") seen := map[uint64]bool{} for _, r := range rows { seen[r.ID] = true } for _, r := range byCode { if !seen[r.ID] { rows = append(rows, r) seen[r.ID] = true if len(rows) >= 10 { break } } } } out := make([]TenantSearchItem, 0, len(rows)) for _, r := range rows { out = append(out, TenantSearchItem{Tid: r.ID, TenantName: r.TenantName, TenantCode: r.TenantCode}) } return out, nil } // JoinApplyDetail 小程序「挂靠企业」补充资料。 // 手机号不允许前端传:统一由后端从身份表读取微信验证过的手机号。 type JoinApplyDetail struct { ApplicantName string DepartmentID uint64 DepartmentName string PositionID uint64 PositionName string WechatMobile string } // CreateJoinApply 「绑定企业」申请:等待目标企业管理员审批 // detail 为可选参数,PC 两步式注册沿用原调用(不传),小程序挂靠表单传入补充资料。 func CreateJoinApply(identityID, tid uint64, detail ...JoinApplyDetail) (*models.AuthTenantApply, error) { if identityID == 0 || tid == 0 { return nil, errors.New("参数错误") } tenant := new(models.SystemTenant) if err := models.Orm.QueryTable(new(models.SystemTenant)). Filter("id", tid). Filter("status", 1). Filter("delete_time__isnull", true). One(tenant); err != nil { return nil, errors.New("企业不存在或已停用") } identity, err := authsvc.GetIdentityByID(identityID) if err != nil || identity == nil { return nil, errors.New("注册身份异常,请重新注册") } // 已绑定该企业则无需申请 if _, berr := authsvc.GetTenantUser(identityID, tid); berr == nil { return nil, errors.New("你已是该企业的成员,无需重复申请") } // 同企业待审申请去重 cnt, _ := models.Orm.QueryTable(new(models.AuthTenantApply)). Filter("identity_id", identityID). Filter("tid", tid). Filter("type", models.TenantApplyTypeJoin). Filter("status", models.TenantApplyStatusPending). Filter("delete_time__isnull", true). Count() if cnt > 0 { return nil, errors.New("已提交过该企业的加入申请,请等待管理员审核") } apply := &models.AuthTenantApply{ IdentityID: identityID, Type: models.TenantApplyTypeJoin, Tid: tid, TenantName: tenant.TenantName, Status: models.TenantApplyStatusPending, } // 小程序挂靠表单:补充姓名 / 部门 / 职位,手机号取微信验证过的身份手机号 if len(detail) > 0 { d := detail[0] apply.ApplicantName = strings.TrimSpace(d.ApplicantName) apply.DepartmentID = d.DepartmentID apply.DepartmentName = strings.TrimSpace(d.DepartmentName) apply.PositionID = d.PositionID apply.PositionName = strings.TrimSpace(d.PositionName) apply.WechatMobile = strings.TrimSpace(d.WechatMobile) if apply.ApplicantName != "" { apply.ContactPerson = apply.ApplicantName } } if apply.ContactPerson == "" { apply.ContactPerson = identityNickname(identity) } if identity.Mobile != nil { apply.ContactPhone = *identity.Mobile } if _, err := models.Orm.Insert(apply); err != nil { return nil, errors.New("提交申请失败,请稍后重试") } return apply, nil } // TenantCreateApplyInput 「创建企业」申请入参 type TenantCreateApplyInput struct { TenantName string `json:"tenant_name"` CreditCode string `json:"credit_code"` LegalPerson string `json:"legal_person"` LicenseFile string `json:"license_file"` ContactPerson string `json:"contact_person"` ContactPhone string `json:"contact_phone"` ContactEmail string `json:"contact_email"` Address string `json:"address"` } var creditCodeRegexp = regexp.MustCompile(`^[0-9A-Z]{18}$`) // CreateTenantApply 「创建企业」申请:等待平台审批 func CreateTenantApply(identityID uint64, in TenantCreateApplyInput) (*models.AuthTenantApply, error) { in.TenantName = strings.TrimSpace(in.TenantName) in.CreditCode = strings.ToUpper(strings.TrimSpace(in.CreditCode)) in.LegalPerson = strings.TrimSpace(in.LegalPerson) in.LicenseFile = strings.TrimSpace(in.LicenseFile) in.ContactPerson = strings.TrimSpace(in.ContactPerson) in.ContactPhone = strings.TrimSpace(in.ContactPhone) in.ContactEmail = strings.TrimSpace(in.ContactEmail) in.Address = strings.TrimSpace(in.Address) if in.TenantName == "" { return nil, errors.New("请填写公司名称") } if len(in.TenantName) > 128 { return nil, errors.New("公司名称过长") } if !creditCodeRegexp.MatchString(in.CreditCode) { return nil, errors.New("请填写正确的统一社会信用代码(18位)") } if in.LegalPerson == "" { return nil, errors.New("请填写法定代表人姓名") } if in.LicenseFile == "" { return nil, errors.New("请上传营业执照电子档") } if in.ContactPerson == "" { return nil, errors.New("请填写联系人") } if !IsMobile(in.ContactPhone) { return nil, errors.New("请填写正确的联系电话") } if in.ContactEmail == "" || !strings.Contains(in.ContactEmail, "@") { return nil, errors.New("请填写正确的电子邮箱") } identity, err := authsvc.GetIdentityByID(identityID) if err != nil || identity == nil { return nil, errors.New("注册身份异常,请重新注册") } // 同一身份同时只允许一条待审的创建企业申请 cnt, _ := models.Orm.QueryTable(new(models.AuthTenantApply)). Filter("identity_id", identityID). Filter("type", models.TenantApplyTypeCreate). Filter("status", models.TenantApplyStatusPending). Filter("delete_time__isnull", true). Count() if cnt > 0 { return nil, errors.New("已提交过创建企业申请,请等待平台审核") } // 信用代码防重:已有启用租户使用同一信用代码则拒绝 if cc := in.CreditCode; cc != "" { dup, _ := models.Orm.QueryTable(new(models.AuthTenantApply)). Filter("credit_code", cc). Filter("delete_time__isnull", true). Exclude("status", models.TenantApplyStatusReject). Count() if dup > 0 { return nil, errors.New("该统一社会信用代码已提交过申请") } } apply := &models.AuthTenantApply{ IdentityID: identityID, Type: models.TenantApplyTypeCreate, TenantName: in.TenantName, CreditCode: in.CreditCode, LegalPerson: in.LegalPerson, LicenseFile: in.LicenseFile, ContactPerson: in.ContactPerson, ContactPhone: in.ContactPhone, ContactEmail: in.ContactEmail, Address: in.Address, Status: models.TenantApplyStatusPending, } if _, err := models.Orm.Insert(apply); err != nil { return nil, errors.New("提交申请失败,请稍后重试") } return apply, nil } // ---------------- 审批:backend(加入申请) ---------------- // ListTenantAppliesForTenant 企业管理员的「加入申请」列表(tid 隔离) func ListTenantAppliesForTenant(tid uint64, status *int8, page, pageSize int) ([]models.AuthTenantApply, int64, error) { qs := models.Orm.QueryTable(new(models.AuthTenantApply)). Filter("type", models.TenantApplyTypeJoin). Filter("tid", tid). Filter("delete_time__isnull", true) if status != nil { qs = qs.Filter("status", *status) } total, err := qs.Count() if err != nil { return nil, 0, err } var rows []models.AuthTenantApply _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows) return rows, total, err } // ApproveJoinApply 企业管理员通过加入申请:建绑定(默认「租户用户」角色)。 // 绑定成功后才置申请为已通过;绑定失败则申请保持待审并返回错误。 func ApproveJoinApply(applyID, tid, reviewerID uint64, reviewerName string) error { apply, err := getPendingApply(applyID, models.TenantApplyTypeJoin, tid) if err != nil { return err } identity, err := authsvc.GetIdentityByID(apply.IdentityID) if err != nil || identity == nil { return errors.New("申请人身份不存在") } // 配额校验 if check, qerr := CheckTenantUserQuota(tid, 0); qerr == nil && !check.Allowed { return fmt.Errorf("企业用户数已达上限(%d/%d),请先扩容后再通过申请", check.Used, check.Quota) } if err := bindIdentityToTenant(identity, tid, models.GetTenantUserRole); err != nil { return err } // 挂靠申请选了部门:审批通过后把 org_id 落到认证中心绑定行 if apply.DepartmentID > 0 { _, _ = models.Orm.QueryTable(new(models.AuthTenantUser)). Filter("tid", tid). Filter("identity_id", apply.IdentityID). Update(map[string]interface{}{"org_id": apply.DepartmentID, "update_time": time.Now()}) } return finishApply(apply, models.TenantApplyStatusPassed, "", reviewerID, reviewerName) } // RejectJoinApply 企业管理员驳回加入申请 func RejectJoinApply(applyID, tid, reviewerID uint64, reviewerName, reason string) error { apply, err := getPendingApply(applyID, models.TenantApplyTypeJoin, tid) if err != nil { return err } return finishApply(apply, models.TenantApplyStatusReject, reason, reviewerID, reviewerName) } // ---------------- 审批:platform(创建企业申请) ---------------- // ListTenantAppliesForPlatform 平台「企业创建审核」列表 func ListTenantAppliesForPlatform(applyType *int8, status *int8, page, pageSize int) ([]models.AuthTenantApply, int64, error) { qs := models.Orm.QueryTable(new(models.AuthTenantApply)).Filter("delete_time__isnull", true) if applyType != nil { qs = qs.Filter("type", *applyType) } if status != nil { qs = qs.Filter("status", *status) } total, err := qs.Count() if err != nil { return nil, 0, err } var rows []models.AuthTenantApply _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows) return rows, total, err } // ApproveCreateApply 平台通过创建企业申请:建租户(默认套餐配额), // 申请人绑定为租户管理员(全局「租户管理员」角色),并回填申请 tid。 func ApproveCreateApply(applyID, reviewerID uint64, reviewerName string) (tid uint64, err error) { apply, err := getPendingApply(applyID, models.TenantApplyTypeCreate, 0) if err != nil { return 0, err } identity, err := authsvc.GetIdentityByID(apply.IdentityID) if err != nil || identity == nil { return 0, errors.New("申请人身份不存在") } // 建租户 tenant := &models.SystemTenant{ TenantName: apply.TenantName, ContactPerson: applyStrPtr(apply.ContactPerson), ContactPhone: applyStrPtr(apply.ContactPhone), ContactEmail: applyStrPtr(apply.ContactEmail), Address: applyStrPtr(apply.Address), Status: 1, Remark: applyStrPtr(fmt.Sprintf("注册创建;信用代码 %s;法定代表人 %s;营业执照 %s", apply.CreditCode, apply.LegalPerson, apply.LicenseFile)), } tenant.TenantCode, err = genUniqueTenantCode() if err != nil { return 0, err } if pkg, perr := GetDefaultTenantPackage(); perr == nil && pkg != nil { tenant.PackageID = pkg.ID if pkg.UserQuota > 0 { tenant.UserQuota = pkg.UserQuota } } if tenant.UserQuota <= 0 { tenant.UserQuota = DefaultTenantUserQuota } newID, err := models.Orm.Insert(tenant) if err != nil { return 0, errors.New("创建租户失败:" + err.Error()) } tid = uint64(newID) // 申请人绑定为租户管理员(不新建账号、不覆盖密码) if err := bindIdentityToTenant(identity, tid, models.GetTenantAdminRole); err != nil { // 绑定失败:租户保留(平台可在租户管理里手动补绑),申请保持待审 return tid, fmt.Errorf("租户已创建(编码 %s),但管理员绑定失败:%s", tenant.TenantCode, err.Error()) } // 申请已通过标记失败不影响结果(租户与绑定已生效) apply.Tid = tid if ferr := finishApply(apply, models.TenantApplyStatusPassed, "", reviewerID, reviewerName); ferr != nil { return tid, nil } return tid, nil } // RejectCreateApply 平台驳回创建企业申请 func RejectCreateApply(applyID, reviewerID uint64, reviewerName, reason string) error { apply, err := getPendingApply(applyID, models.TenantApplyTypeCreate, 0) if err != nil { return err } return finishApply(apply, models.TenantApplyStatusReject, reason, reviewerID, reviewerName) } // ---------------- 内部辅助 ---------------- // getPendingApply 取指定类型的待审申请;tid>0 时校验归属 func getPendingApply(applyID uint64, applyType int8, tid uint64) (*models.AuthTenantApply, error) { apply := new(models.AuthTenantApply) if err := models.Orm.QueryTable(new(models.AuthTenantApply)). Filter("id", applyID). Filter("delete_time__isnull", true). One(apply); err != nil { return nil, errors.New("申请不存在") } if apply.Type != applyType { return nil, errors.New("申请类型不匹配") } if tid > 0 && apply.Tid != tid { return nil, errors.New("无权处理该申请") } if apply.Status != models.TenantApplyStatusPending { return nil, errors.New("该申请已处理,请刷新列表") } return apply, nil } // finishApply CAS 置申请终态,防止并发重复审批 func finishApply(apply *models.AuthTenantApply, status int8, reason string, reviewerID uint64, reviewerName string) error { now := time.Now() update := map[string]interface{}{ "status": status, "reject_reason": reason, "reviewer_id": reviewerID, "reviewer_name": reviewerName, "review_time": now, "update_time": now, } // 创建企业审批通过时新建的租户必须回写到申请单(否则申请记录里 tid 一直为 0, // 事后无法按 tid 追溯/修数据) if apply.Tid > 0 { update["tid"] = apply.Tid } affected, err := models.Orm.QueryTable(new(models.AuthTenantApply)). Filter("id", apply.ID). Filter("status", models.TenantApplyStatusPending). Update(update) if err != nil { return err } if affected == 0 { return errors.New("该申请已处理,请刷新列表") } return nil } // bindIdentityToTenant 把身份绑定为租户成员(老表 + 认证中心双写),角色由 rolePicker 决定。 // 企业内账号:手机号 > user{id};姓名取昵称;密码镜像身份层现有哈希。 func bindIdentityToTenant(identity *models.AuthIdentity, tid uint64, rolePicker func() (uint64, error)) error { account := "" if identity.Mobile != nil && strings.TrimSpace(*identity.Mobile) != "" { account = strings.TrimSpace(*identity.Mobile) } if account == "" { account = fmt.Sprintf("user%d", identity.ID) } // 企业内账号被其他身份占用时拒绝 var dup models.SystemTenantUser if err := models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("tid", tid). Filter("account", account). Filter("delete_time__isnull", true). One(&dup); err == nil && dup.Uid != identity.ID { return errors.New("企业内账号已被占用(" + account + "),请管理员调整后重试") } name := identityNickname(identity) var pwd *string if identity.PasswordHash != nil { h := *identity.PasswordHash pwd = &h } if _, err := BindTenantUser( tid, identity.ID, &account, &name, identity.Mobile, identity.Email, nil, nil, pwd, 0, 1, nil, ); err != nil { return errors.New("建立企业绑定失败:" + err.Error()) } groupID := uint64(0) if roleID, rerr := rolePicker(); rerr == nil && roleID > 0 { groupID = roleID _, _ = models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("tid", tid). Filter("uid", identity.ID). Update(map[string]interface{}{"group_id": roleID}) } if err := upsertAuthTenantBind(identity, tid, account, name, groupID); err != nil { return errors.New("认证中心绑定失败:" + err.Error()) } return nil } // upsertAuthTenantBind 按已知身份 ID 直接写认证中心绑定表 yz_auth_tenant_user // (登录时的企业列表读的就是这张表)。 // // 不能用 SyncTenantUser 的「账号/手机/邮箱归并键」来匹配:账号注册的身份没有手机号、 // 邮箱,企业内账号又是拼出来的 user{id},归并键匹配不到既有身份,会新建一个空身份并 // 绑定到新租户,申请人自己的身份反而没有绑定 → 登录后提示「该账号未绑定任何企业」。 func upsertAuthTenantBind(identity *models.AuthIdentity, tid uint64, account, name string, groupID uint64) error { if identity == nil || identity.ID == 0 || tid == 0 { return errors.New("参数错误") } now := time.Now() var existed models.AuthTenantUser if err := models.Orm.QueryTable(new(models.AuthTenantUser)). Filter("tid", tid). Filter("identity_id", identity.ID). One(&existed); err == nil { _, uerr := models.Orm.QueryTable(new(models.AuthTenantUser)). Filter("id", existed.ID). Update(map[string]interface{}{ "account": account, "name": name, "phone": identity.Mobile, "email": identity.Email, "group_id": groupID, "status": 1, "update_time": now, }) return uerr } item := &models.AuthTenantUser{ Tid: tid, IdentityID: identity.ID, GroupID: groupID, Status: 1, IsDefault: 1, // 申请人是该企业的第一个成员,设为默认企业 CreateTime: now, UpdateTime: &now, } if account != "" { item.Account = &account } if name != "" { item.Name = &name } if identity.Mobile != nil { item.Phone = identity.Mobile } if identity.Email != nil { item.Email = identity.Email } _, err := models.Orm.Insert(item) return err } // applyStrVal 取指针字符串值(nil 返回空串) func applyStrVal(s *string) string { if s == nil { return "" } return *s } // applyStrPtr 字符串转指针 func applyStrPtr(s string) *string { return &s } // identityNickname 身份昵称(空则用「用户{id}」兜底) func identityNickname(identity *models.AuthIdentity) string { if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" { return strings.TrimSpace(*identity.Nickname) } return fmt.Sprintf("用户%d", identity.ID) } // genUniqueTenantCode 生成唯一租户编码(T + 时间戳 + 随机) func genUniqueTenantCode() (string, error) { for i := 0; i < 5; i++ { code := fmt.Sprintf("T%d%03d", time.Now().Unix(), time.Now().UnixNano()%1000) cnt, err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("tenant_code", code).Count() if err != nil { return "", errors.New("生成租户编码失败") } if cnt == 0 { return code, nil } } return "", errors.New("生成租户编码失败,请稍后重试") } // BindRegisterWechat 注册完成后把服务号 openid 绑到新身份(公众号核验模式)。 // openid 已绑定其他身份时不抢占,静默跳过。 func BindRegisterWechat(identityID uint64, openid string) { BindRegisterThird(identityID, openid, "", models.IdPWechat) } // BindRegisterThird 注册完成后把微信身份绑到新身份。 // provider 区分公众号(wechat)与小程序(wechat_mini);openid 已绑定时不抢占,静默跳过。 func BindRegisterThird(identityID uint64, openid, unionid, provider string) { openid = strings.TrimSpace(openid) if openid == "" || identityID == 0 { return } if provider == "" { provider = models.IdPWechat } var row models.AuthIdentityThird err := models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("provider", provider). Filter("open_id", openid). One(&row) if err == nil { return // 已绑定(无论绑给谁),不抢占 } if err != orm.ErrNoRows { return } item := &models.AuthIdentityThird{ IdentityID: identityID, Provider: provider, OpenID: openid, } if uid := strings.TrimSpace(unionid); uid != "" { item.UnionID = &uid } _, _ = models.Orm.Insert(item) }