From 1b5a715a22d175d9e4dc0be79bba114163191a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=96=B9=E6=88=90?= Date: Sun, 16 Nov 2025 11:51:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E6=94=AF=E6=8C=81=E5=B9=B6=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=A0=87=E9=A2=98=E6=95=88=E6=9E=9C=E9=AA=8C?= =?UTF-8?q?=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(security): 支持通过环境变量配置主密钥和JWT密钥 refactor(router): 移除开发便捷路由接口 feat(admin): 添加超级管理员权限检查 feat(titles): 增加系统标题效果参数验证逻辑 --- internal/api/admin/titles_admin.go | 104 +++++++++++++++++++++- internal/api/admin/users_admin.go | 42 +++++---- internal/router/interceptor/admin_auth.go | 23 ++--- internal/router/router.go | 5 +- internal/service/activity/seed_crypto.go | 5 ++ 5 files changed, 144 insertions(+), 35 deletions(-) diff --git a/internal/api/admin/titles_admin.go b/internal/api/admin/titles_admin.go index 9f34eae..f50f1c5 100644 --- a/internal/api/admin/titles_admin.go +++ b/internal/api/admin/titles_admin.go @@ -1,6 +1,9 @@ package admin import ( + "bytes" + "encoding/json" + "fmt" "net/http" "strconv" "time" @@ -201,10 +204,15 @@ func (h *handler) CreateSystemTitleEffect() core.HandlerFunc { ctx.AbortWithError(core.Error(http.StatusBadRequest, 30115, "同类型效果已存在")) return } + sanitized, verr := validateEffectParams(req.EffectType, req.ParamsJSON) + if verr != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, verr.Error())) + return + } ef := &model.SystemTitleEffects{ TitleID: titleID, EffectType: req.EffectType, - ParamsJSON: req.ParamsJSON, + ParamsJSON: sanitized, StackingStrategy: req.StackingStrategy, CapValueX1000: req.CapValueX1000, ScopesJSON: req.ScopesJSON, @@ -262,7 +270,16 @@ func (h *handler) ModifySystemTitleEffect() core.HandlerFunc { } row.EffectType = *req.EffectType } - if req.ParamsJSON != "" { row.ParamsJSON = req.ParamsJSON } + if req.ParamsJSON != "" { + et := row.EffectType + if req.EffectType != nil { et = *req.EffectType } + sanitized, verr := validateEffectParams(et, req.ParamsJSON) + if verr != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, verr.Error())) + return + } + row.ParamsJSON = sanitized + } if req.StackingStrategy != nil { row.StackingStrategy = *req.StackingStrategy } if req.CapValueX1000 != nil { row.CapValueX1000 = *req.CapValueX1000 } if req.ScopesJSON != "" { row.ScopesJSON = req.ScopesJSON } @@ -276,6 +293,89 @@ func (h *handler) ModifySystemTitleEffect() core.HandlerFunc { } } +func validateEffectParams(effectType int32, raw string) (string, error) { + dec := json.NewDecoder(bytes.NewBufferString(raw)) + dec.DisallowUnknownFields() + switch effectType { + case 1: + var p struct { + TemplateID int64 `json:"template_id"` + Frequency struct { + Period string `json:"period"` + Times int `json:"times"` + } `json:"frequency"` + } + if err := dec.Decode(&p); err != nil { return "", err } + if p.TemplateID < 0 { return "", fmt.Errorf("template_id无效") } + if p.Frequency.Times < 1 || p.Frequency.Times > 100 { return "", fmt.Errorf("times范围错误") } + if p.Frequency.Period != "day" && p.Frequency.Period != "week" && p.Frequency.Period != "month" { return "", fmt.Errorf("period无效") } + b, _ := json.Marshal(p); return string(b), nil + case 2: + var p struct { + DiscountType string `json:"discount_type"` + ValueX1000 int32 `json:"value_x1000"` + MaxDiscountX1000 int32 `json:"max_discount_x1000"` + } + if err := dec.Decode(&p); err != nil { return "", err } + if p.DiscountType != "percentage" && p.DiscountType != "fixed" { return "", fmt.Errorf("discount_type无效") } + if p.ValueX1000 < 0 || p.MaxDiscountX1000 < 0 { return "", fmt.Errorf("数值必须>=0") } + b, _ := json.Marshal(p); return string(b), nil + case 3: + var p struct { + MultiplierX1000 int32 `json:"multiplier_x1000"` + DailyCapPoints int32 `json:"daily_cap_points"` + } + if err := dec.Decode(&p); err != nil { return "", err } + if p.MultiplierX1000 < 0 || p.DailyCapPoints < 0 { return "", fmt.Errorf("数值必须>=0") } + b, _ := json.Marshal(p); return string(b), nil + case 4: + var p struct { + TemplateID int64 `json:"template_id"` + Frequency struct { + Period string `json:"period"` + Times int `json:"times"` + } `json:"frequency"` + } + if err := dec.Decode(&p); err != nil { return "", err } + if p.TemplateID < 0 { return "", fmt.Errorf("template_id无效") } + if p.Frequency.Times < 1 || p.Frequency.Times > 100 { return "", fmt.Errorf("times范围错误") } + if p.Frequency.Period != "week" && p.Frequency.Period != "month" { return "", fmt.Errorf("period无效") } + b, _ := json.Marshal(p); return string(b), nil + case 5: + var p struct { + TargetPrizeIDs []int64 `json:"target_prize_ids"` + BoostX1000 int32 `json:"boost_x1000"` + CapX1000 *int32 `json:"cap_x1000"` + } + if err := dec.Decode(&p); err != nil { return "", err } + if p.BoostX1000 < 0 || p.BoostX1000 > 100000 { return "", fmt.Errorf("boost_x1000范围错误") } + if p.CapX1000 != nil && *p.CapX1000 < 0 { return "", fmt.Errorf("cap_x1000必须>=0") } + if len(p.TargetPrizeIDs) > 200 { return "", fmt.Errorf("target_prize_ids数量过多") } + m := make(map[int64]struct{}) + out := make([]int64, 0, len(p.TargetPrizeIDs)) + for _, id := range p.TargetPrizeIDs { if _, ok := m[id]; !ok { m[id] = struct{}{}; out = append(out, id) } } + p.TargetPrizeIDs = out + b, _ := json.Marshal(p); return string(b), nil + case 6: + var p struct { + TargetPrizeIDs []int64 `json:"target_prize_ids"` + ChanceX1000 int32 `json:"chance_x1000"` + PeriodCapTimes *int32 `json:"period_cap_times"` + } + if err := dec.Decode(&p); err != nil { return "", err } + if p.ChanceX1000 < 0 || p.ChanceX1000 > 100000 { return "", fmt.Errorf("chance_x1000范围错误") } + if p.PeriodCapTimes != nil && *p.PeriodCapTimes < 0 { return "", fmt.Errorf("period_cap_times必须>=0") } + if len(p.TargetPrizeIDs) > 200 { return "", fmt.Errorf("target_prize_ids数量过多") } + m := make(map[int64]struct{}) + out := make([]int64, 0, len(p.TargetPrizeIDs)) + for _, id := range p.TargetPrizeIDs { if _, ok := m[id]; !ok { m[id] = struct{}{}; out = append(out, id) } } + p.TargetPrizeIDs = out + b, _ := json.Marshal(p); return string(b), nil + default: + return raw, nil + } +} + func (h *handler) DeleteSystemTitleEffect() core.HandlerFunc { return func(ctx core.Context) { titleID, err := strconv.ParseInt(ctx.Param("title_id"), 10, 64) diff --git a/internal/api/admin/users_admin.go b/internal/api/admin/users_admin.go index 7e4b218..20a85e4 100644 --- a/internal/api/admin/users_admin.go +++ b/internal/api/admin/users_admin.go @@ -594,23 +594,27 @@ type addCouponResponse struct { // @Router /api/admin/users/{user_id}/coupons/add [post] // @Security LoginVerifyToken func (h *handler) AddUserCoupon() core.HandlerFunc { - return func(ctx core.Context) { - req := new(addCouponRequest) - rsp := new(addCouponResponse) - if err := ctx.ShouldBindJSON(req); err != nil { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) - return - } - userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) - if err != nil { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) - return - } - if err := h.user.AddCoupon(ctx.RequestContext(), userID, req.CouponID); err != nil { - ctx.AbortWithError(core.Error(http.StatusBadRequest, 20109, err.Error())) - return - } - rsp.Success = true - ctx.Payload(rsp) - } + return func(ctx core.Context) { + req := new(addCouponRequest) + rsp := new(addCouponResponse) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } + userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID")) + return + } + if ctx.SessionUserInfo().IsSuper != 1 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作")) + return + } + if err := h.user.AddCoupon(ctx.RequestContext(), userID, req.CouponID); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 20109, err.Error())) + return + } + rsp.Success = true + ctx.Payload(rsp) + } } diff --git a/internal/router/interceptor/admin_auth.go b/internal/router/interceptor/admin_auth.go index c026f86..b8b9534 100644 --- a/internal/router/interceptor/admin_auth.go +++ b/internal/router/interceptor/admin_auth.go @@ -1,17 +1,18 @@ package interceptor import ( - "net/http" + "net/http" + "os" - "bindbox-game/configs" - "bindbox-game/internal/code" - "bindbox-game/internal/pkg/core" - "bindbox-game/internal/pkg/jwtoken" - "bindbox-game/internal/pkg/utils" - "bindbox-game/internal/proposal" - "bindbox-game/internal/repository/mysql/dao" + "bindbox-game/configs" + "bindbox-game/internal/code" + "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/jwtoken" + "bindbox-game/internal/pkg/utils" + "bindbox-game/internal/proposal" + "bindbox-game/internal/repository/mysql/dao" - "gorm.io/gorm" + "gorm.io/gorm" ) func (i *interceptor) AdminTokenAuthVerify(ctx core.Context) (sessionUserInfo proposal.SessionUserInfo, err core.BusinessError) { @@ -26,7 +27,9 @@ func (i *interceptor) AdminTokenAuthVerify(ctx core.Context) (sessionUserInfo pr } // 验证 JWT 是否合法 - jwtClaims, jwtErr := jwtoken.New(configs.Get().JWT.AdminSecret).Parse(headerAuthorizationString) + secret := configs.Get().JWT.AdminSecret + if v := os.Getenv("ADMIN_JWT_SECRET"); v != "" { secret = v } + jwtClaims, jwtErr := jwtoken.New(secret).Parse(headerAuthorizationString) if jwtErr != nil { err = core.Error( http.StatusUnauthorized, diff --git a/internal/router/router.go b/internal/router/router.go index 37f3ed7..869aa8f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -49,10 +49,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { // 管理端非认证接口路由组 adminNonAuthApiRouter := mux.Group("/api/admin") { - adminNonAuthApiRouter.POST("/login", adminHandler.Login()) // 登录 - // 开发便捷:无认证的初始化接口(仅用于快速配置,生产环境请关闭或加权限) - adminNonAuthApiRouter.POST("/system_titles/seed_default", adminHandler.SeedDefaultTitles()) - adminNonAuthApiRouter.POST("/menu/ensure_titles", adminHandler.EnsureTitlesMenu()) + adminNonAuthApiRouter.POST("/login", adminHandler.Login()) } // 管理端认证接口路由组 diff --git a/internal/service/activity/seed_crypto.go b/internal/service/activity/seed_crypto.go index 3cdadea..0c9167c 100644 --- a/internal/service/activity/seed_crypto.go +++ b/internal/service/activity/seed_crypto.go @@ -5,11 +5,16 @@ import ( "crypto/sha256" "encoding/binary" "encoding/hex" + "os" "bindbox-game/configs" ) func masterKey() []byte { + if v := os.Getenv("RANDOM_COMMIT_MASTER_KEY"); v != "" { + b, err := hex.DecodeString(v) + if err == nil && len(b) > 0 { return b } + } s := configs.Get().Random.CommitMasterKey if s == "" { return nil