Files
yunzerwebsiteallinone/go/services/identity_register.go
T
2026-10-31 01:21:55 +08:00

950 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// CreateJoinApply 「绑定企业」申请:等待目标企业管理员审批
func CreateJoinApply(identityID, tid uint64) (*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,
}
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
}
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)
}