Files
yunzerwebsiteallinone/go/services/tenant_quota.go
T
2026-09-15 17:28:08 +08:00

338 lines
9.9 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 (
"errors"
"sort"
"strings"
"server/models"
)
// DefaultTenantUserQuota 租户默认用户数上限(未配置套餐/套餐未设置时兜底)
const DefaultTenantUserQuota = 20
// DefaultTenantExtraUserPrice 单个用户增购默认单价(元/人)
const DefaultTenantExtraUserPrice = 200.00
// ErrTenantQuotaExceeded 用户数已达上限
var ErrTenantQuotaExceeded = errors.New("tenant user quota exceeded")
// CountTenantUsers 统计租户有效用户数(未软删)
func CountTenantUsers(tid uint64) (int, error) {
if tid == 0 {
return 0, nil
}
cnt, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("delete_time__isnull", true).
Count()
return int(cnt), err
}
// GetTenantPackage 获取租户生效套餐:优先取绑定套餐,未绑定/已删除时回退到默认套餐
func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) {
if tid == 0 {
return nil, errors.New("tid 不能为空")
}
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return nil, err
}
if tenant.PackageID > 0 {
var pkg models.SystemTenantPackage
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("id", tenant.PackageID).
Filter("delete_time__isnull", true).
One(&pkg); err == nil {
return &pkg, nil
}
}
return GetDefaultTenantPackage()
}
// GetDefaultTenantPackage 获取默认套餐(is_default=1 优先,其次按排序取第一个启用套餐)
func GetDefaultTenantPackage() (*models.SystemTenantPackage, error) {
var pkg models.SystemTenantPackage
err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("delete_time__isnull", true).
Filter("status", 1).
Filter("is_default", 1).
OrderBy("sort", "id").
One(&pkg)
if err == nil {
return &pkg, nil
}
var fallback models.SystemTenantPackage
if err2 := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("delete_time__isnull", true).
Filter("status", 1).
OrderBy("sort", "id").
One(&fallback); err2 != nil {
return nil, err2
}
return &fallback, nil
}
// GetTenantPackageModules 获取租户套餐包含的功能模块(按套餐未绑定则取默认套餐)
func GetTenantPackageModules(tid uint64) ([]models.SystemTenantPackageModule, error) {
pkg, err := GetTenantPackage(tid)
if err != nil {
return nil, err
}
var rows []models.SystemTenantPackageModule
_, err = models.Orm.QueryTable(new(models.SystemTenantPackageModule)).
Filter("package_id", pkg.ID).
OrderBy("id").
All(&rows)
return rows, err
}
// GetTenantModuleCodes 获取租户已开通的功能模块编码集合(如 erp / oa / crm)
func GetTenantModuleCodes(tid uint64) map[string]bool {
codes := map[string]bool{}
rows, err := GetTenantPackageModules(tid)
if err != nil {
return codes
}
for _, r := range rows {
code := strings.TrimSpace(r.ModuleCode)
if code != "" {
codes[code] = true
}
}
return codes
}
// TenantQuotaCheck 用户数配额校验结果
type TenantQuotaCheck struct {
Quota int `json:"quota"`
Used int `json:"used"`
Allowed bool `json:"allowed"`
}
// CheckTenantUserQuota 校验租户是否还能新增用户。
// uid > 0 且该用户已绑定到本租户时视为「编辑/复用已有绑定」,不占用新增名额。
func CheckTenantUserQuota(tid, uid uint64) (*TenantQuotaCheck, error) {
result := &TenantQuotaCheck{Quota: DefaultTenantUserQuota, Allowed: true}
if tid == 0 {
return result, nil
}
quota := DefaultTenantUserQuota
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err == nil && tenant.UserQuota > 0 {
quota = tenant.UserQuota
}
result.Quota = quota
if uid > 0 {
exist, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("uid", uid).
Filter("delete_time__isnull", true).
Count()
if err == nil && exist > 0 {
result.Allowed = true
return result, nil
}
}
used, err := CountTenantUsers(tid)
if err != nil {
return nil, err
}
result.Used = used
result.Allowed = used < quota
return result, nil
}
// EnsureTenantUserQuota 用户数已满时返回 ErrTenantQuotaExceeded
func EnsureTenantUserQuota(tid, uid uint64) error {
check, err := CheckTenantUserQuota(tid, uid)
if err != nil {
return err
}
if !check.Allowed {
return ErrTenantQuotaExceeded
}
return nil
}
// GetTenantQuotaInfo 租户用户数使用情况
type TenantQuotaInfo struct {
Tid uint64 `json:"tid"`
PackageID uint64 `json:"package_id"`
PackageName string `json:"package_name"`
Quota int `json:"quota"`
Used int `json:"used"`
Remaining int `json:"remaining"`
ExtraUserPrice float64 `json:"extra_user_price"`
EffectivePackageID uint64 `json:"effective_package_id"`
}
// GetTenantQuotaInfo 查询租户用户数使用情况(含生效套餐信息)
func GetTenantQuotaInfo(tid uint64) (*TenantQuotaInfo, error) {
info := &TenantQuotaInfo{Tid: tid, Quota: DefaultTenantUserQuota, ExtraUserPrice: DefaultTenantExtraUserPrice}
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return nil, err
}
info.PackageID = tenant.PackageID
if tenant.UserQuota > 0 {
info.Quota = tenant.UserQuota
}
used, err := CountTenantUsers(tid)
if err != nil {
return nil, err
}
info.Used = used
info.Remaining = info.Quota - used
if info.Remaining < 0 {
info.Remaining = 0
}
if pkg, err := GetTenantPackage(tid); err == nil && pkg != nil {
info.EffectivePackageID = pkg.ID
info.PackageName = pkg.Name
if pkg.ExtraUserPrice > 0 {
info.ExtraUserPrice = pkg.ExtraUserPrice
}
}
return info, nil
}
// IncreaseTenantQuota 增加租户用户数上限,返回(变更前上限、变更后上限)
func IncreaseTenantQuota(tid uint64, count int) (int, int, error) {
if tid == 0 || count <= 0 {
return 0, 0, errors.New("参数错误")
}
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return 0, 0, errors.New("租户不存在")
}
before := tenant.UserQuota
if before <= 0 {
before = DefaultTenantUserQuota
}
after := before + count
_, err := models.Orm.QueryTable(new(models.SystemTenant)).
Filter("id", tid).
Update(map[string]interface{}{"user_quota": after})
if err != nil {
return before, after, err
}
return before, after, nil
}
// ApplyPackageToTenant 为租户绑定套餐:同步套餐包含的用户数(不低于当前已开用户数,且不降低已有上限)
func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
if tid == 0 || packageID == 0 {
return 0, errors.New("参数错误")
}
var pkg models.SystemTenantPackage
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("id", packageID).
Filter("delete_time__isnull", true).
One(&pkg); err != nil {
return 0, errors.New("套餐不存在")
}
update := map[string]interface{}{"package_id": packageID}
newQuota := 0
if syncQuota {
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return 0, errors.New("租户不存在")
}
current := tenant.UserQuota
if current <= 0 {
current = DefaultTenantUserQuota
}
newQuota = pkg.UserQuota
if newQuota <= 0 {
newQuota = DefaultTenantUserQuota
}
// 不低于当前上限:换套餐不回收已购用户数
if newQuota < current {
newQuota = current
}
update["user_quota"] = newQuota
}
if _, err := models.Orm.QueryTable(new(models.SystemTenant)).
Filter("id", tid).
Update(update); err != nil {
return 0, err
}
return newQuota, nil
}
// sortModulesByPathDesc 模块按 path 长度倒序:长路径优先匹配,避免 /apps 误吞 /apps/erp
func sortModulesByPathDesc(modules []models.SystemModules) []models.SystemModules {
list := make([]models.SystemModules, 0, len(modules))
for _, m := range modules {
if strings.TrimSpace(m.Path) != "" {
list = append(list, m)
}
}
sort.SliceStable(list, func(i, j int) bool {
return len(list[i].Path) > len(list[j].Path)
})
return list
}
// ownerModuleCode 返回菜单路径归属的模块编码(按模块 path 前缀匹配)。
// 不属于任何模块时 ok=false —— 视为公共菜单,不参与套餐过滤。
func ownerModuleCode(sortedModules []models.SystemModules, path string) (string, bool) {
p := strings.TrimSuffix(strings.TrimSpace(path), "/")
if p == "" {
return "", false
}
for _, m := range sortedModules {
mp := strings.TrimSuffix(strings.TrimSpace(m.Path), "/")
if mp == "" {
continue
}
if p == mp || strings.HasPrefix(p, mp+"/") {
return m.Code, true
}
}
return "", false
}
// FilterMenusByTenantModules 按租户套餐包含的功能模块过滤菜单:
// - 菜单不属于任何模块(公共/基础菜单)→ 保留;
// - 菜单归属某模块 → 仅当该模块已被租户套餐开通时保留。
func FilterMenusByTenantModules(menus []models.SystemMenu, allModules []models.SystemModules, allowedCodes map[string]bool) []models.SystemMenu {
if len(menus) == 0 || len(allModules) == 0 {
return menus
}
sorted := sortModulesByPathDesc(allModules)
out := make([]models.SystemMenu, 0, len(menus))
for _, m := range menus {
path := ""
if m.Path != nil {
path = *m.Path
}
code, ok := ownerModuleCode(sorted, path)
if !ok {
out = append(out, m)
continue
}
if allowedCodes[code] {
out = append(out, m)
}
}
return out
}
// ListEnabledModules 获取全部启用且未删除的模块(用于菜单归属判定)
func ListEnabledModules() ([]models.SystemModules, error) {
var rows []models.SystemModules
_, err := models.Orm.QueryTable(new(models.SystemModules)).
Filter("delete_time__isnull", true).
Filter("status", 1).
OrderBy("sort", "id").
All(&rows)
return rows, err
}