Files
yunzerwebsiteallinone/go/middleware/plan.go
T
2026-09-29 18:25:09 +08:00

156 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"errors"
"log"
"net/http"
"sort"
"strings"
"sync"
"server/pkg/jwtutil"
"server/services"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/server/web/context"
)
// 模块套餐鉴权(PlanAuth)
//
// 把「接口前缀」映射到「功能模块编码」(yz_system_modules.code),
// 当前租户生效套餐(含已开通的单品订单)未包含该模块时拒绝访问。
//
// 与 JWT 鉴权一致支持三档模式(app.conf: plan_enforce):
// - off :完全不启用
// - warn:观察模式,只记录「本会被拦截」的请求(默认,便于上线前核对映射)
// - on :真正拦截,未开通返回 403
//
// 映射表(app.conf: plan_module_paths)示例:
// plan_module_paths = /backend/crm:crm,/backend/erp:erp,/backend/oa:oa
// 按最长前缀匹配;未出现在映射表中的接口不做模块校验(默认放行),
// 因此可以按模块逐个灰度开启,不会误伤用户/角色/文件/日志等通用接口。
const planMappingSeparator = ":"
var (
errPlanNoToken = errors.New("未提供认证信息")
errPlanBadToken = errors.New("认证信息格式错误")
planOnce sync.Once
planMode = AuthModeWarn
planRoutes []planRoute
)
// planRoute 接口前缀 → 模块编码
type planRoute struct {
prefix string
module string
}
func planInit() {
planOnce.Do(func() {
if v, err := beego.AppConfig.String("plan_enforce"); err == nil {
switch strings.ToLower(strings.TrimSpace(v)) {
case AuthModeOff, AuthModeWarn, AuthModeOn:
planMode = strings.ToLower(strings.TrimSpace(v))
}
}
raw, _ := beego.AppConfig.String("plan_module_paths")
for _, item := range strings.Split(raw, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
parts := strings.SplitN(item, planMappingSeparator, 2)
if len(parts) != 2 {
log.Printf("[plan] 忽略无效映射(应为 前缀:模块编码): %s", item)
continue
}
prefix := strings.TrimSpace(parts[0])
module := services.NormalizeModuleCode(parts[1])
if prefix == "" || module == "" {
log.Printf("[plan] 忽略无效映射(前缀或模块编码为空): %s", item)
continue
}
planRoutes = append(planRoutes, planRoute{prefix: prefix, module: module})
}
// 最长前缀优先:/backend/crm/bidding 必须先于 /backend/crm 命中
sort.Slice(planRoutes, func(i, j int) bool {
return len(planRoutes[i].prefix) > len(planRoutes[j].prefix)
})
log.Printf("[plan] 模块套餐鉴权模式=%s,接口映射=%d 条", planMode, len(planRoutes))
})
}
// planModuleOf 返回路径归属的模块编码;未配置归属时返回空字符串
func planModuleOf(path string) string {
for _, r := range planRoutes {
if strings.HasPrefix(path, r.prefix) {
return r.module
}
}
return ""
}
// PlanAuthMiddleware 模块套餐鉴权中间件
func PlanAuthMiddleware() beego.FilterFunc {
return func(ctx *context.Context) {
planInit()
if planMode == AuthModeOff || len(planRoutes) == 0 {
return
}
if ctx.Input.Method() == http.MethodOptions {
return
}
path := cleanPath(ctx.Request.RequestURI)
if !isProtected(path) || isWhitelisted(path) {
return
}
module := planModuleOf(path)
if module == "" {
return
}
claims, err := planClaims(ctx)
if err != nil {
// 未登录 / 令牌无效:交给 JWTAuthMiddleware 与各控制器按原逻辑处理
return
}
if claims.TenantId <= 0 {
return
}
if services.IsModuleEnabled(uint64(claims.TenantId), module) {
return
}
reason := "模块未开通: " + module
if planMode == AuthModeWarn {
warnLog(path, reason)
return
}
ctx.Output.SetStatus(http.StatusForbidden)
_ = ctx.Output.JSON(map[string]interface{}{
"code": 403,
"msg": "该功能未开通,请先购买对应套餐",
}, false, false)
}
}
// planClaims 解析 Authorization 头中的 JWT(仅用于本中间件的模块归属判断)
func planClaims(ctx *context.Context) (*jwtutil.Claims, error) {
authHeader := ctx.Request.Header.Get("Authorization")
if authHeader == "" {
return nil, errPlanNoToken
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, errPlanBadToken
}
return jwtutil.ParseToken(parts[1])
}