Compare commits
7 Commits
master
...
9912df9ac6
| Author | SHA1 | Date | |
|---|---|---|---|
| 9912df9ac6 | |||
| bafb13cf06 | |||
| 9a8ae8b6fd | |||
| c8258dc93b | |||
| c0d839deb9 | |||
| 800f9c8460 | |||
| 954b19c332 |
+1
-1
@@ -28,7 +28,7 @@ FROM scratch
|
|||||||
|
|
||||||
# Copy CA certificates and timezone data
|
# Copy CA certificates and timezone data
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai
|
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||||
|
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ type (
|
|||||||
UserAgent string `json:"user_agent" validate:"required"`
|
UserAgent string `json:"user_agent" validate:"required"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
ShortCode string `json:"short_code,optional"`
|
ShortCode string `json:"short_code,optional"`
|
||||||
|
BasePayload string `json:"base_payload,optional"`
|
||||||
}
|
}
|
||||||
GenerateCaptchaResponse {
|
GenerateCaptchaResponse {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ type (
|
|||||||
SubscribeDiscount {
|
SubscribeDiscount {
|
||||||
Quantity int64 `json:"quantity"`
|
Quantity int64 `json:"quantity"`
|
||||||
Discount float64 `json:"discount"`
|
Discount float64 `json:"discount"`
|
||||||
|
MapApple string `json:"map_apple"`
|
||||||
}
|
}
|
||||||
TrafficLimit {
|
TrafficLimit {
|
||||||
StatType string `json:"stat_type"`
|
StatType string `json:"stat_type"`
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user_device` DROP COLUMN `base_payload`;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user_device` ADD COLUMN `base_payload` TEXT DEFAULT NULL COMMENT 'Base Payload' AFTER `short_code`;
|
||||||
@@ -96,6 +96,17 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user failed: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user failed: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update base_payload if provided
|
||||||
|
if req.BasePayload != "" && req.BasePayload != deviceInfo.BasePayload {
|
||||||
|
deviceInfo.BasePayload = req.BasePayload
|
||||||
|
if updateErr := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); updateErr != nil {
|
||||||
|
l.Errorw("update device base_payload failed",
|
||||||
|
logger.Field("device_id", deviceInfo.Id),
|
||||||
|
logger.Field("error", updateErr.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 注销后 device auth_method 被删除,重新登录时需要补回
|
// 注销后 device auth_method 被删除,重新登录时需要补回
|
||||||
hasDeviceAuth := false
|
hasDeviceAuth := false
|
||||||
for _, am := range userInfo.AuthMethods {
|
for _, am := range userInfo.AuthMethods {
|
||||||
@@ -225,6 +236,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
|
|||||||
UserAgent: req.UserAgent,
|
UserAgent: req.UserAgent,
|
||||||
Identifier: req.Identifier,
|
Identifier: req.Identifier,
|
||||||
ShortCode: req.ShortCode,
|
ShortCode: req.ShortCode,
|
||||||
|
BasePayload: req.BasePayload,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Online: false,
|
Online: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rc := l.svcCtx.Config.Register
|
rc := l.svcCtx.Config.Register
|
||||||
if ShouldGrantTrialForEmail(rc, req.Email) {
|
if ShouldGrantTrialForEmail(rc, req.Email) && !NormalizedEmailHasTrial(l.ctx, l.svcCtx.DB, req.Email, rc.TrialSubscribe) {
|
||||||
if err = l.activeTrial(userInfo.Id); err != nil {
|
if err = l.activeTrial(userInfo.Id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ func (l *OAuthLoginGetTokenLogic) register(email, avatar, method, openid, reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
rc := l.svcCtx.Config.Register
|
rc := l.svcCtx.Config.Register
|
||||||
shouldActivateTrial := email != "" && authlogic.ShouldGrantTrialForEmail(rc, email)
|
shouldActivateTrial := email != "" && authlogic.ShouldGrantTrialForEmail(rc, email) && !authlogic.NormalizedEmailHasTrial(l.ctx, l.svcCtx.DB, email, rc.TrialSubscribe)
|
||||||
|
|
||||||
if shouldActivateTrial {
|
if shouldActivateTrial {
|
||||||
l.Debugw("activating trial subscription",
|
l.Debugw("activating trial subscription",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/config"
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsEmailDomainWhitelisted checks if the email's domain is in the comma-separated whitelist.
|
// IsEmailDomainWhitelisted checks if the email's domain is in the comma-separated whitelist.
|
||||||
@@ -37,3 +40,77 @@ func ShouldGrantTrialForEmail(register config.RegisterConfig, email string) bool
|
|||||||
}
|
}
|
||||||
return IsEmailDomainWhitelisted(email, register.TrialEmailDomainWhitelist)
|
return IsEmailDomainWhitelisted(email, register.TrialEmailDomainWhitelist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeEmail returns a canonical form of the email for trial deduplication.
|
||||||
|
// Strips "+" aliases universally (user+tag@any.com → user@any.com).
|
||||||
|
// Removes dots from local part for Gmail-like providers (gmail.com, googlemail.com).
|
||||||
|
func NormalizeEmail(email string) string {
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
parts := strings.SplitN(email, "@", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
local, domain := parts[0], parts[1]
|
||||||
|
|
||||||
|
// Strip + alias
|
||||||
|
if idx := strings.IndexByte(local, '+'); idx != -1 {
|
||||||
|
local = local[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove dots for Gmail-like providers that ignore dots in local part
|
||||||
|
if isGmailLikeDomain(domain) {
|
||||||
|
local = strings.ReplaceAll(local, ".", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return local + "@" + domain
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGmailLikeDomain(domain string) bool {
|
||||||
|
switch domain {
|
||||||
|
case "gmail.com", "googlemail.com":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizedEmailHasTrial returns true if any user with the same normalized email
|
||||||
|
// already holds a trial subscription. Only performs the cross-user DB check when
|
||||||
|
// normalization actually changes the email (i.e., dots removed or + alias stripped).
|
||||||
|
func NormalizedEmailHasTrial(ctx context.Context, db *gorm.DB, email string, trialSubscribeId int64) bool {
|
||||||
|
normalized := NormalizeEmail(email)
|
||||||
|
if normalized == strings.ToLower(strings.TrimSpace(email)) {
|
||||||
|
return false // normalization changed nothing, skip cross-user check
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(normalized, "@", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
domain := parts[1]
|
||||||
|
|
||||||
|
var authMethods []usermodel.AuthMethods
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Model(&usermodel.AuthMethods{}).
|
||||||
|
Select("user_id, auth_identifier").
|
||||||
|
Where("auth_type = ? AND auth_identifier LIKE ?", "email", "%@"+domain).
|
||||||
|
Find(&authMethods).Error; err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, am := range authMethods {
|
||||||
|
if NormalizeEmail(am.AuthIdentifier) != normalized {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Model(&usermodel.Subscribe{}).
|
||||||
|
Where("user_id = ? AND subscribe_id = ?", am.UserId, trialSubscribeId).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
|||||||
|
|
||||||
// Activate trial subscription after transaction success (moved outside transaction to reduce lock time)
|
// Activate trial subscription after transaction success (moved outside transaction to reduce lock time)
|
||||||
rc := l.svcCtx.Config.Register
|
rc := l.svcCtx.Config.Register
|
||||||
if ShouldGrantTrialForEmail(rc, req.Email) {
|
if ShouldGrantTrialForEmail(rc, req.Email) && !NormalizedEmailHasTrial(l.ctx, l.svcCtx.DB, req.Email, rc.TrialSubscribe) {
|
||||||
trialSubscribe, err = l.activeTrial(userInfo.Id)
|
trialSubscribe, err = l.activeTrial(userInfo.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("Failed to activate trial subscription", logger.Field("error", err.Error()))
|
l.Errorw("Failed to activate trial subscription", logger.Field("error", err.Error()))
|
||||||
|
|||||||
@@ -111,6 +111,26 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 非单订阅模式下,若用户已有同套餐订阅(含过期),提前路由为续费,防止创建重复订阅
|
||||||
|
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
||||||
|
var existSub user.Subscribe
|
||||||
|
if e := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&user.Subscribe{}).
|
||||||
|
Where("user_id = ? AND subscribe_id = ?", entitlement.EffectiveUserID, targetSubscribeID).
|
||||||
|
Order("expire_time DESC").
|
||||||
|
First(&existSub).Error; e == nil && existSub.Id > 0 && existSub.Token != "" {
|
||||||
|
orderType = 2
|
||||||
|
parentOrderID = existSub.OrderId
|
||||||
|
subscribeToken = existSub.Token
|
||||||
|
l.Infow("[Purchase] non-single mode purchase routed to renewal (existing subscription found)",
|
||||||
|
logger.Field("existing_subscribe_id", existSub.Id),
|
||||||
|
logger.Field("existing_status", existSub.Status),
|
||||||
|
logger.Field("user_id", u.Id),
|
||||||
|
logger.Field("subscribe_id", targetSubscribeID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 单订阅模式下,若已有同套餐 pending 订单,关闭旧单后继续创建新单(确保新单参数生效)
|
// 单订阅模式下,若已有同套餐 pending 订单,关闭旧单后继续创建新单(确保新单参数生效)
|
||||||
if l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
if l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
||||||
var existPending order.Order
|
var existPending order.Order
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ func tryGrantTrialOnEmailBind(ctx context.Context, svcCtx *svc.ServiceContext, l
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-user check: prevent the same real inbox (via dot trick / + alias) from
|
||||||
|
// getting multiple trials across different accounts.
|
||||||
|
if auth.NormalizedEmailHasTrial(ctx, svcCtx.DB, email, rc.TrialSubscribe) {
|
||||||
|
log.Infow("normalized email already has trial via another account, skip",
|
||||||
|
logger.Field("email", email),
|
||||||
|
logger.Field("owner_user_id", ownerUserId),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sub, err := svcCtx.SubscribeModel.FindOne(ctx, rc.TrialSubscribe)
|
sub, err := svcCtx.SubscribeModel.FindOne(ctx, rc.TrialSubscribe)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorw("failed to find trial subscribe template", logger.Field("error", err.Error()))
|
log.Errorw("failed to find trial subscribe template", logger.Field("error", err.Error()))
|
||||||
|
|||||||
@@ -166,8 +166,8 @@ func (m *customOrderModel) QueryMonthlyOrders(ctx context.Context, date time.Tim
|
|||||||
|
|
||||||
// QueryDateOrders Query orders by date
|
// QueryDateOrders Query orders by date
|
||||||
func (m *customOrderModel) QueryDateOrders(ctx context.Context, date time.Time) (OrdersTotal, error) {
|
func (m *customOrderModel) QueryDateOrders(ctx context.Context, date time.Time) (OrdersTotal, error) {
|
||||||
start := date.Truncate(24 * time.Hour)
|
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||||
end := start.Add(24 * time.Hour).Add(-time.Nanosecond)
|
end := start.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||||
var result OrdersTotal
|
var result OrdersTotal
|
||||||
err := m.QueryNoCacheCtx(ctx, &result, func(conn *gorm.DB, v interface{}) error {
|
err := m.QueryNoCacheCtx(ctx, &result, func(conn *gorm.DB, v interface{}) error {
|
||||||
return conn.Model(&Order{}).
|
return conn.Model(&Order{}).
|
||||||
@@ -276,7 +276,7 @@ func (m *customOrderModel) QueryDailyOrdersList(ctx context.Context, date time.T
|
|||||||
// 当月 1 号 00:00:00
|
// 当月 1 号 00:00:00
|
||||||
firstDay := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
|
firstDay := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
|
||||||
// 第二天 00:00:00
|
// 第二天 00:00:00
|
||||||
nextDay := date.AddDate(0, 0, 1).Truncate(24 * time.Hour)
|
nextDay := time.Date(date.Year(), date.Month(), date.Day()+1, 0, 0, 0, 0, date.Location())
|
||||||
|
|
||||||
return conn.Model(&Order{}).
|
return conn.Model(&Order{}).
|
||||||
Select(`
|
Select(`
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ func NewModel(conn *gorm.DB) Model {
|
|||||||
|
|
||||||
func (m *customTrafficModel) QueryServerTrafficByDay(ctx context.Context, serverId int64, date time.Time) (*TotalTraffic, error) {
|
func (m *customTrafficModel) QueryServerTrafficByDay(ctx context.Context, serverId int64, date time.Time) (*TotalTraffic, error) {
|
||||||
var data TotalTraffic
|
var data TotalTraffic
|
||||||
start := date.Truncate(24 * time.Hour)
|
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||||
end := start.Add(24 * time.Hour).Add(-time.Nanosecond)
|
end := start.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||||
err := m.Conn.WithContext(ctx).Model(&TrafficLog{}).
|
err := m.Conn.WithContext(ctx).Model(&TrafficLog{}).
|
||||||
Select("sum(download) as download, sum(upload) as upload").
|
Select("sum(download) as download, sum(upload) as upload").
|
||||||
Where("server_id = ? AND timestamp BETWEEN ? AND ?", serverId, start, end).
|
Where("server_id = ? AND timestamp BETWEEN ? AND ?", serverId, start, end).
|
||||||
|
|||||||
@@ -314,8 +314,8 @@ func (m *customUserModel) UpdateUserSubscribeWithTraffic(ctx context.Context, id
|
|||||||
|
|
||||||
func (m *customUserModel) QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error) {
|
func (m *customUserModel) QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
start := date.Truncate(24 * time.Hour)
|
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||||
end := start.Add(24 * time.Hour).Add(-time.Second)
|
end := start.AddDate(0, 0, 1).Add(-time.Second)
|
||||||
err := m.QueryNoCacheCtx(ctx, &total, func(conn *gorm.DB, v interface{}) error {
|
err := m.QueryNoCacheCtx(ctx, &total, func(conn *gorm.DB, v interface{}) error {
|
||||||
return conn.Model(&User{}).Where("created_at > ? and created_at < ?", start, end).Count(&total).Error
|
return conn.Model(&User{}).Where("created_at > ? and created_at < ?", start, end).Count(&total).Error
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ type Device struct {
|
|||||||
UserAgent string `gorm:"default:null;comment:UserAgent."`
|
UserAgent string `gorm:"default:null;comment:UserAgent."`
|
||||||
Identifier string `gorm:"type:varchar(255);unique;index:idx_identifier;default:'';comment:Device Identifier"`
|
Identifier string `gorm:"type:varchar(255);unique;index:idx_identifier;default:'';comment:Device Identifier"`
|
||||||
ShortCode string `gorm:"type:varchar(255);default:'';comment:Short Code"`
|
ShortCode string `gorm:"type:varchar(255);default:'';comment:Short Code"`
|
||||||
|
BasePayload string `gorm:"type:text;default:null;comment:Base Payload"`
|
||||||
Online bool `gorm:"default:false;not null;comment:Online"`
|
Online bool `gorm:"default:false;not null;comment:Online"`
|
||||||
Enabled bool `gorm:"default:true;not null;comment:Enabled"`
|
Enabled bool `gorm:"default:true;not null;comment:Enabled"`
|
||||||
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
||||||
|
|||||||
@@ -642,6 +642,7 @@ type DeviceLoginRequest struct {
|
|||||||
UserAgent string `json:"user_agent" validate:"required"`
|
UserAgent string `json:"user_agent" validate:"required"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
ShortCode string `json:"short_code,optional"`
|
ShortCode string `json:"short_code,optional"`
|
||||||
|
BasePayload string `json:"base_payload,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DissolveFamilyRequest struct {
|
type DissolveFamilyRequest struct {
|
||||||
@@ -2655,6 +2656,7 @@ type SubscribeDiscount struct {
|
|||||||
Quantity int64 `json:"quantity"`
|
Quantity int64 `json:"quantity"`
|
||||||
Discount float64 `json:"discount"`
|
Discount float64 `json:"discount"`
|
||||||
NewUserOnly bool `json:"new_user_only"`
|
NewUserOnly bool `json:"new_user_only"`
|
||||||
|
MapApple string `json:"map_apple"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubscribeGroup struct {
|
type SubscribeGroup struct {
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/perfect-panel/server/cmd"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/cmd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Ensure time.Local matches DSN loc=Asia/Shanghai.
|
||||||
|
// In scratch Docker images, LoadLocation may fail due to missing zoneinfo,
|
||||||
|
// so fall back to FixedZone as a guaranteed alternative.
|
||||||
|
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
loc = time.FixedZone("CST", 8*3600)
|
||||||
|
}
|
||||||
|
time.Local = loc
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cmd.Execute()
|
cmd.Execute()
|
||||||
|
|||||||
@@ -292,7 +292,48 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有合并赠送订阅,则正常创建新订阅
|
// 兜底:创建新订阅前,查找用户是否已有同套餐的订阅记录(含过期/赠送),
|
||||||
|
// 有则复用旧记录续期,避免出现重复订阅。
|
||||||
|
// 需要同时检查 UserId 和 SubscriptionUserId,因为家庭组绑定前后 owner 可能不同。
|
||||||
|
if userSub == nil {
|
||||||
|
candidateUserIds := []int64{orderInfo.UserId}
|
||||||
|
if orderInfo.SubscriptionUserId > 0 && orderInfo.SubscriptionUserId != orderInfo.UserId {
|
||||||
|
candidateUserIds = append(candidateUserIds, orderInfo.SubscriptionUserId)
|
||||||
|
}
|
||||||
|
var existingSub user.Subscribe
|
||||||
|
if findErr := l.svc.DB.Model(&user.Subscribe{}).
|
||||||
|
Where("user_id IN ? AND subscribe_id = ?", candidateUserIds, orderInfo.SubscribeId).
|
||||||
|
Order("expire_time DESC").
|
||||||
|
First(&existingSub).Error; findErr == nil {
|
||||||
|
// 家庭组场景:订阅 owner 可能变更(如成员注册的试用 → 被家主收归),
|
||||||
|
// 续期前把 user_id 校正为当前订单的 SubscriptionUserId
|
||||||
|
effectiveOwner := orderInfo.UserId
|
||||||
|
if orderInfo.SubscriptionUserId > 0 {
|
||||||
|
effectiveOwner = orderInfo.SubscriptionUserId
|
||||||
|
}
|
||||||
|
if existingSub.UserId != effectiveOwner {
|
||||||
|
existingSub.UserId = effectiveOwner
|
||||||
|
}
|
||||||
|
// 找到已有记录,走续期逻辑
|
||||||
|
if renewErr := l.updateSubscriptionForRenewal(ctx, &existingSub, sub, orderInfo); renewErr != nil {
|
||||||
|
logger.WithContext(ctx).Error("Fallback renew existing subscription failed, will create new",
|
||||||
|
logger.Field("error", renewErr.Error()),
|
||||||
|
logger.Field("existing_subscribe_id", existingSub.Id),
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
userSub = &existingSub
|
||||||
|
logger.WithContext(ctx).Infow("Fallback: renewed existing subscription instead of creating duplicate",
|
||||||
|
logger.Field("existing_subscribe_id", existingSub.Id),
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
logger.Field("candidate_user_ids", candidateUserIds),
|
||||||
|
logger.Field("owner_corrected_to", effectiveOwner),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果仍然没有可复用的订阅,才创建新订阅
|
||||||
if userSub == nil {
|
if userSub == nil {
|
||||||
userSub, err = l.createUserSubscription(ctx, orderInfo, sub)
|
userSub, err = l.createUserSubscription(ctx, orderInfo, sub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -500,13 +541,13 @@ func (l *ActivateOrderLogic) patchOrderParentID(ctx context.Context, orderID int
|
|||||||
return l.svc.DB.WithContext(ctx).Model(&order.Order{}).Where("id = ? AND (parent_id = 0 OR parent_id IS NULL)", orderID).Update("parent_id", parentID).Error
|
return l.svc.DB.WithContext(ctx).Model(&order.Order{}).Where("id = ? AND (parent_id = 0 OR parent_id IS NULL)", orderID).Update("parent_id", parentID).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// findGiftSubscription 查找用户指定套餐的赠送订阅(order_id=0),包括已过期的
|
// findGiftSubscription 查找用户的赠送订阅(order_id=0),包括已过期的。
|
||||||
// 返回找到的赠送订阅记录,如果没有则返回 nil
|
// 单订阅模式下,用户若以不同套餐首次购买,需要将赠送订阅合并为付费订阅,
|
||||||
func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId int64, subscribeId int64) (*user.Subscribe, error) {
|
// 因此不再过滤 subscribe_id,避免套餐不同时绕过合并路径创建重复订阅。
|
||||||
// 直接查询数据库,查找 order_id=0(赠送)且同套餐的订阅,不限制过期状态
|
func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId int64, _ int64) (*user.Subscribe, error) {
|
||||||
var giftSub user.Subscribe
|
var giftSub user.Subscribe
|
||||||
err := l.svc.DB.Model(&user.Subscribe{}).
|
err := l.svc.DB.Model(&user.Subscribe{}).
|
||||||
Where("user_id = ? AND order_id = 0 AND subscribe_id = ?", userId, subscribeId).
|
Where("user_id = ? AND order_id = 0", userId).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
First(&giftSub).Error
|
First(&giftSub).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -515,23 +556,28 @@ func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId in
|
|||||||
return &giftSub, nil
|
return &giftSub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extendGiftSubscription 在现有赠送订阅上延长到期时间,保持 token 不变
|
// extendGiftSubscription 在现有赠送订阅上延长到期时间,保持 token/UUID 不变。
|
||||||
// 将购买的天数叠加到赠送订阅的到期时间上,并更新 order_id 为新订单 ID
|
// 若购买套餐与赠送套餐不同,同步更新套餐 ID 和流量配额并重置已用量(套餐变更语义)。
|
||||||
func (l *ActivateOrderLogic) extendGiftSubscription(ctx context.Context, giftSub *user.Subscribe, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) {
|
func (l *ActivateOrderLogic) extendGiftSubscription(ctx context.Context, giftSub *user.Subscribe, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
// 计算基准时间:取赠送订阅到期时间和当前时间的较大值
|
|
||||||
baseTime := giftSub.ExpireTime
|
baseTime := giftSub.ExpireTime
|
||||||
if baseTime.Before(now) {
|
if baseTime.Before(now) {
|
||||||
baseTime = now
|
baseTime = now
|
||||||
}
|
}
|
||||||
// 在基准时间上增加购买的天数
|
|
||||||
newExpireTime := tool.AddTime(sub.UnitTime, orderInfo.Quantity, baseTime)
|
newExpireTime := tool.AddTime(sub.UnitTime, orderInfo.Quantity, baseTime)
|
||||||
|
|
||||||
// 更新赠送订阅的信息
|
|
||||||
giftSub.OrderId = orderInfo.Id
|
giftSub.OrderId = orderInfo.Id
|
||||||
giftSub.ExpireTime = newExpireTime
|
giftSub.ExpireTime = newExpireTime
|
||||||
giftSub.Status = 1
|
giftSub.Status = 1
|
||||||
|
|
||||||
|
// 套餐变更:更新套餐 ID 和流量配额,重置已用流量(与 updateSubscriptionForRenewal 逻辑一致)
|
||||||
|
if giftSub.SubscribeId != orderInfo.SubscribeId {
|
||||||
|
giftSub.SubscribeId = orderInfo.SubscribeId
|
||||||
|
giftSub.Traffic = sub.Traffic
|
||||||
|
giftSub.Download = 0
|
||||||
|
giftSub.Upload = 0
|
||||||
|
}
|
||||||
|
|
||||||
if err := l.svc.UserModel.UpdateSubscribe(ctx, giftSub); err != nil {
|
if err := l.svc.UserModel.UpdateSubscribe(ctx, giftSub); err != nil {
|
||||||
logger.WithContext(ctx).Error("Update gift subscription failed",
|
logger.WithContext(ctx).Error("Update gift subscription failed",
|
||||||
logger.Field("error", err.Error()),
|
logger.Field("error", err.Error()),
|
||||||
@@ -849,6 +895,9 @@ func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger user group recalculation (needed when renewing an expired subscription)
|
||||||
|
l.triggerUserGroupRecalculation(ctx, userInfo.Id)
|
||||||
|
|
||||||
// Clear user subscription cache
|
// Clear user subscription cache
|
||||||
err = l.svc.UserModel.ClearSubscribeCache(ctx, userSub)
|
err = l.svc.UserModel.ClearSubscribeCache(ctx, userSub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
| 2026-03-12 | 分析并确认 Unknown column 错误 | [x] 已完成 | 确认为 `user_device` 缺少 `short_code` 字段,已提供 SQL |
|
| 2026-03-12 | 分析并确认 Unknown column 错误 | [x] 已完成 | 确认为 `user_device` 缺少 `short_code` 字段,已提供 SQL |
|
||||||
| 2026-03-12 | 提供 SSL 证书替换指令 | [x] 已完成 | 已提供备份与替换证书的组合指令 |
|
| 2026-03-12 | 提供 SSL 证书替换指令 | [x] 已完成 | 已提供备份与替换证书的组合指令 |
|
||||||
| 2026-03-17 | 合并 internal 到 internal/main | [x] 已完成 | 已查验均为fast-forward,受限网络/权限,需手动push完成合并 |
|
| 2026-03-17 | 合并 internal 到 internal/main | [x] 已完成 | 已查验均为fast-forward,受限网络/权限,需手动push完成合并 |
|
||||||
|
| 2026-04-14 | 排查支付成功但订阅未下发问题 | [x] 已完成 | 已提供 Docker 相关的日志排查与数据库核对命令 |
|
||||||
|
|
||||||
certbot certonly --manual --preferred-challenges dns -d airoport.win -d "*.airoport.win" -d hifastapp.com
|
certbot certonly --manual --preferred-challenges dns -d airoport.win -d "*.airoport.win" -d hifastapp.com
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user