950 lines
33 KiB
Go
950 lines
33 KiB
Go
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)
|
||
}
|