Compare commits

...

2 Commits

Author SHA1 Message Date
shanshanzhong147 e61988d78f 新功能(#74): 新增促销系统模型层
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 22:11:02 -07:00
shanshanzhong147 5ebfe0a981 新功能(#65): 添加用户订阅限速数据层字段
Build docker and publish / build (20.15.1) (push) Failing after 8m30s
Build docker and publish / build (20.15.1) (pull_request) Successful in 7m58s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 11:23:57 -07:00
20 changed files with 855 additions and 99 deletions
+14 -33
View File
@@ -76,29 +76,6 @@ type (
GiftAmount int64 `json:"gift_amount"`
IsAdmin bool `json:"is_admin"`
}
UserSubscribeDetail {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
User User `json:"user"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"`
NodeGroupId int64 `json:"node_group_id"`
GroupLocked bool `json:"group_locked"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
Token string `json:"token"`
Status uint8 `json:"status"`
EffectiveSpeed int64 `json:"effective_speed"`
IsThrottled bool `json:"is_throttled"`
ThrottleRule string `json:"throttle_rule,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
BatchDeleteUserRequest {
Ids []int64 `json:"ids" validate:"required"`
}
@@ -158,18 +135,22 @@ type (
Total int64 `json:"total"`
}
CreateUserSubscribeRequest {
UserId int64 `json:"user_id"`
ExpiredAt int64 `json:"expired_at"`
Traffic int64 `json:"traffic"`
SubscribeId int64 `json:"subscribe_id"`
UserId int64 `json:"user_id"`
ExpiredAt int64 `json:"expired_at"`
Traffic int64 `json:"traffic"`
SubscribeId int64 `json:"subscribe_id"`
SpeedLimit int64 `json:"speed_limit,optional"`
TrafficLimit string `json:"traffic_limit,optional"`
}
UpdateUserSubscribeRequest {
UserSubscribeId int64 `json:"user_subscribe_id"`
SubscribeId int64 `json:"subscribe_id"`
Traffic int64 `json:"traffic"`
ExpiredAt int64 `json:"expired_at"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
UserSubscribeId int64 `json:"user_subscribe_id"`
SubscribeId int64 `json:"subscribe_id"`
Traffic int64 `json:"traffic"`
ExpiredAt int64 `json:"expired_at"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
SpeedLimit *int64 `json:"speed_limit,omitempty"`
TrafficLimit *string `json:"traffic_limit,omitempty"`
}
GetUserLoginLogsRequest {
Page int `form:"page"`
+36 -7
View File
@@ -171,13 +171,13 @@ type (
DeviceLimit int64 `json:"device_limit"`
}
VerifyConfig {
CaptchaType string `json:"captcha_type"` // local or turnstile
TurnstileSiteKey string `json:"turnstile_site_key"`
TurnstileSecret string `json:"turnstile_secret"`
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` // User login captcha
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` // User register captcha
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` // Admin login captcha
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` // User reset password captcha
CaptchaType string `json:"captcha_type"` // local or turnstile
TurnstileSiteKey string `json:"turnstile_site_key"`
TurnstileSecret string `json:"turnstile_secret"`
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` // User login captcha
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` // User register captcha
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` // Admin login captcha
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` // User reset password captcha
}
NodeConfig {
NodeSecret string `json:"node_secret"`
@@ -536,6 +536,35 @@ type (
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
UserSubscribeDetail {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
User User `json:"user"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"`
NodeGroupId int64 `json:"node_group_id"`
NodeGroupName string `json:"node_group_name"`
GroupLocked bool `json:"group_locked"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
SpeedLimit int64 `json:"speed_limit"`
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
PlanSpeedLimit int64 `json:"plan_speed_limit"`
Token string `json:"token"`
Status uint8 `json:"status"`
EffectiveSpeed int64 `json:"effective_speed"`
IsThrottled bool `json:"is_throttled"`
ThrottleRule string `json:"throttle_rule,omitempty"`
ThrottleStart int64 `json:"throttle_start,omitempty"`
ThrottleEnd int64 `json:"throttle_end,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
UserAffiliate {
Avatar string `json:"avatar"`
Identifier string `json:"identifier"`
@@ -0,0 +1,5 @@
-- Purpose: Rollback user-level speed limit overrides from user_subscribe
ALTER TABLE `user_subscribe`
DROP COLUMN IF EXISTS `traffic_limit`,
DROP COLUMN IF EXISTS `speed_limit`;
@@ -0,0 +1,37 @@
-- Purpose: Add user-level speed limit overrides to user_subscribe
SET @column_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_subscribe'
AND COLUMN_NAME = 'speed_limit'
);
SET @sql = IF(
@column_exists = 0,
'ALTER TABLE `user_subscribe` ADD COLUMN `speed_limit` int NOT NULL DEFAULT 0 COMMENT ''User-level speed limit override (Mbps, 0=use plan default)'' AFTER `upload`',
'SELECT ''Column speed_limit already exists in user_subscribe table'''
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_subscribe'
AND COLUMN_NAME = 'traffic_limit'
);
SET @sql = IF(
@column_exists = 0,
'ALTER TABLE `user_subscribe` ADD COLUMN `traffic_limit` text DEFAULT NULL COMMENT ''User-level traffic limit rules override (JSON, NULL=use plan default)'' AFTER `speed_limit`',
'SELECT ''Column traffic_limit already exists in user_subscribe table'''
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,7 @@
ALTER TABLE `order`
DROP COLUMN IF EXISTS `promo_discount`,
DROP COLUMN IF EXISTS `promo_rule_id`;
DROP TABLE IF EXISTS `promo_usage`;
DROP TABLE IF EXISTS `subscribe_promo`;
DROP TABLE IF EXISTS `promo_rule`;
@@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS `promo_rule` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '规则名称,如"新客7天优惠"',
`type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '规则类型:new_user / inactive_user / campaign',
`params` JSON NOT NULL COMMENT '类型专属参数',
`priority` INT NOT NULL DEFAULT 0 COMMENT '优先级,数值越大越优先匹配',
`enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
`start_time` DATETIME DEFAULT NULL COMMENT '生效开始时间,NULL=立即生效',
`end_time` DATETIME DEFAULT NULL COMMENT '生效结束时间,NULL=永不过期',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间',
PRIMARY KEY (`id`),
KEY `idx_enabled_priority` (`enabled`, `priority` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表';
CREATE TABLE IF NOT EXISTS `subscribe_promo` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID',
`promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID',
`promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '该规格在此规则下的优惠价(分)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_subscribe_rule` (`subscribe_id`, `promo_rule_id`),
KEY `idx_promo_rule_id` (`promo_rule_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表';
CREATE TABLE IF NOT EXISTS `promo_usage` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
`promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '使用的规则 ID',
`subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '购买的规格 ID',
`order_no` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联订单号',
`promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '使用时的促销单价(分)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_rule` (`user_id`, `promo_rule_id`),
KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表';
SET @promo_rule_id_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'order' AND COLUMN_NAME = 'promo_rule_id');
SET @promo_rule_id_sql = IF(@promo_rule_id_exists = 0, 'ALTER TABLE `order` ADD COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''促销规则ID, 0=未使用促销'' AFTER `discount`', 'SELECT 1');
PREPARE stmt FROM @promo_rule_id_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @promo_discount_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'order' AND COLUMN_NAME = 'promo_discount');
SET @promo_discount_sql = IF(@promo_discount_exists = 0, 'ALTER TABLE `order` ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT ''促销优惠金额(分)'' AFTER `promo_rule_id`', 'SELECT 1');
PREPARE stmt FROM @promo_discount_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+2
View File
@@ -22,6 +22,8 @@ type Details struct {
Price int64 `gorm:"type:int;not null;default:0;comment:Original price"`
Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"`
Discount int64 `gorm:"type:int;not null;default:0;comment:Order Discount"`
PromoRuleID int64 `gorm:"type:bigint unsigned;not null;default:0;comment:Promo Rule ID, 0 means no promo"`
PromoDiscount int64 `gorm:"type:bigint;not null;default:0;comment:Promo Discount Amount"`
Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"`
CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount"`
PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Id"`
+26 -24
View File
@@ -3,32 +3,34 @@ package order
import "time"
type Order struct {
Id int64 `gorm:"primaryKey"`
ParentId int64 `gorm:"type:bigint;default:null;comment:Parent Order Id"`
Id int64 `gorm:"primaryKey"`
ParentId int64 `gorm:"type:bigint;default:null;comment:Parent Order Id"`
UserId int64 `gorm:"type:bigint;not null;default:0;comment:User Id"`
SubscriptionUserId int64 `gorm:"type:bigint;not null;default:0;comment:Target user ID for subscription (0=same as UserId)"`
OrderNo string `gorm:"type:varchar(255);not null;default:'';unique;comment:Order No"`
Type uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Type: 1: Subscribe, 2: Renewal, 3: ResetTraffic, 4: Recharge"`
Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"`
Price int64 `gorm:"type:int;not null;default:0;comment:Original price"`
Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"`
GiftAmount int64 `gorm:"type:int;not null;default:0;comment:User Gift Amount"`
Discount int64 `gorm:"type:int;not null;default:0;comment:Discount Amount"`
Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"`
CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount Amount"`
Commission int64 `gorm:"type:int;not null;default:0;comment:Order Commission"`
PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Method Id"`
Method string `gorm:"type:varchar(255);not null;default:'';comment:Payment Method"`
FeeAmount int64 `gorm:"type:int;not null;default:0;comment:Fee Amount"`
TradeNo string `gorm:"type:varchar(255);default:null;comment:Trade No"`
Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished;"`
SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"`
SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"`
AppAccountToken string `gorm:"type:varchar(36);default:null;comment:Apple IAP App Account Token (UUID)"`
ActivationContext string `gorm:"type:text;default:null;comment:Activation context JSON (guest/redemption info for DB fallback)"`
IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
OrderNo string `gorm:"type:varchar(255);not null;default:'';unique;comment:Order No"`
Type uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Type: 1: Subscribe, 2: Renewal, 3: ResetTraffic, 4: Recharge"`
Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"`
Price int64 `gorm:"type:int;not null;default:0;comment:Original price"`
Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"`
GiftAmount int64 `gorm:"type:int;not null;default:0;comment:User Gift Amount"`
Discount int64 `gorm:"type:int;not null;default:0;comment:Discount Amount"`
PromoRuleID int64 `gorm:"type:bigint unsigned;not null;default:0;comment:Promo Rule ID, 0 means no promo"`
PromoDiscount int64 `gorm:"type:bigint;not null;default:0;comment:Promo Discount Amount"`
Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"`
CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount Amount"`
Commission int64 `gorm:"type:int;not null;default:0;comment:Order Commission"`
PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Method Id"`
Method string `gorm:"type:varchar(255);not null;default:'';comment:Payment Method"`
FeeAmount int64 `gorm:"type:int;not null;default:0;comment:Fee Amount"`
TradeNo string `gorm:"type:varchar(255);default:null;comment:Trade No"`
Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished;"`
SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"`
SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"`
AppAccountToken string `gorm:"type:varchar(36);default:null;comment:Apple IAP App Account Token (UUID)"`
ActivationContext string `gorm:"type:text;default:null;comment:Activation context JSON (guest/redemption info for DB fallback)"`
IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
type OrdersTotal struct {
+106
View File
@@ -0,0 +1,106 @@
package promo_rule
import (
"context"
"errors"
"fmt"
"github.com/perfect-panel/server/pkg/cache"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
var _ Model = (*customPromoRuleModel)(nil)
var cachePromoRuleIdPrefix = "cache:promo_rule:id:"
type (
Model interface {
promoRuleModel
customPromoRuleLogicModel
}
promoRuleModel interface {
Insert(ctx context.Context, data *PromoRule, tx ...*gorm.DB) error
FindOne(ctx context.Context, id int64) (*PromoRule, error)
Update(ctx context.Context, data *PromoRule, tx ...*gorm.DB) error
Delete(ctx context.Context, id int64, tx ...*gorm.DB) error
Transaction(ctx context.Context, fn func(db *gorm.DB) error) error
}
customPromoRuleModel struct {
*defaultPromoRuleModel
}
defaultPromoRuleModel struct {
cache.CachedConn
table string
}
)
func newPromoRuleModel(db *gorm.DB, c *redis.Client) *defaultPromoRuleModel {
return &defaultPromoRuleModel{
CachedConn: cache.NewConn(db, c),
table: "`promo_rule`",
}
}
func (m *defaultPromoRuleModel) getCacheKeys(data *PromoRule) []string {
if data == nil {
return []string{}
}
return []string{fmt.Sprintf("%s%v", cachePromoRuleIdPrefix, data.Id)}
}
func (m *defaultPromoRuleModel) Insert(ctx context.Context, data *PromoRule, tx ...*gorm.DB) error {
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Create(data).Error
}, m.getCacheKeys(data)...)
}
func (m *defaultPromoRuleModel) FindOne(ctx context.Context, id int64) (*PromoRule, error) {
key := fmt.Sprintf("%s%v", cachePromoRuleIdPrefix, id)
var resp PromoRule
err := m.QueryCtx(ctx, &resp, key, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&PromoRule{}).Where("`id` = ?", id).First(v).Error
})
if err != nil {
return nil, err
}
return &resp, nil
}
func (m *defaultPromoRuleModel) Update(ctx context.Context, data *PromoRule, tx ...*gorm.DB) error {
old, err := m.FindOne(ctx, data.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Save(data).Error
}, m.getCacheKeys(old)...)
}
func (m *defaultPromoRuleModel) Delete(ctx context.Context, id int64, tx ...*gorm.DB) error {
data, err := m.FindOne(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Delete(&PromoRule{}, id).Error
}, m.getCacheKeys(data)...)
}
func (m *defaultPromoRuleModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error {
return m.TransactCtx(ctx, fn)
}
+44
View File
@@ -0,0 +1,44 @@
package promo_rule
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type customPromoRuleLogicModel interface {
QueryEnabledByPriority(ctx context.Context) ([]*PromoRule, error)
QueryAvailableByPriority(ctx context.Context, now time.Time) ([]*PromoRule, error)
}
func NewModel(conn *gorm.DB, c *redis.Client) Model {
return &customPromoRuleModel{
defaultPromoRuleModel: newPromoRuleModel(conn, c),
}
}
func (m *customPromoRuleModel) QueryEnabledByPriority(ctx context.Context) ([]*PromoRule, error) {
var list []*PromoRule
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&PromoRule{}).
Where("enabled = ?", true).
Order("priority DESC, id DESC").
Find(v).Error
})
return list, err
}
func (m *customPromoRuleModel) QueryAvailableByPriority(ctx context.Context, now time.Time) ([]*PromoRule, error) {
var list []*PromoRule
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&PromoRule{}).
Where("enabled = ?", true).
Where("(start_time IS NULL OR start_time <= ?)", now).
Where("(end_time IS NULL OR end_time >= ?)", now).
Order("priority DESC, id DESC").
Find(v).Error
})
return list, err
}
+73
View File
@@ -0,0 +1,73 @@
package promo_rule
import (
"database/sql/driver"
"encoding/json"
"time"
"gorm.io/gorm"
)
type JSONRawMessage json.RawMessage
func (j *JSONRawMessage) Scan(value interface{}) error {
if value == nil {
*j = JSONRawMessage([]byte("{}"))
return nil
}
switch v := value.(type) {
case []byte:
*j = JSONRawMessage(v)
case string:
*j = JSONRawMessage([]byte(v))
default:
*j = JSONRawMessage([]byte("{}"))
}
return nil
}
func (j JSONRawMessage) Value() (driver.Value, error) {
if len(j) == 0 {
return "{}", nil
}
data, err := json.RawMessage(j).MarshalJSON()
if err != nil {
return nil, err
}
return string(data), nil
}
func (j JSONRawMessage) MarshalJSON() ([]byte, error) {
if len(j) == 0 {
return []byte("{}"), nil
}
return json.RawMessage(j).MarshalJSON()
}
func (j *JSONRawMessage) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
*j = JSONRawMessage([]byte("{}"))
return nil
}
*j = JSONRawMessage(data)
return nil
}
type PromoRule struct {
Id int64 `gorm:"type:bigint unsigned;primaryKey"`
Name string `gorm:"type:varchar(100);not null;default:'';comment:Rule Name"`
Type string `gorm:"type:varchar(32);not null;default:'';comment:Rule Type: new_user, inactive_user, campaign"`
Params JSONRawMessage `gorm:"type:json;not null;comment:Rule Params"`
Priority int64 `gorm:"type:int;not null;default:0;index:idx_enabled_priority,priority:2,sort:desc;comment:Priority"`
Enabled bool `gorm:"type:tinyint(1);not null;default:1;index:idx_enabled_priority,priority:1;comment:Enabled"`
StartTime *time.Time `gorm:"type:datetime;default:null;comment:Start Time"`
EndTime *time.Time `gorm:"type:datetime;default:null;comment:End Time"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:Delete Time"`
}
func (PromoRule) TableName() string {
return "promo_rule"
}
+106
View File
@@ -0,0 +1,106 @@
package promo_usage
import (
"context"
"errors"
"fmt"
"github.com/perfect-panel/server/pkg/cache"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
var _ Model = (*customPromoUsageModel)(nil)
var cachePromoUsageIdPrefix = "cache:promo_usage:id:"
type (
Model interface {
promoUsageModel
customPromoUsageLogicModel
}
promoUsageModel interface {
Insert(ctx context.Context, data *PromoUsage, tx ...*gorm.DB) error
FindOne(ctx context.Context, id int64) (*PromoUsage, error)
Update(ctx context.Context, data *PromoUsage, tx ...*gorm.DB) error
Delete(ctx context.Context, id int64, tx ...*gorm.DB) error
Transaction(ctx context.Context, fn func(db *gorm.DB) error) error
}
customPromoUsageModel struct {
*defaultPromoUsageModel
}
defaultPromoUsageModel struct {
cache.CachedConn
table string
}
)
func newPromoUsageModel(db *gorm.DB, c *redis.Client) *defaultPromoUsageModel {
return &defaultPromoUsageModel{
CachedConn: cache.NewConn(db, c),
table: "`promo_usage`",
}
}
func (m *defaultPromoUsageModel) getCacheKeys(data *PromoUsage) []string {
if data == nil {
return []string{}
}
return []string{fmt.Sprintf("%s%v", cachePromoUsageIdPrefix, data.Id)}
}
func (m *defaultPromoUsageModel) Insert(ctx context.Context, data *PromoUsage, tx ...*gorm.DB) error {
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Create(data).Error
}, m.getCacheKeys(data)...)
}
func (m *defaultPromoUsageModel) FindOne(ctx context.Context, id int64) (*PromoUsage, error) {
key := fmt.Sprintf("%s%v", cachePromoUsageIdPrefix, id)
var resp PromoUsage
err := m.QueryCtx(ctx, &resp, key, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&PromoUsage{}).Where("`id` = ?", id).First(v).Error
})
if err != nil {
return nil, err
}
return &resp, nil
}
func (m *defaultPromoUsageModel) Update(ctx context.Context, data *PromoUsage, tx ...*gorm.DB) error {
old, err := m.FindOne(ctx, data.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Save(data).Error
}, m.getCacheKeys(old)...)
}
func (m *defaultPromoUsageModel) Delete(ctx context.Context, id int64, tx ...*gorm.DB) error {
data, err := m.FindOne(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Delete(&PromoUsage{}, id).Error
}, m.getCacheKeys(data)...)
}
func (m *defaultPromoUsageModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error {
return m.TransactCtx(ctx, fn)
}
+71
View File
@@ -0,0 +1,71 @@
package promo_usage
import (
"context"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type customPromoUsageLogicModel interface {
FindByOrderNo(ctx context.Context, orderNo string) ([]*PromoUsage, error)
FindByUserAndRule(ctx context.Context, userId, promoRuleId int64) ([]*PromoUsage, error)
QueryListByPage(ctx context.Context, page, size int, userId, promoRuleId, subscribeId int64) (int64, []*PromoUsage, error)
}
func NewModel(conn *gorm.DB, c *redis.Client) Model {
return &customPromoUsageModel{
defaultPromoUsageModel: newPromoUsageModel(conn, c),
}
}
func (m *customPromoUsageModel) FindByOrderNo(ctx context.Context, orderNo string) ([]*PromoUsage, error) {
var list []*PromoUsage
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&PromoUsage{}).
Where("order_no = ?", orderNo).
Order("id DESC").
Find(v).Error
})
return list, err
}
func (m *customPromoUsageModel) FindByUserAndRule(ctx context.Context, userId, promoRuleId int64) ([]*PromoUsage, error) {
var list []*PromoUsage
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&PromoUsage{}).
Where("user_id = ? AND promo_rule_id = ?", userId, promoRuleId).
Order("id DESC").
Find(v).Error
})
return list, err
}
func (m *customPromoUsageModel) QueryListByPage(ctx context.Context, page, size int, userId, promoRuleId, subscribeId int64) (int64, []*PromoUsage, error) {
var list []*PromoUsage
var total int64
if page <= 0 {
page = 1
}
if size <= 0 {
size = 20
}
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
db := conn.Model(&PromoUsage{})
if userId > 0 {
db = db.Where("user_id = ?", userId)
}
if promoRuleId > 0 {
db = db.Where("promo_rule_id = ?", promoRuleId)
}
if subscribeId > 0 {
db = db.Where("subscribe_id = ?", subscribeId)
}
if err := db.Count(&total).Error; err != nil {
return err
}
return db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(v).Error
})
return total, list, err
}
+17
View File
@@ -0,0 +1,17 @@
package promo_usage
import "time"
type PromoUsage struct {
Id int64 `gorm:"type:bigint unsigned;primaryKey"`
UserId int64 `gorm:"type:bigint unsigned;not null;index:idx_user_rule,priority:1;comment:User Id"`
PromoRuleId int64 `gorm:"type:bigint unsigned;not null;index:idx_user_rule,priority:2;comment:Promo Rule Id"`
SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe Id"`
OrderNo string `gorm:"type:varchar(255);not null;default:'';index:idx_order_no;comment:Order No"`
PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
}
func (PromoUsage) TableName() string {
return "promo_usage"
}
+106
View File
@@ -0,0 +1,106 @@
package subscribe_promo
import (
"context"
"errors"
"fmt"
"github.com/perfect-panel/server/pkg/cache"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
var _ Model = (*customSubscribePromoModel)(nil)
var cacheSubscribePromoIdPrefix = "cache:subscribe_promo:id:"
type (
Model interface {
subscribePromoModel
customSubscribePromoLogicModel
}
subscribePromoModel interface {
Insert(ctx context.Context, data *SubscribePromo, tx ...*gorm.DB) error
FindOne(ctx context.Context, id int64) (*SubscribePromo, error)
Update(ctx context.Context, data *SubscribePromo, tx ...*gorm.DB) error
Delete(ctx context.Context, id int64, tx ...*gorm.DB) error
Transaction(ctx context.Context, fn func(db *gorm.DB) error) error
}
customSubscribePromoModel struct {
*defaultSubscribePromoModel
}
defaultSubscribePromoModel struct {
cache.CachedConn
table string
}
)
func newSubscribePromoModel(db *gorm.DB, c *redis.Client) *defaultSubscribePromoModel {
return &defaultSubscribePromoModel{
CachedConn: cache.NewConn(db, c),
table: "`subscribe_promo`",
}
}
func (m *defaultSubscribePromoModel) getCacheKeys(data *SubscribePromo) []string {
if data == nil {
return []string{}
}
return []string{fmt.Sprintf("%s%v", cacheSubscribePromoIdPrefix, data.Id)}
}
func (m *defaultSubscribePromoModel) Insert(ctx context.Context, data *SubscribePromo, tx ...*gorm.DB) error {
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Create(data).Error
}, m.getCacheKeys(data)...)
}
func (m *defaultSubscribePromoModel) FindOne(ctx context.Context, id int64) (*SubscribePromo, error) {
key := fmt.Sprintf("%s%v", cacheSubscribePromoIdPrefix, id)
var resp SubscribePromo
err := m.QueryCtx(ctx, &resp, key, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&SubscribePromo{}).Where("`id` = ?", id).First(v).Error
})
if err != nil {
return nil, err
}
return &resp, nil
}
func (m *defaultSubscribePromoModel) Update(ctx context.Context, data *SubscribePromo, tx ...*gorm.DB) error {
old, err := m.FindOne(ctx, data.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Save(data).Error
}, m.getCacheKeys(old)...)
}
func (m *defaultSubscribePromoModel) Delete(ctx context.Context, id int64, tx ...*gorm.DB) error {
data, err := m.FindOne(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Delete(&SubscribePromo{}, id).Error
}, m.getCacheKeys(data)...)
}
func (m *defaultSubscribePromoModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error {
return m.TransactCtx(ctx, fn)
}
+84
View File
@@ -0,0 +1,84 @@
package subscribe_promo
import (
"context"
"time"
"github.com/perfect-panel/server/internal/model/promo_rule"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type RulePromo struct {
Id int64
SubscribeId int64
PromoRuleId int64
PromoPrice int64
RuleName string
RuleType string
Params promo_rule.JSONRawMessage
Priority int64
StartTime *time.Time
EndTime *time.Time
}
type customSubscribePromoLogicModel interface {
FindOneBySubscribeAndRule(ctx context.Context, subscribeId, promoRuleId int64) (*SubscribePromo, error)
FindBySubscribeId(ctx context.Context, subscribeId int64) ([]*SubscribePromo, error)
FindByPromoRuleId(ctx context.Context, promoRuleId int64) ([]*SubscribePromo, error)
QueryEnabledBySubscribeId(ctx context.Context, subscribeId int64) ([]*RulePromo, error)
}
func NewModel(conn *gorm.DB, c *redis.Client) Model {
return &customSubscribePromoModel{
defaultSubscribePromoModel: newSubscribePromoModel(conn, c),
}
}
func (m *customSubscribePromoModel) FindOneBySubscribeAndRule(ctx context.Context, subscribeId, promoRuleId int64) (*SubscribePromo, error) {
var resp SubscribePromo
err := m.QueryNoCacheCtx(ctx, &resp, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&SubscribePromo{}).
Where("subscribe_id = ? AND promo_rule_id = ?", subscribeId, promoRuleId).
First(v).Error
})
if err != nil {
return nil, err
}
return &resp, nil
}
func (m *customSubscribePromoModel) FindBySubscribeId(ctx context.Context, subscribeId int64) ([]*SubscribePromo, error) {
var list []*SubscribePromo
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&SubscribePromo{}).
Where("subscribe_id = ?", subscribeId).
Order("id DESC").
Find(v).Error
})
return list, err
}
func (m *customSubscribePromoModel) FindByPromoRuleId(ctx context.Context, promoRuleId int64) ([]*SubscribePromo, error) {
var list []*SubscribePromo
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&SubscribePromo{}).
Where("promo_rule_id = ?", promoRuleId).
Order("id DESC").
Find(v).Error
})
return list, err
}
func (m *customSubscribePromoModel) QueryEnabledBySubscribeId(ctx context.Context, subscribeId int64) ([]*RulePromo, error) {
var list []*RulePromo
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
return conn.Table("subscribe_promo AS sp").
Select("sp.id, sp.subscribe_id, sp.promo_rule_id, sp.promo_price, pr.name AS rule_name, pr.type AS rule_type, pr.params, pr.priority, 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 = ? AND pr.enabled = ?", subscribeId, true).
Order("pr.priority DESC, pr.id DESC").
Scan(v).Error
})
return list, err
}
@@ -0,0 +1,16 @@
package subscribe_promo
import "time"
type SubscribePromo struct {
Id int64 `gorm:"type:bigint unsigned;primaryKey"`
SubscribeId int64 `gorm:"type:bigint unsigned;not null;index:uk_subscribe_rule,unique,priority:1;comment:Subscribe Id"`
PromoRuleId int64 `gorm:"type:bigint unsigned;not null;index:uk_subscribe_rule,unique,priority:2;index:idx_promo_rule_id;comment:Promo Rule Id"`
PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
func (SubscribePromo) TableName() string {
return "subscribe_promo"
}
+2
View File
@@ -101,6 +101,8 @@ type Subscribe struct {
Traffic int64 `gorm:"default:0;comment:Traffic"`
Download int64 `gorm:"default:0;comment:Download Traffic"`
Upload int64 `gorm:"default:0;comment:Upload Traffic"`
SpeedLimit int64 `gorm:"default:0;comment:User-level speed limit override (Mbps, 0=use plan default)"`
TrafficLimit string `gorm:"type:text;default:null;comment:User-level traffic limit rules override (JSON)"`
ExpiredDownload int64 `gorm:"default:0;comment:Expired period download traffic (bytes)"`
ExpiredUpload int64 `gorm:"default:0;comment:Expired period upload traffic (bytes)"`
Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"`
+10 -1
View File
@@ -21,7 +21,10 @@ import (
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/payment"
"github.com/perfect-panel/server/internal/model/promo_rule"
"github.com/perfect-panel/server/internal/model/promo_usage"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/model/subscribe_promo"
"github.com/perfect-panel/server/internal/model/system"
"github.com/perfect-panel/server/internal/model/ticket"
"github.com/perfect-panel/server/internal/model/traffic"
@@ -45,7 +48,7 @@ type ServiceContext struct {
ExchangeRate float64
GeoIP *IPLocation
SignatureValidator *signature.Validator
S3Store *storage.S3Store
S3Store *storage.S3Store
//NodeCache *cache.NodeCacheClient
AuthModel auth.Model
@@ -65,6 +68,9 @@ type ServiceContext struct {
PaymentModel payment.Model
DocumentModel document.Model
SubscribeModel subscribe.Model
PromoRuleModel promo_rule.Model
SubscribePromoModel subscribe_promo.Model
PromoUsageModel promo_usage.Model
TrafficLogModel traffic.Model
AnnouncementModel announcement.Model
IAPAppleTransactionModel iapapple.Model
@@ -140,6 +146,9 @@ func NewServiceContext(c config.Config) *ServiceContext {
PaymentModel: payment.NewModel(db, rds),
DocumentModel: document.NewModel(db, rds),
SubscribeModel: subscribe.NewModel(db, rds),
PromoRuleModel: promo_rule.NewModel(db, rds),
SubscribePromoModel: subscribe_promo.NewModel(db, rds),
PromoUsageModel: promo_usage.NewModel(db, rds),
TrafficLogModel: traffic.NewModel(db),
AnnouncementModel: announcement.NewModel(db, rds),
}
+41 -34
View File
@@ -541,10 +541,12 @@ type CreateUserRequest struct {
}
type CreateUserSubscribeRequest struct {
UserId int64 `json:"user_id"`
ExpiredAt int64 `json:"expired_at"`
Traffic int64 `json:"traffic"`
SubscribeId int64 `json:"subscribe_id"`
UserId int64 `json:"user_id"`
ExpiredAt int64 `json:"expired_at"`
Traffic int64 `json:"traffic"`
SubscribeId int64 `json:"subscribe_id"`
SpeedLimit int64 `json:"speed_limit,optional"`
TrafficLimit string `json:"traffic_limit,optional"`
}
type CreateUserTicketFollowRequest struct {
@@ -3317,12 +3319,14 @@ type UpdateUserSubscribeNoteRequest struct {
}
type UpdateUserSubscribeRequest struct {
UserSubscribeId int64 `json:"user_subscribe_id"`
SubscribeId int64 `json:"subscribe_id"`
Traffic int64 `json:"traffic"`
ExpiredAt int64 `json:"expired_at"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
UserSubscribeId int64 `json:"user_subscribe_id"`
SubscribeId int64 `json:"subscribe_id"`
Traffic int64 `json:"traffic"`
ExpiredAt int64 `json:"expired_at"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
SpeedLimit *int64 `json:"speed_limit,omitempty"`
TrafficLimit *string `json:"traffic_limit,omitempty"`
}
type UpdateUserTicketStatusRequest struct {
@@ -3474,30 +3478,33 @@ type UserSubscribe struct {
}
type UserSubscribeDetail struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
User User `json:"user"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"`
NodeGroupId int64 `json:"node_group_id"`
NodeGroupName string `json:"node_group_name"`
GroupLocked bool `json:"group_locked"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
Token string `json:"token"`
Status uint8 `json:"status"`
EffectiveSpeed int64 `json:"effective_speed"`
IsThrottled bool `json:"is_throttled"`
ThrottleRule string `json:"throttle_rule,omitempty"`
ThrottleStart int64 `json:"throttle_start,omitempty"`
ThrottleEnd int64 `json:"throttle_end,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
User User `json:"user"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"`
NodeGroupId int64 `json:"node_group_id"`
NodeGroupName string `json:"node_group_name"`
GroupLocked bool `json:"group_locked"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
SpeedLimit int64 `json:"speed_limit"`
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
PlanSpeedLimit int64 `json:"plan_speed_limit"`
Token string `json:"token"`
Status uint8 `json:"status"`
EffectiveSpeed int64 `json:"effective_speed"`
IsThrottled bool `json:"is_throttled"`
ThrottleRule string `json:"throttle_rule,omitempty"`
ThrottleStart int64 `json:"throttle_start,omitempty"`
ThrottleEnd int64 `json:"throttle_end,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type UserSubscribeInfo struct {