From 60b2658d4b3e95290820f98131e98119f80f4df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Wed, 16 Sep 2026 00:22:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8A=9F=E8=83=BD=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package-lock.json | 300 +++++++++ backend/package.json | 1 + backend/src/api/product.js | 56 ++ backend/src/components/CommonAside.vue | 27 + backend/src/router/dynamicRoutes.js | 31 + backend/src/router/index.js | 7 + backend/src/views/home/index.vue | 224 ++++++- backend/src/views/locked/index.vue | 109 ++++ backend/src/views/product/index.vue | 585 ++++++++++++++++++ go/controllers/backend_menu.go | 44 +- go/controllers/backend_modules.go | 41 +- go/controllers/backend_product_purchase.go | 312 ++++++++++ go/controllers/backend_tenant_quota.go | 21 +- go/controllers/platform_product.go | 431 +++++++++++++ go/controllers/platform_tenant.go | 76 ++- go/controllers/platform_tenant_package.go | 39 ++ go/docs/sql/alter_tenant_package_billing.sql | 14 + .../sql/create_platform_product_pricing.sql | 91 +++ go/models/init.go | 4 + go/models/platform_product.go | 69 +++ go/models/system_tenant.go | 2 + go/models/system_tenant_package.go | 3 +- go/routers/backend/backend.go | 10 + go/routers/platform/platform.go | 10 + go/services/tenant_quota.go | 208 ++++++- platform/src/api/product.js | 65 ++ platform/src/api/tenantPackage.js | 17 + .../basicSettings/pricing/components/edit.vue | 366 +++++++++++ .../src/views/basicSettings/pricing/index.vue | 328 ++++++++++ .../tenantpackage/components/edit.vue | 25 +- .../basicSettings/tenantpackage/index.vue | 88 ++- .../tenants/components/TenantPackageTab.vue | 51 +- 32 files changed, 3578 insertions(+), 77 deletions(-) create mode 100644 backend/src/api/product.js create mode 100644 backend/src/views/locked/index.vue create mode 100644 backend/src/views/product/index.vue create mode 100644 go/controllers/backend_product_purchase.go create mode 100644 go/controllers/platform_product.go create mode 100644 go/docs/sql/alter_tenant_package_billing.sql create mode 100644 go/docs/sql/create_platform_product_pricing.sql create mode 100644 go/models/platform_product.go create mode 100644 platform/src/api/product.js create mode 100644 platform/src/views/basicSettings/pricing/components/edit.vue create mode 100644 platform/src/views/basicSettings/pricing/index.vue diff --git a/backend/package-lock.json b/backend/package-lock.json index 6d58ff6..07a003b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "pdfjs-dist": "^6.2.108", "pinia": "^3.0.3", "pinyin-pro": "^3.29.4", + "qrcode": "^1.5.4", "tesseract.js": "^7.0.0", "v-viewer": "^3.0.11", "vue": "^3.5.22", @@ -3306,6 +3307,42 @@ "node": ">=0.8" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-styles/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", @@ -3495,6 +3532,15 @@ "tslib": "^2.0.3" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/canvg": { "version": "3.0.11", "resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz", @@ -3578,6 +3624,17 @@ "node": ">= 10.0" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz", @@ -4289,6 +4346,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/default-passive-events": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/default-passive-events/-/default-passive-events-4.0.0.tgz", @@ -4369,6 +4435,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/docx-preview": { "version": "0.3.7", "resolved": "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.3.7.tgz", @@ -4519,6 +4591,12 @@ "vue": "^3.3.0" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", @@ -4859,6 +4937,19 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -4981,6 +5072,15 @@ "node": ">= 0.4" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5519,6 +5619,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -5938,6 +6047,18 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", @@ -6427,6 +6548,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-manager-detector": { "version": "1.7.0", "resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.7.0.tgz", @@ -6474,6 +6631,15 @@ "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", @@ -6587,6 +6753,15 @@ "url-polyfill": "^1.1.13" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -6952,6 +7127,23 @@ "integrity": "sha512-uxq2BMTdnKW7jDghpLJrczCd9KDOdyghFtEEpfomqMJkUM83/N+W7sFJPJ3AxBf0mJ3xtxAycl6NW8p6F53jEw==", "license": "ISC" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", @@ -7091,6 +7283,21 @@ "node": ">= 0.10" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", @@ -7317,6 +7524,12 @@ "semver": "bin/semver" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7552,6 +7765,20 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmmirror.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -7608,6 +7835,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", @@ -8406,6 +8645,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -8445,6 +8690,20 @@ "node": ">=0.8" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/xlsx": { "version": "0.18.5", "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz", @@ -8524,6 +8783,47 @@ "yjs": "^13.0.0" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/yjs": { "version": "13.6.29", "resolved": "https://registry.npmmirror.com/yjs/-/yjs-13.6.29.tgz", diff --git a/backend/package.json b/backend/package.json index f1aaffd..46de7bd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "pdfjs-dist": "^6.2.108", "pinia": "^3.0.3", "pinyin-pro": "^3.29.4", + "qrcode": "^1.5.4", "tesseract.js": "^7.0.0", "v-viewer": "^3.0.11", "vue": "^3.5.22", diff --git a/backend/src/api/product.js b/backend/src/api/product.js new file mode 100644 index 0000000..b3740b8 --- /dev/null +++ b/backend/src/api/product.js @@ -0,0 +1,56 @@ +import request from "@/utils/request"; + +/************************************************* + * 租户端「产品购买」相关接口 + *************************************************/ + +/** 可购买产品列表(含功能节点与价格档位) */ +export function getProductList(params) { + return request({ + url: "/backend/product/list", + method: "get", + params, + }); +} + +/** 按产品编码取详情 */ +export function getProductDetailByCode(code) { + return request({ + url: `/backend/product/detail/${code}`, + method: "get", + }); +} + +/** 下单(channel 为空表示线下/待平台开通) */ +export function createProductOrder(data) { + return request({ + url: "/backend/product/order/create", + method: "post", + data, + }); +} + +/** 查询订单状态(已支付会自动开通) */ +export function getProductOrderStatus(orderNo) { + return request({ + url: "/backend/product/order/status", + method: "get", + params: { order_no: orderNo }, + }); +} + +/** 本租户购买记录 */ +export function getMyProductOrders() { + return request({ + url: "/backend/product/order/list", + method: "get", + }); +} + +/** 收银台可用支付渠道 */ +export function getPaymentChannels() { + return request({ + url: "/backend/payment/channels", + method: "get", + }); +} diff --git a/backend/src/components/CommonAside.vue b/backend/src/components/CommonAside.vue index b895a77..978745d 100644 --- a/backend/src/components/CommonAside.vue +++ b/backend/src/components/CommonAside.vue @@ -41,10 +41,12 @@ @@ -53,10 +55,12 @@ v-else :index="item.path || item.id.toString()" :unique-opened="true" + :class="{ 'is-locked': item.locked }" > @@ -64,10 +68,12 @@ @@ -75,10 +81,12 @@ v-else :index="child.path || child.id.toString()" :unique-opened="true" + :class="{ 'is-locked': child.locked }" > @@ -91,6 +99,7 @@ !grandchild.children || grandchild.children.length === 0 " :index="grandchild.path || grandchild.id.toString()" + :class="{ 'is-locked': grandchild.locked }" > @@ -106,6 +116,7 @@ v-else :index="grandchild.path || grandchild.id.toString()" :unique-opened="true" + :class="{ 'is-locked': grandchild.locked }" > @@ -125,6 +137,7 @@ :index=" greatGrandchild.path || greatGrandchild.id.toString() " + :class="{ 'is-locked': greatGrandchild.locked }" > @@ -283,6 +297,7 @@ const processMenus = (menus) => { title: menu.title, route: menu.path, component_path: menu.component_path, + locked: !!menu.locked, parentId: menu.pid || 0, order: menu.sort || 0, children: menu.children ? processMenus(menu.children) : [], @@ -608,6 +623,18 @@ h3 { padding-left: 48px !important; } + // 套餐未开通模块:锁定态置灰 + 锁图标 + .el-menu-item.is-locked, + .el-sub-menu.is-locked > .el-sub-menu__title { + opacity: 0.65; + + .lock-badge { + margin-left: 6px; + font-size: 12px; + color: rgba(255, 255, 255, 0.75); + } + } + // 暗色主题适配 html.dark & { .el-menu-item.is-active { diff --git a/backend/src/router/dynamicRoutes.js b/backend/src/router/dynamicRoutes.js index 24c0c48..fc82a52 100644 --- a/backend/src/router/dynamicRoutes.js +++ b/backend/src/router/dynamicRoutes.js @@ -38,6 +38,9 @@ function resolvePageComponent(menu) { return () => import('@/views/404/404.vue'); } +/** 套餐未开通模块的锁定占位页组件 */ +const lockedPageComponent = () => import('@/views/locked/index.vue'); + /** * 菜单子节点 -> 嵌套路由(path 相对 layoutAbsPath) */ @@ -59,6 +62,20 @@ function nestedMenuToRoute(menu, layoutAbsPath) { componentPath: menu.component_path, }; + // 套餐未开通模块:渲染锁定占位页(子路由同样注册,保证深链接不 404) + if (menu.locked) { + const route = { + path: relPath, + name: `menu_${menu.id}`, + meta, + component: lockedPageComponent, + }; + if (hasChildren) { + route.children = convertNestedMenuChildren(menu.children, childAbs); + } + return route; + } + // 既有自己的页面又有子菜单:套一层 EmptyLayout,避免父页面组件里没有 导致子路由无法渲染 if (hasChildren && ownPage) { return { @@ -128,6 +145,20 @@ export function convertMenusToRoutes(menus, parentPath = '') { componentPath: menu.component_path, }; + // 套餐未开通模块:渲染锁定占位页(子路由同样注册,保证深链接不 404) + if (menu.locked) { + const route = { + path: fullPath || menu.path || '', + name: `menu_${menu.id}`, + meta, + component: lockedPageComponent, + }; + if (hasChildren) { + route.children = convertMenusToRoutes(menu.children, fullPath); + } + return route; + } + // 顶层:有页面 + 有子菜单 -> EmptyLayout + 默认子路由 + 相对 path 子路由 if (hasChildren && ownPage) { return { diff --git a/backend/src/router/index.js b/backend/src/router/index.js index b41817f..a94d8c3 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -157,6 +157,13 @@ const staticRoutes = [ path: "/apps/erp/dashborad", redirect: "/apps/erp/dashboard" }, + // 产品购买页:home 未开通卡片「购买」按钮跳转,独立于 Main 框架展示 + { + path: "/product/:code", + name: "ProductPurchase", + component: () => import("@/views/product/index.vue"), + meta: { requiresAuth: true, title: "产品购买", isStandalone: true } + }, { path: "/:pathMatch(.*)*", name: "NotFound", diff --git a/backend/src/views/home/index.vue b/backend/src/views/home/index.vue index 0fca01d..f584796 100644 --- a/backend/src/views/home/index.vue +++ b/backend/src/views/home/index.vue @@ -71,16 +71,24 @@ v-for="module in basicModules" :key="module.path" class="module-card" + :class="{ 'is-locked': module.locked }" @click="handleNavigate(module)" >
- {{ module.name }} + + {{ module.name }} + +

{{ module.description || "暂无描述" }}

+
+ 套餐未开通 + 购买 +
@@ -93,16 +101,24 @@ v-for="module in systemModules" :key="module.path" class="module-card" + :class="{ 'is-locked': module.locked }" @click="handleNavigate(module)" >
- {{ module.name }} + + {{ module.name }} + +

{{ module.description || "暂无描述" }}

+
+ 套餐未开通 + 购买 +
@@ -115,16 +131,24 @@ v-for="module in uncategorizedModules" :key="module.path" class="module-card" + :class="{ 'is-locked': module.locked }" @click="handleNavigate(module)" >
- {{ module.name }} + + {{ module.name }} + +

{{ module.description || "暂无描述" }}

+
+ 套餐未开通 + 购买 +
@@ -146,7 +170,7 @@ + + diff --git a/backend/src/views/product/index.vue b/backend/src/views/product/index.vue new file mode 100644 index 0000000..d7ada1f --- /dev/null +++ b/backend/src/views/product/index.vue @@ -0,0 +1,585 @@ + + + + + diff --git a/go/controllers/backend_menu.go b/go/controllers/backend_menu.go index c5a4c90..fbe912a 100644 --- a/go/controllers/backend_menu.go +++ b/go/controllers/backend_menu.go @@ -113,10 +113,12 @@ func (c *BackendMenuController) GetBackendMenu() { } } - // 套餐功能开通过滤:仅展示租户套餐已包含的功能模块对应的菜单(未包含的整块菜单不展示) - backendMenus = filterMenusByTenantPackage(c.Ctx.Request.Header.Get("Authorization"), backendMenus, tid) + // 套餐功能开通标记:未开通模块的菜单保留但标记 locked(前端展示锁定态与升级提示,不再 404) + backendMenus, lockedMenuIDs := filterMenusByTenantPackageLocked(c.Ctx.Request.Header.Get("Authorization"), backendMenus, tid, true) + menuTree := buildMenuTree(backendMenus, 0) + markTreeLocked(menuTree, lockedMenuIDs) - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(backendMenus, 0)} + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": menuTree} _ = c.ServeJSON() } @@ -139,26 +141,51 @@ func backendJWTTenantID(auth string) uint64 { return uint64(claims.TenantId) } -// filterMenusByTenantPackage 按租户套餐包含的功能模块过滤菜单。 +// filterMenusByTenantPackage 按租户套餐包含的功能模块过滤菜单(剔除未开通模块,用于角色菜单分配等场景)。 // tid 优先取 token 中的租户ID,其次用调用方传入的兜底值;两者都取不到时不处理(保持原行为)。 func filterMenusByTenantPackage(authHeader string, menus []models.SystemMenu, tid uint64) []models.SystemMenu { + filtered, _ := filterMenusByTenantPackageLocked(authHeader, menus, tid, false) + return filtered +} + +// filterMenusByTenantPackageLocked 按租户套餐处理菜单。 +// markLocked=false:剔除未开通模块的菜单(原行为);markLocked=true:保留菜单并返回锁定菜单ID集合(含后代)。 +func filterMenusByTenantPackageLocked(authHeader string, menus []models.SystemMenu, tid uint64, markLocked bool) ([]models.SystemMenu, map[uint64]bool) { if jwtTid := backendJWTTenantID(authHeader); jwtTid > 0 { tid = jwtTid } if tid == 0 { - return menus + return menus, nil } allModules, err := services.ListEnabledModules() if err != nil || len(allModules) == 0 { - return menus + return menus, nil } allowed := services.GetTenantModuleCodes(tid) // 安全兜底:租户没有绑定套餐或套餐未配置任何功能模块时不做过滤, // 避免因套餐数据缺失导致租户端菜单整体消失。 if len(allowed) == 0 { - return menus + return menus, nil + } + if markLocked { + return services.MarkMenusLockedByTenantModules(menus, allModules, allowed) + } + return services.FilterMenusByTenantModules(menus, allModules, allowed), nil +} + +// markTreeLocked 将菜单树中位于锁定集合的节点标记为 locked +func markTreeLocked(nodes []*menuNode, locked map[uint64]bool) { + if len(nodes) == 0 || len(locked) == 0 { + return + } + for _, n := range nodes { + if locked[n.ID] { + n.Locked = true + } + if len(n.Children) > 0 { + markTreeLocked(n.Children, locked) + } } - return services.FilterMenusByTenantModules(menus, allModules, allowed) } // parseRightsToSet 将角色 rights 解析为菜单 ID 集合(兼容 JSON 数组 / 逗号分隔) @@ -350,6 +377,7 @@ type menuNode struct { Views []int `json:"views,omitempty"` Type int8 `json:"type"` Permission string `json:"permission,omitempty"` + Locked bool `json:"locked,omitempty"` Children []*menuNode `json:"children,omitempty"` } diff --git a/go/controllers/backend_modules.go b/go/controllers/backend_modules.go index d2d7e59..c0c9813 100644 --- a/go/controllers/backend_modules.go +++ b/go/controllers/backend_modules.go @@ -6,10 +6,18 @@ import ( "server/models" "server/pkg/jwtutil" + "server/services" beego "github.com/beego/beego/v2/server/web" ) +// tenantModuleItem 模块列表返回项:在模块基础信息上补充套餐开通状态, +// locked=true 表示该模块不在当前租户已购套餐内(前端置灰并提示购买套餐)。 +type tenantModuleItem struct { + models.SystemModules + Locked bool `json:"locked"` +} + // BackendModulesController backend 模块接口(yz_system_modules) type BackendModulesController struct { beego.Controller @@ -41,14 +49,16 @@ func (c *BackendModulesController) jsonErr(httpStatus, bizCode int, msg string) } // GetTenantList GET /backend/modules/getTenantList -// 返回当前 backend 账号可见的模块。当前实现:返回 status=1 且 is_show=1 的全部模块。 +// 返回当前 backend 账号可见的模块(status=1 且 is_show=1)。 +// 同时按当前租户已购套餐标记 locked:未开通的功能模块前端置灰、禁止跳转并提示购买套餐。 func (c *BackendModulesController) GetTenantList() { - if _, err := c.backendModulesClaims(); err != nil { + claims, err := c.backendModulesClaims() + if err != nil { c.jsonErr(401, 401, err.Error()) return } var rows []models.SystemModules - _, err := models.Orm.QueryTable(new(models.SystemModules)). + _, err = models.Orm.QueryTable(new(models.SystemModules)). Filter("delete_time__isnull", true). Filter("status", 1). Filter("is_show", 1). @@ -58,12 +68,33 @@ func (c *BackendModulesController) GetTenantList() { c.jsonErr(500, 500, "获取失败:"+err.Error()) return } + + // 租户已开通的模块编码集合(来自租户绑定/生效套餐) + allowed := map[string]bool{} + packageName := "" + if tid := uint64(claims.TenantId); tid > 0 { + allowed = services.GetTenantModuleCodes(tid) + if pkg, e := services.GetTenantPackage(tid); e == nil && pkg != nil { + packageName = pkg.Name + } + } + + list := make([]tenantModuleItem, 0, len(rows)) + for _, m := range rows { + // 安全兜底:租户未绑定套餐或套餐未配置任何功能模块时不做锁定, + // 避免套餐数据缺失导致全部功能不可用。 + locked := len(allowed) > 0 && !allowed[strings.TrimSpace(m.Code)] + list = append(list, tenantModuleItem{SystemModules: m, Locked: locked}) + } + c.Data["json"] = map[string]interface{}{ "code": 200, "msg": "获取成功", "data": map[string]interface{}{ - "list": rows, - "total": len(rows), + "list": list, + "total": len(list), + "package_name": packageName, + "purchase_url": services.GetPackagePurchaseURL(), }, } _ = c.ServeJSON() diff --git a/go/controllers/backend_product_purchase.go b/go/controllers/backend_product_purchase.go new file mode 100644 index 0000000..268b4f0 --- /dev/null +++ b/go/controllers/backend_product_purchase.go @@ -0,0 +1,312 @@ +package controllers + +import ( + "context" + "encoding/json" + "errors" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services/payment" + + beego "github.com/beego/beego/v2/server/web" +) + +// BackendProductPurchaseController 租户端「产品购买」接口(只读产品 + 下单 + 订单状态)。 +// 注意:与 CMS 产品管理的 BackendProductController 区分开。 +// 租户身份来自 JWT:UserType=backend,TenantId 即租户ID。 +type BackendProductPurchaseController struct { + beego.Controller +} + +func (c *BackendProductPurchaseController) claims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, errors.New("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, errors.New("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, errors.New("无效的token") + } + if claims.UserType != "backend" { + return nil, errors.New("无权访问") + } + return claims, nil +} + +func (c *BackendProductPurchaseController) 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 *BackendProductPurchaseController) 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() +} + +func (c *BackendProductPurchaseController) tenantName(tid uint64) string { + if tid == 0 { + return "" + } + tenant := &models.SystemTenant{} + if err := models.Orm.QueryTable(new(models.SystemTenant)). + Filter("id", tid). + Filter("delete_time__isnull", true). + One(tenant); err != nil { + return "" + } + return tenant.TenantName +} + +// GetProductList GET /backend/product/list +// 上架产品列表(含功能节点与启用档位),供购买页/产品列表展示 +func (c *BackendProductPurchaseController) GetProductList() { + if _, err := c.claims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + var rows []models.PlatformProduct + if _, err := models.Orm.QueryTable(new(models.PlatformProduct)). + Filter("delete_time__isnull", true). + Filter("status", 1). + 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) + } + featureMap := loadProductFeatures(ids) + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, productToMap(r, featureMap[r.ID])) + } + c.jsonOK(map[string]interface{}{"list": list, "total": len(list)}, "获取成功") +} + +// GetProductDetailByCode GET /backend/product/detail/:code +// 按产品编码取详情(租户端 home 卡片按模块 code 跳转购买页时使用) +func (c *BackendProductPurchaseController) GetProductDetailByCode() { + if _, err := c.claims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + code := strings.TrimSpace(c.Ctx.Input.Param(":code")) + if code == "" { + c.jsonErr(400, 400, "产品编码不能为空") + return + } + var row models.PlatformProduct + if err := models.Orm.QueryTable(new(models.PlatformProduct)). + Filter("code", code). + Filter("delete_time__isnull", true). + Filter("status", 1). + One(&row); err != nil { + c.jsonErr(404, 404, "产品不存在或未上架") + return + } + c.jsonOK(productToMap(row, loadProductFeatures([]uint64{row.ID})[row.ID]), "获取成功") +} + +type createProductOrderPayload struct { + ProductCode string `json:"product_code"` + Channel string `json:"channel"` + PayType string `json:"pay_type"` + ReturnURL string `json:"return_url"` +} + +// CreateOrder POST /backend/product/order/create +// 创建购买订单;channel 非空时同时创建支付单并返回拉起支付参数。 +func (c *BackendProductPurchaseController) CreateOrder() { + claims, err := c.claims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + tid := uint64(claims.TenantId) + if tid == 0 { + c.jsonErr(401, 401, "未获取到租户信息") + return + } + + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p createProductOrderPayload + if err := json.Unmarshal(body, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + p.ProductCode = strings.TrimSpace(p.ProductCode) + p.Channel = strings.TrimSpace(p.Channel) + if p.ProductCode == "" { + c.jsonErr(400, 400, "产品不能为空") + return + } + + var product models.PlatformProduct + if err := models.Orm.QueryTable(new(models.PlatformProduct)). + Filter("code", p.ProductCode). + Filter("delete_time__isnull", true). + Filter("status", 1). + One(&product); err != nil { + c.jsonErr(404, 404, "产品不存在或未上架") + return + } + if product.Price <= 0 { + c.jsonErr(400, 400, "该产品未配置价格,请联系平台") + return + } + + now := time.Now() + order := &models.PlatformProductOrder{ + OrderNo: "PO" + strconv.FormatInt(now.UnixNano(), 10), + Tid: tid, + TenantName: c.tenantName(tid), + ProductID: product.ID, + ProductCode: product.Code, + ProductName: product.Name, + Amount: product.Price, + Status: models.ProductOrderStatusPending, + UpdateTime: &now, + } + + // 未选择支付渠道:仅落单,等待平台确认(线下转账/试用审批场景) + if p.Channel == "" { + if _, err := models.Orm.Insert(order); err != nil { + c.jsonErr(500, 500, "下单失败:"+err.Error()) + return + } + c.jsonOK(map[string]interface{}{ + "order_no": order.OrderNo, + "pay_no": "", + "amount": order.Amount, + "offline": true, + }, "下单成功,请等待平台开通") + return + } + + amountFen := int64(order.Amount*100 + 0.5) + subject := product.Name + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + payOrder, params, err := payment.CreatePayment(ctx, payment.CreateInput{ + OutTradeNo: order.OrderNo, + OrderType: models.CommissionOrderTypeModuleShop, + TenantID: strconv.FormatUint(tid, 10), + TenantName: order.TenantName, + Amount: amountFen, + Channel: p.Channel, + Subject: subject, + ReturnURL: strings.TrimSpace(p.ReturnURL), + ClientIP: c.Ctx.Input.IP(), + PayType: p.PayType, + }) + if err != nil { + c.jsonErr(400, 400, "创建支付单失败:"+err.Error()) + return + } + + order.PayNo = payOrder.PayNo + order.Channel = p.Channel + if _, err := models.Orm.Insert(order); err != nil { + c.jsonErr(500, 500, "下单失败:"+err.Error()) + return + } + c.jsonOK(map[string]interface{}{ + "order_no": order.OrderNo, + "pay_no": payOrder.PayNo, + "amount": order.Amount, + "channel": p.Channel, + "pay_params": params, + "offline": false, + }, "下单成功") +} + +// GetOrderStatus GET /backend/product/order/status?order_no=xxx +// 查询订单状态;在线支付时同步渠道状态,已支付则自动开通套餐。 +func (c *BackendProductPurchaseController) GetOrderStatus() { + claims, err := c.claims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + orderNo := strings.TrimSpace(c.GetString("order_no")) + if orderNo == "" { + c.jsonErr(400, 400, "订单号不能为空") + return + } + var order models.PlatformProductOrder + if err := models.Orm.QueryTable(new(models.PlatformProductOrder)). + Filter("order_no", orderNo). + Filter("tid", uint64(claims.TenantId)). + Filter("delete_time__isnull", true). + One(&order); err != nil { + c.jsonErr(404, 404, "订单不存在") + return + } + + // 在线支付:同步渠道状态,支付成功后自动开通 + if order.Status == models.ProductOrderStatusPending && order.PayNo != "" { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if payOrder, qerr := payment.QueryPayment(ctx, order.PayNo, true); qerr == nil && payOrder.Status == models.PayStatusPaid { + now := time.Now() + order.Status = models.ProductOrderStatusPaid + order.UpdateTime = &now + _, _ = models.Orm.Update(&order, "Status", "UpdateTime") + } + } + if order.Status == models.ProductOrderStatusPaid { + if err := activateProductOrder(&order, "支付成功自动开通"); err == nil { + _ = models.Orm.Read(&order) + } + } + + c.jsonOK(map[string]interface{}{ + "order_no": order.OrderNo, + "status": order.Status, + "amount": order.Amount, + "product_name": order.ProductName, + "pay_no": order.PayNo, + "activate_time": order.ActivateTime, + }, "获取成功") +} + +// GetMyOrders GET /backend/product/order/list 本租户购买记录 +func (c *BackendProductPurchaseController) GetMyOrders() { + claims, err := c.claims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + var rows []models.PlatformProductOrder + if _, err := models.Orm.QueryTable(new(models.PlatformProductOrder)). + Filter("tid", uint64(claims.TenantId)). + Filter("delete_time__isnull", true). + OrderBy("-id"). + Limit(50). + All(&rows); err != nil { + c.jsonErr(500, 500, "获取失败:"+err.Error()) + return + } + c.jsonOK(map[string]interface{}{"list": rows, "total": len(rows)}, "获取成功") +} diff --git a/go/controllers/backend_tenant_quota.go b/go/controllers/backend_tenant_quota.go index 50e578d..c776e9e 100644 --- a/go/controllers/backend_tenant_quota.go +++ b/go/controllers/backend_tenant_quota.go @@ -47,15 +47,18 @@ func (c *BackendTenantQuotaController) GetQuotaInfo() { "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, + "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, + "duration_days": info.DurationDays, + "package_expire_time": info.PackageExpireTime, + "days_remaining": info.DaysRemaining, + "module_codes": moduleCodes, + "module_names": moduleNames, }, } _ = c.ServeJSON() diff --git a/go/controllers/platform_product.go b/go/controllers/platform_product.go new file mode 100644 index 0000000..8e3f1b4 --- /dev/null +++ b/go/controllers/platform_product.go @@ -0,0 +1,431 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/services" + + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformProductController 平台端「产品定价」管理 +// +// 产品(名称 / 编码 / 价格 / 关联功能 / 上下架)+ 功能节点树 -> 租户端购买展示与下单。 +type PlatformProductController struct { + beego.Controller +} + +func (c *PlatformProductController) 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 *PlatformProductController) 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() +} + +func (c *PlatformProductController) 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 +} + +// ============================ 产品 ============================ + +type productFeaturePayload struct { + ID uint64 `json:"id"` + Pid uint64 `json:"pid"` + Title string `json:"title"` + Description string `json:"description"` + Sort int `json:"sort"` +} + +type productPayload struct { + Code string `json:"code"` + Name string `json:"name"` + Price *float64 `json:"price"` + ModuleCode string `json:"module_code"` + Sort *int `json:"sort"` + Status *int8 `json:"status"` + Remark string `json:"remark"` + Features []productFeaturePayload `json:"features"` +} + +// loadProductFeatures 批量加载产品功能节点(扁平,含 pid,前端按 pid 组装树) +func loadProductFeatures(productIDs []uint64) map[uint64][]models.PlatformProductFeature { + result := map[uint64][]models.PlatformProductFeature{} + if len(productIDs) == 0 { + return result + } + var rows []models.PlatformProductFeature + if _, err := models.Orm.QueryTable(new(models.PlatformProductFeature)). + Filter("product_id__in", productIDs). + OrderBy("sort", "id"). + All(&rows); err != nil { + return result + } + for _, r := range rows { + result[r.ProductID] = append(result[r.ProductID], r) + } + return result +} + +func productToMap(p models.PlatformProduct, features []models.PlatformProductFeature) map[string]interface{} { + if features == nil { + features = []models.PlatformProductFeature{} + } + return map[string]interface{}{ + "id": p.ID, + "code": p.Code, + "name": p.Name, + "price": p.Price, + "module_code": p.ModuleCode, + "sort": p.Sort, + "status": p.Status, + "remark": p.Remark, + "features": features, + "create_time": p.CreateTime, + "update_time": p.UpdateTime, + } +} + +// GetProductList GET /platform/product/list +func (c *PlatformProductController) GetProductList() { + keyword := strings.TrimSpace(c.GetString("keyword")) + status := strings.TrimSpace(c.GetString("status")) + + qs := models.Orm.QueryTable(new(models.PlatformProduct)).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.PlatformProduct + 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) + } + featureMap := loadProductFeatures(ids) + + list := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + list = append(list, productToMap(r, featureMap[r.ID])) + } + c.jsonOK(map[string]interface{}{"list": list, "total": len(list)}, "获取成功") +} + +// GetProductDetail GET /platform/product/detail/:id +func (c *PlatformProductController) GetProductDetail() { + id := parseUint64Param(c.Ctx.Input.Param(":id")) + if id == 0 { + c.jsonErr(400, 400, "参数错误") + return + } + var row models.PlatformProduct + if err := models.Orm.QueryTable(new(models.PlatformProduct)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(&row); err != nil { + c.jsonErr(404, 404, "产品不存在") + return + } + c.jsonOK(productToMap(row, loadProductFeatures([]uint64{id})[id]), "获取成功") +} + +// saveProductFeatures 覆盖式保存功能节点(含父子关系 pid) +// 前端传扁平数组,顶级节点 pid=0;子节点的 pid 指向父节点在库中的真实 ID, +// 因此这里先按「前端临时序号 -> 库 ID」建立映射再落库。 +func saveProductFeatures(productID uint64, items []productFeaturePayload) error { + if _, err := models.Orm.QueryTable(new(models.PlatformProductFeature)). + Filter("product_id", productID).Delete(); err != nil { + return err + } + if len(items) == 0 { + return nil + } + + // 第一遍:按输入顺序建节点,记录 oldID(库中已有ID或前端临时ID) -> newID + oldToNew := map[uint64]uint64{} + type pending struct { + item productFeaturePayload + newID uint64 + } + created := make([]pending, 0, len(items)) + + for i, it := range items { + title := strings.TrimSpace(it.Title) + if title == "" { + continue + } + sort := it.Sort + if sort == 0 { + sort = i + 1 + } + row := &models.PlatformProductFeature{ + ProductID: productID, + Title: title, + Description: strings.TrimSpace(it.Description), + Sort: sort, + } + id, err := models.Orm.Insert(row) + if err != nil { + return err + } + // 前端用 id 字段标识节点:已存在节点传库中真实ID,新增节点传负数临时ID, + // 统一建立「旧标识 -> 新ID」映射,供子节点回填 pid。 + if it.ID != 0 { + oldToNew[it.ID] = uint64(id) + } + created = append(created, pending{item: it, newID: uint64(id)}) + } + + // 第二遍:回填父级 + for _, p := range created { + pid := p.item.Pid + if pid == 0 { + continue + } + if newPid, ok := oldToNew[pid]; ok { + pid = newPid + } + // 父节点已被删除或指向自身时降级为顶级 + if pid == p.newID { + pid = 0 + } + if _, err := models.Orm.QueryTable(new(models.PlatformProductFeature)). + Filter("id", p.newID). + Update(map[string]interface{}{"pid": pid}); err != nil { + return err + } + } + return nil +} + +// CreateProduct POST /platform/product/create +func (c *PlatformProductController) CreateProduct() { + var p productPayload + if !c.readBody(&p) { + return + } + p.Code = strings.TrimSpace(p.Code) + p.Name = strings.TrimSpace(p.Name) + if p.Name == "" { + c.jsonErr(400, 400, "产品名称不能为空") + return + } + if p.Code == "" { + c.jsonErr(400, 400, "产品编码不能为空") + return + } + cnt, _ := models.Orm.QueryTable(new(models.PlatformProduct)). + Filter("code", p.Code). + Filter("delete_time__isnull", true). + Count() + if cnt > 0 { + c.jsonErr(400, 400, "产品编码已存在") + return + } + + now := time.Now() + row := &models.PlatformProduct{ + Code: p.Code, + Name: p.Name, + Price: valueFloat(p.Price, 0), + ModuleCode: strings.TrimSpace(p.ModuleCode), + 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 + } + if p.Features != nil { + if err := saveProductFeatures(uint64(id), p.Features); err != nil { + c.jsonErr(500, 500, "功能节点保存失败:"+err.Error()) + return + } + } + c.jsonOK(map[string]interface{}{"id": uint64(id)}, "创建成功") +} + +// EditProduct POST /platform/product/edit/:id +func (c *PlatformProductController) EditProduct() { + id := parseUint64Param(c.Ctx.Input.Param(":id")) + if id == 0 { + c.jsonErr(400, 400, "参数错误") + return + } + var p productPayload + if !c.readBody(&p) { + return + } + p.Code = strings.TrimSpace(p.Code) + p.Name = strings.TrimSpace(p.Name) + if p.Name == "" { + c.jsonErr(400, 400, "产品名称不能为空") + return + } + if p.Code != "" { + cnt, _ := models.Orm.QueryTable(new(models.PlatformProduct)). + 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, + "module_code": strings.TrimSpace(p.ModuleCode), + "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 _, err := models.Orm.QueryTable(new(models.PlatformProduct)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(update); err != nil { + c.jsonErr(500, 500, "编辑失败:"+err.Error()) + return + } + if p.Features != nil { + if err := saveProductFeatures(id, p.Features); err != nil { + c.jsonErr(500, 500, "功能节点保存失败:"+err.Error()) + return + } + } + c.jsonOK(nil, "编辑成功") +} + +// DeleteProduct DELETE /platform/product/delete/:id(软删) +func (c *PlatformProductController) DeleteProduct() { + 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.PlatformProduct)). + 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 + } + _, _ = models.Orm.QueryTable(new(models.PlatformProductFeature)).Filter("product_id", id).Delete() + c.jsonOK(nil, "删除成功") +} + +// ============================ 购买订单 ============================ + +// GetProductOrders GET /platform/product/orders?status=&keyword= +func (c *PlatformProductController) GetProductOrders() { + status := strings.TrimSpace(c.GetString("status")) + keyword := strings.TrimSpace(c.GetString("keyword")) + + qs := models.Orm.QueryTable(new(models.PlatformProductOrder)).Filter("delete_time__isnull", true) + if status != "" { + qs = qs.Filter("status", status) + } + if keyword != "" { + qs = qs.Filter("product_name__icontains", keyword) + } + var rows []models.PlatformProductOrder + if _, err := qs.OrderBy("-id").Limit(200).All(&rows); err != nil { + c.jsonErr(500, 500, "获取失败:"+err.Error()) + return + } + c.jsonOK(map[string]interface{}{"list": rows, "total": len(rows)}, "获取成功") +} + +// ActivateProductOrder POST /platform/product/order/activate/:id +// 手动开通:将订单置为已支付并绑定套餐(支付回调失败/线下付款时使用) +func (c *PlatformProductController) ActivateProductOrder() { + 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.Status == models.ProductOrderStatusActivated { + c.jsonErr(400, 400, "订单已开通") + return + } + if err := activateProductOrder(&order, "平台手动开通"); err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + c.jsonOK(nil, "开通成功") +} + +// activateProductOrder 订单开通:绑定套餐(产品配置了套餐时)并置为已开通 +func activateProductOrder(order *models.PlatformProductOrder, operator string) error { + if order.PackageID > 0 && order.Tid > 0 { + if _, err := services.ApplyPackageToTenant(order.Tid, order.PackageID, true); err != nil { + return fmt.Errorf("开通失败:%v", err) + } + } + now := time.Now() + order.Status = models.ProductOrderStatusActivated + order.ActivateTime = &now + order.UpdateTime = &now + order.Remark = strings.TrimSpace(order.Remark + " " + operator) + _, err := models.Orm.Update(order, "Status", "ActivateTime", "Remark", "UpdateTime") + return err +} diff --git a/go/controllers/platform_tenant.go b/go/controllers/platform_tenant.go index 80d72f1..60903a3 100644 --- a/go/controllers/platform_tenant.go +++ b/go/controllers/platform_tenant.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "math" "strconv" "strings" "time" @@ -32,9 +33,12 @@ type tenantDTO struct { // 套餐与用户数配额 PackageID uint64 `json:"package_id"` PackageName string `json:"package_name"` - UserQuota int `json:"user_quota"` - UserUsed int `json:"user_used"` - Remark string `json:"remark"` + // PackageExpireTime 套餐计费到期时间(NULL-永久有效);DaysRemaining 剩余天数 + PackageExpireTime *time.Time `json:"package_expire_time"` + DaysRemaining int `json:"days_remaining"` + 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"` @@ -82,6 +86,13 @@ func stringPtr(s string) *string { func toTenantDTO(t models.SystemTenant) tenantDTO { ct := t.CreateTime ut := t.UpdateTime + daysRemaining := 0 + if t.PackageExpireTime != nil { + daysRemaining = int(math.Ceil(time.Until(*t.PackageExpireTime).Hours() / 24)) + if daysRemaining < 0 { + daysRemaining = 0 + } + } return tenantDTO{ ID: t.ID, TenantCode: t.TenantCode, @@ -93,6 +104,8 @@ func toTenantDTO(t models.SystemTenant) tenantDTO { Worktime: stringValue(t.Worktime), Status: t.Status, PackageID: t.PackageID, + PackageExpireTime: t.PackageExpireTime, + DaysRemaining: daysRemaining, UserQuota: t.UserQuota, Remark: stringValue(t.Remark), CreateTime: &ct, @@ -188,6 +201,11 @@ func (c *PlatformTenantController) GetTenantDetail() { dto := toTenantDTO(t) if pkg, err := services.GetTenantPackage(t.ID); err == nil && pkg != nil { + if pkg.ID != t.PackageID { + // 套餐已因到期自动降级,原到期时间不再有效 + dto.PackageExpireTime = nil + dto.DaysRemaining = 0 + } dto.PackageID = pkg.ID dto.PackageName = pkg.Name } @@ -293,7 +311,10 @@ func (c *PlatformTenantController) CreateTenant() { // 套餐与用户数上限:未指定套餐时使用默认套餐,用户数取其包含用户数(兜底 20) packageID := uint64(0) userQuota := services.DefaultTenantUserQuota - if p.PackageID != nil && *p.PackageID > 0 { + // 显式选择的套餐按套餐有效时长计费;未指定而兜底绑定默认套餐的不设置到期时间 + explicitPackage := p.PackageID != nil && *p.PackageID > 0 + var packageExpire *time.Time + if explicitPackage { packageID = *p.PackageID } if packageID == 0 { @@ -309,8 +330,14 @@ func (c *PlatformTenantController) CreateTenant() { 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 + One(&pkg); err == nil { + if pkg.UserQuota > 0 { + userQuota = pkg.UserQuota + } + if explicitPackage { + e := services.PackageExpireFromNow(&pkg) + packageExpire = &e + } } } if p.UserQuota != nil && *p.UserQuota > 0 { @@ -321,17 +348,18 @@ func (c *PlatformTenantController) CreateTenant() { } t := models.SystemTenant{ - TenantCode: p.TenantCode, - TenantName: p.TenantName, - ContactPerson: stringPtr(p.ContactPerson), - ContactPhone: stringPtr(p.ContactPhone), - ContactEmail: stringPtr(p.ContactEmail), - Address: stringPtr(p.Address), - Worktime: stringPtr(p.Worktime), - Status: status, - PackageID: packageID, - UserQuota: userQuota, - Remark: stringPtr(p.Remark), + TenantCode: p.TenantCode, + TenantName: p.TenantName, + ContactPerson: stringPtr(p.ContactPerson), + ContactPhone: stringPtr(p.ContactPhone), + ContactEmail: stringPtr(p.ContactEmail), + Address: stringPtr(p.Address), + Worktime: stringPtr(p.Worktime), + Status: status, + PackageID: packageID, + PackageExpireTime: packageExpire, + UserQuota: userQuota, + Remark: stringPtr(p.Remark), } id, err := models.Orm.Insert(&t) @@ -381,17 +409,17 @@ func (c *PlatformTenantController) EditTenant() { 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": "套餐不存在"} + // 套餐切换/续费:按套餐有效时长重新计费(同套餐未到期在原到期时间上顺延),不动用户数上限 + if _, err := services.ApplyPackageToTenant(id, pkgID, false); err != nil { + c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()} _ = c.ServeJSON() return } + } else { + // 解绑套餐:回退未绑定状态(视为默认套餐),清除计费到期时间 + update["package_id"] = 0 + update["package_expire_time"] = nil } - update["package_id"] = pkgID } if p.UserQuota != nil { // 用户数上限只增不减:不允许调低到当前已开用户数以下 diff --git a/go/controllers/platform_tenant_package.go b/go/controllers/platform_tenant_package.go index 42ee247..f100fa5 100644 --- a/go/controllers/platform_tenant_package.go +++ b/go/controllers/platform_tenant_package.go @@ -99,6 +99,7 @@ type tenantPackagePayload struct { Code string `json:"code"` Description string `json:"description"` Price *float64 `json:"price"` + DurationDays *int `json:"duration_days"` UserQuota *int `json:"user_quota"` ExtraUserPrice *float64 `json:"extra_user_price"` IsDefault *int8 `json:"is_default"` @@ -108,6 +109,14 @@ type tenantPackagePayload struct { Modules []string `json:"modules"` } +// normalizePackageDuration 套餐有效时长(天)规范化:<=0 时按 365(年付) +func normalizePackageDuration(v *int) int { + if v == nil || *v <= 0 { + return services.DefaultPackageDurationDays + } + return *v +} + // loadPackageModules 批量加载套餐模块(packageID -> 模块列表) func loadPackageModules(packageIDs []uint64) map[uint64][]tenantPackageModuleItem { result := map[uint64][]tenantPackageModuleItem{} @@ -180,6 +189,7 @@ func (c *PlatformTenantPackageController) GetPackageList() { "code": r.Code, "description": r.Description, "price": r.Price, + "duration_days": r.DurationDays, "user_quota": r.UserQuota, "extra_user_price": r.ExtraUserPrice, "is_default": r.IsDefault, @@ -235,6 +245,7 @@ func (c *PlatformTenantPackageController) GetPackageSelectList() { "code": r.Code, "description": r.Description, "price": r.Price, + "duration_days": r.DurationDays, "user_quota": r.UserQuota, "extra_user_price": r.ExtraUserPrice, "is_default": r.IsDefault, @@ -267,6 +278,27 @@ func (c *PlatformTenantPackageController) GetPackageDetail() { c.jsonOK(tenantPackageDTO{SystemTenantPackage: row, Modules: modules}, "获取成功") } +// GetPurchaseURL GET /platform/tenantPackage/purchaseUrl +// 套餐购买链接:租户端未开通功能卡片「购买」按钮的跳转地址 +func (c *PlatformTenantPackageController) GetPurchaseURL() { + c.jsonOK(map[string]interface{}{"purchase_url": services.GetPackagePurchaseURL()}, "获取成功") +} + +// SavePurchaseURL POST /platform/tenantPackage/purchaseUrl +func (c *PlatformTenantPackageController) SavePurchaseURL() { + var p struct { + PurchaseURL string `json:"purchase_url"` + } + if !c.readBody(&p) { + return + } + if err := services.SavePackagePurchaseURL(p.PurchaseURL); err != nil { + c.jsonErr(500, 500, "保存失败:"+err.Error()) + return + } + c.jsonOK(nil, "保存成功") +} + // GetModuleOptions GET /platform/tenantPackage/moduleOptions // 可加入套餐的功能模块清单(取 yz_system_modules 中启用且未删除的模块) func (c *PlatformTenantPackageController) GetModuleOptions() { @@ -365,6 +397,7 @@ func (c *PlatformTenantPackageController) CreatePackage() { 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), @@ -436,6 +469,9 @@ func (c *PlatformTenantPackageController) EditPackage() { if p.Price != nil { update["price"] = *p.Price } + if p.DurationDays != nil { + update["duration_days"] = normalizePackageDuration(p.DurationDays) + } if p.UserQuota != nil { quota := *p.UserQuota if quota <= 0 { @@ -718,6 +754,9 @@ func (c *PlatformTenantPackageController) GetTenantQuotaInfo() { "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, }, "获取成功") } diff --git a/go/docs/sql/alter_tenant_package_billing.sql b/go/docs/sql/alter_tenant_package_billing.sql new file mode 100644 index 0000000..125130e --- /dev/null +++ b/go/docs/sql/alter_tenant_package_billing.sql @@ -0,0 +1,14 @@ +-- 租户套餐按年计费(元/年)+ 到期自动切换基础套餐 +-- 1) 套餐表增加有效时长(天),365 = 按年计费;2) 租户表增加套餐到期时间 + +-- yz_system_tenant_package 增加 duration_days +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 = 'duration_days'); +SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant_package` ADD COLUMN `duration_days` int(11) NOT NULL DEFAULT 365 COMMENT ''套餐有效时长(天),365=按年计费;到期后租户自动切换基础套餐''', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- yz_system_tenant 增加 package_expire_time +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_expire_time'); +SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant` ADD COLUMN `package_expire_time` datetime NULL COMMENT ''套餐到期时间(NULL-永久有效);超过时效自动切换基础套餐''', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/go/docs/sql/create_platform_product_pricing.sql b/go/docs/sql/create_platform_product_pricing.sql new file mode 100644 index 0000000..cd8ec19 --- /dev/null +++ b/go/docs/sql/create_platform_product_pricing.sql @@ -0,0 +1,91 @@ +-- ============================================================= +-- 产品定价(platform「基础设置 → 产品定价」) +-- +-- 业务说明: +-- 1. 产品(yz_platform_product):平台对外售卖的功能产品,如「进销存 ERP」「CRM 客户管理」。 +-- 仅维护:产品名称、产品编码、价格、关联功能(module_code)、上下架状态。 +-- 2. 功能节点(yz_platform_product_feature):产品的功能树,支持父子层级(pid)。 +-- 购买页按树形展示产品包含的功能清单。 +-- 3. 购买订单(yz_platform_product_order):租户下单记录。 +-- 在线支付走 /backend/payment/create;未配置支付渠道时可先下单,由平台在订单里手动开通。 +-- +-- 本脚本可重复执行:建表用 IF NOT EXISTS,补列做存在性判断,预置数据做存在性判断。 +-- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < create_platform_product_pricing.sql +-- ============================================================= + +-- 1) 产品表 +CREATE TABLE IF NOT EXISTS `yz_platform_product` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `code` varchar(64) NOT NULL DEFAULT '' COMMENT '产品编码(唯一,建议与功能模块 code 一致,如 erp/crm)', + `name` varchar(128) NOT NULL DEFAULT '' COMMENT '产品名称', + `price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '产品价格(元)', + `module_code` varchar(64) NOT NULL DEFAULT '' COMMENT '关联功能模块编码 yz_system_modules(code)', + `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, + `update_time` datetime DEFAULT NULL, + `delete_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_code` (`code`, `delete_time`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品定价-产品表'; + +-- 2) 产品功能节点表(树形,pid=0 为顶级) +CREATE TABLE IF NOT EXISTS `yz_platform_product_feature` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `product_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '产品ID 关联 yz_platform_product(id)', + `pid` bigint(20) NOT NULL DEFAULT 0 COMMENT '上级节点ID,0=顶级节点', + `title` varchar(128) NOT NULL DEFAULT '' COMMENT '功能节点标题', + `description` varchar(500) NOT NULL DEFAULT '' COMMENT '功能节点说明', + `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_product_id` (`product_id`), + KEY `idx_pid` (`pid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品定价-产品功能节点表(树形)'; + +-- 3) 产品购买订单表 +CREATE TABLE IF NOT EXISTS `yz_platform_product_order` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `order_no` varchar(40) NOT NULL DEFAULT '' COMMENT '业务订单号', + `tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '租户ID', + `tenant_name` varchar(128) NOT NULL DEFAULT '' COMMENT '租户名称(快照)', + `product_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '产品ID', + `product_code` varchar(64) NOT NULL DEFAULT '' COMMENT '产品编码(快照)', + `product_name` varchar(128) NOT NULL DEFAULT '' COMMENT '产品名称(快照)', + `amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额(元)', + `package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '购买后绑定的套餐ID(快照)', + `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '支付单号 yz_platform_payment_order(pay_no)', + `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 '开通时间', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` datetime DEFAULT NULL, + `delete_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_no` (`order_no`), + KEY `idx_tid` (`tid`), + KEY `idx_status` (`status`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品定价-购买订单表'; + +-- 4) 兼容旧结构:历史库已建过产品表时补齐/清理字段 +SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product'); +SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product' AND COLUMN_NAME = 'price'); +SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_product` ADD COLUMN `price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT ''产品价格(元)'' AFTER `name`', '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_platform_product_feature'); +SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product_feature' AND COLUMN_NAME = 'pid'); +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; + +-- 5) 平台端菜单:基础设置 → 产品定价(仅平台端可见) +-- pid=17 为「基础设置」菜单;若环境中 17 不是基础设置,请在平台端「菜单管理」里手工挂载本页面: +-- 组件路径 /basicSettings/pricing/index.vue,路径 /basicSettings/pricing +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/pricing', '/basicSettings/pricing/index.vue', 'Goods', 5, 1, 1, '[1]', 2, '', 'system', '产品定价:产品与功能节点树', NOW(), NOW() + WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_menu` WHERE `path` = '/basicSettings/pricing' 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); diff --git a/go/models/init.go b/go/models/init.go index 2d4f36b..0aed1f6 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -40,6 +40,10 @@ func Init(_ string) { new(SystemTenantPackageModule), new(SystemTenantUserQuotaPackage), new(SystemTenantQuotaOrder), + // 产品定价(docs/sql/create_platform_product_pricing.sql) + new(PlatformProduct), + new(PlatformProductFeature), + new(PlatformProductOrder), new(BackendOrganization), new(BackendEmployee), new(BackendPosition), diff --git a/go/models/platform_product.go b/go/models/platform_product.go new file mode 100644 index 0000000..5911a2c --- /dev/null +++ b/go/models/platform_product.go @@ -0,0 +1,69 @@ +package models + +import "time" + +// ============================================================= +// 产品定价模型(表结构见 docs/sql/create_platform_product_pricing.sql) +// 产品(名称/编码/价格/关联功能/上下架) -> 功能节点树 -> 购买订单 +// ============================================================= + +// PlatformProduct 产品 yz_platform_product +type PlatformProduct struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Code string `orm:"column(code);size(64)" json:"code"` + Name string `orm:"column(name);size(128)" json:"name"` + Price float64 `orm:"column(price);digits(10);decimals(2)" json:"price"` + ModuleCode string `orm:"column(module_code);size(64)" json:"module_code"` + 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 *PlatformProduct) TableName() string { return "yz_platform_product" } + +// PlatformProductFeature 产品功能节点(树形,pid=0 为顶级) yz_platform_product_feature +type PlatformProductFeature struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + ProductID uint64 `orm:"column(product_id)" json:"product_id"` + Pid uint64 `orm:"column(pid)" json:"pid"` + Title string `orm:"column(title);size(128)" json:"title"` + Description string `orm:"column(description);size(500)" json:"description"` + Sort int `orm:"column(sort);default(0)" json:"sort"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` +} + +func (m *PlatformProductFeature) TableName() string { return "yz_platform_product_feature" } + +// 购买订单状态 +const ( + ProductOrderStatusPending = "pending" // 待支付 + ProductOrderStatusPaid = "paid" // 已支付(待开通) + ProductOrderStatusActivated = "activated" // 已开通 + ProductOrderStatusClosed = "closed" // 已关闭 +) + +// PlatformProductOrder 产品购买订单 yz_platform_product_order +type PlatformProductOrder struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + OrderNo string `orm:"column(order_no);size(40)" json:"order_no"` + Tid uint64 `orm:"column(tid)" json:"tid"` + TenantName string `orm:"column(tenant_name);size(128)" json:"tenant_name"` + ProductID uint64 `orm:"column(product_id)" json:"product_id"` + ProductCode string `orm:"column(product_code);size(64)" json:"product_code"` + ProductName string `orm:"column(product_name);size(128)" json:"product_name"` + Amount float64 `orm:"column(amount);digits(10);decimals(2)" json:"amount"` + PackageID uint64 `orm:"column(package_id)" json:"package_id"` + PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"` + 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"` + 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 *PlatformProductOrder) TableName() string { return "yz_platform_product_order" } diff --git a/go/models/system_tenant.go b/go/models/system_tenant.go index 431e23f..fb962b1 100644 --- a/go/models/system_tenant.go +++ b/go/models/system_tenant.go @@ -15,6 +15,8 @@ type SystemTenant struct { Status int8 `orm:"column(status);default(1)" json:"status"` // PackageID 套餐ID:关联 yz_system_tenant_package(id),0 表示未绑定(视为默认套餐) PackageID uint64 `orm:"column(package_id);default(0)" json:"package_id"` + // PackageExpireTime 套餐到期时间(NULL-永久有效);超过时效自动切换基础套餐 + PackageExpireTime *time.Time `orm:"column(package_expire_time);type(datetime);null" json:"package_expire_time"` // UserQuota 用户数上限:默认 20,增购后累加。租户用户数达到上限后不允许再新增账号。 UserQuota int `orm:"column(user_quota);default(20)" json:"user_quota"` Remark *string `orm:"column(remark);size(512);null" json:"remark"` diff --git a/go/models/system_tenant_package.go b/go/models/system_tenant_package.go index 8714994..19d0e49 100644 --- a/go/models/system_tenant_package.go +++ b/go/models/system_tenant_package.go @@ -9,7 +9,8 @@ type SystemTenantPackage struct { 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"` + Price float64 `orm:"column(price);digits(10);decimals(2)" json:"price"` // 套餐售价(元/年,按有效时长一次性计费) + DurationDays int `orm:"column(duration_days);default(365)" json:"duration_days"` // 套餐有效时长(天),365=按年计费;到期后租户自动切换基础套餐 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"` diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index 3eb9ca3..6e12dc8 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -12,6 +12,16 @@ func Register() { RegisterAuthRoutes() RegisterRoleRoutes() RegisterPaymentRoutes() + RegisterProductRoutes() +} + +// RegisterProductRoutes 注册租户端「产品购买」路由。 +func RegisterProductRoutes() { + beego.Router("/backend/product/list", &controllers.BackendProductPurchaseController{}, "get:GetProductList") + beego.Router("/backend/product/detail/:code", &controllers.BackendProductPurchaseController{}, "get:GetProductDetailByCode") + beego.Router("/backend/product/order/create", &controllers.BackendProductPurchaseController{}, "post:CreateOrder") + beego.Router("/backend/product/order/status", &controllers.BackendProductPurchaseController{}, "get:GetOrderStatus") + beego.Router("/backend/product/order/list", &controllers.BackendProductPurchaseController{}, "get:GetMyOrders") } // RegisterPaymentRoutes 注册租户端支付路由(下单页 / 收银台)。 diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index a20c3cf..90575d0 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -70,6 +70,16 @@ func Register() { 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") + beego.Router("/platform/tenantPackage/purchaseUrl", &controllers.PlatformTenantPackageController{}, "get:GetPurchaseURL;post:SavePurchaseURL") + + // 产品定价(yz_platform_product / feature / sku / order) + beego.Router("/platform/product/list", &controllers.PlatformProductController{}, "get:GetProductList") + beego.Router("/platform/product/detail/:id", &controllers.PlatformProductController{}, "get:GetProductDetail") + beego.Router("/platform/product/create", &controllers.PlatformProductController{}, "post:CreateProduct") + beego.Router("/platform/product/edit/:id", &controllers.PlatformProductController{}, "post:EditProduct") + 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") // 用户数加购规格(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 553eb73..e84955f 100644 --- a/go/services/tenant_quota.go +++ b/go/services/tenant_quota.go @@ -2,8 +2,10 @@ package services import ( "errors" + "math" "sort" "strings" + "time" "server/models" ) @@ -11,6 +13,9 @@ import ( // DefaultTenantUserQuota 租户默认用户数上限(未配置套餐/套餐未设置时兜底) const DefaultTenantUserQuota = 20 +// DefaultPackageDurationDays 套餐默认有效时长(天):按年计费 +const DefaultPackageDurationDays = 365 + // DefaultTenantExtraUserPrice 单个用户增购默认单价(元/人) const DefaultTenantExtraUserPrice = 200.00 @@ -29,7 +34,26 @@ func CountTenantUsers(tid uint64) (int, error) { return int(cnt), err } -// GetTenantPackage 获取租户生效套餐:优先取绑定套餐,未绑定/已删除时回退到默认套餐 +// PackageDurationDays 套餐有效时长(天),未配置时按 365(年付) +func PackageDurationDays(pkg *models.SystemTenantPackage) int { + if pkg == nil || pkg.DurationDays <= 0 { + return DefaultPackageDurationDays + } + return pkg.DurationDays +} + +// PackageExpireFromNow 从当前时间起算套餐到期时间 +func PackageExpireFromNow(pkg *models.SystemTenantPackage) time.Time { + return time.Now().AddDate(0, 0, PackageDurationDays(pkg)) +} + +// tenantPackageExpired 租户套餐是否已超过时效(未设置到期时间视为永久有效) +func tenantPackageExpired(tenant *models.SystemTenant) bool { + return tenant.PackageExpireTime != nil && tenant.PackageExpireTime.Before(time.Now()) +} + +// GetTenantPackage 获取租户生效套餐:优先取绑定套餐,未绑定/已删除时回退到默认套餐。 +// 绑定套餐已超过时效时,自动切换回基础套餐并落库(到期时间清空),返回基础套餐。 func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) { if tid == 0 { return nil, errors.New("tid 不能为空") @@ -39,6 +63,10 @@ func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) { return nil, err } if tenant.PackageID > 0 { + // 套餐到期:自动切换基础套餐(降级落库,保证只执行一次) + if tenantPackageExpired(&tenant) { + return downgradeExpiredTenantPackage(tid) + } var pkg models.SystemTenantPackage if err := models.Orm.QueryTable(new(models.SystemTenantPackage)). Filter("id", tenant.PackageID). @@ -50,6 +78,35 @@ func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) { return GetDefaultTenantPackage() } +// GetBasicTenantPackage 获取基础套餐(code=basic;不存在时回退默认套餐) +func GetBasicTenantPackage() (*models.SystemTenantPackage, error) { + var pkg models.SystemTenantPackage + err := models.Orm.QueryTable(new(models.SystemTenantPackage)). + Filter("code", "basic"). + Filter("delete_time__isnull", true). + Filter("status", 1). + One(&pkg) + if err == nil { + return &pkg, nil + } + return GetDefaultTenantPackage() +} + +// downgradeExpiredTenantPackage 套餐到期:切换基础套餐并清除到期时间,返回基础套餐 +func downgradeExpiredTenantPackage(tid uint64) (*models.SystemTenantPackage, error) { + basic, err := GetBasicTenantPackage() + if err != nil { + return nil, err + } + _, _ = models.Orm.QueryTable(new(models.SystemTenant)). + Filter("id", tid). + Update(map[string]interface{}{ + "package_id": basic.ID, + "package_expire_time": nil, + }) + return basic, nil +} + // GetDefaultTenantPackage 获取默认套餐(is_default=1 优先,其次按排序取第一个启用套餐) func GetDefaultTenantPackage() (*models.SystemTenantPackage, error) { var pkg models.SystemTenantPackage @@ -160,24 +217,26 @@ func EnsureTenantUserQuota(tid, uid uint64) error { // 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"` + Tid uint64 `json:"tid"` + PackageID uint64 `json:"package_id"` + PackageName string `json:"package_name"` + Quota int `json:"quota"` + Used int `json:"used"` + Remaining int `json:"remaining"` + ExtraUserPrice float64 `json:"extra_user_price"` + EffectivePackageID uint64 `json:"effective_package_id"` + DurationDays int `json:"duration_days"` + PackageExpireTime *time.Time `json:"package_expire_time"` + DaysRemaining int `json:"days_remaining"` } -// GetTenantQuotaInfo 查询租户用户数使用情况(含生效套餐信息) +// 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 } @@ -190,13 +249,29 @@ func GetTenantQuotaInfo(tid uint64) (*TenantQuotaInfo, error) { if info.Remaining < 0 { info.Remaining = 0 } + // 生效套餐(到期会自动降级基础套餐并落库) if pkg, err := GetTenantPackage(tid); err == nil && pkg != nil { info.EffectivePackageID = pkg.ID info.PackageName = pkg.Name + info.DurationDays = PackageDurationDays(pkg) if pkg.ExtraUserPrice > 0 { info.ExtraUserPrice = pkg.ExtraUserPrice } } + // 重新读取租户绑定信息(GetTenantPackage 可能已触发到期降级落库) + var fresh models.SystemTenant + if e := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&fresh); e == nil { + tenant = fresh + } + info.PackageID = tenant.PackageID + info.PackageExpireTime = tenant.PackageExpireTime + if tenant.PackageExpireTime != nil { + remain := int(math.Ceil(time.Until(*tenant.PackageExpireTime).Hours() / 24)) + if remain < 0 { + remain = 0 + } + info.DaysRemaining = remain + } return info, nil } @@ -223,7 +298,10 @@ func IncreaseTenantQuota(tid uint64, count int) (int, int, error) { return before, after, nil } -// ApplyPackageToTenant 为租户绑定套餐:同步套餐包含的用户数(不低于当前已开用户数,且不降低已有上限) +// ApplyPackageToTenant 为租户绑定/续费套餐: +// - 同步套餐包含的用户数(不低于当前已开用户数,且不降低已有上限,syncQuota=true 时) +// - 按套餐有效时长(duration_days,默认 365 天 = 年付)设置计费到期时间; +// 同套餐未到期时再次绑定视为续费,在原到期时间上顺延 func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) { if tid == 0 || packageID == 0 { return 0, errors.New("参数错误") @@ -235,14 +313,14 @@ func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) { One(&pkg); err != nil { return 0, errors.New("套餐不存在") } + var tenant models.SystemTenant + if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil { + return 0, errors.New("租户不存在") + } update := map[string]interface{}{"package_id": packageID} - newQuota := 0 + newQuota := tenant.UserQuota 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 @@ -258,6 +336,13 @@ func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) { update["user_quota"] = newQuota } + // 计费时效:同套餐未到期续费 → 从原到期时间顺延;换套餐/已到期 → 从当前时间起算 + base := time.Now() + if tenant.PackageID == packageID && tenant.PackageExpireTime != nil && tenant.PackageExpireTime.After(base) { + base = *tenant.PackageExpireTime + } + update["package_expire_time"] = base.AddDate(0, 0, PackageDurationDays(&pkg)) + if _, err := models.Orm.QueryTable(new(models.SystemTenant)). Filter("id", tid). Update(update); err != nil { @@ -325,6 +410,95 @@ func FilterMenusByTenantModules(menus []models.SystemMenu, allModules []models.S return out } +// MarkMenusLockedByTenantModules 按租户套餐标记未开通模块的菜单:菜单保留,返回锁定菜单ID集合。 +// lockedIDs 包含未开通模块对应的菜单及其全部后代菜单ID,供前端展示锁定态(而非直接剔除导致 404)。 +func MarkMenusLockedByTenantModules(menus []models.SystemMenu, allModules []models.SystemModules, allowedCodes map[string]bool) ([]models.SystemMenu, map[uint64]bool) { + lockedIDs := map[uint64]bool{} + if len(menus) == 0 || len(allModules) == 0 { + return menus, lockedIDs + } + sorted := sortModulesByPathDesc(allModules) + kept := make([]models.SystemMenu, 0, len(menus)) + for _, m := range menus { + path := "" + if m.Path != nil { + path = *m.Path + } + code, ok := ownerModuleCode(sorted, path) + if ok && !allowedCodes[code] { + lockedIDs[m.ID] = true + } + kept = append(kept, m) + } + if len(lockedIDs) == 0 { + return kept, lockedIDs + } + // 锁定传递给所有后代菜单 + childrenOf := map[uint64][]uint64{} + for _, m := range kept { + if m.Pid > 0 { + pid := uint64(m.Pid) + childrenOf[pid] = append(childrenOf[pid], m.ID) + } + } + queue := make([]uint64, 0, len(lockedIDs)) + for id := range lockedIDs { + queue = append(queue, id) + } + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + for _, child := range childrenOf[id] { + if !lockedIDs[child] { + lockedIDs[child] = true + queue = append(queue, child) + } + } + } + return kept, lockedIDs +} + +// PackagePurchaseURLCode 平台通用配置(yz_platform_normal_setting)中「套餐购买链接」的配置 code +const PackagePurchaseURLCode = "package_purchase_url" + +// GetPackagePurchaseURL 读取平台配置的套餐购买链接。 +// 租户端 home 页未开通(锁定)功能卡片的「购买」按钮跳转到该地址;未配置时返回空串。 +func GetPackagePurchaseURL() string { + var s models.PlatformNormalSetting + if err := models.Orm.QueryTable(new(models.PlatformNormalSetting)). + Filter("code", PackagePurchaseURLCode). + Filter("delete_time__isnull", true). + One(&s); err != nil { + return "" + } + return strings.TrimSpace(s.Value) +} + +// SavePackagePurchaseURL 保存平台配置的套餐购买链接(不存在则创建) +func SavePackagePurchaseURL(url string) error { + url = strings.TrimSpace(url) + var s models.PlatformNormalSetting + err := models.Orm.QueryTable(new(models.PlatformNormalSetting)). + Filter("code", PackagePurchaseURLCode). + Filter("delete_time__isnull", true). + One(&s) + if err != nil { + s = models.PlatformNormalSetting{ + Name: "套餐购买链接", + Code: PackagePurchaseURLCode, + Value: url, + Remark: "租户端未开通功能卡片「购买」按钮跳转地址", + } + _, err = models.Orm.Insert(&s) + return err + } + now := time.Now() + s.Value = url + s.UpdateTime = &now + _, err = models.Orm.Update(&s) + return err +} + // ListEnabledModules 获取全部启用且未删除的模块(用于菜单归属判定) func ListEnabledModules() ([]models.SystemModules, error) { var rows []models.SystemModules diff --git a/platform/src/api/product.js b/platform/src/api/product.js new file mode 100644 index 0000000..998098b --- /dev/null +++ b/platform/src/api/product.js @@ -0,0 +1,65 @@ +import request from "@/utils/request"; + +/************************************************* + * 产品定价(产品 / 功能节点 / 价格档位 / 购买订单) + *************************************************/ + +/** 产品列表 */ +export function getProductList(params) { + return request({ + url: "/platform/product/list", + method: "get", + params, + }); +} + +/** 产品详情(含功能节点与价格档位) */ +export function getProductDetail(id) { + return request({ + url: `/platform/product/detail/${id}`, + method: "get", + }); +} + +/** 新增产品 */ +export function createProduct(data) { + return request({ + url: "/platform/product/create", + method: "post", + data, + }); +} + +/** 编辑产品 */ +export function updateProduct(id, data) { + return request({ + url: `/platform/product/edit/${id}`, + method: "post", + data, + }); +} + +/** 删除产品 */ +export function deleteProduct(id) { + return request({ + url: `/platform/product/delete/${id}`, + method: "delete", + }); +} + +/** 购买订单列表 */ +export function getProductOrders(params) { + return request({ + url: "/platform/product/orders", + method: "get", + params, + }); +} + +/** 手动开通订单 */ +export function activateProductOrder(id) { + return request({ + url: `/platform/product/order/activate/${id}`, + method: "post", + }); +} diff --git a/platform/src/api/tenantPackage.js b/platform/src/api/tenantPackage.js index 58cee1c..3654550 100644 --- a/platform/src/api/tenantPackage.js +++ b/platform/src/api/tenantPackage.js @@ -63,6 +63,23 @@ export function deleteTenantPackage(id) { }); } +/** 套餐购买链接(租户端未开通功能「购买」按钮跳转地址) */ +export function getPackagePurchaseUrl() { + return request({ + url: "/platform/tenantPackage/purchaseUrl", + method: "get", + }); +} + +/** 保存套餐购买链接 */ +export function savePackagePurchaseUrl(purchaseUrl) { + return request({ + url: "/platform/tenantPackage/purchaseUrl", + method: "post", + data: { purchase_url: purchaseUrl }, + }); +} + /** 用户数加购规格列表(enabled=1 仅启用) */ export function getQuotaPackageList(params) { return request({ diff --git a/platform/src/views/basicSettings/pricing/components/edit.vue b/platform/src/views/basicSettings/pricing/components/edit.vue new file mode 100644 index 0000000..cd2209f --- /dev/null +++ b/platform/src/views/basicSettings/pricing/components/edit.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/platform/src/views/basicSettings/pricing/index.vue b/platform/src/views/basicSettings/pricing/index.vue new file mode 100644 index 0000000..1ff0c33 --- /dev/null +++ b/platform/src/views/basicSettings/pricing/index.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/platform/src/views/basicSettings/tenantpackage/components/edit.vue b/platform/src/views/basicSettings/tenantpackage/components/edit.vue index e28ad27..59a7cec 100644 --- a/platform/src/views/basicSettings/tenantpackage/components/edit.vue +++ b/platform/src/views/basicSettings/tenantpackage/components/edit.vue @@ -62,7 +62,7 @@ 元/人,单个增购用户数时的默认单价(如 200) - + - 元,仅作记录展示,可填 0 + {{ priceUnitText }},绑定/续费按该价格一次性计费 + + + + + 天(365 = 按年计费);租户套餐到期后自动切换为基础套餐 @@ -147,6 +157,7 @@ const emptyForm = () => ({ code: "", description: "", price: 0, + duration_days: 365, user_quota: 20, extra_user_price: 200, is_default: 0, @@ -158,6 +169,15 @@ const emptyForm = () => ({ const form = ref(emptyForm()); +/** 价格单位随有效时长变化:365天=元/年,30=元/月,90=元/季,其余=元/N天 */ +const priceUnitText = computed(() => { + const d = Number(form.value.duration_days || 365); + if (d === 365) return "元/年"; + if (d === 30) return "元/月"; + if (d === 90) return "元/季"; + return `元 / ${d}天`; +}); + const rules = { name: [{ required: true, message: "请输入套餐名称", trigger: "blur" }], code: [{ required: true, message: "请输入套餐编码", trigger: "blur" }], @@ -208,6 +228,7 @@ const loadDetail = async (id: number) => { code: d.code || "", description: d.description || "", price: Number(d.price || 0), + duration_days: Number(d.duration_days || 365), user_quota: Number(d.user_quota || 20), extra_user_price: Number(d.extra_user_price || 0), is_default: Number(d.is_default || 0), diff --git a/platform/src/views/basicSettings/tenantpackage/index.vue b/platform/src/views/basicSettings/tenantpackage/index.vue index 10330c3..d0e001e 100644 --- a/platform/src/views/basicSettings/tenantpackage/index.vue +++ b/platform/src/views/basicSettings/tenantpackage/index.vue @@ -23,10 +23,25 @@ +
+ 套餐购买链接 + + + 保存 + + 留空时租户端点击「购买」仅提示联系管理员 +
+ @@ -45,6 +60,12 @@ {{ row.code }} + + +