增加租户套餐

This commit is contained in:
2026-09-15 17:28:08 +08:00
parent a82b300b1a
commit 71a14c46f9
37 changed files with 3855 additions and 178 deletions
+60 -5
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -16,6 +17,7 @@ import (
"server/models"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
@@ -35,16 +37,26 @@ import (
// appid 公众号 / 小程序 APPID
// cert_serial_no 商户 API 证书序列号
// api_v3_key APIv3 密钥
// pub_key_id 微信支付公钥 ID(PUB_KEY_ID_ 开头;公钥模式必填,与 pub_key 成对)
// pub_key 微信支付公钥 PEM 内容(公钥模式必填)
//
// 证书(cert_json):
//
// key_path apiclient_key.pem 上传后的服务端路径(不入库内容,见渠道证书上传接口)
type WechatChannel struct{}
// connectTestOutTradeNo 测试连接用的探测单号:查询不存在的订单,返回 404 即视为鉴权链路通过
const connectTestOutTradeNo = "CONNECT_TEST_NO_0000000000000001"
func (c *WechatChannel) Code() string { return ChannelWechat }
func (c *WechatChannel) Name() string { return "微信支付" }
// buildClient 创建微信支付 APIv3 客户端(带自动证书更新与加解密能力)
//
// 初始化模式自动选择:
// - 配置了「微信支付公钥」(pub_key_id + pub_key)时走公钥模式:
// 2024 年后新开通商户默认使用公钥,平台证书下载接口对其停用(403 NOT_ENOUGH);
// - 否则回退平台证书模式(自动下载并轮换平台证书,老商户适用)。
func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, error) {
mchID := cfg.Get("mch_id")
serial := cfg.Get("cert_serial_no")
@@ -66,6 +78,23 @@ func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, e
return nil, "", fmt.Errorf("解析商户私钥失败: %w", err)
}
pubKeyID := cfg.Get("pub_key_id")
pubKeyPEM := cfg.Get("pub_key")
if pubKeyID != "" && pubKeyPEM != "" {
pubKey, perr := utils.LoadPublicKey(pubKeyPEM)
if perr != nil {
return nil, "", fmt.Errorf("解析微信支付公钥失败: %w", perr)
}
client, cerr := core.NewClient(context.Background(),
option.WithMerchantCredential(mchID, serial, privateKey),
option.WithWechatPayPublicKeyAuthCipher(mchID, serial, privateKey, pubKeyID, pubKey),
)
if cerr != nil {
return nil, "", fmt.Errorf("创建微信支付客户端失败: %w", cerr)
}
return client, mchID, nil
}
client, err := core.NewClient(context.Background(),
option.WithMerchantCredential(mchID, serial, privateKey),
option.WithWechatPayAutoAuthCipher(mchID, serial, privateKey, apiV3Key),
@@ -212,9 +241,21 @@ func (c *WechatChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *C
mchID := cfg.Get("mch_id")
apiV3Key := cfg.Get("api_v3_key")
// 平台证书由 WithWechatPayAutoAuthCipher 注册的下载器维护,直接复用其证书访问器验签
visitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
handler, err := notify.NewRSANotifyHandler(apiV3Key, verifiers.NewSHA256WithRSAVerifier(visitor))
// 验签器按初始化模式选择:公钥模式用微信支付公钥,平台证书模式用下载器维护的证书访问器
var verifier auth.Verifier
pubKeyID := cfg.Get("pub_key_id")
pubKeyPEM := cfg.Get("pub_key")
if pubKeyID != "" && pubKeyPEM != "" {
pubKey, perr := utils.LoadPublicKey(pubKeyPEM)
if perr != nil {
return nil, fmt.Errorf("解析微信支付公钥失败: %w", perr)
}
verifier = verifiers.NewSHA256WithRSAPubkeyVerifier(pubKeyID, *pubKey)
} else {
visitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
verifier = verifiers.NewSHA256WithRSAVerifier(visitor)
}
handler, err := notify.NewRSANotifyHandler(apiV3Key, verifier)
if err != nil {
return nil, fmt.Errorf("创建微信通知处理器失败: %w", err)
}
@@ -284,11 +325,25 @@ func (c *WechatChannel) Refund(ctx context.Context, order *models.PlatformPaymen
}
func (c *WechatChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
client, _, err := c.buildClient(cfg)
client, mchID, err := c.buildClient(cfg)
if err != nil {
return "", err
}
// 平台证书接口只做签名校验,不产生任何交易
// 公钥模式:/v3/certificates 已对其停用,改查一笔不存在的订单验证鉴权(纯读,不产生交易)
if cfg.Get("pub_key_id") != "" && cfg.Get("pub_key") != "" {
_, qerr := client.Get(ctx,
consts.WechatPayAPIServer+"/v3/pay/transactions/out-trade-no/"+connectTestOutTradeNo+"?mchid="+mchID)
if qerr == nil {
return "连接成功:商户私钥签名与微信支付公钥验签均通过(公钥模式)", nil
}
var apiErr *core.APIError
if errors.As(qerr, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
// 订单不存在属预期结果:说明请求签名与应答验签链路均已通过
return "连接成功:商户私钥签名与微信支付公钥验签均通过(公钥模式)", nil
}
return "", fmt.Errorf("微信凭证校验失败: %w", qerr)
}
// 平台证书模式:平台证书接口只做签名校验,不产生任何交易
result, err := client.Get(ctx, consts.WechatPayAPIServer+"/v3/certificates")
if err != nil {
return "", fmt.Errorf("微信凭证校验失败: %w", err)
+337
View File
@@ -0,0 +1,337 @@
package services
import (
"errors"
"sort"
"strings"
"server/models"
)
// DefaultTenantUserQuota 租户默认用户数上限(未配置套餐/套餐未设置时兜底)
const DefaultTenantUserQuota = 20
// DefaultTenantExtraUserPrice 单个用户增购默认单价(元/人)
const DefaultTenantExtraUserPrice = 200.00
// ErrTenantQuotaExceeded 用户数已达上限
var ErrTenantQuotaExceeded = errors.New("tenant user quota exceeded")
// CountTenantUsers 统计租户有效用户数(未软删)
func CountTenantUsers(tid uint64) (int, error) {
if tid == 0 {
return 0, nil
}
cnt, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("delete_time__isnull", true).
Count()
return int(cnt), err
}
// GetTenantPackage 获取租户生效套餐:优先取绑定套餐,未绑定/已删除时回退到默认套餐
func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) {
if tid == 0 {
return nil, errors.New("tid 不能为空")
}
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return nil, err
}
if tenant.PackageID > 0 {
var pkg models.SystemTenantPackage
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("id", tenant.PackageID).
Filter("delete_time__isnull", true).
One(&pkg); err == nil {
return &pkg, nil
}
}
return GetDefaultTenantPackage()
}
// GetDefaultTenantPackage 获取默认套餐(is_default=1 优先,其次按排序取第一个启用套餐)
func GetDefaultTenantPackage() (*models.SystemTenantPackage, error) {
var pkg models.SystemTenantPackage
err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("delete_time__isnull", true).
Filter("status", 1).
Filter("is_default", 1).
OrderBy("sort", "id").
One(&pkg)
if err == nil {
return &pkg, nil
}
var fallback models.SystemTenantPackage
if err2 := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("delete_time__isnull", true).
Filter("status", 1).
OrderBy("sort", "id").
One(&fallback); err2 != nil {
return nil, err2
}
return &fallback, nil
}
// GetTenantPackageModules 获取租户套餐包含的功能模块(按套餐未绑定则取默认套餐)
func GetTenantPackageModules(tid uint64) ([]models.SystemTenantPackageModule, error) {
pkg, err := GetTenantPackage(tid)
if err != nil {
return nil, err
}
var rows []models.SystemTenantPackageModule
_, err = models.Orm.QueryTable(new(models.SystemTenantPackageModule)).
Filter("package_id", pkg.ID).
OrderBy("id").
All(&rows)
return rows, err
}
// GetTenantModuleCodes 获取租户已开通的功能模块编码集合(如 erp / oa / crm)
func GetTenantModuleCodes(tid uint64) map[string]bool {
codes := map[string]bool{}
rows, err := GetTenantPackageModules(tid)
if err != nil {
return codes
}
for _, r := range rows {
code := strings.TrimSpace(r.ModuleCode)
if code != "" {
codes[code] = true
}
}
return codes
}
// TenantQuotaCheck 用户数配额校验结果
type TenantQuotaCheck struct {
Quota int `json:"quota"`
Used int `json:"used"`
Allowed bool `json:"allowed"`
}
// CheckTenantUserQuota 校验租户是否还能新增用户。
// uid > 0 且该用户已绑定到本租户时视为「编辑/复用已有绑定」,不占用新增名额。
func CheckTenantUserQuota(tid, uid uint64) (*TenantQuotaCheck, error) {
result := &TenantQuotaCheck{Quota: DefaultTenantUserQuota, Allowed: true}
if tid == 0 {
return result, nil
}
quota := DefaultTenantUserQuota
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err == nil && tenant.UserQuota > 0 {
quota = tenant.UserQuota
}
result.Quota = quota
if uid > 0 {
exist, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("uid", uid).
Filter("delete_time__isnull", true).
Count()
if err == nil && exist > 0 {
result.Allowed = true
return result, nil
}
}
used, err := CountTenantUsers(tid)
if err != nil {
return nil, err
}
result.Used = used
result.Allowed = used < quota
return result, nil
}
// EnsureTenantUserQuota 用户数已满时返回 ErrTenantQuotaExceeded
func EnsureTenantUserQuota(tid, uid uint64) error {
check, err := CheckTenantUserQuota(tid, uid)
if err != nil {
return err
}
if !check.Allowed {
return ErrTenantQuotaExceeded
}
return nil
}
// GetTenantQuotaInfo 租户用户数使用情况
type TenantQuotaInfo struct {
Tid uint64 `json:"tid"`
PackageID uint64 `json:"package_id"`
PackageName string `json:"package_name"`
Quota int `json:"quota"`
Used int `json:"used"`
Remaining int `json:"remaining"`
ExtraUserPrice float64 `json:"extra_user_price"`
EffectivePackageID uint64 `json:"effective_package_id"`
}
// GetTenantQuotaInfo 查询租户用户数使用情况(含生效套餐信息)
func GetTenantQuotaInfo(tid uint64) (*TenantQuotaInfo, error) {
info := &TenantQuotaInfo{Tid: tid, Quota: DefaultTenantUserQuota, ExtraUserPrice: DefaultTenantExtraUserPrice}
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return nil, err
}
info.PackageID = tenant.PackageID
if tenant.UserQuota > 0 {
info.Quota = tenant.UserQuota
}
used, err := CountTenantUsers(tid)
if err != nil {
return nil, err
}
info.Used = used
info.Remaining = info.Quota - used
if info.Remaining < 0 {
info.Remaining = 0
}
if pkg, err := GetTenantPackage(tid); err == nil && pkg != nil {
info.EffectivePackageID = pkg.ID
info.PackageName = pkg.Name
if pkg.ExtraUserPrice > 0 {
info.ExtraUserPrice = pkg.ExtraUserPrice
}
}
return info, nil
}
// IncreaseTenantQuota 增加租户用户数上限,返回(变更前上限、变更后上限)
func IncreaseTenantQuota(tid uint64, count int) (int, int, error) {
if tid == 0 || count <= 0 {
return 0, 0, errors.New("参数错误")
}
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return 0, 0, errors.New("租户不存在")
}
before := tenant.UserQuota
if before <= 0 {
before = DefaultTenantUserQuota
}
after := before + count
_, err := models.Orm.QueryTable(new(models.SystemTenant)).
Filter("id", tid).
Update(map[string]interface{}{"user_quota": after})
if err != nil {
return before, after, err
}
return before, after, nil
}
// ApplyPackageToTenant 为租户绑定套餐:同步套餐包含的用户数(不低于当前已开用户数,且不降低已有上限)
func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
if tid == 0 || packageID == 0 {
return 0, errors.New("参数错误")
}
var pkg models.SystemTenantPackage
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("id", packageID).
Filter("delete_time__isnull", true).
One(&pkg); err != nil {
return 0, errors.New("套餐不存在")
}
update := map[string]interface{}{"package_id": packageID}
newQuota := 0
if syncQuota {
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return 0, errors.New("租户不存在")
}
current := tenant.UserQuota
if current <= 0 {
current = DefaultTenantUserQuota
}
newQuota = pkg.UserQuota
if newQuota <= 0 {
newQuota = DefaultTenantUserQuota
}
// 不低于当前上限:换套餐不回收已购用户数
if newQuota < current {
newQuota = current
}
update["user_quota"] = newQuota
}
if _, err := models.Orm.QueryTable(new(models.SystemTenant)).
Filter("id", tid).
Update(update); err != nil {
return 0, err
}
return newQuota, nil
}
// sortModulesByPathDesc 模块按 path 长度倒序:长路径优先匹配,避免 /apps 误吞 /apps/erp
func sortModulesByPathDesc(modules []models.SystemModules) []models.SystemModules {
list := make([]models.SystemModules, 0, len(modules))
for _, m := range modules {
if strings.TrimSpace(m.Path) != "" {
list = append(list, m)
}
}
sort.SliceStable(list, func(i, j int) bool {
return len(list[i].Path) > len(list[j].Path)
})
return list
}
// ownerModuleCode 返回菜单路径归属的模块编码(按模块 path 前缀匹配)。
// 不属于任何模块时 ok=false —— 视为公共菜单,不参与套餐过滤。
func ownerModuleCode(sortedModules []models.SystemModules, path string) (string, bool) {
p := strings.TrimSuffix(strings.TrimSpace(path), "/")
if p == "" {
return "", false
}
for _, m := range sortedModules {
mp := strings.TrimSuffix(strings.TrimSpace(m.Path), "/")
if mp == "" {
continue
}
if p == mp || strings.HasPrefix(p, mp+"/") {
return m.Code, true
}
}
return "", false
}
// FilterMenusByTenantModules 按租户套餐包含的功能模块过滤菜单:
// - 菜单不属于任何模块(公共/基础菜单)→ 保留;
// - 菜单归属某模块 → 仅当该模块已被租户套餐开通时保留。
func FilterMenusByTenantModules(menus []models.SystemMenu, allModules []models.SystemModules, allowedCodes map[string]bool) []models.SystemMenu {
if len(menus) == 0 || len(allModules) == 0 {
return menus
}
sorted := sortModulesByPathDesc(allModules)
out := make([]models.SystemMenu, 0, len(menus))
for _, m := range menus {
path := ""
if m.Path != nil {
path = *m.Path
}
code, ok := ownerModuleCode(sorted, path)
if !ok {
out = append(out, m)
continue
}
if allowedCodes[code] {
out = append(out, m)
}
}
return out
}
// ListEnabledModules 获取全部启用且未删除的模块(用于菜单归属判定)
func ListEnabledModules() ([]models.SystemModules, error) {
var rows []models.SystemModules
_, err := models.Orm.QueryTable(new(models.SystemModules)).
Filter("delete_time__isnull", true).
Filter("status", 1).
OrderBy("sort", "id").
All(&rows)
return rows, err
}