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 } // NormalizeModuleCode 规范化模块编码:去空格 + 统一小写。 // 模块、套餐模块、产品关联模块来自不同配置入口,编码大小写可能不一致 // (如模块 code=oa、产品关联功能填 OA),统一规范化后再比对。 func NormalizeModuleCode(code string) string { return strings.ToLower(strings.TrimSpace(code)) } // DayStart 取时间所在自然日的 0 点(有效期按「日」计算,不精确到秒) func DayStart(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) } // ProductOrderExpireAt 单品订单有效期截止时间(精确到日): // - 优先取落库的 expire_time(按日规范化到当天 0 点); // - 历史订单未落库时按「开通日 0 点 + 默认有效期(1 年)」兜底计算,保证老订单同样按 1 年到期; // - 两者都无(尚未开通)返回 nil,视为无到期时间。 func ProductOrderExpireAt(order *models.PlatformProductOrder) *time.Time { if order == nil { return nil } if order.ExpireTime != nil { t := DayStart(*order.ExpireTime) return &t } if order.ActivateTime != nil { t := DayStart(*order.ActivateTime).AddDate(0, 0, models.ProductOrderDurationDays) return &t } return nil } // ProductOrderExpired 单品订单是否已过有效期: // 到期日当天 0 点起即视为过期(到期后权益自动失效,需重新购买续费)。 func ProductOrderExpired(order *models.PlatformProductOrder, now time.Time) bool { expire := ProductOrderExpireAt(order) return expire != nil && !now.Before(*expire) } // GetTenantModuleCodes 获取租户已开通的功能模块编码集合(如 erp / oa / crm)。 // // 权益由两部分组成: // - 当前生效套餐包含的模块; // - 已开通单品订单关联产品的模块。 // // 单品购买不会替换租户当前套餐,因此必须与套餐模块取并集;否则产品订单即使 // 已显示“已开通”,首页仍会把该模块判定为未购买。历史订单同样按产品当前的 // module_code 识别,保证已存在的已开通订单可立即恢复对应模块权限。 // 返回的 key 均为 NormalizeModuleCode 规范化后的编码。 func GetTenantModuleCodes(tid uint64) map[string]bool { codes := map[string]bool{} mark := func(code string) { if c := NormalizeModuleCode(code); c != "" { codes[c] = true } } rows, err := GetTenantPackageModules(tid) if err == nil { for _, r := range rows { mark(r.ModuleCode) } } if tid == 0 { return codes } var orders []models.PlatformProductOrder if _, err := models.Orm.QueryTable(new(models.PlatformProductOrder)). Filter("tid", tid). Filter("status", models.ProductOrderStatusActivated). Filter("product_id__gt", 0). Filter("delete_time__isnull", true). All(&orders); err != nil || len(orders) == 0 { return codes } // 只统计仍在有效期内的订单:到期后权益自动失效,需重新购买续费 now := time.Now() validOrders := make([]models.PlatformProductOrder, 0, len(orders)) for _, order := range orders { if ProductOrderExpired(&order, now) { continue } validOrders = append(validOrders, order) } if len(validOrders) == 0 { return codes } productIDs := make([]uint64, 0, len(validOrders)) for _, order := range validOrders { productIDs = append(productIDs, order.ProductID) } var products []models.PlatformProduct if _, err := models.Orm.QueryTable(new(models.PlatformProduct)). Filter("id__in", productIDs). All(&products); err != nil { return codes } for _, product := range products { code := product.ModuleCode if strings.TrimSpace(code) == "" { // 产品未显式关联功能模块时,按产品编码兜底匹配(产品编码通常即模块编码,如 oa) code = product.Code } mark(code) } 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[NormalizeModuleCode(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[NormalizeModuleCode(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 }