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

512 lines
16 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"
"math"
"sort"
"strings"
"time"
"server/models"
)
// DefaultTenantUserQuota 租户默认用户数上限(未配置套餐/套餐未设置时兜底)
const DefaultTenantUserQuota = 20
// DefaultPackageDurationDays 套餐默认有效时长(天):按年计费
const DefaultPackageDurationDays = 365
// 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
}
// PackageDurationDays 套餐有效时长(天),未配置时按 365(年付)
func PackageDurationDays(pkg *models.SystemTenantPackage) int {
if pkg == nil || pkg.DurationDays <= 0 {
return DefaultPackageDurationDays
}
return pkg.DurationDays
}
// PackageExpireFromNow 从当前时间起算套餐到期时间
func PackageExpireFromNow(pkg *models.SystemTenantPackage) time.Time {
return time.Now().AddDate(0, 0, PackageDurationDays(pkg))
}
// tenantPackageExpired 租户套餐是否已超过时效(未设置到期时间视为永久有效)
func tenantPackageExpired(tenant *models.SystemTenant) bool {
return tenant.PackageExpireTime != nil && tenant.PackageExpireTime.Before(time.Now())
}
// 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 {
// 套餐到期:自动切换基础套餐(降级落库,保证只执行一次)
if tenantPackageExpired(&tenant) {
return downgradeExpiredTenantPackage(tid)
}
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()
}
// GetBasicTenantPackage 获取基础套餐(code=basic;不存在时回退默认套餐)
func GetBasicTenantPackage() (*models.SystemTenantPackage, error) {
var pkg models.SystemTenantPackage
err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("code", "basic").
Filter("delete_time__isnull", true).
Filter("status", 1).
One(&pkg)
if err == nil {
return &pkg, nil
}
return GetDefaultTenantPackage()
}
// downgradeExpiredTenantPackage 套餐到期:切换基础套餐并清除到期时间,返回基础套餐
func downgradeExpiredTenantPackage(tid uint64) (*models.SystemTenantPackage, error) {
basic, err := GetBasicTenantPackage()
if err != nil {
return nil, err
}
_, _ = models.Orm.QueryTable(new(models.SystemTenant)).
Filter("id", tid).
Update(map[string]interface{}{
"package_id": basic.ID,
"package_expire_time": nil,
})
return basic, nil
}
// 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"`
DurationDays int `json:"duration_days"`
PackageExpireTime *time.Time `json:"package_expire_time"`
DaysRemaining int `json:"days_remaining"`
}
// 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
}
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
info.DurationDays = PackageDurationDays(pkg)
if pkg.ExtraUserPrice > 0 {
info.ExtraUserPrice = pkg.ExtraUserPrice
}
}
// 重新读取租户绑定信息(GetTenantPackage 可能已触发到期降级落库)
var fresh models.SystemTenant
if e := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&fresh); e == nil {
tenant = fresh
}
info.PackageID = tenant.PackageID
info.PackageExpireTime = tenant.PackageExpireTime
if tenant.PackageExpireTime != nil {
remain := int(math.Ceil(time.Until(*tenant.PackageExpireTime).Hours() / 24))
if remain < 0 {
remain = 0
}
info.DaysRemaining = remain
}
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 为租户绑定/续费套餐:
// - 同步套餐包含的用户数(不低于当前已开用户数,且不降低已有上限,syncQuota=true 时)
// - 按套餐有效时长(duration_days,默认 365 天 = 年付)设置计费到期时间;
// 同套餐未到期时再次绑定视为续费,在原到期时间上顺延
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("套餐不存在")
}
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return 0, errors.New("租户不存在")
}
update := map[string]interface{}{"package_id": packageID}
newQuota := tenant.UserQuota
if syncQuota {
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
}
// 计费时效:同套餐未到期续费 → 从原到期时间顺延;换套餐/已到期 → 从当前时间起算
base := time.Now()
if tenant.PackageID == packageID && tenant.PackageExpireTime != nil && tenant.PackageExpireTime.After(base) {
base = *tenant.PackageExpireTime
}
update["package_expire_time"] = base.AddDate(0, 0, PackageDurationDays(&pkg))
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
}
// MarkMenusLockedByTenantModules 按租户套餐标记未开通模块的菜单:菜单保留,返回锁定菜单ID集合。
// lockedIDs 包含未开通模块对应的菜单及其全部后代菜单ID,供前端展示锁定态(而非直接剔除导致 404)。
func MarkMenusLockedByTenantModules(menus []models.SystemMenu, allModules []models.SystemModules, allowedCodes map[string]bool) ([]models.SystemMenu, map[uint64]bool) {
lockedIDs := map[uint64]bool{}
if len(menus) == 0 || len(allModules) == 0 {
return menus, lockedIDs
}
sorted := sortModulesByPathDesc(allModules)
kept := 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 && !allowedCodes[code] {
lockedIDs[m.ID] = true
}
kept = append(kept, m)
}
if len(lockedIDs) == 0 {
return kept, lockedIDs
}
// 锁定传递给所有后代菜单
childrenOf := map[uint64][]uint64{}
for _, m := range kept {
if m.Pid > 0 {
pid := uint64(m.Pid)
childrenOf[pid] = append(childrenOf[pid], m.ID)
}
}
queue := make([]uint64, 0, len(lockedIDs))
for id := range lockedIDs {
queue = append(queue, id)
}
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
for _, child := range childrenOf[id] {
if !lockedIDs[child] {
lockedIDs[child] = true
queue = append(queue, child)
}
}
}
return kept, lockedIDs
}
// PackagePurchaseURLCode 平台通用配置(yz_platform_normal_setting)中「套餐购买链接」的配置 code
const PackagePurchaseURLCode = "package_purchase_url"
// GetPackagePurchaseURL 读取平台配置的套餐购买链接。
// 租户端 home 页未开通(锁定)功能卡片的「购买」按钮跳转到该地址;未配置时返回空串。
func GetPackagePurchaseURL() string {
var s models.PlatformNormalSetting
if err := models.Orm.QueryTable(new(models.PlatformNormalSetting)).
Filter("code", PackagePurchaseURLCode).
Filter("delete_time__isnull", true).
One(&s); err != nil {
return ""
}
return strings.TrimSpace(s.Value)
}
// SavePackagePurchaseURL 保存平台配置的套餐购买链接(不存在则创建)
func SavePackagePurchaseURL(url string) error {
url = strings.TrimSpace(url)
var s models.PlatformNormalSetting
err := models.Orm.QueryTable(new(models.PlatformNormalSetting)).
Filter("code", PackagePurchaseURLCode).
Filter("delete_time__isnull", true).
One(&s)
if err != nil {
s = models.PlatformNormalSetting{
Name: "套餐购买链接",
Code: PackagePurchaseURLCode,
Value: url,
Remark: "租户端未开通功能卡片「购买」按钮跳转地址",
}
_, err = models.Orm.Insert(&s)
return err
}
now := time.Now()
s.Value = url
s.UpdateTime = &now
_, err = models.Orm.Update(&s)
return err
}
// 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
}