diff --git a/backend/components.d.ts b/backend/components.d.ts index db5e9e8..b2c4634 100644 --- a/backend/components.d.ts +++ b/backend/components.d.ts @@ -54,6 +54,7 @@ declare module 'vue' { ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] + ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElPopover: typeof import('element-plus/es')['ElPopover'] ElProgress: typeof import('element-plus/es')['ElProgress'] diff --git a/backend/src/api/tenant.js b/backend/src/api/tenant.js index 319287c..cc530e2 100644 --- a/backend/src/api/tenant.js +++ b/backend/src/api/tenant.js @@ -81,4 +81,15 @@ export function checkTenantCode(tenant_code) { method: 'get', params: { tenant_code } }); +} + +/** + * 获取当前租户的用户数配额(套餐、已用/上限、剩余) + * @returns {Promise} + */ +export function getTenantQuotaInfo() { + return request({ + url: '/backend/tenant/quotaInfo', + method: 'get', + }); } \ No newline at end of file diff --git a/backend/src/views/apps/oa/reimburse/index.vue b/backend/src/views/apps/oa/reimburse/index.vue index 196eff0..767217d 100644 --- a/backend/src/views/apps/oa/reimburse/index.vue +++ b/backend/src/views/apps/oa/reimburse/index.vue @@ -354,7 +354,7 @@ const handleExport = async (row) => { const money = (value) => Number(value || 0).toFixed(2); // 日期显示统一为:YYYY-MM-DD -const formatDate = (value: any) => { +const formatDate = (value) => { if (!value) return "-"; return formatDateUtil(value); }; diff --git a/backend/src/views/basicSettings/users/index.vue b/backend/src/views/basicSettings/users/index.vue index 701f757..81d1b96 100644 --- a/backend/src/views/basicSettings/users/index.vue +++ b/backend/src/views/basicSettings/users/index.vue @@ -1,7 +1,13 @@ @@ -286,6 +329,15 @@ onMounted(async () => { margin-bottom: 12px; } +.quota-tag { + margin-left: 10px; + vertical-align: middle; +} + +.quota-alert { + margin-bottom: 12px; +} + .card-header { display: flex; justify-content: space-between; diff --git a/go/certs/payment/wechat/cert_1789456993453130900.pem b/go/certs/payment/wechat/cert_1789456993453130900.pem new file mode 100644 index 0000000..f32099a --- /dev/null +++ b/go/certs/payment/wechat/cert_1789456993453130900.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIEKzCCAxOgAwIBAgIUSiAuYZmyPcbL72sYhc5224ydH6MwDQYJKoZIhvcNAQEL +BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT +FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg +Q0EwHhcNMjYwOTE1MDcyMjQzWhcNMzEwOTE0MDcyMjQzWjCBhDETMBEGA1UEAwwK +MTYxMDU0NDY0ODEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTAwLgYDVQQL +DCfov57kupHmuK/kupHms73lub/lkYrkvKDlqpLmnInpmZDlhazlj7gxCzAJBgNV +BAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAKaX6Mr2rQvDSWa2JFfJnwerZjiSV2wiZUvGUF23YtyArHNBaUef +kYps1cMmGAN43+Vc1DPd4dkRxqs8LQzDOY4Qwk47/XPICOaOu02x9fafMUDYixgq +RTd6lBOxOt9clN86cR8rU8fnh9XJSya4R1eFgkH35LEPa0qwGpF+W5OfXmtKTSam +6ffsIvWIns0zb9m8J584Rs5Sunjco6rlHXNQC40oejCXKRfIrQlGnEJA5rYwHl5u +jsdVuigLQew1bP9Z3w5e5FfUXfCAhFO5tG8bLJRW74m9fIiU782xadlidvjGdx0w +NHxK6wTaa01qXuAmB7oJOj7r3zBvW9Jmct0CAwEAAaOBuTCBtjAJBgNVHRMEAjAA +MAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2 +Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJD +MDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJC +MjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQB5 +VOT+FUud8JYeVJrKjXWiL3UT0Pu5t2rXa/frpm2JW/esIlv7x8Q6uGB0aH49L/g4 +zvDGAerg33e5sfMwzD5JlQX/C22jNM5q1tvDTfA7a5PJDc2nCUmLiBGmuj5Ga6m0 +6QCbLnE8BEx6bJrH49VCeopQ42fPlzVFVCSnslPrQu+digXLKT3YNO7Lw8bwNpZ1 +3iciVkdiV5ooFOZusohnhX6CluzXr/lyqDDnHobyy87yGOkbl8nnwCt1yauH2im1 +yezUPcs83IcTbfLZKBEFykJsWKc3ORiss6cCNXQ327+2VjyMq0L8yiLwpbb4iGcF +ZJVKyyn+bDfYhA3a5+xy +-----END CERTIFICATE----- diff --git a/go/certs/payment/wechat/key_1789456997350945300.pem b/go/certs/payment/wechat/key_1789456997350945300.pem new file mode 100644 index 0000000..bad378f --- /dev/null +++ b/go/certs/payment/wechat/key_1789456997350945300.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCml+jK9q0Lw0lm +tiRXyZ8Hq2Y4kldsImVLxlBdt2LcgKxzQWlHn5GKbNXDJhgDeN/lXNQz3eHZEcar +PC0MwzmOEMJOO/1zyAjmjrtNsfX2nzFA2IsYKkU3epQTsTrfXJTfOnEfK1PH54fV +yUsmuEdXhYJB9+SxD2tKsBqRfluTn15rSk0mpun37CL1iJ7NM2/ZvCefOEbOUrp4 +3KOq5R1zUAuNKHowlykXyK0JRpxCQOa2MB5ebo7HVbooC0HsNWz/Wd8OXuRX1F3w +gIRTubRvGyyUVu+JvXyIlO/NsWnZYnb4xncdMDR8SusE2mtNal7gJge6CTo+698w +b1vSZnLdAgMBAAECggEAG+0XHv7e3zvrUAZ0bSrlN7npts4BTaUuaddinXMiZWhE +gEWUymbbVOMNqlr2Z46Zqa83+0HUnDi4nvfFbYBEY6Y3jDnOMNx1cYC3sTyFyMPC +0+X42yIq3DP2Ip7xCYRRiGIZmGoA71/Gi3Xk3SbC/s7omPpfIbc9+xUuPvl5jHND +SzU1c4/wJk+HZcNEMmrEb+L9Wh9or1eJyjwynfOTvK75sOWBo2kyLMKOwnqBlic4 +hqedNGoWnNLqH9hxd+uUY9pSPRsutSHALh665OwmEGmLZQsl4kD2mVsRaFshpKZS +bSwiarlk6EscgSm4oiqYCUWHNXON2kduArb0/S3wwQKBgQDeEV68kYQxjywcTxsK +gv9y2sulFjo8cIC4+Q5a6OimhHkEkNfwh4Oyyn960CXXfnlttdYDhZyUOM1I+sqh +cgMrwynFGU2rc8iviYI/1j1D5s/rOkicz9kWthYUhZtet189nOjdXidL/6aHkEGj +v2gmGzMThinE8NutjlUR81PqOQKBgQDADItnXbCvaJ4mryCurO+lUSnl6sxLXNYg +lwPoDKR7ZqTrHnKvwwPzzz/i7J69YsaeMeO7ZlQrJPAFvC0RoA/Ck5oUGTMPxy/E +9kgcyn/dhyNRyiC13lE/IgLS0Bzk9/nU2BnS0Ssf6ECK4G9aROdfaGbxJoaxPhg0 +AbaPrqDdxQKBgE31LRvnvc+kImkhwC11hXXtp3YiEnmrSRUmocp6X0ot0UowCw6u +lCZM/mm/7rjy9m/0WNhNrIVAV5jaKg55NvHDBsPhuSmKcRZmoKoGbOWMfstmX51l +PiHB9Ihpyvx6RV4c2ob0CbmdWvnWQ6TXihlujQS6oak4oPYRtH2jvX7JAoGAJuOj +awgO2kZOGDlIXfbJBGn+GP/F5NDkz26lLN06hX6QkMf2o17TMxDZuAMAVgtleu1R +V0Zubtoo/JBRdAs8hcNL5RwBOgW0x6azjZj38Y0G+u36V7mwLB+gAL6By1/nqFEm +UYlnuStXvoeaJOZlD+3dQqHxjnyrfASIymmrS50CgYEAgHjnKRwTK5RexE6qJDf3 +QoKT+B+t5rrLtzaGgxTGBqf67bhWTBu6TgMVe/9am+K8dbZDZP+bMeDJ9j+MSnIb +W/359RaTI7TNI+Tx9aipXRFDtduhXTaOhsvRgDk6+s6rhhzfYmNYXqhclQFkQDFy +DsjbwjonGVj5L+GLV3EenuE= +-----END PRIVATE KEY----- diff --git a/go/conf/app.conf b/go/conf/app.conf index d8f755a..f7e2e98 100644 --- a/go/conf/app.conf +++ b/go/conf/app.conf @@ -61,4 +61,4 @@ payment_secret_key = f656fe85ed6a1caba9f19966d8cdde46114709969bea5875cde7664d4ad # TODO: 填你自己服务器的公网 HTTPS 域名(如 https://api.yunzer.com.cn),不是申请来的。 # 要求:公网可访问、HTTPS、无鉴权无重定向;微信 JSAPI 还需在商户平台「支付授权目录」登记同一域名。 # 本地开发收不到渠道回调,留空即可(回调地址退化为相对路径 /api/payment/callback/{渠道},由网关补齐域名)。 -payment_callback_base = https://api.yunzer.com.cn \ No newline at end of file +payment_callback_base = https://api.yunzer.cn \ No newline at end of file diff --git a/go/controllers/backend_admin_user.go b/go/controllers/backend_admin_user.go index 875c2e5..ac518e1 100644 --- a/go/controllers/backend_admin_user.go +++ b/go/controllers/backend_admin_user.go @@ -2,6 +2,7 @@ package controllers import ( "encoding/json" + "fmt" "io" "strconv" "strings" @@ -373,6 +374,20 @@ func (c *BackendAdminUserController) AddUser() { status = *p.Status } + // 用户数配额校验:达到上限后需要平台端为租户增购用户数才能继续添加 + if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()} + _ = c.ServeJSON() + return + } else if !check.Allowed { + c.Data["json"] = map[string]interface{}{ + "code": 400, + "msg": fmt.Sprintf("当前租户用户数已达上限(%d/%d),请联系平台管理员增购用户数", check.Used, check.Quota), + } + _ = c.ServeJSON() + return + } + id, err := services.BindTenantUser( p.Tid, p.Uid, diff --git a/go/controllers/backend_menu.go b/go/controllers/backend_menu.go index 7578de9..c5a4c90 100644 --- a/go/controllers/backend_menu.go +++ b/go/controllers/backend_menu.go @@ -3,6 +3,8 @@ package controllers import ( "encoding/json" "server/models" + "server/pkg/jwtutil" + "server/services" "strconv" "strings" @@ -91,26 +93,74 @@ func (c *BackendMenuController) GetBackendMenu() { // 否则仅返回角色允许访问的菜单(含其祖先节点,保证树结构完整)。 idStr := c.Ctx.Input.Param(":id") uid, _ := strconv.ParseUint(idStr, 10, 64) + tid := uint64(0) if uid > 0 { var tu models.SystemTenantUser if e := models.Orm.QueryTable(new(models.SystemTenantUser)). Filter("id", uid). Filter("delete_time__isnull", true). - One(&tu); e == nil && tu.GroupID > 0 { - var role models.AdminRole - if re := models.Orm.QueryTable(new(models.AdminRole)). - Filter("id", tu.GroupID). - Filter("cid", 2). - One(&role); re == nil { - backendMenus = filterMenusByRights(backendMenus, role.Rights) + One(&tu); e == nil { + tid = tu.Tid + if tu.GroupID > 0 { + var role models.AdminRole + if re := models.Orm.QueryTable(new(models.AdminRole)). + Filter("id", tu.GroupID). + Filter("cid", 2). + One(&role); re == nil { + backendMenus = filterMenusByRights(backendMenus, role.Rights) + } } } } + // 套餐功能开通过滤:仅展示租户套餐已包含的功能模块对应的菜单(未包含的整块菜单不展示) + backendMenus = filterMenusByTenantPackage(c.Ctx.Request.Header.Get("Authorization"), backendMenus, tid) + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(backendMenus, 0)} _ = c.ServeJSON() } +// backendJWTTenantID 从 Authorization 的 Bearer Token 中解析租户ID;取不到时返回 0。 +// 说明:backend 端登录时 token 里写入了租户ID(GenerateToken 的 tenantId), +// 用它做套餐功能过滤比依赖前端传参更可靠。 +func backendJWTTenantID(auth string) uint64 { + auth = strings.TrimSpace(auth) + if auth == "" { + return 0 + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return 0 + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil || claims == nil || claims.TenantId <= 0 { + return 0 + } + return uint64(claims.TenantId) +} + +// filterMenusByTenantPackage 按租户套餐包含的功能模块过滤菜单。 +// tid 优先取 token 中的租户ID,其次用调用方传入的兜底值;两者都取不到时不处理(保持原行为)。 +func filterMenusByTenantPackage(authHeader string, menus []models.SystemMenu, tid uint64) []models.SystemMenu { + if jwtTid := backendJWTTenantID(authHeader); jwtTid > 0 { + tid = jwtTid + } + if tid == 0 { + return menus + } + allModules, err := services.ListEnabledModules() + if err != nil || len(allModules) == 0 { + return menus + } + allowed := services.GetTenantModuleCodes(tid) + // 安全兜底:租户没有绑定套餐或套餐未配置任何功能模块时不做过滤, + // 避免因套餐数据缺失导致租户端菜单整体消失。 + if len(allowed) == 0 { + return menus + } + return services.FilterMenusByTenantModules(menus, allModules, allowed) +} + // parseRightsToSet 将角色 rights 解析为菜单 ID 集合(兼容 JSON 数组 / 逗号分隔) func parseRightsToSet(raw string) map[uint64]bool { set := map[uint64]bool{} @@ -267,6 +317,10 @@ func (c *BackendMenuController) GetAssignableMenus() { menus = filterMenusByView(menus, cid) } menus = filterAssignableMenus(menus) + // 租户端:仅可分配当前租户套餐已开通的功能模块下的菜单 + if cid == 2 { + menus = filterMenusByTenantPackage(c.Ctx.Request.Header.Get("Authorization"), menus, 0) + } c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(menus, 0)} _ = c.ServeJSON() diff --git a/go/controllers/backend_tenant_quota.go b/go/controllers/backend_tenant_quota.go new file mode 100644 index 0000000..50e578d --- /dev/null +++ b/go/controllers/backend_tenant_quota.go @@ -0,0 +1,62 @@ +package controllers + +import ( + "server/services" + + beego "github.com/beego/beego/v2/server/web" +) + +// BackendTenantQuotaController 租户端「用户数配额」信息(只读) +// 供租户端用户管理页展示「已用/上限」;增购由平台端操作。 +type BackendTenantQuotaController struct { + beego.Controller +} + +// GetQuotaInfo GET /backend/tenant/quotaInfo +// 返回当前登录租户的用户数使用情况与生效套餐。 +func (c *BackendTenantQuotaController) GetQuotaInfo() { + tid := backendJWTTenantID(c.Ctx.Request.Header.Get("Authorization")) + if tid == 0 { + // 兜底:从 tid 参数取(部分历史调用未带 token 场景) + if v, err := c.GetUint64("tid"); err == nil { + tid = v + } + } + if tid == 0 { + c.Data["json"] = map[string]interface{}{"code": 401, "msg": "未登录或非法请求"} + _ = c.ServeJSON() + return + } + + info, err := services.GetTenantQuotaInfo(tid) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 404, "msg": "租户不存在"} + _ = c.ServeJSON() + return + } + + modules, _ := services.GetTenantPackageModules(tid) + moduleCodes := make([]string, 0, len(modules)) + moduleNames := make([]string, 0, len(modules)) + for _, m := range modules { + moduleCodes = append(moduleCodes, m.ModuleCode) + moduleNames = append(moduleNames, m.ModuleName) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "tid": info.Tid, + "package_id": info.EffectivePackageID, + "package_name": info.PackageName, + "quota": info.Quota, + "used": info.Used, + "remaining": info.Remaining, + "extra_user_price": info.ExtraUserPrice, + "module_codes": moduleCodes, + "module_names": moduleNames, + }, + } + _ = c.ServeJSON() +} diff --git a/go/controllers/platform_tenant.go b/go/controllers/platform_tenant.go index 134c68b..80d72f1 100644 --- a/go/controllers/platform_tenant.go +++ b/go/controllers/platform_tenant.go @@ -2,12 +2,14 @@ package controllers import ( "encoding/json" + "fmt" "io" "strconv" "strings" "time" "server/models" + "server/services" beego "github.com/beego/beego/v2/server/web" ) @@ -27,10 +29,43 @@ type tenantDTO struct { Address string `json:"address"` Worktime string `json:"worktime"` Status int8 `json:"status"` - Remark string `json:"remark"` - CreateTime *time.Time `json:"create_time,omitempty"` - UpdateTime *time.Time `json:"update_time,omitempty"` - DeleteTime *time.Time `json:"delete_time,omitempty"` + // 套餐与用户数配额 + PackageID uint64 `json:"package_id"` + PackageName string `json:"package_name"` + UserQuota int `json:"user_quota"` + UserUsed int `json:"user_used"` + Remark string `json:"remark"` + CreateTime *time.Time `json:"create_time,omitempty"` + UpdateTime *time.Time `json:"update_time,omitempty"` + DeleteTime *time.Time `json:"delete_time,omitempty"` +} + +// tenantPackageNameMap 套餐ID -> 套餐名称 +func tenantPackageNameMap() map[uint64]string { + result := map[uint64]string{} + var rows []models.SystemTenantPackage + if _, err := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("delete_time__isnull", true). + All(&rows, "ID", "Name"); err == nil { + for _, r := range rows { + result[r.ID] = r.Name + } + } + return result +} + +// tenantUserCountMap 租户ID -> 有效用户数 +func tenantUserCountMap() map[uint64]int { + result := map[uint64]int{} + var rows []models.SystemTenantUser + if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)). + Filter("delete_time__isnull", true). + All(&rows, "ID", "Tid"); err == nil { + for _, r := range rows { + result[r.Tid]++ + } + } + return result } func stringValue(s *string) string { @@ -57,6 +92,8 @@ func toTenantDTO(t models.SystemTenant) tenantDTO { Address: stringValue(t.Address), Worktime: stringValue(t.Worktime), Status: t.Status, + PackageID: t.PackageID, + UserQuota: t.UserQuota, Remark: stringValue(t.Remark), CreateTime: &ct, UpdateTime: &ut, @@ -110,9 +147,14 @@ func (c *PlatformTenantController) GetTenant() { return } + packageNames := tenantPackageNameMap() + userCounts := tenantUserCountMap() list := make([]tenantDTO, 0, len(rows)) for _, t := range rows { - list = append(list, toTenantDTO(t)) + dto := toTenantDTO(t) + dto.PackageName = packageNames[t.PackageID] + dto.UserUsed = userCounts[t.ID] + list = append(list, dto) } c.Data["json"] = map[string]interface{}{ @@ -144,10 +186,19 @@ func (c *PlatformTenantController) GetTenantDetail() { return } + dto := toTenantDTO(t) + if pkg, err := services.GetTenantPackage(t.ID); err == nil && pkg != nil { + dto.PackageID = pkg.ID + dto.PackageName = pkg.Name + } + if used, err := services.CountTenantUsers(t.ID); err == nil { + dto.UserUsed = used + } + c.Data["json"] = map[string]interface{}{ "code": 200, "msg": "success", - "data": toTenantDTO(t), + "data": dto, } _ = c.ServeJSON() } @@ -161,7 +212,10 @@ type tenantPayload struct { Address string `json:"address"` Worktime string `json:"worktime"` Status *int8 `json:"status"` - Remark string `json:"remark"` + // 套餐与用户数上限(可选) + PackageID *uint64 `json:"package_id"` + UserQuota *int `json:"user_quota"` + Remark string `json:"remark"` } func (c *PlatformTenantController) parseTenantPayload() (tenantPayload, error) { @@ -182,6 +236,16 @@ func (c *PlatformTenantController) parseTenantPayload() (tenantPayload, error) { p.Status = &tmp } } + if s := strings.TrimSpace(c.GetString("package_id")); s != "" { + if v, err := strconv.ParseUint(s, 10, 64); err == nil { + p.PackageID = &v + } + } + if s := strings.TrimSpace(c.GetString("user_quota")); s != "" { + if v, err := strconv.Atoi(s); err == nil { + p.UserQuota = &v + } + } // 如果关键字段为空,尝试从 JSON body 解析(editTenant 靘莤 JSONďź? if p.TenantName == "" && p.TenantCode == "" { @@ -226,6 +290,36 @@ func (c *PlatformTenantController) CreateTenant() { status = *p.Status } + // 套餐与用户数上限:未指定套餐时使用默认套餐,用户数取其包含用户数(兜底 20) + packageID := uint64(0) + userQuota := services.DefaultTenantUserQuota + if p.PackageID != nil && *p.PackageID > 0 { + packageID = *p.PackageID + } + if packageID == 0 { + if pkg, err := services.GetDefaultTenantPackage(); err == nil && pkg != nil { + packageID = pkg.ID + if pkg.UserQuota > 0 { + userQuota = pkg.UserQuota + } + } + } + if packageID > 0 { + var pkg models.SystemTenantPackage + if err := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("id", packageID). + Filter("delete_time__isnull", true). + One(&pkg); err == nil && pkg.UserQuota > 0 { + userQuota = pkg.UserQuota + } + } + if p.UserQuota != nil && *p.UserQuota > 0 { + userQuota = *p.UserQuota + } + if userQuota <= 0 { + userQuota = services.DefaultTenantUserQuota + } + t := models.SystemTenant{ TenantCode: p.TenantCode, TenantName: p.TenantName, @@ -235,6 +329,8 @@ func (c *PlatformTenantController) CreateTenant() { Address: stringPtr(p.Address), Worktime: stringPtr(p.Worktime), Status: status, + PackageID: packageID, + UserQuota: userQuota, Remark: stringPtr(p.Remark), } @@ -282,6 +378,37 @@ func (c *PlatformTenantController) EditTenant() { if p.Status != nil { update["status"] = *p.Status } + if p.PackageID != nil { + pkgID := *p.PackageID + if pkgID > 0 { + var pkg models.SystemTenantPackage + if err := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("id", pkgID). + Filter("delete_time__isnull", true). + One(&pkg); err != nil { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": "套餐不存在"} + _ = c.ServeJSON() + return + } + } + update["package_id"] = pkgID + } + if p.UserQuota != nil { + // 用户数上限只增不减:不允许调低到当前已开用户数以下 + used, _ := services.CountTenantUsers(id) + quota := *p.UserQuota + if quota > 0 && quota < used { + c.Data["json"] = map[string]interface{}{ + "code": 400, + "msg": fmt.Sprintf("用户数上限不能小于该租户已开用户数(%d)", used), + } + _ = c.ServeJSON() + return + } + if quota > 0 { + update["user_quota"] = quota + } + } _, err = models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", id).Update(update) if err != nil { diff --git a/go/controllers/platform_tenant_package.go b/go/controllers/platform_tenant_package.go new file mode 100644 index 0000000..42ee247 --- /dev/null +++ b/go/controllers/platform_tenant_package.go @@ -0,0 +1,837 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services" + + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformTenantPackageController 平台端「租户套餐」管理 +// +// 两块能力: +// 1. 功能套餐(yz_system_tenant_package + yz_system_tenant_package_module): +// 勾选套餐包含的功能模块(erp / oa / crm / cms ...),租户绑定后按其开通功能。 +// 2. 用户数配额(yz_system_tenant_user_quota_package + yz_system_tenant_quota_order): +// 每个租户有用户数上限(默认 20),支持按单个用户或按加购套餐增购。 +type PlatformTenantPackageController struct { + beego.Controller +} + +func (c *PlatformTenantPackageController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *PlatformTenantPackageController) jsonOK(data interface{}, msg string) { + if msg == "" { + msg = "success" + } + resp := map[string]interface{}{"code": 200, "msg": msg} + if data != nil { + resp["data"] = data + } + c.Data["json"] = resp + _ = c.ServeJSON() +} + +// currentOperator 解析当前平台账号名(用于增购记录留痕;取不到时返回空串,不阻断请求) +func (c *PlatformTenantPackageController) currentOperator() string { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return "" + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return "" + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil || claims == nil { + return "" + } + if strings.TrimSpace(claims.Username) != "" { + return claims.Username + } + return "" +} + +func (c *PlatformTenantPackageController) readBody(target interface{}) bool { + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return false + } + if err := json.Unmarshal(raw, target); err != nil { + c.jsonErr(400, 400, "参数错误") + return false + } + return true +} + +func parseUint64Param(raw string) uint64 { + v, _ := strconv.ParseUint(strings.TrimSpace(raw), 10, 64) + return v +} + +// ============================ 功能套餐 ============================ + +type tenantPackageModuleItem struct { + ModuleCode string `json:"module_code"` + ModuleName string `json:"module_name"` +} + +type tenantPackageDTO struct { + models.SystemTenantPackage + Modules []tenantPackageModuleItem `json:"modules"` +} + +type tenantPackagePayload struct { + Name string `json:"name"` + Code string `json:"code"` + Description string `json:"description"` + Price *float64 `json:"price"` + UserQuota *int `json:"user_quota"` + ExtraUserPrice *float64 `json:"extra_user_price"` + IsDefault *int8 `json:"is_default"` + Sort *int `json:"sort"` + Status *int8 `json:"status"` + Remark string `json:"remark"` + Modules []string `json:"modules"` +} + +// loadPackageModules 批量加载套餐模块(packageID -> 模块列表) +func loadPackageModules(packageIDs []uint64) map[uint64][]tenantPackageModuleItem { + result := map[uint64][]tenantPackageModuleItem{} + if len(packageIDs) == 0 { + return result + } + var rows []models.SystemTenantPackageModule + _, err := models.Orm.QueryTable(new(models.SystemTenantPackageModule)). + Filter("package_id__in", packageIDs). + OrderBy("id"). + All(&rows) + if err != nil { + return result + } + for _, r := range rows { + result[r.PackageID] = append(result[r.PackageID], tenantPackageModuleItem{ + ModuleCode: r.ModuleCode, + ModuleName: r.ModuleName, + }) + } + return result +} + +// GetPackageList GET /platform/tenantPackage/list +func (c *PlatformTenantPackageController) GetPackageList() { + keyword := strings.TrimSpace(c.GetString("keyword")) + status := strings.TrimSpace(c.GetString("status")) + + qs := models.Orm.QueryTable(new(models.SystemTenantPackage)).Filter("delete_time__isnull", true) + if keyword != "" { + qs = qs.Filter("name__icontains", keyword) + } + if status == "0" || status == "1" { + v, _ := strconv.Atoi(status) + qs = qs.Filter("status", v) + } + + var rows []models.SystemTenantPackage + if _, err := qs.OrderBy("sort", "id").All(&rows); err != nil { + c.jsonErr(500, 500, "获取失败:"+err.Error()) + return + } + + ids := make([]uint64, 0, len(rows)) + for _, r := range rows { + ids = append(ids, r.ID) + } + moduleMap := loadPackageModules(ids) + + // 每个套餐的租户数 + tenantCount := map[uint64]int{} + if len(ids) > 0 { + var tenants []models.SystemTenant + if _, err := models.Orm.QueryTable(new(models.SystemTenant)).All(&tenants, "ID", "PackageID"); err == nil { + for _, t := range tenants { + tenantCount[t.PackageID]++ + } + } + } + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + modules := moduleMap[r.ID] + if modules == nil { + modules = []tenantPackageModuleItem{} + } + list = append(list, map[string]interface{}{ + "id": r.ID, + "name": r.Name, + "code": r.Code, + "description": r.Description, + "price": r.Price, + "user_quota": r.UserQuota, + "extra_user_price": r.ExtraUserPrice, + "is_default": r.IsDefault, + "sort": r.Sort, + "status": r.Status, + "remark": r.Remark, + "create_time": r.CreateTime, + "update_time": r.UpdateTime, + "modules": modules, + "module_codes": moduleCodes(modules), + "tenant_count": tenantCount[r.ID], + }) + } + + c.jsonOK(map[string]interface{}{"list": list, "total": len(list)}, "获取成功") +} + +func moduleCodes(items []tenantPackageModuleItem) []string { + codes := make([]string, 0, len(items)) + for _, it := range items { + codes = append(codes, it.ModuleCode) + } + return codes +} + +// GetPackageSelectList GET /platform/tenantPackage/select/list +func (c *PlatformTenantPackageController) GetPackageSelectList() { + var rows []models.SystemTenantPackage + _, err := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("delete_time__isnull", true). + Filter("status", 1). + OrderBy("sort", "id"). + All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取失败:"+err.Error()) + return + } + ids := make([]uint64, 0, len(rows)) + for _, r := range rows { + ids = append(ids, r.ID) + } + moduleMap := loadPackageModules(ids) + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + modules := moduleMap[r.ID] + if modules == nil { + modules = []tenantPackageModuleItem{} + } + list = append(list, map[string]interface{}{ + "id": r.ID, + "name": r.Name, + "code": r.Code, + "description": r.Description, + "price": r.Price, + "user_quota": r.UserQuota, + "extra_user_price": r.ExtraUserPrice, + "is_default": r.IsDefault, + "modules": modules, + "module_codes": moduleCodes(modules), + }) + } + c.jsonOK(list, "获取成功") +} + +// GetPackageDetail GET /platform/tenantPackage/detail/:id +func (c *PlatformTenantPackageController) GetPackageDetail() { + id := parseUint64Param(c.Ctx.Input.Param(":id")) + if id == 0 { + c.jsonErr(400, 400, "参数错误") + return + } + var row models.SystemTenantPackage + if err := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(&row); err != nil { + c.jsonErr(404, 404, "套餐不存在") + return + } + modules := loadPackageModules([]uint64{id})[id] + if modules == nil { + modules = []tenantPackageModuleItem{} + } + c.jsonOK(tenantPackageDTO{SystemTenantPackage: row, Modules: modules}, "获取成功") +} + +// GetModuleOptions GET /platform/tenantPackage/moduleOptions +// 可加入套餐的功能模块清单(取 yz_system_modules 中启用且未删除的模块) +func (c *PlatformTenantPackageController) GetModuleOptions() { + var rows []models.SystemModules + _, err := models.Orm.QueryTable(new(models.SystemModules)). + Filter("delete_time__isnull", true). + Filter("status", 1). + OrderBy("sort", "id"). + All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取失败:"+err.Error()) + return + } + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, map[string]interface{}{ + "id": r.ID, + "name": r.Name, + "code": r.Code, + "path": r.Path, + "icon": r.Icon, + "description": r.Description, + "type": r.Type, + "sort": r.Sort, + }) + } + c.jsonOK(map[string]interface{}{"list": list, "total": len(list)}, "获取成功") +} + +// savePackageModules 覆盖式保存套餐模块 +func savePackageModules(packageID uint64, modules []string) error { + if _, err := models.Orm.QueryTable(new(models.SystemTenantPackageModule)). + Filter("package_id", packageID). + Delete(); err != nil { + return err + } + if len(modules) == 0 { + return nil + } + nameMap := map[string]string{} + var allModules []models.SystemModules + if _, err := models.Orm.QueryTable(new(models.SystemModules)). + Filter("delete_time__isnull", true). + All(&allModules, "ID", "Name", "Code"); err == nil { + for _, m := range allModules { + nameMap[m.Code] = m.Name + } + } + seen := map[string]bool{} + for _, code := range modules { + code = strings.TrimSpace(code) + if code == "" || seen[code] { + continue + } + seen[code] = true + row := &models.SystemTenantPackageModule{ + PackageID: packageID, + ModuleCode: code, + ModuleName: nameMap[code], + } + if _, err := models.Orm.Insert(row); err != nil { + return err + } + } + return nil +} + +// CreatePackage POST /platform/tenantPackage/create +func (c *PlatformTenantPackageController) CreatePackage() { + var p tenantPackagePayload + if !c.readBody(&p) { + return + } + p.Name = strings.TrimSpace(p.Name) + p.Code = strings.TrimSpace(p.Code) + if p.Name == "" { + c.jsonErr(400, 400, "套餐名称不能为空") + return + } + if p.Code == "" { + c.jsonErr(400, 400, "套餐编码不能为空") + return + } + cnt, _ := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("code", p.Code). + Filter("delete_time__isnull", true). + Count() + if cnt > 0 { + c.jsonErr(400, 400, "套餐编码已存在") + return + } + + now := time.Now() + row := &models.SystemTenantPackage{ + Name: p.Name, + Code: p.Code, + Description: strings.TrimSpace(p.Description), + Price: valueFloat(p.Price, 0), + UserQuota: valueInt(p.UserQuota, services.DefaultTenantUserQuota), + ExtraUserPrice: valueFloat(p.ExtraUserPrice, services.DefaultTenantExtraUserPrice), + IsDefault: valueInt8(p.IsDefault, 0), + Sort: valueInt(p.Sort, 0), + Status: valueInt8(p.Status, 1), + Remark: strings.TrimSpace(p.Remark), + UpdateTime: &now, + } + if row.UserQuota <= 0 { + row.UserQuota = services.DefaultTenantUserQuota + } + id, err := models.Orm.Insert(row) + if err != nil { + c.jsonErr(500, 500, "创建失败:"+err.Error()) + return + } + if err := savePackageModules(uint64(id), p.Modules); err != nil { + c.jsonErr(500, 500, "套餐功能保存失败:"+err.Error()) + return + } + if row.IsDefault == 1 { + clearOtherDefaultPackages(uint64(id)) + } + c.jsonOK(map[string]interface{}{"id": uint64(id)}, "创建成功") +} + +// EditPackage POST /platform/tenantPackage/edit/:id +func (c *PlatformTenantPackageController) EditPackage() { + id := parseUint64Param(c.Ctx.Input.Param(":id")) + if id == 0 { + c.jsonErr(400, 400, "参数错误") + return + } + var p tenantPackagePayload + if !c.readBody(&p) { + return + } + p.Name = strings.TrimSpace(p.Name) + p.Code = strings.TrimSpace(p.Code) + if p.Name == "" { + c.jsonErr(400, 400, "套餐名称不能为空") + return + } + if p.Code != "" { + cnt, _ := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("code", p.Code). + Filter("id__ne", id). + Filter("delete_time__isnull", true). + Count() + if cnt > 0 { + c.jsonErr(400, 400, "套餐编码已存在") + return + } + } + + now := time.Now() + update := map[string]interface{}{ + "name": p.Name, + "description": strings.TrimSpace(p.Description), + "extra_user_price": valueFloat(p.ExtraUserPrice, services.DefaultTenantExtraUserPrice), + "sort": valueInt(p.Sort, 0), + "status": valueInt8(p.Status, 1), + "remark": strings.TrimSpace(p.Remark), + "update_time": now, + } + if p.Code != "" { + update["code"] = p.Code + } + if p.Price != nil { + update["price"] = *p.Price + } + if p.UserQuota != nil { + quota := *p.UserQuota + if quota <= 0 { + quota = services.DefaultTenantUserQuota + } + update["user_quota"] = quota + } + if p.IsDefault != nil { + update["is_default"] = *p.IsDefault + update["status"] = 1 + } + + if _, err := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(update); err != nil { + c.jsonErr(500, 500, "编辑失败:"+err.Error()) + return + } + if p.Modules != nil { + if err := savePackageModules(id, p.Modules); err != nil { + c.jsonErr(500, 500, "套餐功能保存失败:"+err.Error()) + return + } + } + if p.IsDefault != nil && *p.IsDefault == 1 { + clearOtherDefaultPackages(id) + } + c.jsonOK(nil, "编辑成功") +} + +// clearOtherDefaultPackages 保证全局只有一个默认套餐 +func clearOtherDefaultPackages(keepID uint64) { + _, _ = models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("id__ne", keepID). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"is_default": 0}) +} + +// DeletePackage DELETE /platform/tenantPackage/delete/:id(软删) +func (c *PlatformTenantPackageController) DeletePackage() { + id := parseUint64Param(c.Ctx.Input.Param(":id")) + if id == 0 { + c.jsonErr(400, 400, "参数错误") + return + } + // 有租户正在使用该套餐时不允许删除 + used, _ := models.Orm.QueryTable(new(models.SystemTenant)).Filter("package_id", id).Count() + if used > 0 { + c.jsonErr(400, 400, fmt.Sprintf("该套餐已被 %d 个租户使用,请先调整租户套餐", used)) + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now, "update_time": now, "is_default": 0}) + if err != nil { + c.jsonErr(500, 500, "删除失败:"+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "套餐不存在") + return + } + _, _ = models.Orm.QueryTable(new(models.SystemTenantPackageModule)).Filter("package_id", id).Delete() + c.jsonOK(nil, "删除成功") +} + +func valueFloat(v *float64, def float64) float64 { + if v == nil { + return def + } + return *v +} + +func valueInt(v *int, def int) int { + if v == nil { + return def + } + return *v +} + +// ============================ 用户数加购套餐 ============================ + +type quotaPackagePayload struct { + Name string `json:"name"` + UserCount *int `json:"user_count"` + Price *float64 `json:"price"` + Sort *int `json:"sort"` + Status *int8 `json:"status"` + Remark string `json:"remark"` +} + +// GetQuotaPackageList GET /platform/tenantQuotaPackage/list +func (c *PlatformTenantPackageController) GetQuotaPackageList() { + onlyEnabled := c.GetString("enabled") == "1" + qs := models.Orm.QueryTable(new(models.SystemTenantUserQuotaPackage)).Filter("delete_time__isnull", true) + if onlyEnabled { + qs = qs.Filter("status", 1) + } + var rows []models.SystemTenantUserQuotaPackage + if _, err := qs.OrderBy("sort", "id").All(&rows); err != nil { + c.jsonErr(500, 500, "获取失败:"+err.Error()) + return + } + c.jsonOK(map[string]interface{}{"list": rows, "total": len(rows)}, "获取成功") +} + +// CreateQuotaPackage POST /platform/tenantQuotaPackage/create +func (c *PlatformTenantPackageController) CreateQuotaPackage() { + var p quotaPackagePayload + if !c.readBody(&p) { + return + } + p.Name = strings.TrimSpace(p.Name) + if p.Name == "" { + c.jsonErr(400, 400, "规格名称不能为空") + return + } + if p.UserCount == nil || *p.UserCount <= 0 { + c.jsonErr(400, 400, "包含用户数必须大于 0") + return + } + now := time.Now() + row := &models.SystemTenantUserQuotaPackage{ + Name: p.Name, + UserCount: *p.UserCount, + Price: valueFloat(p.Price, 0), + Sort: valueInt(p.Sort, 0), + Status: valueInt8(p.Status, 1), + Remark: strings.TrimSpace(p.Remark), + UpdateTime: &now, + } + id, err := models.Orm.Insert(row) + if err != nil { + c.jsonErr(500, 500, "创建失败:"+err.Error()) + return + } + c.jsonOK(map[string]interface{}{"id": uint64(id)}, "创建成功") +} + +// EditQuotaPackage POST /platform/tenantQuotaPackage/edit/:id +func (c *PlatformTenantPackageController) EditQuotaPackage() { + id := parseUint64Param(c.Ctx.Input.Param(":id")) + if id == 0 { + c.jsonErr(400, 400, "参数错误") + return + } + var p quotaPackagePayload + if !c.readBody(&p) { + return + } + p.Name = strings.TrimSpace(p.Name) + if p.Name == "" { + c.jsonErr(400, 400, "规格名称不能为空") + return + } + if p.UserCount != nil && *p.UserCount <= 0 { + c.jsonErr(400, 400, "包含用户数必须大于 0") + return + } + now := time.Now() + update := map[string]interface{}{ + "name": p.Name, + "sort": valueInt(p.Sort, 0), + "status": valueInt8(p.Status, 1), + "remark": strings.TrimSpace(p.Remark), + "update_time": now, + } + if p.UserCount != nil { + update["user_count"] = *p.UserCount + } + if p.Price != nil { + update["price"] = *p.Price + } + n, err := models.Orm.QueryTable(new(models.SystemTenantUserQuotaPackage)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(update) + if err != nil { + c.jsonErr(500, 500, "编辑失败:"+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "规格不存在") + return + } + c.jsonOK(nil, "编辑成功") +} + +// DeleteQuotaPackage DELETE /platform/tenantQuotaPackage/delete/:id(软删) +func (c *PlatformTenantPackageController) DeleteQuotaPackage() { + id := parseUint64Param(c.Ctx.Input.Param(":id")) + if id == 0 { + c.jsonErr(400, 400, "参数错误") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemTenantUserQuotaPackage)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now, "update_time": now}) + if err != nil { + c.jsonErr(500, 500, "删除失败:"+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "规格不存在") + return + } + c.jsonOK(nil, "删除成功") +} + +// ============================ 租户套餐 / 用户数配额 ============================ + +type setTenantPackagePayload struct { + Tid uint64 `json:"tid"` + PackageID uint64 `json:"package_id"` + SyncQuota *bool `json:"sync_quota"` +} + +// SetTenantPackage POST /platform/tenant/setPackage +// 为租户绑定套餐;sync_quota 为 true(默认)时按套餐包含用户数与现有上限取较大值同步。 +func (c *PlatformTenantPackageController) SetTenantPackage() { + var p setTenantPackagePayload + if !c.readBody(&p) { + return + } + if p.Tid == 0 || p.PackageID == 0 { + c.jsonErr(400, 400, "租户与套餐不能为空") + return + } + syncQuota := true + if p.SyncQuota != nil { + syncQuota = *p.SyncQuota + } + newQuota, err := services.ApplyPackageToTenant(p.Tid, p.PackageID, syncQuota) + if err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + c.jsonOK(map[string]interface{}{"user_quota": newQuota}, "套餐设置成功") +} + +// GetTenantQuotaInfo GET /platform/tenant/quotaInfo?tid=1 +// 返回租户套餐、用户数使用情况、可增购规格与单价 +func (c *PlatformTenantPackageController) GetTenantQuotaInfo() { + tid, _ := c.GetUint64("tid") + if tid == 0 { + c.jsonErr(400, 400, "tid 不能为空") + return + } + info, err := services.GetTenantQuotaInfo(tid) + if err != nil { + c.jsonErr(404, 404, "租户不存在") + return + } + + var quotaPackages []models.SystemTenantUserQuotaPackage + _, _ = models.Orm.QueryTable(new(models.SystemTenantUserQuotaPackage)). + Filter("delete_time__isnull", true). + Filter("status", 1). + OrderBy("sort", "id"). + All("aPackages) + + modules, _ := services.GetTenantPackageModules(tid) + moduleList := make([]tenantPackageModuleItem, 0, len(modules)) + for _, m := range modules { + moduleList = append(moduleList, tenantPackageModuleItem{ModuleCode: m.ModuleCode, ModuleName: m.ModuleName}) + } + + c.jsonOK(map[string]interface{}{ + "tid": info.Tid, + "package_id": info.PackageID, + "effective_package_id": info.EffectivePackageID, + "package_name": info.PackageName, + "modules": moduleList, + "quota": info.Quota, + "used": info.Used, + "remaining": info.Remaining, + "extra_user_price": info.ExtraUserPrice, + "quota_packages": quotaPackages, + }, "获取成功") +} + +type rechargeQuotaPayload struct { + Tid uint64 `json:"tid"` + Type int8 `json:"type"` // 1 单个增购 2 套餐增购 + QuotaPackageID uint64 `json:"quota_package_id"` + UserCount int `json:"user_count"` + UnitPrice float64 `json:"unit_price"` + Remark string `json:"remark"` +} + +// RechargeTenantQuota POST /platform/tenant/rechargeQuota +// 用户数增购:单个增购(单价 x 人数)或 按加购套餐(如 5人/500元) +func (c *PlatformTenantPackageController) RechargeTenantQuota() { + var p rechargeQuotaPayload + if !c.readBody(&p) { + return + } + if p.Tid == 0 { + c.jsonErr(400, 400, "租户不能为空") + return + } + + order := models.SystemTenantQuotaOrder{ + Tid: p.Tid, + Type: p.Type, + Operator: c.currentOperator(), + Remark: strings.TrimSpace(p.Remark), + } + if order.Type != 2 { + order.Type = 1 + } + + if order.Type == 2 { + var pkg models.SystemTenantUserQuotaPackage + if err := models.Orm.QueryTable(new(models.SystemTenantUserQuotaPackage)). + Filter("id", p.QuotaPackageID). + Filter("delete_time__isnull", true). + One(&pkg); err != nil { + c.jsonErr(400, 400, "加购规格不存在") + return + } + if pkg.UserCount <= 0 { + c.jsonErr(400, 400, "加购规格用户数配置有误") + return + } + order.QuotaPackageID = pkg.ID + order.PackageName = pkg.Name + order.UserCount = pkg.UserCount + order.Amount = pkg.Price + order.UnitPrice = round2(pkg.Price / float64(pkg.UserCount)) + } else { + if p.UserCount <= 0 { + c.jsonErr(400, 400, "增购用户数必须大于 0") + return + } + unitPrice := p.UnitPrice + if unitPrice <= 0 { + // 未传单价时取租户套餐的单个用户增购价 + if info, err := services.GetTenantQuotaInfo(p.Tid); err == nil && info.ExtraUserPrice > 0 { + unitPrice = info.ExtraUserPrice + } else { + unitPrice = services.DefaultTenantExtraUserPrice + } + } + order.UserCount = p.UserCount + order.UnitPrice = unitPrice + order.Amount = round2(unitPrice * float64(p.UserCount)) + order.PackageName = "单个增购" + } + + before, after, err := services.IncreaseTenantQuota(p.Tid, order.UserCount) + if err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + order.QuotaBefore = before + order.QuotaAfter = after + + if _, err := models.Orm.Insert(&order); err != nil { + c.jsonErr(500, 500, "增购记录写入失败:"+err.Error()) + return + } + + if info, err := services.GetTenantQuotaInfo(p.Tid); err == nil { + c.jsonOK(map[string]interface{}{ + "quota": info.Quota, + "used": info.Used, + "order": order, + "amount": order.Amount, + "add": order.UserCount, + }, "增购成功") + return + } + c.jsonOK(map[string]interface{}{"quota": after, "add": order.UserCount, "amount": order.Amount}, "增购成功") +} + +// GetQuotaOrders GET /platform/tenant/quotaOrders?tid=1 +func (c *PlatformTenantPackageController) GetQuotaOrders() { + tid, _ := c.GetUint64("tid") + if tid == 0 { + c.jsonErr(400, 400, "tid 不能为空") + return + } + var rows []models.SystemTenantQuotaOrder + _, err := models.Orm.QueryTable(new(models.SystemTenantQuotaOrder)). + Filter("tid", tid). + OrderBy("-id"). + All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取失败:"+err.Error()) + return + } + c.jsonOK(map[string]interface{}{"list": rows, "total": len(rows)}, "获取成功") +} diff --git a/go/controllers/platform_tenant_user.go b/go/controllers/platform_tenant_user.go index 1d2ac3f..480583e 100644 --- a/go/controllers/platform_tenant_user.go +++ b/go/controllers/platform_tenant_user.go @@ -3,6 +3,7 @@ package controllers import ( "encoding/json" "errors" + "fmt" "io" "math/rand" "strconv" @@ -186,6 +187,20 @@ func (c *PlatformTenantUserController) CreateTenantUser() { p.Uid = uid } + // 用户数配额校验:租户用户数达到上限时不允许新增(增购用户数后放开) + if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()} + _ = c.ServeJSON() + return + } else if !check.Allowed { + c.Data["json"] = map[string]interface{}{ + "code": 400, + "msg": fmt.Sprintf("该租户用户数已达上限(%d/%d),请先为租户增购用户数", check.Used, check.Quota), + } + _ = c.ServeJSON() + return + } + isDefault := int8(0) status := int8(1) if p.IsDefault != nil { diff --git a/go/docs/sql/create_tenant_package.sql b/go/docs/sql/create_tenant_package.sql new file mode 100644 index 0000000..8093fb8 --- /dev/null +++ b/go/docs/sql/create_tenant_package.sql @@ -0,0 +1,218 @@ +-- ============================================================= +-- 租户套餐 + 用户数配额(平台端「租户套餐」功能) +-- +-- 业务说明: +-- 1. 套餐(yz_system_tenant_package)决定租户能开通哪些功能模块 +-- (模块取自 yz_system_modules,如 erp / oa / crm / cms), +-- 租户绑定套餐后,租户端菜单按套餐包含的模块过滤(未包含的整块菜单不展示)。 +-- 2. 所有租户都有用户数上限(yz_system_tenant.user_quota,默认 20), +-- 租户用户数达到上限后不允许再新增账号,需要先增购用户数: +-- · 单个增购:按套餐的 extra_user_price(如 200 元/人) +-- · 套餐增购:按 yz_system_tenant_user_quota_package 配置的规格(如 5人/500元) +-- 每次增购写入 yz_system_tenant_quota_order 记录,并累加到租户的 user_quota。 +-- +-- 本脚本可重复执行:建表用 IF NOT EXISTS,补列/预置数据均做存在性判断。 +-- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < create_tenant_package.sql +-- ============================================================= + +-- 1) 租户套餐表 +CREATE TABLE IF NOT EXISTS `yz_system_tenant_package` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `name` varchar(64) NOT NULL DEFAULT '' COMMENT '套餐名称,如:ERP套餐', + `code` varchar(64) NOT NULL DEFAULT '' COMMENT '套餐编码(唯一,程序识别用)', + `description` varchar(500) NOT NULL DEFAULT '' COMMENT '套餐说明', + `price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '套餐售价(元)', + `user_quota` int(11) NOT NULL DEFAULT 20 COMMENT '套餐包含的用户数(新租户绑定套餐后初始化的用户数上限)', + `extra_user_price` decimal(10,2) NOT NULL DEFAULT 200.00 COMMENT '单个用户增购价格(元/人)', + `is_default` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否默认套餐:1是 0否(新建租户未指定套餐时使用)', + `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', + `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + KEY `idx_code` (`code`, `delete_time`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户套餐表(功能模块 + 用户数配额)'; + +-- 2) 套餐-功能模块关联表(一个套餐勾选多个模块,模块取自 yz_system_modules.code) +CREATE TABLE IF NOT EXISTS `yz_system_tenant_package_module` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '套餐ID 关联 yz_system_tenant_package(id)', + `module_code` varchar(64) NOT NULL DEFAULT '' COMMENT '功能模块编码 关联 yz_system_modules(code),如 erp/oa/crm/cms', + `module_name` varchar(64) NOT NULL DEFAULT '' COMMENT '功能模块名称(冗余,便于展示)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_package_id` (`package_id`), + KEY `idx_module_code` (`module_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户套餐-功能模块关联表'; + +-- 3) 用户数加购套餐表(平台可维护的「人/元」规格,如 5人 500元) +CREATE TABLE IF NOT EXISTS `yz_system_tenant_user_quota_package` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `name` varchar(64) NOT NULL DEFAULT '' COMMENT '规格名称,如:5人套餐', + `user_count` int(11) NOT NULL DEFAULT 0 COMMENT '包含用户数', + `price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '售价(元)', + `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', + `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户用户数加购套餐表'; + +-- 4) 租户用户数增购记录表 +CREATE TABLE IF NOT EXISTS `yz_system_tenant_quota_order` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '租户ID 关联 yz_system_tenant(id)', + `type` tinyint(4) NOT NULL DEFAULT 1 COMMENT '增购方式:1单个增购 2套餐增购', + `quota_package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '加购套餐ID(type=2 时有效)', + `package_name` varchar(64) NOT NULL DEFAULT '' COMMENT '加购规格名称(冗余)', + `user_count` int(11) NOT NULL DEFAULT 0 COMMENT '本次增购用户数', + `unit_price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '单价(元/人,type=1 时使用)', + `amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '本次金额(元)', + `quota_before` int(11) NOT NULL DEFAULT 0 COMMENT '增购前用户数上限', + `quota_after` int(11) NOT NULL DEFAULT 0 COMMENT '增购后用户数上限', + `operator` varchar(64) NOT NULL DEFAULT '' COMMENT '操作人(平台账号)', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_tid` (`tid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户用户数增购记录表'; + +-- 5) 租户表补列:套餐ID + 用户数上限 +SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant'); +SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant' AND COLUMN_NAME = 'package_id'); +SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant` ADD COLUMN `package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT ''套餐ID 关联 yz_system_tenant_package(id),0-未绑定''', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant'); +SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant' AND COLUMN_NAME = 'user_quota'); +SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant` ADD COLUMN `user_quota` int(11) NOT NULL DEFAULT 20 COMMENT ''用户数上限(默认20,增购后累加)''', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 存量租户兜底:用户数上限为 0 的按默认 20 初始化 +UPDATE `yz_system_tenant` SET `user_quota` = 20 WHERE `user_quota` <= 0; + +-- 存量租户兜底:用户数上限小于已开用户数时,按已开用户数抬高(避免历史租户立刻被锁死) +UPDATE `yz_system_tenant` t + SET t.`user_quota` = ( + SELECT cnt FROM (SELECT COUNT(*) AS cnt FROM `yz_system_tenant_user` u WHERE u.`tid` = t.`id` AND u.`delete_time` IS NULL) c + ) + WHERE t.`user_quota` < ( + SELECT cnt FROM (SELECT COUNT(*) AS cnt FROM `yz_system_tenant_user` u WHERE u.`tid` = t.`id` AND u.`delete_time` IS NULL) c2 + ); + +-- 6) 预置套餐(幂等:按 code 判断,不存在才插入) +INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`) +SELECT '基础版', 'basic', '基础功能:系统管理、基础设置、便捷工具', 0.00, 20, 200.00, 1, 1, 1, '默认套餐,新建租户自动绑定', NOW(), NOW() + WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'basic' AND `delete_time` IS NULL) t); + +INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`) +SELECT 'ERP套餐', 'erp', '基础功能 + 进销存(ERP)', 0.00, 20, 200.00, 0, 2, 1, '', NOW(), NOW() + WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'erp' AND `delete_time` IS NULL) t); + +INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`) +SELECT 'CRM套餐', 'crm', '基础功能 + 客户关系管理(CRM)', 0.00, 20, 200.00, 0, 3, 1, '', NOW(), NOW() + WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'crm' AND `delete_time` IS NULL) t); + +INSERT INTO `yz_system_tenant_package` (`name`, `code`, `description`, `price`, `user_quota`, `extra_user_price`, `is_default`, `sort`, `status`, `remark`, `create_time`, `update_time`) +SELECT '企业全功能版', 'all', '基础功能 + 进销存 + OA + CRM + 网站管理', 0.00, 20, 200.00, 0, 9, 1, '', NOW(), NOW() + WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL) t); + +-- 7) 预置套餐功能模块(按套餐 code + 模块 code 幂等插入) +-- 注意:MySQL 的派生表不能引用外层字段,因此这里把「已存在的关联」整表materialize 成 ex, +-- 再在 WHERE 中与外层套餐/模块做关联,既避免了「不能 SELECT 目标表」,也保证可重复执行。 +-- 基础模块:基础设置(basicSettings)、系统管理(systemManager)、便捷工具(bianjiegongju) +INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`) +SELECT p.`id`, m.`code`, m.`name`, NOW() + FROM `yz_system_tenant_package` p + JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` IN ('basicSettings', 'systemManager', 'bianjiegongju') + WHERE p.`code` IN ('basic', 'erp', 'crm', 'all') AND p.`delete_time` IS NULL + AND NOT EXISTS ( + SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex + WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code` + ); + +-- ERP套餐:附加 erp 模块 +INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`) +SELECT p.`id`, m.`code`, m.`name`, NOW() + FROM `yz_system_tenant_package` p + JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` = 'erp' + WHERE p.`code` = 'erp' AND p.`delete_time` IS NULL + AND NOT EXISTS ( + SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex + WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code` + ); + +-- CRM套餐:附加 crm 模块 +INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`) +SELECT p.`id`, m.`code`, m.`name`, NOW() + FROM `yz_system_tenant_package` p + JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` = 'crm' + WHERE p.`code` = 'crm' AND p.`delete_time` IS NULL + AND NOT EXISTS ( + SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex + WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code` + ); + +-- 企业全功能版:附加 erp / oa / crm / cms +INSERT INTO `yz_system_tenant_package_module` (`package_id`, `module_code`, `module_name`, `create_time`) +SELECT p.`id`, m.`code`, m.`name`, NOW() + FROM `yz_system_tenant_package` p + JOIN `yz_system_modules` m ON m.`delete_time` IS NULL AND m.`code` IN ('erp', 'oa', 'crm', 'cms') + WHERE p.`code` = 'all' AND p.`delete_time` IS NULL + AND NOT EXISTS ( + SELECT 1 FROM (SELECT `package_id`, `module_code` FROM `yz_system_tenant_package_module`) ex + WHERE ex.`package_id` = p.`id` AND ex.`module_code` = m.`code` + ); + +-- 8) 预置用户数加购套餐(5人 500元;可按需在平台端维护更多规格) +INSERT INTO `yz_system_tenant_user_quota_package` (`name`, `user_count`, `price`, `sort`, `status`, `remark`, `create_time`, `update_time`) +SELECT '5人套餐', 5, 500.00, 1, 1, '一次性增购 5 个用户数', NOW(), NOW() + WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_user_quota_package` WHERE `name` = '5人套餐' AND `delete_time` IS NULL) t); + +INSERT INTO `yz_system_tenant_user_quota_package` (`name`, `user_count`, `price`, `sort`, `status`, `remark`, `create_time`, `update_time`) +SELECT '10人套餐', 10, 900.00, 2, 1, '一次性增购 10 个用户数', NOW(), NOW() + WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_user_quota_package` WHERE `name` = '10人套餐' AND `delete_time` IS NULL) t); + +-- 9) 存量租户兜底:未绑定套餐的存量租户绑定「企业全功能版」, +-- 避免上线后老租户因套餐未配置而看不到原有功能菜单; +-- 之后可在「租户管理 → 编辑 / 套餐与用户数」中按实际购买情况改为 ERP/CRM 等套餐。 +-- (若没有 all 套餐,则退回绑定默认套餐) +UPDATE `yz_system_tenant` t + SET t.`package_id` = (SELECT p.`id` FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL ORDER BY `sort`, `id` LIMIT 1) p) + WHERE (t.`package_id` IS NULL OR t.`package_id` = 0) + AND EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL) p2); + +UPDATE `yz_system_tenant` t + SET t.`package_id` = (SELECT p.`id` FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `is_default` = 1 AND `delete_time` IS NULL ORDER BY `sort`, `id` LIMIT 1) p) + WHERE (t.`package_id` IS NULL OR t.`package_id` = 0) + AND EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `is_default` = 1 AND `delete_time` IS NULL) p2); + +-- 10) 平台端菜单:基础设置 → 租户套餐(仅平台端可见) +-- pid=17 为「基础设置」菜单;若环境中 17 不是基础设置,请在平台端「菜单管理」里手工挂载本页面: +-- 组件路径 /basicSettings/tenantpackage/index.vue,路径 /basicSettings/tenantpackage +INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `permission`, `creater`, `remark`, `create_time`, `update_time`) +SELECT 17, '租户套餐', '/basicSettings/tenantpackage', '/basicSettings/tenantpackage/index.vue', 'Box', 4, 1, 1, '[1]', 2, '', 'system', '租户功能套餐与用户数加购规格', NOW(), NOW() + WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_menu` WHERE `path` = '/basicSettings/tenantpackage' AND `delete_time` IS NULL) t) + AND EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_menu` WHERE `id` = 17 AND `delete_time` IS NULL) t2); + +-- 11) 【按需执行】存量租户套餐调整 +-- 本脚本第 9 步只处理「package_id 为空/0」的租户;若存量租户已被绑定为「基础版」, +-- 上线菜单按套餐过滤后,这些租户将看不到 进销存/OA/CRM/网站管理 等菜单。 +-- 如需要让老租户保留原有全部功能,按下面的语句把它们改为「企业全功能版」 +-- (或改成各自实际购买的套餐;也可直接在平台端「租户管理 → 编辑」里选择套餐)。 +-- +-- UPDATE `yz_system_tenant` +-- SET `package_id` = (SELECT p.`id` FROM (SELECT `id` FROM `yz_system_tenant_package` WHERE `code` = 'all' AND `delete_time` IS NULL ORDER BY `sort`, `id` LIMIT 1) p) +-- WHERE `id` IN (1, 2); -- ← 改成你的租户ID,或去掉 WHERE 批量处理 + +-- 校验:套餐与模块 +-- SELECT p.id, p.name, p.code, p.user_quota, p.extra_user_price, GROUP_CONCAT(pm.module_code) AS modules +-- FROM yz_system_tenant_package p LEFT JOIN yz_system_tenant_package_module pm ON pm.package_id = p.id +-- WHERE p.delete_time IS NULL GROUP BY p.id; +-- SELECT id, tenant_name, package_id, user_quota FROM yz_system_tenant; diff --git a/go/models/init.go b/go/models/init.go index 666d345..2d4f36b 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -35,6 +35,11 @@ func Init(_ string) { orm.RegisterModel( new(SystemTenant), new(SystemTenantUser), + // 租户套餐 / 用户数配额(docs/sql/create_tenant_package.sql) + new(SystemTenantPackage), + new(SystemTenantPackageModule), + new(SystemTenantUserQuotaPackage), + new(SystemTenantQuotaOrder), new(BackendOrganization), new(BackendEmployee), new(BackendPosition), diff --git a/go/models/system_tenant.go b/go/models/system_tenant.go index 90de7fb..431e23f 100644 --- a/go/models/system_tenant.go +++ b/go/models/system_tenant.go @@ -13,10 +13,14 @@ type SystemTenant struct { Address *string `orm:"column(address);size(255);null" json:"address"` Worktime *string `orm:"column(worktime);size(255);null" json:"worktime"` Status int8 `orm:"column(status);default(1)" json:"status"` - Remark *string `orm:"column(remark);size(512);null" json:"remark"` - CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` - UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` - DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` + // PackageID 套餐ID:关联 yz_system_tenant_package(id),0 表示未绑定(视为默认套餐) + PackageID uint64 `orm:"column(package_id);default(0)" json:"package_id"` + // UserQuota 用户数上限:默认 20,增购后累加。租户用户数达到上限后不允许再新增账号。 + UserQuota int `orm:"column(user_quota);default(20)" json:"user_quota"` + Remark *string `orm:"column(remark);size(512);null" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` } func (m *SystemTenant) TableName() string { diff --git a/go/models/system_tenant_package.go b/go/models/system_tenant_package.go new file mode 100644 index 0000000..8714994 --- /dev/null +++ b/go/models/system_tenant_package.go @@ -0,0 +1,80 @@ +package models + +import "time" + +// SystemTenantPackage 租户套餐表 yz_system_tenant_package +// 套餐 = 可开通的功能模块集合 + 用户数配额(含用户数 / 单用户增购价) +type SystemTenantPackage struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Name string `orm:"column(name);size(64)" json:"name"` + Code string `orm:"column(code);size(64)" json:"code"` + Description string `orm:"column(description);size(500)" json:"description"` + Price float64 `orm:"column(price);digits(10);decimals(2)" json:"price"` + UserQuota int `orm:"column(user_quota);default(20)" json:"user_quota"` + ExtraUserPrice float64 `orm:"column(extra_user_price);digits(10);decimals(2)" json:"extra_user_price"` + IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + Remark string `orm:"column(remark);size(255)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *SystemTenantPackage) TableName() string { + return "yz_system_tenant_package" +} + +// SystemTenantPackageModule 套餐-功能模块关联表 yz_system_tenant_package_module +// module_code 对应 yz_system_modules.code(如 erp / oa / crm / cms) +type SystemTenantPackageModule struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + PackageID uint64 `orm:"column(package_id)" json:"package_id"` + ModuleCode string `orm:"column(module_code);size(64)" json:"module_code"` + ModuleName string `orm:"column(module_name);size(64)" json:"module_name"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` +} + +func (m *SystemTenantPackageModule) TableName() string { + return "yz_system_tenant_package_module" +} + +// SystemTenantUserQuotaPackage 用户数加购套餐表 yz_system_tenant_user_quota_package +// 平台可维护的规格,如「5人套餐 / 500元」;租户增购时选择该规格即增加对应人数。 +type SystemTenantUserQuotaPackage struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Name string `orm:"column(name);size(64)" json:"name"` + UserCount int `orm:"column(user_count);default(0)" json:"user_count"` + Price float64 `orm:"column(price);digits(10);decimals(2)" json:"price"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` + Remark string `orm:"column(remark);size(255)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *SystemTenantUserQuotaPackage) TableName() string { + return "yz_system_tenant_user_quota_package" +} + +// SystemTenantQuotaOrder 租户用户数增购记录表 yz_system_tenant_quota_order +type SystemTenantQuotaOrder struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + Type int8 `orm:"column(type);default(1)" json:"type"` // 1单个增购 2套餐增购 + QuotaPackageID uint64 `orm:"column(quota_package_id);default(0)" json:"quota_package_id"` + PackageName string `orm:"column(package_name);size(64)" json:"package_name"` + UserCount int `orm:"column(user_count);default(0)" json:"user_count"` + UnitPrice float64 `orm:"column(unit_price);digits(10);decimals(2)" json:"unit_price"` + Amount float64 `orm:"column(amount);digits(10);decimals(2)" json:"amount"` + QuotaBefore int `orm:"column(quota_before);default(0)" json:"quota_before"` + QuotaAfter int `orm:"column(quota_after);default(0)" json:"quota_after"` + Operator string `orm:"column(operator);size(64)" json:"operator"` + Remark string `orm:"column(remark);size(255)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` +} + +func (m *SystemTenantQuotaOrder) TableName() string { + return "yz_system_tenant_quota_order" +} diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index 3f2fd19..3eb9ca3 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -123,6 +123,9 @@ func RegisterAuthRoutes() { beego.Router("/backend/deleteUser/:id", &controllers.BackendAdminUserController{}, "delete:DeleteUser") beego.Router("/backend/changePassword", &controllers.BackendAdminUserController{}, "post:ChangePassword") + // 租户用户数配额(只读:用户管理页展示已用/上限;增购在平台端) + beego.Router("/backend/tenant/quotaInfo", &controllers.BackendTenantQuotaController{}, "get:GetQuotaInfo") + // 组织架构(组织 / 员工 / 职位) // 组织架构是租户端通用基础数据:进销存(erp)与办公自动化(oa)两套界面读写同一份数据, // 数据按 JWT 中的租户ID 隔离。两个前缀注册到同一个控制器,便于两端各自的菜单与权限配置。 diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index 5635b20..a20c3cf 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -61,6 +61,28 @@ func Register() { beego.Router("/platform/tenantUser/edit/:id", &controllers.PlatformTenantUserController{}, "post:EditTenantUser") beego.Router("/platform/tenantUser/delete/:id", &controllers.PlatformTenantUserController{}, "delete:DeleteTenantUser") + // 租户套餐(yz_system_tenant_package / yz_system_tenant_package_module) + // 注意:固定路径需先于 /:id 通配路径注册 + beego.Router("/platform/tenantPackage/list", &controllers.PlatformTenantPackageController{}, "get:GetPackageList") + beego.Router("/platform/tenantPackage/select/list", &controllers.PlatformTenantPackageController{}, "get:GetPackageSelectList") + beego.Router("/platform/tenantPackage/moduleOptions", &controllers.PlatformTenantPackageController{}, "get:GetModuleOptions") + beego.Router("/platform/tenantPackage/create", &controllers.PlatformTenantPackageController{}, "post:CreatePackage") + beego.Router("/platform/tenantPackage/edit/:id", &controllers.PlatformTenantPackageController{}, "post:EditPackage") + beego.Router("/platform/tenantPackage/delete/:id", &controllers.PlatformTenantPackageController{}, "delete:DeletePackage") + beego.Router("/platform/tenantPackage/detail/:id", &controllers.PlatformTenantPackageController{}, "get:GetPackageDetail") + + // 用户数加购规格(yz_system_tenant_user_quota_package) + beego.Router("/platform/tenantQuotaPackage/list", &controllers.PlatformTenantPackageController{}, "get:GetQuotaPackageList") + beego.Router("/platform/tenantQuotaPackage/create", &controllers.PlatformTenantPackageController{}, "post:CreateQuotaPackage") + beego.Router("/platform/tenantQuotaPackage/edit/:id", &controllers.PlatformTenantPackageController{}, "post:EditQuotaPackage") + beego.Router("/platform/tenantQuotaPackage/delete/:id", &controllers.PlatformTenantPackageController{}, "delete:DeleteQuotaPackage") + + // 租户套餐绑定 / 用户数配额(增购记录 yz_system_tenant_quota_order) + beego.Router("/platform/tenant/setPackage", &controllers.PlatformTenantPackageController{}, "post:SetTenantPackage") + beego.Router("/platform/tenant/quotaInfo", &controllers.PlatformTenantPackageController{}, "get:GetTenantQuotaInfo") + beego.Router("/platform/tenant/rechargeQuota", &controllers.PlatformTenantPackageController{}, "post:RechargeTenantQuota") + beego.Router("/platform/tenant/quotaOrders", &controllers.PlatformTenantPackageController{}, "get:GetQuotaOrders") + // 平台管理员用户管理(yz_system_admin_user) beego.Router("/platform/getAllUsers", &controllers.PlatformAdminUserController{}, "get:GetAllUsers") beego.Router("/platform/getUserInfo/:id", &controllers.PlatformAdminUserController{}, "get:GetUserInfo") diff --git a/go/services/payment/channel_wechat.go b/go/services/payment/channel_wechat.go index d86a17d..a636a3d 100644 --- a/go/services/payment/channel_wechat.go +++ b/go/services/payment/channel_wechat.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -16,6 +17,7 @@ import ( "server/models" "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/core/auth" "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" "github.com/wechatpay-apiv3/wechatpay-go/core/consts" "github.com/wechatpay-apiv3/wechatpay-go/core/downloader" @@ -35,16 +37,26 @@ import ( // appid 公众号 / 小程序 APPID // cert_serial_no 商户 API 证书序列号 // api_v3_key APIv3 密钥 +// pub_key_id 微信支付公钥 ID(PUB_KEY_ID_ 开头;公钥模式必填,与 pub_key 成对) +// pub_key 微信支付公钥 PEM 内容(公钥模式必填) // // 证书(cert_json): // // key_path apiclient_key.pem 上传后的服务端路径(不入库内容,见渠道证书上传接口) type WechatChannel struct{} +// connectTestOutTradeNo 测试连接用的探测单号:查询不存在的订单,返回 404 即视为鉴权链路通过 +const connectTestOutTradeNo = "CONNECT_TEST_NO_0000000000000001" + func (c *WechatChannel) Code() string { return ChannelWechat } func (c *WechatChannel) Name() string { return "微信支付" } // buildClient 创建微信支付 APIv3 客户端(带自动证书更新与加解密能力) +// +// 初始化模式自动选择: +// - 配置了「微信支付公钥」(pub_key_id + pub_key)时走公钥模式: +// 2024 年后新开通商户默认使用公钥,平台证书下载接口对其停用(403 NOT_ENOUGH); +// - 否则回退平台证书模式(自动下载并轮换平台证书,老商户适用)。 func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, error) { mchID := cfg.Get("mch_id") serial := cfg.Get("cert_serial_no") @@ -66,6 +78,23 @@ func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, e return nil, "", fmt.Errorf("解析商户私钥失败: %w", err) } + pubKeyID := cfg.Get("pub_key_id") + pubKeyPEM := cfg.Get("pub_key") + if pubKeyID != "" && pubKeyPEM != "" { + pubKey, perr := utils.LoadPublicKey(pubKeyPEM) + if perr != nil { + return nil, "", fmt.Errorf("解析微信支付公钥失败: %w", perr) + } + client, cerr := core.NewClient(context.Background(), + option.WithMerchantCredential(mchID, serial, privateKey), + option.WithWechatPayPublicKeyAuthCipher(mchID, serial, privateKey, pubKeyID, pubKey), + ) + if cerr != nil { + return nil, "", fmt.Errorf("创建微信支付客户端失败: %w", cerr) + } + return client, mchID, nil + } + client, err := core.NewClient(context.Background(), option.WithMerchantCredential(mchID, serial, privateKey), option.WithWechatPayAutoAuthCipher(mchID, serial, privateKey, apiV3Key), @@ -212,9 +241,21 @@ func (c *WechatChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *C mchID := cfg.Get("mch_id") apiV3Key := cfg.Get("api_v3_key") - // 平台证书由 WithWechatPayAutoAuthCipher 注册的下载器维护,直接复用其证书访问器验签 - visitor := downloader.MgrInstance().GetCertificateVisitor(mchID) - handler, err := notify.NewRSANotifyHandler(apiV3Key, verifiers.NewSHA256WithRSAVerifier(visitor)) + // 验签器按初始化模式选择:公钥模式用微信支付公钥,平台证书模式用下载器维护的证书访问器 + var verifier auth.Verifier + pubKeyID := cfg.Get("pub_key_id") + pubKeyPEM := cfg.Get("pub_key") + if pubKeyID != "" && pubKeyPEM != "" { + pubKey, perr := utils.LoadPublicKey(pubKeyPEM) + if perr != nil { + return nil, fmt.Errorf("解析微信支付公钥失败: %w", perr) + } + verifier = verifiers.NewSHA256WithRSAPubkeyVerifier(pubKeyID, *pubKey) + } else { + visitor := downloader.MgrInstance().GetCertificateVisitor(mchID) + verifier = verifiers.NewSHA256WithRSAVerifier(visitor) + } + handler, err := notify.NewRSANotifyHandler(apiV3Key, verifier) if err != nil { return nil, fmt.Errorf("创建微信通知处理器失败: %w", err) } @@ -284,11 +325,25 @@ func (c *WechatChannel) Refund(ctx context.Context, order *models.PlatformPaymen } func (c *WechatChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) { - client, _, err := c.buildClient(cfg) + client, mchID, err := c.buildClient(cfg) if err != nil { return "", err } - // 平台证书接口只做签名校验,不产生任何交易 + // 公钥模式:/v3/certificates 已对其停用,改查一笔不存在的订单验证鉴权(纯读,不产生交易) + if cfg.Get("pub_key_id") != "" && cfg.Get("pub_key") != "" { + _, qerr := client.Get(ctx, + consts.WechatPayAPIServer+"/v3/pay/transactions/out-trade-no/"+connectTestOutTradeNo+"?mchid="+mchID) + if qerr == nil { + return "连接成功:商户私钥签名与微信支付公钥验签均通过(公钥模式)", nil + } + var apiErr *core.APIError + if errors.As(qerr, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + // 订单不存在属预期结果:说明请求签名与应答验签链路均已通过 + return "连接成功:商户私钥签名与微信支付公钥验签均通过(公钥模式)", nil + } + return "", fmt.Errorf("微信凭证校验失败: %w", qerr) + } + // 平台证书模式:平台证书接口只做签名校验,不产生任何交易 result, err := client.Get(ctx, consts.WechatPayAPIServer+"/v3/certificates") if err != nil { return "", fmt.Errorf("微信凭证校验失败: %w", err) diff --git a/go/services/tenant_quota.go b/go/services/tenant_quota.go new file mode 100644 index 0000000..553eb73 --- /dev/null +++ b/go/services/tenant_quota.go @@ -0,0 +1,337 @@ +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 +} diff --git a/platform/components.d.ts b/platform/components.d.ts index df529bb..c83c562 100644 --- a/platform/components.d.ts +++ b/platform/components.d.ts @@ -24,6 +24,8 @@ declare module 'vue' { ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCol: typeof import('element-plus/es')['ElCol'] + ElCollapse: typeof import('element-plus/es')['ElCollapse'] + ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElContainer: typeof import('element-plus/es')['ElContainer'] @@ -49,6 +51,7 @@ declare module 'vue' { ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] + ElPageHeader: typeof import('element-plus/es')['ElPageHeader'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElProgress: typeof import('element-plus/es')['ElProgress'] ElRadio: typeof import('element-plus/es')['ElRadio'] @@ -65,6 +68,8 @@ declare module 'vue' { ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] + ElTimeline: typeof import('element-plus/es')['ElTimeline'] + ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTree: typeof import('element-plus/es')['ElTree'] ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] diff --git a/platform/src/api/tenantPackage.js b/platform/src/api/tenantPackage.js new file mode 100644 index 0000000..58cee1c --- /dev/null +++ b/platform/src/api/tenantPackage.js @@ -0,0 +1,135 @@ +import request from "@/utils/request"; + +/************************************************* + * 租户套餐(功能开通 + 用户数配额)相关接口 + *************************************************/ + +/** 套餐列表 */ +export function getTenantPackageList(params) { + return request({ + url: "/platform/tenantPackage/list", + method: "get", + params, + }); +} + +/** 套餐下拉(仅启用套餐) */ +export function getTenantPackageSelectList() { + return request({ + url: "/platform/tenantPackage/select/list", + method: "get", + }); +} + +/** 套餐详情(含功能模块) */ +export function getTenantPackageDetail(id) { + return request({ + url: `/platform/tenantPackage/detail/${id}`, + method: "get", + }); +} + +/** 可加入套餐的功能模块清单 */ +export function getTenantPackageModuleOptions() { + return request({ + url: "/platform/tenantPackage/moduleOptions", + method: "get", + }); +} + +/** 新增套餐 */ +export function createTenantPackage(data) { + return request({ + url: "/platform/tenantPackage/create", + method: "post", + data, + }); +} + +/** 编辑套餐 */ +export function updateTenantPackage(id, data) { + return request({ + url: `/platform/tenantPackage/edit/${id}`, + method: "post", + data, + }); +} + +/** 删除套餐 */ +export function deleteTenantPackage(id) { + return request({ + url: `/platform/tenantPackage/delete/${id}`, + method: "delete", + }); +} + +/** 用户数加购规格列表(enabled=1 仅启用) */ +export function getQuotaPackageList(params) { + return request({ + url: "/platform/tenantQuotaPackage/list", + method: "get", + params, + }); +} + +/** 新增加购规格 */ +export function createQuotaPackage(data) { + return request({ + url: "/platform/tenantQuotaPackage/create", + method: "post", + data, + }); +} + +/** 编辑加购规格 */ +export function updateQuotaPackage(id, data) { + return request({ + url: `/platform/tenantQuotaPackage/edit/${id}`, + method: "post", + data, + }); +} + +/** 删除加购规格 */ +export function deleteQuotaPackage(id) { + return request({ + url: `/platform/tenantQuotaPackage/delete/${id}`, + method: "delete", + }); +} + +/** 为租户设置套餐 */ +export function setTenantPackage(data) { + return request({ + url: "/platform/tenant/setPackage", + method: "post", + data, + }); +} + +/** 租户用户数配额信息(含套餐、已用、可加购规格) */ +export function getTenantQuotaInfo(tid) { + return request({ + url: "/platform/tenant/quotaInfo", + method: "get", + params: { tid }, + }); +} + +/** 租户用户数增购(type=1 单个增购 / type=2 按加购规格) */ +export function rechargeTenantQuota(data) { + return request({ + url: "/platform/tenant/rechargeQuota", + method: "post", + data, + }); +} + +/** 租户用户数增购记录 */ +export function getTenantQuotaOrders(tid) { + return request({ + url: "/platform/tenant/quotaOrders", + method: "get", + params: { tid }, + }); +} diff --git a/platform/src/views/basicSettings/tenantpackage/components/edit.vue b/platform/src/views/basicSettings/tenantpackage/components/edit.vue new file mode 100644 index 0000000..e28ad27 --- /dev/null +++ b/platform/src/views/basicSettings/tenantpackage/components/edit.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/platform/src/views/basicSettings/tenantpackage/components/quotaEdit.vue b/platform/src/views/basicSettings/tenantpackage/components/quotaEdit.vue new file mode 100644 index 0000000..5e5ba20 --- /dev/null +++ b/platform/src/views/basicSettings/tenantpackage/components/quotaEdit.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/platform/src/views/basicSettings/tenantpackage/index.vue b/platform/src/views/basicSettings/tenantpackage/index.vue new file mode 100644 index 0000000..10330c3 --- /dev/null +++ b/platform/src/views/basicSettings/tenantpackage/index.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/platform/src/views/basicSettings/tenants/components/TenantPackageTab.vue b/platform/src/views/basicSettings/tenants/components/TenantPackageTab.vue new file mode 100644 index 0000000..3be93c5 --- /dev/null +++ b/platform/src/views/basicSettings/tenants/components/TenantPackageTab.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/platform/src/views/basicSettings/tenants/components/TenantUsersTab.vue b/platform/src/views/basicSettings/tenants/components/TenantUsersTab.vue index fdf16ce..82550b9 100644 --- a/platform/src/views/basicSettings/tenants/components/TenantUsersTab.vue +++ b/platform/src/views/basicSettings/tenants/components/TenantUsersTab.vue @@ -1,7 +1,14 @@ \ No newline at end of file + + + \ No newline at end of file diff --git a/platform/src/views/basicSettings/tenants/index.vue b/platform/src/views/basicSettings/tenants/index.vue index 2a23d37..00cf636 100644 --- a/platform/src/views/basicSettings/tenants/index.vue +++ b/platform/src/views/basicSettings/tenants/index.vue @@ -62,6 +62,13 @@ + + + + + +