diff --git a/apis/types.api b/apis/types.api index 5be4d4c..fe1ded4 100644 --- a/apis/types.api +++ b/apis/types.api @@ -230,6 +230,12 @@ type ( Discount float64 `json:"discount"` MapApple string `json:"map_apple"` } + SubscribePromo { + RuleName string `json:"rule_name"` + RuleType string `json:"rule_type"` + PromoPrice int64 `json:"promo_price"` + ExpiresAt int64 `json:"expires_at"` + } TrafficLimit { StatType string `json:"stat_type"` StatValue int64 `json:"stat_value"` @@ -244,6 +250,7 @@ type ( UnitPrice int64 `json:"unit_price"` UnitTime string `json:"unit_time"` Discount []SubscribeDiscount `json:"discount"` + Promo *SubscribePromo `json:"promo"` NodeCount int64 `json:"node_count"` Replacement int64 `json:"replacement"` Inventory int64 `json:"inventory"` diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 7b61ba9..7da38a6 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -978,17 +978,16 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { } publicSubscribeGroupRouter := router.Group("/v1/public/subscribe") - publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) { // Get subscribe list - publicSubscribeGroupRouter.GET("/list", publicSubscribe.QuerySubscribeListHandler(serverCtx)) + publicSubscribeGroupRouter.GET("/list", middleware.OptionalAuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QuerySubscribeListHandler(serverCtx)) // Get user subscribe node info - publicSubscribeGroupRouter.GET("/node/list", publicSubscribe.QueryUserSubscribeNodeListHandler(serverCtx)) + publicSubscribeGroupRouter.GET("/node/list", middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QueryUserSubscribeNodeListHandler(serverCtx)) // Get subscribe group list - publicSubscribeGroupRouter.GET("/group/list", publicSubscribe.QuerySubscribeGroupListHandler(serverCtx)) + publicSubscribeGroupRouter.GET("/group/list", middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QuerySubscribeGroupListHandler(serverCtx)) } publicTicketGroupRouter := router.Group("/v1/public/ticket") diff --git a/internal/logic/public/subscribe/promo.go b/internal/logic/public/subscribe/promo.go new file mode 100644 index 0000000..1456d3c --- /dev/null +++ b/internal/logic/public/subscribe/promo.go @@ -0,0 +1,224 @@ +package subscribe + +import ( + "context" + "encoding/json" + stderrors "errors" + "strings" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +const ( + promoRuleTypeNewUser = "new_user" + promoRuleTypeInactiveUser = "inactive_user" + promoRuleTypeCampaign = "campaign" +) + +type subscribePromoCandidate struct { + SubscribeId int64 `gorm:"column:subscribe_id"` + RuleName string `gorm:"column:rule_name"` + RuleType string `gorm:"column:rule_type"` + PromoPrice int64 `gorm:"column:promo_price"` + Params string `gorm:"column:params"` + StartTime *time.Time `gorm:"column:start_time"` + EndTime *time.Time `gorm:"column:end_time"` +} + +type promoRuleParams struct { + WindowHours int64 `json:"window_hours"` + InactiveMonths int `json:"inactive_months"` +} + +func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64) (map[int64]*types.SubscribePromo, error) { + result := make(map[int64]*types.SubscribePromo) + if len(subscribeIDs) == 0 || svcCtx == nil || svcCtx.DB == nil { + return result, nil + } + + userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User) + candidates, err := querySubscribePromoCandidates(ctx, svcCtx, subscribeIDs, userInfo != nil) + if err != nil { + if isMissingPromoTableError(err) { + return result, nil + } + return nil, err + } + + evaluator := promoEligibilityEvaluator{ctx: ctx, db: svcCtx.DB, userInfo: userInfo} + now := time.Now() + for _, candidate := range candidates { + if _, exists := result[candidate.SubscribeId]; exists { + continue + } + if !candidate.isActive(now) { + continue + } + ok, expiresAt, err := evaluator.match(candidate, now) + if err != nil { + return nil, err + } + if !ok { + continue + } + result[candidate.SubscribeId] = &types.SubscribePromo{ + RuleName: candidate.RuleName, + RuleType: candidate.RuleType, + PromoPrice: candidate.PromoPrice, + ExpiresAt: unixSeconds(expiresAt), + } + } + + return result, nil +} + +func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64, loggedIn bool) ([]subscribePromoCandidate, error) { + var candidates []subscribePromoCandidate + query := svcCtx.DB.WithContext(ctx). + Table("subscribe_promo AS sp"). + Select("sp.subscribe_id, sp.promo_price, pr.name AS rule_name, pr.type AS rule_type, pr.params, pr.start_time, pr.end_time"). + Joins("JOIN promo_rule AS pr ON pr.id = sp.promo_rule_id AND pr.deleted_at IS NULL"). + Where("sp.subscribe_id IN ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeIDs, true) + if !loggedIn { + query = query.Where("pr.type = ?", promoRuleTypeCampaign) + } + err := query. + Order("sp.subscribe_id ASC"). + Order("pr.priority DESC"). + Order("pr.id ASC"). + Scan(&candidates).Error + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query subscribe promo candidates failed: %v", err) + } + return candidates, nil +} + +func (c subscribePromoCandidate) isActive(now time.Time) bool { + if c.PromoPrice <= 0 { + return false + } + if c.StartTime != nil && now.Before(*c.StartTime) { + return false + } + if c.EndTime != nil && now.After(*c.EndTime) { + return false + } + return true +} + +type promoEligibilityEvaluator struct { + ctx context.Context + db *gorm.DB + userInfo *user.User + lastExpire *time.Time +} + +func (e *promoEligibilityEvaluator) match(candidate subscribePromoCandidate, now time.Time) (bool, time.Time, error) { + switch candidate.RuleType { + case promoRuleTypeCampaign: + return true, candidate.expiresAt(), nil + case promoRuleTypeNewUser: + if e.userInfo == nil { + return false, time.Time{}, nil + } + params, err := candidate.params() + if err != nil { + return false, time.Time{}, err + } + if params.WindowHours <= 0 || e.userInfo.CreatedAt.IsZero() { + return false, time.Time{}, nil + } + expiresAt := e.userInfo.CreatedAt.Add(time.Duration(params.WindowHours) * time.Hour) + return now.Before(expiresAt), expiresAt, nil + case promoRuleTypeInactiveUser: + if e.userInfo == nil { + return false, time.Time{}, nil + } + params, err := candidate.params() + if err != nil { + return false, time.Time{}, err + } + if params.InactiveMonths <= 0 { + return false, time.Time{}, nil + } + lastExpire, err := e.lastSubscribeExpireAt() + if err != nil { + return false, time.Time{}, err + } + if lastExpire.Equal(time.UnixMilli(0)) || lastExpire.After(now) { + return false, time.Time{}, nil + } + if lastExpire.IsZero() { + return true, candidate.expiresAt(), nil + } + threshold := now.AddDate(0, -params.InactiveMonths, 0) + return !lastExpire.After(threshold), candidate.expiresAt(), nil + default: + return false, time.Time{}, nil + } +} + +func (e *promoEligibilityEvaluator) lastSubscribeExpireAt() (time.Time, error) { + if e.lastExpire != nil { + return *e.lastExpire, nil + } + var item user.Subscribe + err := e.db.WithContext(e.ctx). + Model(&user.Subscribe{}). + Where("user_id = ?", e.userInfo.Id). + Where("expire_time != ?", time.UnixMilli(0)). + Order("expire_time DESC"). + Limit(1). + Take(&item).Error + if err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + zero := time.Time{} + e.lastExpire = &zero + return zero, nil + } + return time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo user last subscription failed") + } + e.lastExpire = &item.ExpireTime + return item.ExpireTime, nil +} + +func (c subscribePromoCandidate) expiresAt() time.Time { + if c.EndTime == nil { + return time.Time{} + } + return *c.EndTime +} + +func (c subscribePromoCandidate) params() (promoRuleParams, error) { + if c.Params == "" { + return promoRuleParams{}, nil + } + var params promoRuleParams + if err := json.Unmarshal([]byte(c.Params), ¶ms); err != nil { + return promoRuleParams{}, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "parse promo rule params failed") + } + return params, nil +} + +func unixSeconds(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} + +func isMissingPromoTableError(err error) bool { + var mysqlErr *mysql.MySQLError + if stderrors.As(err, &mysqlErr) { + return mysqlErr.Number == 1146 + } + return strings.Contains(err.Error(), "Error 1146") +} diff --git a/internal/logic/public/subscribe/promo_test.go b/internal/logic/public/subscribe/promo_test.go new file mode 100644 index 0000000..f62e75b --- /dev/null +++ b/internal/logic/public/subscribe/promo_test.go @@ -0,0 +1,75 @@ +package subscribe + +import ( + "testing" + "time" + + "github.com/perfect-panel/server/internal/model/user" +) + +func TestPromoEligibilityEvaluatorMatch(t *testing.T) { + now := time.Unix(1710000000, 0) + campaignEnd := now.Add(2 * time.Hour) + + campaign := subscribePromoCandidate{ + RuleName: "限时活动", + RuleType: promoRuleTypeCampaign, + PromoPrice: 99, + EndTime: &campaignEnd, + } + ok, expiresAt, err := (&promoEligibilityEvaluator{}).match(campaign, now) + if err != nil { + t.Fatalf("campaign match error: %v", err) + } + if !ok { + t.Fatal("campaign promo should match without login") + } + if got, want := unixSeconds(expiresAt), campaignEnd.Unix(); got != want { + t.Fatalf("campaign expires_at = %d, want %d", got, want) + } + + newUser := subscribePromoCandidate{ + RuleName: "新客7天优惠", + RuleType: promoRuleTypeNewUser, + PromoPrice: 279, + Params: `{"window_hours":168}`, + } + ok, _, err = (&promoEligibilityEvaluator{}).match(newUser, now) + if err != nil { + t.Fatalf("anonymous new_user match error: %v", err) + } + if ok { + t.Fatal("new_user promo should not match without login") + } + + userInfo := &user.User{Id: 1, CreatedAt: now.Add(-24 * time.Hour)} + ok, expiresAt, err = (&promoEligibilityEvaluator{userInfo: userInfo}).match(newUser, now) + if err != nil { + t.Fatalf("logged-in new_user match error: %v", err) + } + if !ok { + t.Fatal("new_user promo should match inside window") + } + if got, want := unixSeconds(expiresAt), userInfo.CreatedAt.Add(168*time.Hour).Unix(); got != want { + t.Fatalf("new_user expires_at = %d, want %d", got, want) + } +} + +func TestSubscribePromoCandidateActiveWindow(t *testing.T) { + now := time.Unix(1710000000, 0) + start := now.Add(-time.Hour) + end := now.Add(time.Hour) + + if !(subscribePromoCandidate{PromoPrice: 1, StartTime: &start, EndTime: &end}).isActive(now) { + t.Fatal("candidate inside active window should be active") + } + if (subscribePromoCandidate{PromoPrice: 0, StartTime: &start, EndTime: &end}).isActive(now) { + t.Fatal("candidate with zero promo price should not be active") + } + if (subscribePromoCandidate{PromoPrice: 1, StartTime: &end}).isActive(now) { + t.Fatal("candidate before start time should not be active") + } + if (subscribePromoCandidate{PromoPrice: 1, EndTime: &start}).isActive(now) { + t.Fatal("candidate after end time should not be active") + } +} diff --git a/internal/logic/public/subscribe/querySubscribeListLogic.go b/internal/logic/public/subscribe/querySubscribeListLogic.go index f2c80cf..4b7dda0 100644 --- a/internal/logic/public/subscribe/querySubscribeListLogic.go +++ b/internal/logic/public/subscribe/querySubscribeListLogic.go @@ -47,9 +47,11 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi Total: total, } list := make([]types.Subscribe, len(data)) + subscribeIDs := make([]int64, 0, len(data)) for i, item := range data { var sub types.Subscribe tool.DeepCopy(&sub, item) + subscribeIDs = append(subscribeIDs, sub.Id) if item.Discount != "" { var discount []types.SubscribeDiscount _ = json.Unmarshal([]byte(item.Discount), &discount) @@ -69,6 +71,15 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi } } + promos, err := loadSubscribePromoMap(l.ctx, l.svcCtx, subscribeIDs) + if err != nil { + l.Errorw("[QuerySubscribeListLogic] Query Promo Error", logger.Field("error", err.Error())) + return nil, err + } + for i := range list { + list[i].Promo = promos[list[i].Id] + } + resp.List = list resp.Total = int64(len(list)) return diff --git a/internal/middleware/authMiddleware.go b/internal/middleware/authMiddleware.go index 94f6814..617d2ec 100644 --- a/internal/middleware/authMiddleware.go +++ b/internal/middleware/authMiddleware.go @@ -22,77 +22,92 @@ import ( func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { - ctx := c.Request.Context() + if !authenticateRequest(c, svc, c.GetHeader("Authorization"), true) { + return + } + c.Next() + } +} - jwtConfig := svc.Config.JwtAuth - // get token from header +func OptionalAuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { token := c.GetHeader("Authorization") if token == "" { + c.Next() + return + } + if !authenticateRequest(c, svc, token, false) { + return + } + c.Next() + } +} + +func authenticateRequest(c *gin.Context, svc *svc.ServiceContext, token string, requireToken bool) bool { + ctx := c.Request.Context() + + if token == "" { + if requireToken { logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Token Empty") result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.ErrorTokenEmpty), "Token Empty")) c.Abort() - return } - // parse token - claims, err := jwt.ParseJwtToken(token, jwtConfig.AccessSecret) - if err != nil { - logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] ParseJwtToken", logger.Field("error", err.Error()), logger.Field("token", token)) - result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.ErrorTokenExpire), "Token Invalid")) - c.Abort() - return - } - - loginType := parseLoginType(claims) - if claims["identifier"] != nil { - ctx = context.WithValue(ctx, constant.CtxKeyIdentifier, claims["identifier"].(string)) - } - // get user id from token - userId := int64(claims["UserId"].(float64)) - // get session id from token - sessionId := claims["SessionId"].(string) - // get session id from redis - sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) - value, err := svc.Redis.Get(c, sessionIdCacheKey).Result() - if err != nil { - logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Redis Get", logger.Field("error", err.Error()), logger.Field("sessionId", sessionId)) - result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) - c.Abort() - return - } - - //verify user id - if value != fmt.Sprintf("%v", userId) { - logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Invalid Access", logger.Field("userId", userId), logger.Field("sessionId", sessionId)) - result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) - c.Abort() - return - } - - // sliding session: refresh TTL on every active request - svc.Redis.Expire(c, sessionIdCacheKey, time.Duration(svc.Config.JwtAuth.AccessExpire)*time.Second) - - userInfo, err := svc.UserModel.FindOne(c, userId) - if err != nil { - logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] UserModel FindOne", logger.Field("error", err.Error()), logger.Field("userId", userId)) - result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Database Query Error")) - c.Abort() - return - } - // admin verify - paths := strings.Split(c.Request.URL.Path, "/") - if tool.StringSliceContains(paths, "admin") && !*userInfo.IsAdmin { - logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Not Admin User", logger.Field("userId", userId), logger.Field("sessionId", sessionId)) - result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) - c.Abort() - return - } - ctx = context.WithValue(ctx, constant.CtxLoginType, loginType) - ctx = context.WithValue(ctx, constant.CtxKeyUser, userInfo) - ctx = context.WithValue(ctx, constant.CtxKeySessionID, sessionId) - - c.Request = c.Request.WithContext(ctx) - c.Next() + return !requireToken } + + claims, err := jwt.ParseJwtToken(token, svc.Config.JwtAuth.AccessSecret) + if err != nil { + logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] ParseJwtToken", logger.Field("error", err.Error()), logger.Field("token", token)) + result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.ErrorTokenExpire), "Token Invalid")) + c.Abort() + return false + } + + loginType := parseLoginType(claims) + if claims["identifier"] != nil { + ctx = context.WithValue(ctx, constant.CtxKeyIdentifier, claims["identifier"].(string)) + } + userId := int64(claims["UserId"].(float64)) + sessionId := claims["SessionId"].(string) + sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) + value, err := svc.Redis.Get(c, sessionIdCacheKey).Result() + if err != nil { + logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Redis Get", logger.Field("error", err.Error()), logger.Field("sessionId", sessionId)) + result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) + c.Abort() + return false + } + + if value != fmt.Sprintf("%v", userId) { + logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Invalid Access", logger.Field("userId", userId), logger.Field("sessionId", sessionId)) + result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) + c.Abort() + return false + } + + svc.Redis.Expire(c, sessionIdCacheKey, time.Duration(svc.Config.JwtAuth.AccessExpire)*time.Second) + + userInfo, err := svc.UserModel.FindOne(c, userId) + if err != nil { + logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] UserModel FindOne", logger.Field("error", err.Error()), logger.Field("userId", userId)) + result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Database Query Error")) + c.Abort() + return false + } + + paths := strings.Split(c.Request.URL.Path, "/") + if tool.StringSliceContains(paths, "admin") && !*userInfo.IsAdmin { + logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] Not Admin User", logger.Field("userId", userId), logger.Field("sessionId", sessionId)) + result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")) + c.Abort() + return false + } + ctx = context.WithValue(ctx, constant.CtxLoginType, loginType) + ctx = context.WithValue(ctx, constant.CtxKeyUser, userInfo) + ctx = context.WithValue(ctx, constant.CtxKeySessionID, sessionId) + + c.Request = c.Request.WithContext(ctx) + return true } func parseLoginType(claims map[string]interface{}) string { diff --git a/internal/types/types.go b/internal/types/types.go index 4941bc0..1e282c3 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -2785,6 +2785,13 @@ type StripePayment struct { PublishableKey string `json:"publishable_key"` } +type SubscribePromo struct { + RuleName string `json:"rule_name"` + RuleType string `json:"rule_type"` + PromoPrice int64 `json:"promo_price"` + ExpiresAt int64 `json:"expires_at"` +} + type Subscribe struct { Id int64 `json:"id"` Name string `json:"name"` @@ -2793,6 +2800,7 @@ type Subscribe struct { UnitPrice int64 `json:"unit_price"` UnitTime string `json:"unit_time"` Discount []SubscribeDiscount `json:"discount"` + Promo *SubscribePromo `json:"promo"` NodeCount int64 `json:"node_count"` Replacement int64 `json:"replacement"` Inventory int64 `json:"inventory"`