From b48688cbabec5cf7be93d01be823bde7ab132acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com> Date: Wed, 16 Sep 2026 17:26:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=B4=AD=E4=B9=B0=E5=92=8C?= =?UTF-8?q?=E5=A5=97=E9=A4=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/components.d.ts | 3 + backend/src/router/index.js | 16 ++ backend/src/views/home/index.vue | 12 +- backend/src/views/package/list.vue | 10 +- backend/src/views/product/index.vue | 3 + go/controllers/backend_product_purchase.go | 34 ++- go/controllers/platform_product.go | 67 +++++- go/controllers/platform_tenant_package.go | 199 +++++++++++++++--- .../sql/create_platform_product_pricing.sql | 7 + go/docs/sql/create_tenant_package.sql | 10 + go/models/platform_product.go | 5 + go/models/system_tenant_package.go | 8 +- go/routers/platform/platform.go | 1 + go/services/tenant_quota.go | 46 +++- platform/src/api/product.js | 8 + .../tenantpackage/components/edit.vue | 26 ++- .../basicSettings/tenantpackage/index.vue | 28 ++- .../tenants/components/TenantPackageTab.vue | 116 +++++++++- .../tenants/components/TenantUsersTab.vue | 13 +- 19 files changed, 554 insertions(+), 58 deletions(-) diff --git a/backend/components.d.ts b/backend/components.d.ts index 1e9bdd8..b2c4634 100644 --- a/backend/components.d.ts +++ b/backend/components.d.ts @@ -18,6 +18,7 @@ declare module 'vue' { ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElBacktop: typeof import('element-plus/es')['ElBacktop'] + ElBadge: typeof import('element-plus/es')['ElBadge'] ElButton: typeof import('element-plus/es')['ElButton'] ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup'] ElCard: typeof import('element-plus/es')['ElCard'] @@ -28,6 +29,7 @@ declare module 'vue' { ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition'] + ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] @@ -52,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/router/index.js b/backend/src/router/index.js index 5d0e4a3..b310339 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -231,6 +231,22 @@ export async function loadAndAddDynamicRoutes() { return routesLoadingPromise; } +/** + * 重新拉取菜单并重建动态路由。 + * 动态路由在构建时会把「未开通模块」的菜单注册为锁定占位页(views/locked), + * 仅调用 menuStore.refreshMenus() 只更新菜单数据、不会重建路由, + * 因此支付开通/套餐变更后必须调用本方法,才能让占位页切换为真实页面。 + */ +export async function reloadMenusAndDynamicRoutes() { + const { useMenuStore } = await import("@/stores/menu"); + const menuStore = useMenuStore(); + // 强制从接口拉取最新菜单(含 locked 标记),写入 store 与缓存 + await menuStore.refreshMenus(); + // 重置加载标志后重建路由表 + resetDynamicRoutes(); + await loadAndAddDynamicRoutes(); +} + // 核心修改:移除扁平化,直接使用嵌套菜单生成路由 function addDynamicRoutes(menus) { if (!menus?.length) { diff --git a/backend/src/views/home/index.vue b/backend/src/views/home/index.vue index ba72cb1..6806c1e 100644 --- a/backend/src/views/home/index.vue +++ b/backend/src/views/home/index.vue @@ -232,6 +232,7 @@ import { import { getTenantList } from "@/api/modules"; import { getProductOrderStatus } from "@/api/product"; import { getCurrentUserProfile, logout } from "@/api/login"; +import { reloadMenusAndDynamicRoutes } from "@/router"; import { useAuthStore } from "@/stores/auth"; import { useMenuStore } from "@/stores/menu"; @@ -372,7 +373,8 @@ function handleBuyProduct(module: ModuleItem) { async function handleRefreshMenus() { refreshLoading.value = true; try { - await menuStore.refreshMenus(); + // 仅刷新菜单数据不会替换动态路由里的锁定占位页,需一并重建路由 + await Promise.all([reloadMenusAndDynamicRoutes(), loadModules()]); ElMessage.success("菜单已刷新"); } catch { ElMessage.error("刷新菜单失败"); @@ -462,8 +464,12 @@ async function syncReturnedPayment() { return; } if (status === "activated") { - // 订单已开通:重新拉取模块与菜单,解锁「请购买后使用」的卡片 - await Promise.all([loadModules(), menuStore.refreshMenus()]); + // 订单已开通:重建动态路由 + 刷新模块,解锁「功能未开通」菜单占位页与「请购买后使用」卡片 + try { + await Promise.all([loadModules(), reloadMenusAndDynamicRoutes()]); + } catch (e) { + console.warn("支付成功后刷新菜单失败:", e); + } ElMessage.success("支付成功,功能已开通"); return; } diff --git a/backend/src/views/package/list.vue b/backend/src/views/package/list.vue index 3d5d650..7999078 100644 --- a/backend/src/views/package/list.vue +++ b/backend/src/views/package/list.vue @@ -79,10 +79,11 @@ type="primary" size="large" :loading="buyingIds[pkg.id]" + :disabled="isPurchasedOnce(pkg)" @click="handleBuy(pkg)" class="buy-btn" > - 立即购买 + {{ isPurchasedOnce(pkg) ? "已购买(限购一次)" : "立即购买" }} @@ -141,6 +142,7 @@ import { ElMessage } from "element-plus"; import { ArrowLeft, OfficeBuilding, Box, User, InfoFilled } from "@element-plus/icons-vue"; import QRCode from "qrcode"; import { useAuthStore } from "@/stores/auth"; +import { reloadMenusAndDynamicRoutes } from "@/router"; import { getPackageList, createPackageOrder, @@ -223,6 +225,10 @@ const renderQR = async () => { } }; +/** 仅可购买一次的套餐(体验套餐)在已购买过之后禁用购买按钮 */ +const isPurchasedOnce = (pkg: any) => + Number(pkg?.allow_repurchase) === 0 && pkg?.purchased === true; + const handleBuy = async (pkg: any) => { buyingIds.value[pkg.id] = true; try { @@ -295,6 +301,8 @@ const checkOrder = async (orderNo: string, silent = false) => { ElMessage.success("购买成功,套餐已开通"); payVisible.value = false; loadMyOrders(); + // 重建动态路由,把新开通模块的「功能未开通」菜单占位页切换为真实页面 + reloadMenusAndDynamicRoutes().catch(() => {}); return; } if (status === "paid") { diff --git a/backend/src/views/product/index.vue b/backend/src/views/product/index.vue index c0b6d82..2ab0da6 100644 --- a/backend/src/views/product/index.vue +++ b/backend/src/views/product/index.vue @@ -145,6 +145,7 @@ import { getPaymentChannels, } from "@/api/product"; import { useAuthStore } from "@/stores/auth"; +import { reloadMenusAndDynamicRoutes } from "@/router"; interface FeatureNode { key: number; @@ -311,6 +312,8 @@ const checkOrder = async (silent = false) => { ElMessage.success("购买成功,功能已开通"); payVisible.value = false; loadMyOrders(); + // 重建动态路由,把刚开通模块的「功能未开通」菜单占位页切换为真实页面 + reloadMenusAndDynamicRoutes().catch(() => {}); return; } if (status === "paid") { diff --git a/go/controllers/backend_product_purchase.go b/go/controllers/backend_product_purchase.go index 8c5352e..d633303 100644 --- a/go/controllers/backend_product_purchase.go +++ b/go/controllers/backend_product_purchase.go @@ -325,7 +325,8 @@ func (c *BackendProductPurchaseController) GetMyOrders() { // GetPackageList GET /backend/package/list // 租户端可购买套餐列表(仅启用的套餐) func (c *BackendProductPurchaseController) GetPackageList() { - if _, err := c.claims(); err != nil { + claims, err := c.claims() + if err != nil { c.jsonErr(401, 401, err.Error()) return } @@ -345,6 +346,22 @@ func (c *BackendProductPurchaseController) GetPackageList() { } moduleMap := loadPackageModules(ids) + // 该租户已购买过(已支付/已开通)的套餐:用于前端对「仅可购买一次」的体验套餐禁用购买按钮 + purchased := map[uint64]bool{} + if tid := uint64(claims.TenantId); tid > 0 && len(ids) > 0 { + var orders []models.PlatformProductOrder + if _, err := models.Orm.QueryTable(new(models.PlatformProductOrder)). + Filter("tid", tid). + Filter("package_id__in", ids). + Filter("status__in", models.ProductOrderStatusPaid, models.ProductOrderStatusActivated). + Filter("delete_time__isnull", true). + All(&orders, "ID", "PackageID"); err == nil { + for _, o := range orders { + purchased[o.PackageID] = true + } + } + } + list := make([]map[string]interface{}, 0, len(rows)) for _, r := range rows { modules := moduleMap[r.ID] @@ -361,6 +378,8 @@ func (c *BackendProductPurchaseController) GetPackageList() { "user_quota": r.UserQuota, "extra_user_price": r.ExtraUserPrice, "is_default": r.IsDefault, + "allow_repurchase": r.AllowRepurchase, + "purchased": purchased[r.ID], "sort": r.Sort, "status": r.Status, "remark": r.Remark, @@ -466,6 +485,19 @@ func (c *BackendProductPurchaseController) CreatePackageOrder() { c.jsonErr(400, 400, "该套餐未配置价格,请联系平台") return } + // 体验套餐(allow_repurchase=0):每个租户仅可购买一次 + if pkg.AllowRepurchase == 0 { + cnt, _ := models.Orm.QueryTable(new(models.PlatformProductOrder)). + Filter("tid", tid). + Filter("package_id", pkg.ID). + Filter("status__in", models.ProductOrderStatusPaid, models.ProductOrderStatusActivated). + Filter("delete_time__isnull", true). + Count() + if cnt > 0 { + c.jsonErr(400, 400, "该套餐为体验套餐,每个租户仅可购买一次") + return + } + } now := time.Now() order := &models.PlatformProductOrder{ diff --git a/go/controllers/platform_product.go b/go/controllers/platform_product.go index 8e3f1b4..17afd85 100644 --- a/go/controllers/platform_product.go +++ b/go/controllers/platform_product.go @@ -403,8 +403,8 @@ func (c *PlatformProductController) ActivateProductOrder() { c.jsonErr(404, 404, "订单不存在") return } - if order.Status == models.ProductOrderStatusActivated { - c.jsonErr(400, 400, "订单已开通") + if order.Status == models.ProductOrderStatusActivated && !services.ProductOrderExpired(&order, time.Now()) { + c.jsonErr(400, 400, "订单已开通且在有效期内") return } if err := activateProductOrder(&order, "平台手动开通"); err != nil { @@ -414,18 +414,73 @@ func (c *PlatformProductController) ActivateProductOrder() { c.jsonOK(nil, "开通成功") } -// activateProductOrder 订单开通:绑定套餐(产品配置了套餐时)并置为已开通 +// CloseProductOrder POST /platform/product/order/close/:id +// 关闭已开通的单品功能:订单置为已关闭,租户端对应模块权益即时回收(菜单/卡片恢复锁定)。 +// 仅允许关闭单品订单(package_id=0);误操作可在购买订单列表重新「手动开通」。 +func (c *PlatformProductController) CloseProductOrder() { + id := parseUint64Param(c.Ctx.Input.Param(":id")) + if id == 0 { + c.jsonErr(400, 400, "参数错误") + return + } + var order models.PlatformProductOrder + if err := models.Orm.QueryTable(new(models.PlatformProductOrder)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(&order); err != nil { + c.jsonErr(404, 404, "订单不存在") + return + } + if order.PackageID > 0 { + c.jsonErr(400, 400, "套餐订单请通过「套餐与用户数」调整套餐") + return + } + if order.Status != models.ProductOrderStatusActivated { + c.jsonErr(400, 400, "仅已开通的订单可关闭功能") + return + } + now := time.Now() + order.Status = models.ProductOrderStatusClosed + order.UpdateTime = &now + order.Remark = strings.TrimSpace(order.Remark + " 平台关闭功能") + if _, err := models.Orm.Update(&order, "Status", "Remark", "UpdateTime"); err != nil { + c.jsonErr(500, 500, "关闭失败:"+err.Error()) + return + } + c.jsonOK(nil, "功能已关闭") +} + +// activateProductOrder 订单开通:绑定套餐(产品配置了套餐时)并置为已开通。 +// 单品功能默认 1 年有效期(ProductOrderDurationDays),到期后租户端权益自动失效,需续费。 func activateProductOrder(order *models.PlatformProductOrder, operator string) error { + now := time.Now() if order.PackageID > 0 && order.Tid > 0 { + // 套餐订单:绑定/续费套餐,有效期由套餐自身到期时间管理 if _, err := services.ApplyPackageToTenant(order.Tid, order.PackageID, true); err != nil { return fmt.Errorf("开通失败:%v", err) } + } else if order.ProductID > 0 { + // 单品功能:有效期精确到日(自然日 0 点),到期日当天 0 点起即失效; + // 未到期续费从原到期日顺延 1 年,已过期重新开通从开通当日 0 点起算 1 年 + base := services.DayStart(now) + if cur := services.ProductOrderExpireAt(order); cur != nil && cur.After(base) { + base = *cur + } + expire := base.AddDate(0, 0, models.ProductOrderDurationDays) + order.ExpireTime = &expire } - now := time.Now() order.Status = models.ProductOrderStatusActivated - order.ActivateTime = &now + if order.ActivateTime == nil { + // 开通时间精确到日(自然日 0 点),与有效期的计算粒度保持一致 + day := services.DayStart(now) + order.ActivateTime = &day + } order.UpdateTime = &now order.Remark = strings.TrimSpace(order.Remark + " " + operator) - _, err := models.Orm.Update(order, "Status", "ActivateTime", "Remark", "UpdateTime") + fields := []string{"Status", "ActivateTime", "Remark", "UpdateTime"} + if order.ExpireTime != nil { + fields = append(fields, "ExpireTime") + } + _, err := models.Orm.Update(order, fields...) return err } diff --git a/go/controllers/platform_tenant_package.go b/go/controllers/platform_tenant_package.go index f100fa5..64fcbb8 100644 --- a/go/controllers/platform_tenant_package.go +++ b/go/controllers/platform_tenant_package.go @@ -103,10 +103,12 @@ type tenantPackagePayload struct { 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"` + // AllowRepurchase 是否允许重复购买:1允许(默认) 0仅可购买一次(体验套餐) + AllowRepurchase *int8 `json:"allow_repurchase"` + Sort *int `json:"sort"` + Status *int8 `json:"status"` + Remark string `json:"remark"` + Modules []string `json:"modules"` } // normalizePackageDuration 套餐有效时长(天)规范化:<=0 时按 365(年付) @@ -193,6 +195,7 @@ func (c *PlatformTenantPackageController) GetPackageList() { "user_quota": r.UserQuota, "extra_user_price": r.ExtraUserPrice, "is_default": r.IsDefault, + "allow_repurchase": r.AllowRepurchase, "sort": r.Sort, "status": r.Status, "remark": r.Remark, @@ -249,6 +252,7 @@ func (c *PlatformTenantPackageController) GetPackageSelectList() { "user_quota": r.UserQuota, "extra_user_price": r.ExtraUserPrice, "is_default": r.IsDefault, + "allow_repurchase": r.AllowRepurchase, "modules": modules, "module_codes": moduleCodes(modules), }) @@ -393,22 +397,27 @@ func (c *PlatformTenantPackageController) CreatePackage() { now := time.Now() row := &models.SystemTenantPackage{ - Name: p.Name, - Code: p.Code, - Description: strings.TrimSpace(p.Description), - Price: valueFloat(p.Price, 0), - DurationDays: normalizePackageDuration(p.DurationDays), - 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, + Name: p.Name, + Code: p.Code, + Description: strings.TrimSpace(p.Description), + Price: valueFloat(p.Price, 0), + DurationDays: normalizePackageDuration(p.DurationDays), + UserQuota: valueInt(p.UserQuota, services.DefaultTenantUserQuota), + ExtraUserPrice: valueFloat(p.ExtraUserPrice, services.DefaultTenantExtraUserPrice), + IsDefault: valueInt8(p.IsDefault, 0), + AllowRepurchase: valueInt8(p.AllowRepurchase, 1), + 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 } + // 体验套餐(不允许重复购买)不涉及用户数增购,增购价恒为 0 + if row.AllowRepurchase == 0 { + row.ExtraUserPrice = 0 + } id, err := models.Orm.Insert(row) if err != nil { c.jsonErr(500, 500, "创建失败:"+err.Error()) @@ -483,6 +492,26 @@ func (c *PlatformTenantPackageController) EditPackage() { update["is_default"] = *p.IsDefault update["status"] = 1 } + if p.AllowRepurchase != nil { + update["allow_repurchase"] = *p.AllowRepurchase + } + + // 体验套餐(不允许重复购买)不涉及用户数增购,增购价恒为 0 + allowRepurchase := int8(1) + if p.AllowRepurchase != nil { + allowRepurchase = *p.AllowRepurchase + } else { + var cur models.SystemTenantPackage + if err := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(&cur); err == nil { + allowRepurchase = cur.AllowRepurchase + } + } + if allowRepurchase == 0 { + update["extra_user_price"] = 0 + } if _, err := models.Orm.QueryTable(new(models.SystemTenantPackage)). Filter("id", id). @@ -512,12 +541,21 @@ func clearOtherDefaultPackages(keepID uint64) { } // DeletePackage DELETE /platform/tenantPackage/delete/:id(软删) +// 注意:基础套餐(code=basic)为系统内置兜底套餐(套餐到期自动降级目标),任何情况下不允许删除。 func (c *PlatformTenantPackageController) DeletePackage() { id := parseUint64Param(c.Ctx.Input.Param(":id")) if id == 0 { c.jsonErr(400, 400, "参数错误") return } + var pkg models.SystemTenantPackage + if err := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(&pkg); err == nil && strings.EqualFold(strings.TrimSpace(pkg.Code), "basic") { + c.jsonErr(400, 400, "基础套餐为系统内置套餐,不允许删除") + return + } // 有租户正在使用该套餐时不允许删除 used, _ := models.Orm.QueryTable(new(models.SystemTenant)).Filter("package_id", id).Count() if used > 0 { @@ -744,23 +782,128 @@ func (c *PlatformTenantPackageController) GetTenantQuotaInfo() { moduleList = append(moduleList, tenantPackageModuleItem{ModuleCode: m.ModuleCode, ModuleName: m.ModuleName}) } + // 已开通单品功能:单品订单不改变租户套餐,需与套餐模块并集展示,避免“买了功能看不到” + productOrders, productModules := loadTenantProductEntitlements(tid) + c.jsonOK(map[string]interface{}{ - "tid": info.Tid, - "package_id": info.PackageID, + "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, - "duration_days": info.DurationDays, - "package_expire_time": info.PackageExpireTime, - "days_remaining": info.DaysRemaining, - "quota_packages": quotaPackages, + "package_name": info.PackageName, + "modules": moduleList, + "product_modules": productModules, + "product_orders": productOrders, + "quota": info.Quota, + "used": info.Used, + "remaining": info.Remaining, + "extra_user_price": info.ExtraUserPrice, + "duration_days": info.DurationDays, + "package_expire_time": info.PackageExpireTime, + "days_remaining": info.DaysRemaining, + "quota_packages": quotaPackages, }, "获取成功") } +// productEntitlement 已开通单品功能明细(租户详情「套餐与用户数」展示 / 关闭用) +type productEntitlement struct { + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + ProductID uint64 `json:"product_id"` + ProductName string `json:"product_name"` + ModuleCode string `json:"module_code"` + ModuleName string `json:"module_name"` + Amount float64 `json:"amount"` + ActivateTime *time.Time `json:"activate_time"` + ExpireTime *time.Time `json:"expire_time"` + // Expired 是否已过有效期:到期后租户端权益自动失效,需续费 + Expired bool `json:"expired"` +} + +// loadTenantProductEntitlements 加载租户已开通的单品功能: +// - orders 订单明细(按开通时间倒序),供面板展示与「关闭功能」操作; +// - modules 去重后的模块列表,与套餐模块并集构成租户完整功能权益。 +func loadTenantProductEntitlements(tid uint64) ([]productEntitlement, []tenantPackageModuleItem) { + orders := make([]productEntitlement, 0) + modules := make([]tenantPackageModuleItem, 0) + if tid == 0 { + return orders, modules + } + + var rows []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). + OrderBy("-id"). + All(&rows); err != nil || len(rows) == 0 { + return orders, modules + } + + productIDs := make([]uint64, 0, len(rows)) + for _, r := range rows { + productIDs = append(productIDs, r.ProductID) + } + var products []models.PlatformProduct + _, _ = models.Orm.QueryTable(new(models.PlatformProduct)). + Filter("id__in", productIDs). + All(&products) + productMap := make(map[uint64]models.PlatformProduct, len(products)) + for _, p := range products { + productMap[p.ID] = p + } + + // 模块 code -> 名称(大小写/空格不敏感,与租户端权益判定保持一致) + var moduleRows []models.SystemModules + _, _ = models.Orm.QueryTable(new(models.SystemModules)). + Filter("delete_time__isnull", true). + All(&moduleRows) + moduleNameMap := make(map[string]string, len(moduleRows)) + for _, m := range moduleRows { + moduleNameMap[services.NormalizeModuleCode(m.Code)] = m.Name + } + + now := time.Now() + seen := map[string]bool{} + for _, r := range rows { + code, name := "", "" + if p, ok := productMap[r.ProductID]; ok { + code = strings.TrimSpace(p.ModuleCode) + if code == "" { + // 产品未显式关联模块时按产品编码兜底(与 GetTenantModuleCodes 一致) + code = strings.TrimSpace(p.Code) + } + name = p.Name + } + normCode := services.NormalizeModuleCode(code) + expire := services.ProductOrderExpireAt(&r) + expired := services.ProductOrderExpired(&r, now) + // 权益模块只统计仍在有效期内的订单,与租户端 GetTenantModuleCodes 判定保持一致 + if !expired && normCode != "" && !seen[normCode] { + seen[normCode] = true + moduleName := moduleNameMap[normCode] + if moduleName == "" { + moduleName = name + } + modules = append(modules, tenantPackageModuleItem{ModuleCode: code, ModuleName: moduleName}) + } + // 明细保留全部已开通订单(含已过期):便于平台看到「已过期,待续费并关闭」 + orders = append(orders, productEntitlement{ + OrderID: r.ID, + OrderNo: r.OrderNo, + ProductID: r.ProductID, + ProductName: name, + ModuleCode: code, + ModuleName: moduleNameMap[normCode], + Amount: r.Amount, + ActivateTime: r.ActivateTime, + ExpireTime: expire, + Expired: expired, + }) + } + return orders, modules +} + type rechargeQuotaPayload struct { Tid uint64 `json:"tid"` Type int8 `json:"type"` // 1 单个增购 2 套餐增购 diff --git a/go/docs/sql/create_platform_product_pricing.sql b/go/docs/sql/create_platform_product_pricing.sql index cd8ec19..13d8b09 100644 --- a/go/docs/sql/create_platform_product_pricing.sql +++ b/go/docs/sql/create_platform_product_pricing.sql @@ -60,6 +60,7 @@ CREATE TABLE IF NOT EXISTS `yz_platform_product_order` ( `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '支付渠道:wechat/alipay/... 空=线下', `status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending待支付 paid已支付 activated已开通 closed已关闭', `activate_time` datetime DEFAULT NULL COMMENT '开通时间', + `expire_time` datetime DEFAULT NULL COMMENT '有效期截止时间(单品功能默认开通后 1 年,到期权益自动失效,需续费)', `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT NULL, @@ -82,6 +83,12 @@ SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_product_feature` ADD COLUMN `pid` bigint(20) NOT NULL DEFAULT 0 COMMENT ''上级节点ID,0=顶级节点'' AFTER `product_id`', 'DO 0'); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +-- 购买订单有效期字段(历史库补齐;单品功能默认开通后 1 年有效) +SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product_order'); +SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product_order' AND COLUMN_NAME = 'expire_time'); +SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_product_order` ADD COLUMN `expire_time` datetime DEFAULT NULL COMMENT ''有效期截止时间(单品功能默认开通后 1 年,到期权益自动失效,需续费)'' AFTER `activate_time`', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + -- 5) 平台端菜单:基础设置 → 产品定价(仅平台端可见) -- pid=17 为「基础设置」菜单;若环境中 17 不是基础设置,请在平台端「菜单管理」里手工挂载本页面: -- 组件路径 /basicSettings/pricing/index.vue,路径 /basicSettings/pricing diff --git a/go/docs/sql/create_tenant_package.sql b/go/docs/sql/create_tenant_package.sql index 8093fb8..4428f5d 100644 --- a/go/docs/sql/create_tenant_package.sql +++ b/go/docs/sql/create_tenant_package.sql @@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS `yz_system_tenant_package` ( `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否(新建租户未指定套餐时使用)', + `allow_repurchase` tinyint(4) NOT NULL DEFAULT 1 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 '备注', @@ -83,6 +84,15 @@ CREATE TABLE IF NOT EXISTS `yz_system_tenant_quota_order` ( KEY `idx_tid` (`tid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户用户数增购记录表'; +-- 4.5) 历史库补列:套餐是否允许重复购买(0=仅可购买一次,适用于体验套餐) +SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_package'); +SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_package' AND COLUMN_NAME = 'allow_repurchase'); +SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant_package` ADD COLUMN `allow_repurchase` tinyint(4) NOT NULL DEFAULT 1 COMMENT ''是否允许重复购买:1允许(默认) 0仅可购买一次(体验套餐)'' AFTER `is_default`', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 体验套餐(不允许重复购买)不涉及用户数增购:清理历史增购价 +UPDATE `yz_system_tenant_package` SET `extra_user_price` = 0 WHERE `allow_repurchase` = 0; + -- 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'); diff --git a/go/models/platform_product.go b/go/models/platform_product.go index 5911a2c..ad08dd4 100644 --- a/go/models/platform_product.go +++ b/go/models/platform_product.go @@ -45,6 +45,10 @@ const ( ProductOrderStatusClosed = "closed" // 已关闭 ) +// ProductOrderDurationDays 单品功能默认有效期(天):1 年。 +// 到期后租户端权益自动失效(GetTenantModuleCodes 按有效期过滤),需重新购买续费。 +const ProductOrderDurationDays = 365 + // PlatformProductOrder 产品购买订单 yz_platform_product_order type PlatformProductOrder struct { ID uint64 `orm:"column(id);pk;auto" json:"id"` @@ -60,6 +64,7 @@ type PlatformProductOrder struct { Channel string `orm:"column(channel);size(32)" json:"channel"` Status string `orm:"column(status);size(20);default(pending)" json:"status"` ActivateTime *time.Time `orm:"column(activate_time);type(datetime);null" json:"activate_time"` + ExpireTime *time.Time `orm:"column(expire_time);type(datetime);null" json:"expire_time"` 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"` diff --git a/go/models/system_tenant_package.go b/go/models/system_tenant_package.go index 19d0e49..6da4bad 100644 --- a/go/models/system_tenant_package.go +++ b/go/models/system_tenant_package.go @@ -14,9 +14,11 @@ type SystemTenantPackage struct { 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"` + // AllowRepurchase 是否允许重复购买:1允许(默认) 0仅可购买一次(体验套餐场景) + AllowRepurchase int8 `orm:"column(allow_repurchase);default(1)" json:"allow_repurchase"` + 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"` diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index 90575d0..83e9474 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -80,6 +80,7 @@ func Register() { beego.Router("/platform/product/delete/:id", &controllers.PlatformProductController{}, "delete:DeleteProduct") beego.Router("/platform/product/orders", &controllers.PlatformProductController{}, "get:GetProductOrders") beego.Router("/platform/product/order/activate/:id", &controllers.PlatformProductController{}, "post:ActivateProductOrder") + beego.Router("/platform/product/order/close/:id", &controllers.PlatformProductController{}, "post:CloseProductOrder") // 用户数加购规格(yz_system_tenant_user_quota_package) beego.Router("/platform/tenantQuotaPackage/list", &controllers.PlatformTenantPackageController{}, "get:GetQuotaPackageList") diff --git a/go/services/tenant_quota.go b/go/services/tenant_quota.go index 61569ac..306b08e 100644 --- a/go/services/tenant_quota.go +++ b/go/services/tenant_quota.go @@ -151,6 +151,37 @@ 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)。 // // 权益由两部分组成: @@ -189,8 +220,21 @@ func GetTenantModuleCodes(tid uint64) map[string]bool { return codes } - productIDs := make([]uint64, 0, len(orders)) + // 只统计仍在有效期内的订单:到期后权益自动失效,需重新购买续费 + 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 diff --git a/platform/src/api/product.js b/platform/src/api/product.js index 998098b..fbdba95 100644 --- a/platform/src/api/product.js +++ b/platform/src/api/product.js @@ -63,3 +63,11 @@ export function activateProductOrder(id) { method: "post", }); } + +/** 关闭已开通的单品功能(回收模块权益,可在购买订单中重新开通) */ +export function closeProductOrder(id) { + return request({ + url: `/platform/product/order/close/${id}`, + method: "post", + }); +} diff --git a/platform/src/views/basicSettings/tenantpackage/components/edit.vue b/platform/src/views/basicSettings/tenantpackage/components/edit.vue index 59a7cec..d125c59 100644 --- a/platform/src/views/basicSettings/tenantpackage/components/edit.vue +++ b/platform/src/views/basicSettings/tenantpackage/components/edit.vue @@ -57,9 +57,16 @@ :min="0" :max="999999" :precision="2" + :disabled="Number(form.allow_repurchase) === 0" controls-position="right" /> - 元/人,单个增购用户数时的默认单价(如 200) + + {{ + Number(form.allow_repurchase) === 0 + ? "体验套餐(仅可购买一次)不涉及用户数增购,无需设置" + : "元/人,单个增购用户数时的默认单价(如 200)" + }} + @@ -103,6 +110,11 @@ 新建租户未指定套餐时默认使用 + + + 关闭后每个租户仅可购买一次(体验套餐),已购买过则无法再次下单 + + 启用 @@ -161,6 +173,7 @@ const emptyForm = () => ({ user_quota: 20, extra_user_price: 200, is_default: 0, + allow_repurchase: 1, sort: 0, status: 1, remark: "", @@ -206,6 +219,16 @@ const handleCheckAllModules = (checked: boolean) => { form.value.modules = checked ? moduleOptions.value.map((m) => m.code) : []; }; +// 体验套餐(不允许重复购买)不涉及用户数增购:增购价自动清零 +watch( + () => form.value.allow_repurchase, + (val) => { + if (Number(val) === 0) { + form.value.extra_user_price = 0; + } + } +); + const loadModuleOptions = async () => { try { const res = await getTenantPackageModuleOptions(); @@ -232,6 +255,7 @@ const loadDetail = async (id: number) => { user_quota: Number(d.user_quota || 20), extra_user_price: Number(d.extra_user_price || 0), is_default: Number(d.is_default || 0), + allow_repurchase: Number(d.allow_repurchase ?? 1), sort: Number(d.sort || 0), status: Number(d.status ?? 1), remark: d.remark || "", diff --git a/platform/src/views/basicSettings/tenantpackage/index.vue b/platform/src/views/basicSettings/tenantpackage/index.vue index 177479e..9774bd4 100644 --- a/platform/src/views/basicSettings/tenantpackage/index.vue +++ b/platform/src/views/basicSettings/tenantpackage/index.vue @@ -20,13 +20,13 @@ - +
套餐购买链接 @@ -88,6 +88,17 @@ + + + @@ -271,6 +289,10 @@ const handleEditPackage = (row: any) => { packageDialogVisible.value = true; }; +/** 基础套餐(code=basic)为系统内置兜底套餐(套餐到期自动降级目标),不允许删除 */ +const isBasicPackage = (row: any) => + String(row?.code || "").trim().toLowerCase() === "basic"; + const handleDeletePackage = async (row: any) => { try { await ElMessageBox.confirm( diff --git a/platform/src/views/basicSettings/tenants/components/TenantPackageTab.vue b/platform/src/views/basicSettings/tenants/components/TenantPackageTab.vue index 7dc9650..f8337cf 100644 --- a/platform/src/views/basicSettings/tenants/components/TenantPackageTab.vue +++ b/platform/src/views/basicSettings/tenants/components/TenantPackageTab.vue @@ -1,5 +1,11 @@