批量优化
This commit is contained in:
1 parent
438145cae7
commit
c925b198d1
50 files changed
+5716
-291
No files matched your search
@@ -386,6 +386,61 @@ func resolveApprovers(scope int8, node Node, in SubmitInput) ([]Approver, error)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// resolveNotifyTargets 解析通知节点的通知对象(支持多类型)。
|
||||
// approver_ids 兼容两种存储格式:
|
||||
// 1. 旧格式:纯数字 CSV —— 单类型,配合 node.ApproverType(历史数据);
|
||||
// 2. 多选格式:"类型:ID" CSV(如 "1:52,5:0")—— 类型取值同 approver_type,
|
||||
// 自动定位类型(5发起人本人 / 6发起人主管)无需指定对象,ID 占位 0。
|
||||
// 各类型的解析结果按 ID 去重合并。
|
||||
func resolveNotifyTargets(scope int8, node Node, in SubmitInput) ([]Approver, error) {
|
||||
if !strings.Contains(node.ApproverIDs, ":") {
|
||||
return resolveApprovers(scope, node, in)
|
||||
}
|
||||
typeOrder := make([]int8, 0, 4)
|
||||
idsByType := make(map[int8][]uint64)
|
||||
for _, part := range strings.Split(node.ApproverIDs, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(part, ":", 2)
|
||||
t, err := strconv.Atoi(strings.TrimSpace(kv[0]))
|
||||
if err != nil || t < int(models.ApprovalApproverUser) || t > int(models.ApprovalApproverInitLeader) {
|
||||
continue
|
||||
}
|
||||
at := int8(t)
|
||||
if _, ok := idsByType[at]; !ok {
|
||||
typeOrder = append(typeOrder, at)
|
||||
}
|
||||
if len(kv) == 2 {
|
||||
if id, err := strconv.ParseUint(strings.TrimSpace(kv[1]), 10, 64); err == nil && id > 0 {
|
||||
idsByType[at] = append(idsByType[at], id)
|
||||
}
|
||||
}
|
||||
}
|
||||
merged := make([]Approver, 0, 8)
|
||||
for _, t := range typeOrder {
|
||||
sub := Node{ApproverType: t, ApproverIDs: joinApproverIDs(idsByType[t])}
|
||||
approvers, err := resolveApprovers(scope, sub, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged = append(merged, approvers...)
|
||||
}
|
||||
return dedupeApprovers(merged), nil
|
||||
}
|
||||
|
||||
// joinApproverIDs 把 ID 列表拼成 CSV(与 parseIDs 互逆)
|
||||
func joinApproverIDs(ids []uint64) string {
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
parts = append(parts, strconv.FormatUint(id, 10))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// tenantUsers 按 uid 取租户用户
|
||||
func tenantUsers(tid uint64, uids []uint64) []Approver {
|
||||
if len(uids) == 0 {
|
||||
@@ -895,7 +950,7 @@ func createTasks(instID uint64, in SubmitInput, node Node, approvers []Approver)
|
||||
|
||||
// fireNotifyTasks 为通知节点创建已读通知任务(不阻塞流程)
|
||||
func fireNotifyTasks(instID uint64, in SubmitInput, notifyNode *Node) {
|
||||
recipients, _ := resolveApprovers(in.Scope, *notifyNode, in)
|
||||
recipients, _ := resolveNotifyTargets(in.Scope, *notifyNode, in)
|
||||
recipients = dedupeApprovers(recipients)
|
||||
if len(recipients) == 0 {
|
||||
return
|
||||
|
||||
@@ -36,7 +36,7 @@ type IdentityProfile struct {
|
||||
}
|
||||
|
||||
// FindIdentityByAccount 按登录账号查找身份。
|
||||
// 匹配顺序:手机号 → 邮箱 → 企业内账号(先查绑定再回查身份)。
|
||||
// 匹配顺序:全局账号(账号注册)→ 手机号 → 邮箱 → 企业内账号(先查绑定再回查身份)。
|
||||
func FindIdentityByAccount(account string) (*models.AuthIdentity, error) {
|
||||
account = strings.TrimSpace(account)
|
||||
if account == "" {
|
||||
@@ -44,7 +44,7 @@ func FindIdentityByAccount(account string) (*models.AuthIdentity, error) {
|
||||
}
|
||||
base := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("delete_time__isnull", true)
|
||||
|
||||
for _, field := range []string{"mobile", "email"} {
|
||||
for _, field := range []string{"account", "mobile", "email"} {
|
||||
m := &models.AuthIdentity{}
|
||||
if err := base.Filter(field, account).One(m); err == nil {
|
||||
return m, nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -11,16 +12,22 @@ import (
|
||||
|
||||
// SyncTenantUserInput 老表 → 认证中心同步入参
|
||||
type SyncTenantUserInput struct {
|
||||
Tid uint64
|
||||
Account string // 企业内账号
|
||||
Name string
|
||||
Phone string
|
||||
Email string
|
||||
PasswordHash string // 已哈希的密码;为空表示不修改密码
|
||||
Tid uint64
|
||||
Account string // 企业内账号
|
||||
Name string
|
||||
Phone string
|
||||
Email string
|
||||
// PasswordHash 已哈希的密码;为空表示不修改密码。
|
||||
// 注意:身份已存在时,只有 UpdatePassword=true 才会生效(改密场景),
|
||||
// 否则保持不动 —— 避免 B 企业建同名用户时把 A 企业的密码静默覆盖掉。
|
||||
PasswordHash string
|
||||
GroupID uint64
|
||||
OrgID uint64
|
||||
Status int8
|
||||
IsDefault int8
|
||||
|
||||
// UpdatePassword 身份已存在时是否允许更新密码(仅改密场景传 true)
|
||||
UpdatePassword bool
|
||||
}
|
||||
|
||||
// SyncTenantUser 把租户用户同步到统一认证中心(幂等,可重复调用)。
|
||||
@@ -69,7 +76,7 @@ func RemoveTenantUser(tid uint64, account, phone, email string) error {
|
||||
// findOrCreateIdentity 按归并键查找身份,不存在则创建
|
||||
func findOrCreateIdentity(in SyncTenantUserInput) (*models.AuthIdentity, error) {
|
||||
if m := findIdentityByKey(in.Account, in.Phone, in.Email); m != nil {
|
||||
// 补全手机/邮箱/昵称;密码仅在显式传入时更新
|
||||
// 补全手机/邮箱/昵称;密码仅在显式授权(改密场景)时更新
|
||||
update := map[string]interface{}{}
|
||||
if in.Phone != "" {
|
||||
update["mobile"] = in.Phone
|
||||
@@ -83,7 +90,7 @@ func findOrCreateIdentity(in SyncTenantUserInput) (*models.AuthIdentity, error)
|
||||
if in.Status > 0 {
|
||||
update["status"] = in.Status
|
||||
}
|
||||
if in.PasswordHash != "" {
|
||||
if in.UpdatePassword && in.PasswordHash != "" {
|
||||
update["password_hash"] = in.PasswordHash
|
||||
update["password_algo"] = passwordutil.AlgoOf(in.PasswordHash)
|
||||
}
|
||||
@@ -230,3 +237,37 @@ func upsertTenantBinding(identity *models.AuthIdentity, in SyncTenantUserInput)
|
||||
Update(update)
|
||||
return err
|
||||
}
|
||||
|
||||
// LocateIdentityByKey 按归并键(手机 > 邮箱 > 账号)查找已有身份。
|
||||
// 第二个返回值表示是否命中。供「新增租户用户」流程判断:
|
||||
// 命中则要求先验证该身份的登录密码,验证通过后只建企业绑定。
|
||||
func LocateIdentityByKey(account, phone, email string) (*models.AuthIdentity, bool) {
|
||||
m := findIdentityByKey(account, phone, email)
|
||||
return m, m != nil
|
||||
}
|
||||
|
||||
// GetIdentityByID 按主键读取认证身份
|
||||
func GetIdentityByID(id uint64) (*models.AuthIdentity, error) {
|
||||
var m models.AuthIdentity
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", id).One(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// VerifyIdentityPassword 校验身份的登录密码(兼容 legacy 哈希算法)
|
||||
func VerifyIdentityPassword(identity *models.AuthIdentity, plain string) bool {
|
||||
if identity == nil || identity.PasswordHash == nil || *identity.PasswordHash == "" {
|
||||
return false
|
||||
}
|
||||
return passwordutil.Verify(*identity.PasswordHash, plain)
|
||||
}
|
||||
|
||||
// BindIdentityToTenant 把已有身份绑定到企业(只写绑定层,不改身份资料与密码)。
|
||||
// 供「验证密码后绑定到新企业」流程使用,绑定层的企业内账号/姓名/联系方式独立维护。
|
||||
func BindIdentityToTenant(identity *models.AuthIdentity, in SyncTenantUserInput) error {
|
||||
if identity == nil {
|
||||
return errors.New("身份不存在")
|
||||
}
|
||||
return upsertTenantBinding(identity, in)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
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})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/passwordutil"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 两步式自助注册(统一认证中心):
|
||||
// 第一步 核验身份:手机号(短信验证码)或 账号(服务号验证码),
|
||||
// 核验通过即创建统一身份 yz_auth_identity,并签发一次性
|
||||
// register_token(10 分钟有效),token 携带身份与微信 openid。
|
||||
// 第二步 企业归属(三选一,可跳过):
|
||||
// - 绑定企业:创建 join 申请 → 目标企业管理员在 backend 审批;
|
||||
// - 创建企业:提交企业资料(营业执照)→ 平台在 platform 审批,
|
||||
// 通过后自动建租户并把申请人绑定为租户管理员;
|
||||
// - 暂不加入:只完成身份注册。
|
||||
// =============================================================
|
||||
|
||||
// registerTokenTTL register_token 有效期
|
||||
const registerTokenTTL = 10 * time.Minute
|
||||
|
||||
// registerTokenItem 一次性注册令牌:身份已创建,等待第二步归属操作
|
||||
type registerTokenItem struct {
|
||||
IdentityID uint64
|
||||
OpenID string // 账号注册模式扫码人的微信 openid(用于注册后绑定微信登录)
|
||||
ExpiredAt time.Time
|
||||
}
|
||||
|
||||
var registerTokenStore sync.Map // key: token
|
||||
|
||||
// IssueRegisterToken 身份创建成功后签发注册令牌
|
||||
func IssueRegisterToken(identityID uint64, openid string) string {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("rt%d%d", identityID, time.Now().UnixNano())
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
registerTokenStore.Store(token, registerTokenItem{
|
||||
IdentityID: identityID,
|
||||
OpenID: openid,
|
||||
ExpiredAt: time.Now().Add(registerTokenTTL),
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
// ConsumeRegisterToken 领取注册令牌(一次性,过期作废)
|
||||
func ConsumeRegisterToken(token string) (identityID uint64, openid string, err error) {
|
||||
identityID, openid, err = PeekRegisterToken(token)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
registerTokenStore.Delete(strings.TrimSpace(token))
|
||||
return identityID, openid, nil
|
||||
}
|
||||
|
||||
// PeekRegisterToken 查看注册令牌(不消费);第二步先 Peek 校验,
|
||||
// 业务成功后再 Consume,避免申请失败导致令牌作废无法重试。
|
||||
func PeekRegisterToken(token string) (identityID uint64, openid string, err error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return 0, "", errors.New("注册会话已失效,请重新注册")
|
||||
}
|
||||
v, ok := registerTokenStore.Load(token)
|
||||
if !ok {
|
||||
return 0, "", errors.New("注册会话已失效,请重新注册")
|
||||
}
|
||||
item, ok := v.(registerTokenItem)
|
||||
if !ok {
|
||||
return 0, "", errors.New("注册会话已失效,请重新注册")
|
||||
}
|
||||
if time.Now().After(item.ExpiredAt) {
|
||||
registerTokenStore.Delete(token)
|
||||
return 0, "", errors.New("注册会话已过期,请重新注册")
|
||||
}
|
||||
return item.IdentityID, item.OpenID, nil
|
||||
}
|
||||
|
||||
// ---------------- 第一步:核验并创建身份 ----------------
|
||||
|
||||
// RegisterVerifyInput 第一步核验入参
|
||||
type RegisterVerifyInput struct {
|
||||
Mode string // phone / account
|
||||
Phone string // mode=phone:手机号
|
||||
Account string // mode=account:自定义账号
|
||||
Code string // 验证码(短信 或 服务号下发)
|
||||
Password string
|
||||
Confirm string
|
||||
OpenID string // mode=account:核销服务号验证码得到的 openid
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// VerifyAndCreateIdentity 第一步:校验验证码与密码,创建统一身份,返回身份与新令牌。
|
||||
// 调用方保证:mode=phone 已发短信验证码;mode=account 已核销服务号验证码并传入 openid。
|
||||
func VerifyAndCreateIdentity(in RegisterVerifyInput) (*models.AuthIdentity, string, error) {
|
||||
in.Mode = strings.TrimSpace(in.Mode)
|
||||
if in.Mode != "phone" && in.Mode != "account" {
|
||||
return nil, "", errors.New("请选择注册方式")
|
||||
}
|
||||
if len(strings.TrimSpace(in.Password)) < 6 {
|
||||
return nil, "", errors.New("密码长度不能少于6个字符")
|
||||
}
|
||||
if in.Password != in.Confirm {
|
||||
return nil, "", errors.New("两次输入的密码不一致")
|
||||
}
|
||||
|
||||
identity := &models.AuthIdentity{
|
||||
UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||
}
|
||||
|
||||
switch in.Mode {
|
||||
case "phone":
|
||||
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 findIdentityByMobile(phone) != nil {
|
||||
return nil, "", errors.New("该手机号已注册,请直接登录")
|
||||
}
|
||||
identity.Mobile = &phone
|
||||
nick := "用户" + phone[len(phone)-4:]
|
||||
identity.Nickname = &nick
|
||||
case "account":
|
||||
account := strings.TrimSpace(in.Account)
|
||||
if err := validateAccountName(account); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if findIdentityByAccountName(account) != nil {
|
||||
return nil, "", errors.New("该账号已被占用,请换一个")
|
||||
}
|
||||
identity.Account = &account
|
||||
nick := account
|
||||
identity.Nickname = &nick
|
||||
}
|
||||
|
||||
hashed, err := passwordutil.Hash(strings.TrimSpace(in.Password))
|
||||
if err != nil {
|
||||
return nil, "", errors.New("密码处理失败")
|
||||
}
|
||||
identity.PasswordHash = &hashed
|
||||
identity.PasswordAlgo = passwordutil.AlgoOf(hashed)
|
||||
identity.Status = models.AuthIdentityStatusEnabled
|
||||
|
||||
id, err := models.Orm.Insert(identity)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("注册失败,请稍后重试")
|
||||
}
|
||||
identity.ID = uint64(id)
|
||||
|
||||
token := IssueRegisterToken(identity.ID, in.OpenID)
|
||||
return identity, token, nil
|
||||
}
|
||||
|
||||
// ---------------- 第二步:企业归属 ----------------
|
||||
|
||||
// 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()
|
||||
affected, err := models.Orm.QueryTable(new(models.AuthTenantApply)).
|
||||
Filter("id", apply.ID).
|
||||
Filter("status", models.TenantApplyStatusPending).
|
||||
Update(map[string]interface{}{
|
||||
"status": status,
|
||||
"reject_reason": reason,
|
||||
"reviewer_id": reviewerID,
|
||||
"reviewer_name": reviewerName,
|
||||
"review_time": now,
|
||||
"update_time": now,
|
||||
})
|
||||
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 := authsvc.SyncTenantUser(authsvc.SyncTenantUserInput{
|
||||
Tid: tid,
|
||||
Account: account,
|
||||
Name: name,
|
||||
Phone: applyStrVal(identity.Mobile),
|
||||
Email: applyStrVal(identity.Email),
|
||||
PasswordHash: applyStrVal(identity.PasswordHash),
|
||||
GroupID: groupID,
|
||||
Status: 1,
|
||||
IsDefault: 0,
|
||||
}); err != nil {
|
||||
return errors.New("认证中心同步失败:" + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
openid = strings.TrimSpace(openid)
|
||||
if openid == "" || identityID == 0 {
|
||||
return
|
||||
}
|
||||
var row models.AuthIdentityThird
|
||||
err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("provider", models.IdPWechat).
|
||||
Filter("open_id", openid).
|
||||
One(&row)
|
||||
if err == nil {
|
||||
return // 已绑定(无论绑给谁),不抢占
|
||||
}
|
||||
if err != orm.ErrNoRows {
|
||||
return
|
||||
}
|
||||
_, _ = models.Orm.Insert(&models.AuthIdentityThird{
|
||||
IdentityID: identityID,
|
||||
Provider: models.IdPWechat,
|
||||
OpenID: openid,
|
||||
})
|
||||
}
|
||||
@@ -551,6 +551,26 @@ func ownerModuleCode(sortedModules []models.SystemModules, path string) (string,
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ModuleCodesOwningMenus 返回这些菜单路径归属的模块编码集合(按模块 path 前缀匹配)。
|
||||
// 供角色权限过滤使用:模块名下有菜单但一个都不在授权集合时,模块应整体隐藏。
|
||||
func ModuleCodesOwningMenus(allModules []models.SystemModules, menus []models.SystemMenu) map[string]bool {
|
||||
codes := map[string]bool{}
|
||||
if len(allModules) == 0 || len(menus) == 0 {
|
||||
return codes
|
||||
}
|
||||
sorted := sortModulesByPathDesc(allModules)
|
||||
for _, m := range menus {
|
||||
path := ""
|
||||
if m.Path != nil {
|
||||
path = *m.Path
|
||||
}
|
||||
if code, ok := ownerModuleCode(sorted, path); ok {
|
||||
codes[NormalizeModuleCode(code)] = true
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// FilterMenusByTenantModules 按租户套餐包含的功能模块过滤菜单:
|
||||
// - 菜单不属于任何模块(公共/基础菜单)→ 保留;
|
||||
// - 菜单归属某模块 → 仅当该模块已被租户套餐开通时保留。
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
//
|
||||
// 与 services/wechatmp(公众号)分开:小程序走 sns/jscode2session 与
|
||||
// wxa/business/getuserphonenumber,两者的 AppID / AppSecret 互不相通。
|
||||
//
|
||||
// 配置存储:多应用表 yz_platform_wechat_mini_app(docs/sql/create_wechat_mini_app.sql),
|
||||
// 旧单行表 yz_platform_wechat_mini_config 仅作为未迁移部署的回退数据源。
|
||||
package wechatmini
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +21,7 @@ import (
|
||||
// Config 小程序运行配置(敏感字段已解密)
|
||||
type Config struct {
|
||||
ID uint64
|
||||
Name string
|
||||
AppID string
|
||||
AppSecret string // 明文
|
||||
Enabled bool
|
||||
@@ -27,18 +32,11 @@ type Config struct {
|
||||
// ErrNotConfigured 未配置或未启用
|
||||
var ErrNotConfigured = errors.New("微信小程序未配置或未启用")
|
||||
|
||||
// LoadConfig 读取小程序配置(单行,敏感字段解密);未配置返回 ErrNotConfigured
|
||||
func LoadConfig() (*Config, error) {
|
||||
var row models.WechatMiniConfig
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniConfig)).OrderBy("id").One(&row)
|
||||
if err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// appFromRow 解密行数据为运行配置
|
||||
func appFromRow(row models.WechatMiniApp) (*Config, error) {
|
||||
cfg := &Config{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
AppID: strings.TrimSpace(row.AppID),
|
||||
Enabled: row.Enabled == 1,
|
||||
AutoBind: row.AutoBind != 0,
|
||||
@@ -54,6 +52,44 @@ func LoadConfig() (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadConfig 读取小程序配置(多应用表第一条,敏感字段解密)。
|
||||
// 新表无数据时回退旧单行表(未执行迁移 SQL 的存量部署);均未配置返回 ErrNotConfigured
|
||||
func LoadConfig() (*Config, error) {
|
||||
var rows []models.WechatMiniApp
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("id").
|
||||
All(&rows)
|
||||
if err == nil && len(rows) > 0 {
|
||||
return appFromRow(rows[0])
|
||||
}
|
||||
|
||||
// 旧单行表回退
|
||||
var legacy models.WechatMiniConfig
|
||||
err = models.Orm.QueryTable(new(models.WechatMiniConfig)).OrderBy("id").One(&legacy)
|
||||
if err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cfg := &Config{
|
||||
ID: legacy.ID,
|
||||
AppID: strings.TrimSpace(legacy.AppID),
|
||||
Enabled: legacy.Enabled == 1,
|
||||
AutoBind: legacy.AutoBind != 0,
|
||||
Remark: legacy.Remark,
|
||||
}
|
||||
if legacy.AppSecret != "" {
|
||||
plain, derr := wechatmp.DecryptSecret(legacy.AppSecret)
|
||||
if derr != nil {
|
||||
return nil, derr
|
||||
}
|
||||
cfg.AppSecret = plain
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadEnabledConfig 读取并校验启用状态
|
||||
func LoadEnabledConfig() (*Config, error) {
|
||||
cfg, err := LoadConfig()
|
||||
@@ -66,61 +102,180 @@ func LoadEnabledConfig() (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveInput 保存配置入参(AppSecret 支持掩码表示不修改)
|
||||
type SaveInput struct {
|
||||
AppID string
|
||||
AppSecret string // 掩码 / 空串 = 保持不变
|
||||
Enabled bool
|
||||
AutoBind bool
|
||||
Remark string
|
||||
// LoadEnabledAppByAppID 按小程序 AppID 精确读取启用配置(多小程序场景,
|
||||
// 认证端按客户端传入的 appid 选择对应应用)
|
||||
func LoadEnabledAppByAppID(appID string) (*Config, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
var row models.WechatMiniApp
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("app_id", appID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := appFromRow(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !cfg.Enabled || cfg.AppSecret == "" {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig 保存小程序配置(不存在则创建)
|
||||
func SaveConfig(in SaveInput) error {
|
||||
if strings.TrimSpace(in.AppID) == "" {
|
||||
return errors.New("AppID 不能为空")
|
||||
}
|
||||
// ListApps 应用列表(软删除外,AppSecret 不回显)
|
||||
func ListApps() ([]models.WechatMiniApp, error) {
|
||||
var rows []models.WechatMiniApp
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("id").
|
||||
All(&rows)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
var row models.WechatMiniConfig
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniConfig)).OrderBy("id").One(&row)
|
||||
// SaveAppInput 保存应用入参(AppSecret 支持掩码表示不修改)
|
||||
type SaveAppInput struct {
|
||||
ID uint64
|
||||
Name string
|
||||
AppID string
|
||||
AppSecret string // 掩码 / 空串 = 保持不变
|
||||
Enabled bool
|
||||
AutoBind bool
|
||||
Remark string
|
||||
}
|
||||
|
||||
// SaveApp 新增/更新小程序应用(AppID 全局唯一,排除自身)
|
||||
func SaveApp(in SaveAppInput) (uint64, error) {
|
||||
appID := strings.TrimSpace(in.AppID)
|
||||
if appID == "" {
|
||||
return 0, errors.New("AppID 不能为空")
|
||||
}
|
||||
now := time.Now()
|
||||
isNew := false
|
||||
if err != nil {
|
||||
if err != orm.ErrNoRows {
|
||||
return err
|
||||
|
||||
var row models.WechatMiniApp
|
||||
if in.ID > 0 {
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("id", in.ID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return 0, errors.New("应用不存在或已删除")
|
||||
}
|
||||
isNew = true
|
||||
row = models.WechatMiniConfig{}
|
||||
} else {
|
||||
row = models.WechatMiniApp{CreateTime: now}
|
||||
}
|
||||
|
||||
// AppID 唯一性校验(排除自身)
|
||||
dupQ := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("app_id", appID).
|
||||
Filter("delete_time__isnull", true)
|
||||
if in.ID > 0 {
|
||||
dupQ = dupQ.Filter("id__ne", in.ID)
|
||||
}
|
||||
var dup models.WechatMiniApp
|
||||
if err := dupQ.One(&dup); err == nil {
|
||||
name := strings.TrimSpace(dup.Name)
|
||||
if name == "" {
|
||||
name = dup.AppID
|
||||
}
|
||||
return 0, fmt.Errorf("AppID 已被应用「%s」占用", name)
|
||||
}
|
||||
|
||||
row.Name = strings.TrimSpace(in.Name)
|
||||
row.AppID = appID
|
||||
secret := strings.TrimSpace(in.AppSecret)
|
||||
if secret != "" && !wechatmp.IsMasked(secret) {
|
||||
enc, eerr := wechatmp.EncryptSecret(secret)
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
return 0, eerr
|
||||
}
|
||||
row.AppSecret = enc
|
||||
}
|
||||
|
||||
row.AppID = strings.TrimSpace(in.AppID)
|
||||
row.Remark = strings.TrimSpace(in.Remark)
|
||||
row.Enabled = 0
|
||||
if in.Enabled {
|
||||
row.Enabled = 1
|
||||
} else {
|
||||
row.Enabled = 0
|
||||
}
|
||||
row.AutoBind = 0
|
||||
if in.AutoBind {
|
||||
row.AutoBind = 1
|
||||
} else {
|
||||
row.AutoBind = 0
|
||||
}
|
||||
row.Remark = strings.TrimSpace(in.Remark)
|
||||
row.UpdateTime = &now
|
||||
|
||||
if isNew {
|
||||
row.CreateTime = now
|
||||
_, err = models.Orm.Insert(&row)
|
||||
return err
|
||||
if row.ID == 0 {
|
||||
id, err := models.Orm.Insert(&row)
|
||||
return uint64(id), err
|
||||
}
|
||||
_, err = models.Orm.Update(&row)
|
||||
if _, err := models.Orm.Update(&row); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
// DeleteApp 删除应用(软删并停用)
|
||||
func DeleteApp(id uint64) error {
|
||||
if id == 0 {
|
||||
return errors.New("无效ID")
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("id", id).
|
||||
Filter("delete_time__isnull", true).
|
||||
Update(map[string]interface{}{
|
||||
"delete_time": now,
|
||||
"update_time": now,
|
||||
"enabled": 0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveInput 旧单行配置保存入参(AppSecret 支持掩码表示不修改)——兼容旧端点,
|
||||
// 现写入新多应用表的第一行(无行时插入)
|
||||
type SaveInput struct {
|
||||
AppID string
|
||||
AppSecret string // 掩码 / 空串 = 保持不变
|
||||
Enabled bool
|
||||
AutoBind bool
|
||||
Remark string
|
||||
}
|
||||
|
||||
// SaveConfig 保存配置(兼容旧单行端点:写入新表第一行,不存在则创建)
|
||||
func SaveConfig(in SaveInput) error {
|
||||
var row models.WechatMiniApp
|
||||
err := models.Orm.QueryTable(new(models.WechatMiniApp)).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("id").
|
||||
One(&row)
|
||||
|
||||
if err == orm.ErrNoRows {
|
||||
_, serr := SaveApp(SaveAppInput{
|
||||
Name: "默认小程序",
|
||||
AppID: in.AppID,
|
||||
AppSecret: in.AppSecret,
|
||||
Enabled: in.Enabled,
|
||||
AutoBind: in.AutoBind,
|
||||
Remark: in.Remark,
|
||||
})
|
||||
return serr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, serr := SaveApp(SaveAppInput{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
AppID: in.AppID,
|
||||
AppSecret: in.AppSecret,
|
||||
Enabled: in.Enabled,
|
||||
AutoBind: in.AutoBind,
|
||||
Remark: in.Remark,
|
||||
})
|
||||
return serr
|
||||
}
|
||||
@@ -438,6 +438,23 @@ func handleTextIntent(openid, content string) string {
|
||||
if isLoginKeyword(text) {
|
||||
return DirectLoginReply(openid)
|
||||
}
|
||||
// 「注册」:注册页账号注册方式取码(无二维码兜底路径)
|
||||
if isRegisterKeyword(text) {
|
||||
row, err := LatestPendingRegisterVerifyByOpenID(openid)
|
||||
if err != nil || row == nil {
|
||||
row, err = CreateRegisterOpenIDVerify(openid, 0)
|
||||
if err != nil {
|
||||
return "系统繁忙,请稍后重新发送。"
|
||||
}
|
||||
}
|
||||
minutes := 1
|
||||
if row.ExpireAt != nil {
|
||||
if m := int(time.Until(*row.ExpireAt).Minutes()); m > 1 {
|
||||
minutes = m
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("您的注册验证码是:%s\n请回到注册页面输入完成验证(%d 分钟内有效)。", row.Code, minutes)
|
||||
}
|
||||
if !isBindKeyword(text) {
|
||||
return textBindGuide()
|
||||
}
|
||||
@@ -463,6 +480,11 @@ func handleTextIntent(openid, content string) string {
|
||||
return fmt.Sprintf("您的验证码是:%s\n请在系统页面「绑定微信」处输入完成绑定(%d 分钟内有效)。%s", row.Code, minutes, tip)
|
||||
}
|
||||
|
||||
// isRegisterKeyword 判断文本是否为「注册」意图(注册页账号注册取码)
|
||||
func isRegisterKeyword(text string) bool {
|
||||
return text == "注册" || strings.Contains(text, "注册")
|
||||
}
|
||||
|
||||
// isBindKeyword 判断文本是否为「获取验证码 / 绑定」类意图
|
||||
func isBindKeyword(text string) bool {
|
||||
if text == "" {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package wechatmp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 注册「账号注册」验证码:网页发起 → 展示带参二维码(scene)→
|
||||
// 用户微信扫码关注 → 服务号被动回复 6 位验证码 → 网页填码核销。
|
||||
// 复用 yz_platform_wechat_mp_verify_code 会话表(bind_type="register"),
|
||||
// 扫码事件回复走通用 handleScanScene,无需新增事件处理。
|
||||
// =============================================================
|
||||
|
||||
// WechatBindTypeRegister 注册验证码会话类型(yz_platform_wechat_mp_verify_code.bind_type)
|
||||
const WechatBindTypeRegister = "register"
|
||||
|
||||
// StartRegisterVerify 创建注册验证码会话并生成带参二维码。
|
||||
// 带参二维码生成失败(未认证公众号 48001 等)时回退:qrURL 为空、
|
||||
// notice 返回引导(用户在公众号内发送「注册」获取验证码),scene 仍有效可轮询。
|
||||
func StartRegisterVerify(ttl time.Duration) (scene, qrURL, notice string, expireAt time.Time, err error) {
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultVerifyTTL
|
||||
}
|
||||
cfg, err := LoadEnabledConfig()
|
||||
if err != nil {
|
||||
return "", "", "", time.Time{}, err
|
||||
}
|
||||
scene = randomScene()
|
||||
code := randomCode6()
|
||||
now := time.Now()
|
||||
expireAt = now.Add(ttl)
|
||||
|
||||
row := &models.WechatMpVerifyCode{
|
||||
Scene: scene,
|
||||
Code: code,
|
||||
Status: models.WechatVerifyStatusWaiting,
|
||||
BindType: WechatBindTypeRegister,
|
||||
ExpireAt: &expireAt,
|
||||
CreateTime: now,
|
||||
UpdateTime: &now,
|
||||
}
|
||||
if _, err = models.Orm.Insert(row); err != nil {
|
||||
return "", "", "", time.Time{}, err
|
||||
}
|
||||
qrURL, err = CreateQRCode(cfg, scene, int(ttl.Seconds())+120)
|
||||
if err != nil {
|
||||
// 不吞真实错误:与扫码登录 StartScanLogin 一致,把微信返回的
|
||||
// errcode 与排查建议(IP 白名单 / 48001 未认证等)透出,便于定位;
|
||||
// 同时保留「公众号内发送注册」兜底路径,会话仍有效可轮询核销。
|
||||
notice := "请在公众号内直接发送「注册」获取验证码"
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
notice = "生成带参二维码失败(" + apiErr.Error() + ")。" + notice
|
||||
if hint := apiErr.Explain(); hint != "" {
|
||||
notice += ":" + hint
|
||||
}
|
||||
} else {
|
||||
notice = "生成带参二维码失败:" + err.Error() + "。" + notice
|
||||
}
|
||||
return scene, "", notice, expireAt, nil
|
||||
}
|
||||
return scene, qrURL, "", expireAt, nil
|
||||
}
|
||||
|
||||
// GetRegisterVerifyStatus 查询注册会话扫码状态(网页轮询展示「已扫码」)
|
||||
func GetRegisterVerifyStatus(scene string) (scanned bool, expireAt time.Time, err error) {
|
||||
row, err := GetVerifyCode(scene)
|
||||
if err != nil {
|
||||
return false, time.Time{}, err
|
||||
}
|
||||
if row.ExpireAt != nil {
|
||||
expireAt = *row.ExpireAt
|
||||
}
|
||||
if row.BindType != WechatBindTypeRegister {
|
||||
return false, expireAt, ErrSceneInvalid
|
||||
}
|
||||
return row.Status == models.WechatVerifyStatusScanned, expireAt, nil
|
||||
}
|
||||
|
||||
// ConfirmRegisterVerify 核销注册验证码:校验会话归属与状态,返回扫码人 openid。
|
||||
// 要求会话已被扫码(openid 已写入)、验证码匹配,核销后一次性失效。
|
||||
func ConfirmRegisterVerify(scene, code string) (openid string, err error) {
|
||||
row, err := GetVerifyCode(scene)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if row.BindType != WechatBindTypeRegister {
|
||||
return "", ErrSceneInvalid
|
||||
}
|
||||
switch row.Status {
|
||||
case models.WechatVerifyStatusWaiting:
|
||||
return "", ErrSceneNotScanned
|
||||
case models.WechatVerifyStatusUsed:
|
||||
return "", ErrCodeUsed
|
||||
case models.WechatVerifyStatusExpired:
|
||||
return "", ErrCodeExpired
|
||||
}
|
||||
if strings.TrimSpace(code) != row.Code {
|
||||
return "", ErrCodeNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
if _, uerr := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).
|
||||
Filter("id", row.ID).
|
||||
Filter("status", models.WechatVerifyStatusScanned).
|
||||
Update(map[string]interface{}{"status": models.WechatVerifyStatusUsed, "update_time": now}); uerr != nil {
|
||||
return "", uerr
|
||||
}
|
||||
return row.OpenID, nil
|
||||
}
|
||||
|
||||
// CreateRegisterOpenIDVerify 公众号内发送「注册」时创建的注册会话:
|
||||
// openid 已知(消息发送者),网页端按验证码核销(无带参二维码的兜底路径)。
|
||||
func CreateRegisterOpenIDVerify(openid string, ttl time.Duration) (*models.WechatMpVerifyCode, error) {
|
||||
openid = strings.TrimSpace(openid)
|
||||
if openid == "" {
|
||||
return nil, errors.New("openid 为空")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultVerifyTTL
|
||||
}
|
||||
now := time.Now()
|
||||
expireAt := now.Add(ttl)
|
||||
row := &models.WechatMpVerifyCode{
|
||||
Scene: randomScene(),
|
||||
Code: randomCode6(),
|
||||
OpenID: openid,
|
||||
Status: models.WechatVerifyStatusScanned, // 微信号已知,等同「已扫码待核销」
|
||||
BindType: WechatBindTypeRegister,
|
||||
ExpireAt: &expireAt,
|
||||
CreateTime: now,
|
||||
UpdateTime: &now,
|
||||
}
|
||||
if _, err := models.Orm.Insert(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// LatestPendingRegisterVerifyByOpenID 该微信号最近一条未核销的注册会话(重发验证码时复用)
|
||||
func LatestPendingRegisterVerifyByOpenID(openid string) (*models.WechatMpVerifyCode, error) {
|
||||
var row models.WechatMpVerifyCode
|
||||
err := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).
|
||||
Filter("openid", openid).
|
||||
Filter("bind_type", WechatBindTypeRegister).
|
||||
Filter("status", models.WechatVerifyStatusScanned).
|
||||
Filter("expire_at__gt", time.Now()).
|
||||
OrderBy("-id").
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// ConfirmRegisterVerifyByCode 无二维码兜底:按验证码认领「公众号发起」的注册会话。
|
||||
// 只认领 bind_type=register、openid 非空、已扫码待核销的最近会话,CAS 核销防止重复使用。
|
||||
func ConfirmRegisterVerifyByCode(code string) (string, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return "", ErrCodeNotPending
|
||||
}
|
||||
var row models.WechatMpVerifyCode
|
||||
err := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).
|
||||
Filter("code", code).
|
||||
Filter("bind_type", WechatBindTypeRegister).
|
||||
Filter("openid__ne", "").
|
||||
Filter("status", models.WechatVerifyStatusScanned).
|
||||
Filter("expire_at__gt", time.Now()).
|
||||
OrderBy("-id").
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return "", ErrCodeNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
affected, uerr := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).
|
||||
Filter("id", row.ID).
|
||||
Filter("status", models.WechatVerifyStatusScanned).
|
||||
Update(map[string]interface{}{"status": models.WechatVerifyStatusUsed, "update_time": now})
|
||||
if uerr != nil {
|
||||
return "", uerr
|
||||
}
|
||||
if affected == 0 {
|
||||
return "", ErrCodeUsed
|
||||
}
|
||||
return row.OpenID, nil
|
||||
}
|
||||
Reference in new issue
Block a user