* fix(database): correct name entry for SingBox in initialization script

* fix(purchase): update gift amount deduction logic and handle zero-amount order status

* feat: add type and default fields to rule group requests and update related logic

* feat(rule): implement logic to set a default rule group during creation and update

* fix(rule): add type and default fields to rule group model and update related logic

* feat(proxy): enhance proxy group handling and sorting logic

* refactor(proxy): replace hardcoded group names with constants for better maintainability

* fix(proxy): update group selection logic to skip empty and default names

* feat(proxy): enhance proxy and group handling with new configuration options

* feat(surge): add Surge adapter support and enhance subscription URL handling

* feat(traffic): implement traffic reset logic for subscription cycles

* feat(auth): improve email and mobile config unmarshalling with default values

* fix(auth) upbind email not update

* fix(order) discount set default 1

* fix(order) discount set default 1

* fix: refactor surfboard proxy handling and enhance configuration template

* fix(renewal) discount set default 1

* feat(loon): add Loon configuration template and enhance proxy handling

* feat(subscription): update user subscription status based on expiration time

* fix(renewal): update subscription retrieval method to use token instead of order ID

* feat(order): enhance order processing logic with improved error handling and user subscription management

* fix(order): improve code quality and fix critical bugs in order processing logic

- Fix inconsistent logging calls across all order logic files
- Fix critical gift amount deduction logic bug in renewal process
- Fix variable shadowing errors in database transactions
- Add comprehensive Go-standard documentation comments
- Improve log prefix consistency for better debugging
- Remove redundant discount validation code

* fix(docker): add build argument for version in Docker image build process

* feat(version): add endpoint to retrieve application version information

* fix(auth): improve user authentication method logic and update user cache

* feat(user): add ordering functionality to user list retrieval

* fix(RevenueStatistics) fill list

* fix(UserStatistics) fill list

* fix(user): implement user cache clearing after auth method operations

* fix(auth): enhance OAuth login logic with improved request handling and user registration flow

* fix(user): implement sorting for authentication methods based on priority

* fix(user): correct ordering clause for user retrieval based on filter

* refactor(user): streamline cache management and enhance cache clearing logic

* feat(logs) set logs volume in develop

* fix(handler): implement browser interception to deny access for specific user agents

* fix(resetTraffic) reset daily server

* refactor(trojan): remove unused parameter and clean up logging in slice

* fix(middleware): add domain length check and improve user-agent handling

* fix(middleware): reorder domain processing and enhance user-agent handling

* fix(resetTraffic): update subscription reset logic to use expire_time for monthly and yearly checks

* fix(scheduler): update reset traffic task schedule to run daily at 00:30

* fix(traffic): enhance traffic reset logic for subscriptions and adjust status checks

* fix(activateOrder): update traffic reset logic to include reset day check

* feat(marketing): add batch email task management API and logic

* feat(application): implement CRUD operations for subscribe applications

* feat(types): add user agent limit and list to subscription configuration

* feat(application): update subscription application requests to include structured download links

* feat(application): add scheme field and download link handling to subscribe application

* feat(application): add endpoint to retrieve client information

* feat(application): move DownloadLink and SubscribeApplication types to types.api

* feat(application): add DownloadLink and SubscribeClient types, update client response structure

* feat(application): remove ProxyTemplate field from application API

* feat(application): implement adapter for client configuration and add preview template functionality

* feat(application): move DownloadLink type to types.api and remove from common.api

* feat(application): update PreviewSubscribeTemplate to return structured response

* feat(application): remove ProxyTemplate field from application API

* feat(application): enhance cache key generation for user list and server data

* feat(subscribe): add ClearCache method to manage subscription cache invalidation

* feat(payment): add Description field to PaymentMethodDetail response

* feat(subscribe): update next reset time calculation to use ExpireTime

* feat(purchase): include handling fee in total amount calculation

* feat(subscribe): add V2SubscribeHandler and logic for enhanced subscription management

* feat(subscribe): add output format configuration to subscription adapter

* feat(application): default data

---------

Co-authored-by: Chang lue Tsen <tension@ppanel.dev>
Co-authored-by: NoWay <Bob455668@hotmail.com>
This commit is contained in:
Leif Draven
2025-08-16 01:30:21 +09:00
committed by GitHub
parent c8de30f78c
commit 41d660bb9e
89 changed files with 4542 additions and 523 deletions
@@ -0,0 +1,26 @@
package application
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/application"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Create subscribe application
func CreateSubscribeApplicationHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.CreateSubscribeApplicationRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := application.NewCreateSubscribeApplicationLogic(c.Request.Context(), svcCtx)
resp, err := l.CreateSubscribeApplication(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,26 @@
package application
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/application"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Delete subscribe application
func DeleteSubscribeApplicationHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.DeleteSubscribeApplicationRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := application.NewDeleteSubscribeApplicationLogic(c.Request.Context(), svcCtx)
err := l.DeleteSubscribeApplication(&req)
result.HttpResult(c, nil, err)
}
}
@@ -0,0 +1,26 @@
package application
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/application"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Get subscribe application list
func GetSubscribeApplicationListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.GetSubscribeApplicationListRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := application.NewGetSubscribeApplicationListLogic(c.Request.Context(), svcCtx)
resp, err := l.GetSubscribeApplicationList(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,27 @@
package application
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/application"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Preview Template
func PreviewSubscribeTemplateHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.PreviewSubscribeTemplateRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := application.NewPreviewSubscribeTemplateLogic(c.Request.Context(), svcCtx)
resp, err := l.PreviewSubscribeTemplate(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,26 @@
package application
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/application"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Update subscribe application
func UpdateSubscribeApplicationHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.UpdateSubscribeApplicationRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := application.NewUpdateSubscribeApplicationLogic(c.Request.Context(), svcCtx)
resp, err := l.UpdateSubscribeApplication(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,26 @@
package marketing
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/marketing"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Create a batch send email task
func CreateBatchSendEmailTaskHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.CreateBatchSendEmailTaskRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := marketing.NewCreateBatchSendEmailTaskLogic(c.Request.Context(), svcCtx)
err := l.CreateBatchSendEmailTask(&req)
result.HttpResult(c, nil, err)
}
}
@@ -0,0 +1,26 @@
package marketing
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/marketing"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Get batch send email task list
func GetBatchSendEmailTaskListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.GetBatchSendEmailTaskListRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := marketing.NewGetBatchSendEmailTaskListLogic(c.Request.Context(), svcCtx)
resp, err := l.GetBatchSendEmailTaskList(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,26 @@
package marketing
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/marketing"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Get batch send email task status
func GetBatchSendEmailTaskStatusHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.GetBatchSendEmailTaskStatusRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := marketing.NewGetBatchSendEmailTaskStatusLogic(c.Request.Context(), svcCtx)
resp, err := l.GetBatchSendEmailTaskStatus(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,26 @@
package marketing
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/marketing"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Get pre-send email count
func GetPreSendEmailCountHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.GetPreSendEmailCountRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := marketing.NewGetPreSendEmailCountLogic(c.Request.Context(), svcCtx)
resp, err := l.GetPreSendEmailCount(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,26 @@
package marketing
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/marketing"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// StopBatchSendEmailTaskHandler Stop a batch send email task
func StopBatchSendEmailTaskHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.StopBatchSendEmailTaskRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := marketing.NewStopBatchSendEmailTaskLogic(c.Request.Context(), svcCtx)
err := l.StopBatchSendEmailTask(&req)
result.HttpResult(c, nil, err)
}
}
@@ -0,0 +1,18 @@
package tool
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/tool"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/result"
)
// GetVersionHandler Get Version
func GetVersionHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
l := tool.NewGetVersionLogic(c.Request.Context(), svcCtx)
resp, err := l.GetVersion()
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,18 @@
package common
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/result"
)
// Get Client
func GetClientHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
l := common.NewGetClientLogic(c.Request.Context(), svcCtx)
resp, err := l.GetClient()
result.HttpResult(c, resp, err)
}
}
+48
View File
@@ -7,11 +7,13 @@ import (
"github.com/gin-gonic/gin"
adminAds "github.com/perfect-panel/server/internal/handler/admin/ads"
adminAnnouncement "github.com/perfect-panel/server/internal/handler/admin/announcement"
adminApplication "github.com/perfect-panel/server/internal/handler/admin/application"
adminAuthMethod "github.com/perfect-panel/server/internal/handler/admin/authMethod"
adminConsole "github.com/perfect-panel/server/internal/handler/admin/console"
adminCoupon "github.com/perfect-panel/server/internal/handler/admin/coupon"
adminDocument "github.com/perfect-panel/server/internal/handler/admin/document"
adminLog "github.com/perfect-panel/server/internal/handler/admin/log"
adminMarketing "github.com/perfect-panel/server/internal/handler/admin/marketing"
adminOrder "github.com/perfect-panel/server/internal/handler/admin/order"
adminPayment "github.com/perfect-panel/server/internal/handler/admin/payment"
adminServer "github.com/perfect-panel/server/internal/handler/admin/server"
@@ -86,6 +88,26 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
adminAnnouncementGroupRouter.GET("/list", adminAnnouncement.GetAnnouncementListHandler(serverCtx))
}
adminApplicationGroupRouter := router.Group("/v1/admin/application")
adminApplicationGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
{
// Create subscribe application
adminApplicationGroupRouter.POST("/", adminApplication.CreateSubscribeApplicationHandler(serverCtx))
// Preview Template
adminApplicationGroupRouter.GET("/preview", adminApplication.PreviewSubscribeTemplateHandler(serverCtx))
// Update subscribe application
adminApplicationGroupRouter.PUT("/subscribe_application", adminApplication.UpdateSubscribeApplicationHandler(serverCtx))
// Delete subscribe application
adminApplicationGroupRouter.DELETE("/subscribe_application", adminApplication.DeleteSubscribeApplicationHandler(serverCtx))
// Get subscribe application list
adminApplicationGroupRouter.GET("/subscribe_application_list", adminApplication.GetSubscribeApplicationListHandler(serverCtx))
}
adminAuthMethodGroupRouter := router.Group("/v1/admin/auth-method")
adminAuthMethodGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
@@ -180,6 +202,26 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
adminLogGroupRouter.GET("/message/list", adminLog.GetMessageLogListHandler(serverCtx))
}
adminMarketingGroupRouter := router.Group("/v1/admin/marketing")
adminMarketingGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
{
// Get batch send email task list
adminMarketingGroupRouter.GET("/email/batch/list", adminMarketing.GetBatchSendEmailTaskListHandler(serverCtx))
// Get pre-send email count
adminMarketingGroupRouter.POST("/email/batch/pre-send-count", adminMarketing.GetPreSendEmailCountHandler(serverCtx))
// Create a batch send email task
adminMarketingGroupRouter.POST("/email/batch/send", adminMarketing.CreateBatchSendEmailTaskHandler(serverCtx))
// Get batch send email task status
adminMarketingGroupRouter.POST("/email/batch/status", adminMarketing.GetBatchSendEmailTaskStatusHandler(serverCtx))
// Stop a batch send email task
adminMarketingGroupRouter.POST("/email/batch/stop", adminMarketing.StopBatchSendEmailTaskHandler(serverCtx))
}
adminOrderGroupRouter := router.Group("/v1/admin/order")
adminOrderGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
@@ -441,6 +483,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Restart System
adminToolGroupRouter.GET("/restart", adminTool.RestartSystemHandler(serverCtx))
// Get Version
adminToolGroupRouter.GET("/version", adminTool.GetVersionHandler(serverCtx))
}
adminUserGroupRouter := router.Group("/v1/admin/user")
@@ -720,6 +765,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Check verification code
commonGroupRouter.POST("/check_verification_code", common.CheckVerificationCodeHandler(serverCtx))
// Get Client
commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx))
// Get verification code
commonGroupRouter.POST("/send_code", common.SendEmailCodeHandler(serverCtx))
+57
View File
@@ -1,6 +1,9 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/subscribe"
"github.com/perfect-panel/server/internal/svc"
@@ -17,6 +20,22 @@ func SubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
}
req.UA = c.Request.Header.Get("User-Agent")
req.Flag = c.Query("flag")
// intercept browser
ua := c.GetHeader("User-Agent")
if ua == "" {
c.String(http.StatusForbidden, "Access denied")
return
}
browserKeywords := []string{"chrome", "firefox", "safari", "edge", "opera", "micromessenger"}
for _, keyword := range browserKeywords {
lcUA := strings.ToLower(ua)
if strings.Contains(lcUA, keyword) {
c.String(http.StatusForbidden, "Access denied")
return
}
}
l := subscribe.NewSubscribeLogic(c, svcCtx)
resp, err := l.Generate(&req)
if err != nil {
@@ -27,10 +46,48 @@ func SubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
}
}
func V2SubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.SubscribeRequest
if c.Request.Header.Get("token") != "" {
req.Token = c.Request.Header.Get("token")
} else {
req.Token = c.Query("token")
}
req.UA = c.Request.Header.Get("User-Agent")
req.Flag = c.Query("flag")
// intercept browser
ua := c.GetHeader("User-Agent")
if ua == "" {
c.String(http.StatusForbidden, "Access denied")
return
}
browserKeywords := []string{"chrome", "firefox", "safari", "edge", "opera", "micromessenger"}
for _, keyword := range browserKeywords {
lcUA := strings.ToLower(ua)
if strings.Contains(lcUA, keyword) {
c.String(http.StatusForbidden, "Access denied")
return
}
}
l := subscribe.NewSubscribeLogic(c, svcCtx)
resp, err := l.V2(&req)
if err != nil {
return
}
c.Header("subscription-userinfo", resp.Header)
c.String(200, "%s", string(resp.Config))
}
}
func RegisterSubscribeHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
path := serverCtx.Config.Subscribe.SubscribePath
if path == "" {
path = "/api/subscribe"
}
router.GET(path, SubscribeHandler(serverCtx))
router.GET(path+"/v2", V2SubscribeHandler(serverCtx))
}
@@ -0,0 +1,61 @@
package application
import (
"context"
"github.com/perfect-panel/server/internal/model/client"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type CreateSubscribeApplicationLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewCreateSubscribeApplicationLogic Create subscribe application
func NewCreateSubscribeApplicationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateSubscribeApplicationLogic {
return &CreateSubscribeApplicationLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CreateSubscribeApplicationLogic) CreateSubscribeApplication(req *types.CreateSubscribeApplicationRequest) (resp *types.SubscribeApplication, err error) {
var link client.DownloadLink
tool.DeepCopy(&link, req.DownloadLink)
linkData, err := link.Marshal()
if err != nil {
l.Errorf("Failed to marshal download link: %v", err)
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), " Failed to marshal download link")
}
data := &client.SubscribeApplication{
Name: req.Name,
Icon: req.Icon,
Description: req.Description,
Scheme: req.Scheme,
UserAgent: req.UserAgent,
IsDefault: req.IsDefault,
SubscribeTemplate: req.SubscribeTemplate,
OutputFormat: req.OutputFormat,
DownloadLink: string(linkData),
}
err = l.svcCtx.ClientModel.Insert(l.ctx, data)
if err != nil {
l.Errorf("Failed to create subscribe application: %v", err)
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create subscribe application")
}
resp = &types.SubscribeApplication{}
tool.DeepCopy(resp, data)
resp.DownloadLink = req.DownloadLink
return
}
@@ -0,0 +1,35 @@
package application
import (
"context"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type DeleteSubscribeApplicationLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewDeleteSubscribeApplicationLogic Delete subscribe application
func NewDeleteSubscribeApplicationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteSubscribeApplicationLogic {
return &DeleteSubscribeApplicationLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DeleteSubscribeApplicationLogic) DeleteSubscribeApplication(req *types.DeleteSubscribeApplicationRequest) error {
err := l.svcCtx.ClientModel.Delete(l.ctx, req.Id)
if err != nil {
l.Errorf("Failed to delete subscribe application with ID %d: %v", req.Id, err)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
}
return nil
}
@@ -0,0 +1,61 @@
package application
import (
"context"
"encoding/json"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type GetSubscribeApplicationListLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewGetSubscribeApplicationListLogic Get subscribe application list
func NewGetSubscribeApplicationListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetSubscribeApplicationListLogic {
return &GetSubscribeApplicationListLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetSubscribeApplicationListLogic) GetSubscribeApplicationList(req *types.GetSubscribeApplicationListRequest) (resp *types.GetSubscribeApplicationListResponse, err error) {
data, err := l.svcCtx.ClientModel.List(l.ctx)
if err != nil {
l.Errorf("Failed to get subscribe application list: %v", err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to get subscribe application list")
}
var list []types.SubscribeApplication
for _, item := range data {
var temp types.DownloadLink
if item.DownloadLink != "" {
_ = json.Unmarshal([]byte(item.DownloadLink), &temp)
}
list = append(list, types.SubscribeApplication{
Id: item.Id,
Name: item.Name,
Description: item.Description,
Icon: item.Icon,
Scheme: item.Scheme,
UserAgent: item.UserAgent,
IsDefault: item.IsDefault,
SubscribeTemplate: item.SubscribeTemplate,
OutputFormat: item.OutputFormat,
DownloadLink: temp,
CreatedAt: item.CreatedAt.UnixMilli(),
UpdatedAt: item.UpdatedAt.UnixMilli(),
})
}
resp = &types.GetSubscribeApplicationListResponse{
Total: int64(len(list)),
List: list,
}
return
}
@@ -0,0 +1,69 @@
package application
import (
"context"
"time"
"github.com/perfect-panel/server/adapter"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type PreviewSubscribeTemplateLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Preview Template
func NewPreviewSubscribeTemplateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PreviewSubscribeTemplateLogic {
return &PreviewSubscribeTemplateLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *PreviewSubscribeTemplateLogic) PreviewSubscribeTemplate(req *types.PreviewSubscribeTemplateRequest) (resp *types.PreviewSubscribeTemplateResponse, err error) {
servers, err := l.svcCtx.ServerModel.FindAllServer(l.ctx)
if err != nil {
l.Errorf("[PreviewSubscribeTemplateLogic] FindAllServer error: %v", err.Error())
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindAllServer error: %v", err.Error())
}
data, err := l.svcCtx.ClientModel.FindOne(l.ctx, req.Id)
if err != nil {
l.Errorf("[PreviewSubscribeTemplateLogic] FindOne error: %v", err.Error())
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindOneClient error: %v", err.Error())
}
sub := adapter.NewAdapter(data.SubscribeTemplate, adapter.WithServers(servers),
adapter.WithSiteName("PerfectPanel"),
adapter.WithSubscribeName("Test Subscribe"),
adapter.WithOutputFormat(data.OutputFormat),
adapter.WithUserInfo(adapter.User{
Password: "test-password",
ExpiredAt: time.Now().AddDate(1, 0, 0),
Download: 0,
Upload: 0,
Traffic: 1000,
SubscribeURL: "https://example.com/subscribe",
}))
// Get client config
a, err := sub.Client()
if err != nil {
l.Errorf("[PreviewSubscribeTemplateLogic] Client error: %v", err.Error())
return nil, errors.Wrapf(xerr.NewErrMsg(err.Error()), "Client error: %v", err.Error())
}
bytes, err := a.Build()
if err != nil {
l.Errorf("[PreviewSubscribeTemplateLogic] Build error: %v", err.Error())
return nil, errors.Wrapf(xerr.NewErrMsg(err.Error()), "Build error: %v", err.Error())
}
return &types.PreviewSubscribeTemplateResponse{
Template: string(bytes),
}, nil
}
@@ -0,0 +1,62 @@
package application
import (
"context"
"github.com/perfect-panel/server/internal/model/client"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type UpdateSubscribeApplicationLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewUpdateSubscribeApplicationLogic Update subscribe application
func NewUpdateSubscribeApplicationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateSubscribeApplicationLogic {
return &UpdateSubscribeApplicationLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateSubscribeApplicationLogic) UpdateSubscribeApplication(req *types.UpdateSubscribeApplicationRequest) (resp *types.SubscribeApplication, err error) {
data, err := l.svcCtx.ClientModel.FindOne(l.ctx, req.Id)
if err != nil {
l.Errorf("Failed to find subscribe application with ID %d: %v", req.Id, err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to find subscribe application with ID %d", req.Id)
}
var link client.DownloadLink
tool.DeepCopy(&link, req.DownloadLink)
linkData, err := link.Marshal()
if err != nil {
l.Errorf("Failed to marshal download link: %v", err)
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), " Failed to marshal download link")
}
data.Name = req.Name
data.Icon = req.Icon
data.Description = req.Description
data.Scheme = req.Scheme
data.UserAgent = req.UserAgent
data.IsDefault = req.IsDefault
data.SubscribeTemplate = req.SubscribeTemplate
data.OutputFormat = req.OutputFormat
data.DownloadLink = string(linkData)
err = l.svcCtx.ClientModel.Update(l.ctx, data)
if err != nil {
l.Errorf("Failed to update subscribe application with ID %d: %v", req.Id, err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to update subscribe application with ID %d", req.Id)
}
resp = &types.SubscribeApplication{}
tool.DeepCopy(&resp, data)
resp.DownloadLink = req.DownloadLink
return
}
@@ -50,8 +50,8 @@ func (l *QueryRevenueStatisticsLogic) QueryRevenueStatistics() (resp *types.Reve
// Get monthly's revenue statistics
monthlyData, err := l.svcCtx.OrderModel.QueryMonthlyOrders(l.ctx, now)
if err != nil {
l.Errorw("[QueryRevenueStatisticsLogic] QueryDateOrders error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "QueryDateOrders error: %v", err)
l.Errorw("[QueryRevenueStatisticsLogic] QueryMonthlyOrders error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "QueryMonthlyOrders error: %v", err)
} else {
monthly = types.OrdersStatistics{
AmountTotal: monthlyData.AmountTotal,
@@ -61,6 +61,24 @@ func (l *QueryRevenueStatisticsLogic) QueryRevenueStatistics() (resp *types.Reve
}
}
// Get monthly daily list for the current month (from 1st to current date)
monthlyListData, err := l.svcCtx.OrderModel.QueryDailyOrdersList(l.ctx, now)
if err != nil {
l.Errorw("[QueryRevenueStatisticsLogic] QueryDailyOrdersList error", logger.Field("error", err.Error()))
// Don't return error, just log it and continue with empty list
} else {
monthlyList := make([]types.OrdersStatistics, len(monthlyListData))
for i, data := range monthlyListData {
monthlyList[i] = types.OrdersStatistics{
Date: data.Date,
AmountTotal: data.AmountTotal,
NewOrderAmount: data.NewOrderAmount,
RenewalOrderAmount: data.RenewalOrderAmount,
}
}
monthly.List = monthlyList
}
// Get all revenue statistics
allData, err := l.svcCtx.OrderModel.QueryTotalOrders(l.ctx)
if err != nil {
@@ -74,6 +92,25 @@ func (l *QueryRevenueStatisticsLogic) QueryRevenueStatistics() (resp *types.Reve
List: make([]types.OrdersStatistics, 0),
}
}
// Get all monthly list for the past 6 months
allListData, err := l.svcCtx.OrderModel.QueryMonthlyOrdersList(l.ctx, now)
if err != nil {
l.Errorw("[QueryRevenueStatisticsLogic] QueryMonthlyOrdersList error", logger.Field("error", err.Error()))
// Don't return error, just log it and continue with empty list
} else {
allList := make([]types.OrdersStatistics, len(allListData))
for i, data := range allListData {
allList[i] = types.OrdersStatistics{
Date: data.Date,
AmountTotal: data.AmountTotal,
NewOrderAmount: data.NewOrderAmount,
RenewalOrderAmount: data.RenewalOrderAmount,
}
}
all.List = allList
}
return &types.RevenueStatisticsResponse{
Today: today,
Monthly: monthly,
@@ -85,7 +122,7 @@ func (l *QueryRevenueStatisticsLogic) QueryRevenueStatistics() (resp *types.Reve
func (l *QueryRevenueStatisticsLogic) mockRevenueStatistics() *types.RevenueStatisticsResponse {
now := time.Now()
// Generate daily data for the past 7 days (oldest first)
// Generate daily data for the current month (from 1st to current date)
monthlyList := make([]types.OrdersStatistics, 7)
for i := 0; i < 7; i++ {
dayDate := now.AddDate(0, 0, -(6 - i))
@@ -61,8 +61,24 @@ func (l *QueryUserStatisticsLogic) QueryUserStatistics() (resp *types.UserStatis
} else {
resp.Monthly.NewOrderUsers = newMonth
resp.Monthly.RenewalOrderUsers = renewalMonth
// TODO: Check the purchase status in the past seven days
resp.Monthly.List = make([]types.UserStatistics, 0)
}
// Get monthly daily user statistics list for the current month (from 1st to current date)
monthlyListData, err := l.svcCtx.UserModel.QueryDailyUserStatisticsList(l.ctx, now)
if err != nil {
l.Errorw("[QueryUserStatisticsLogic] QueryDailyUserStatisticsList error", logger.Field("error", err.Error()))
// Don't return error, just log it and continue with empty list
} else {
monthlyList := make([]types.UserStatistics, len(monthlyListData))
for i, data := range monthlyListData {
monthlyList[i] = types.UserStatistics{
Date: data.Date,
Register: data.Register,
NewOrderUsers: data.NewOrderUsers,
RenewalOrderUsers: data.RenewalOrderUsers,
}
}
resp.Monthly.List = monthlyList
}
// query all user count
@@ -81,13 +97,32 @@ func (l *QueryUserStatisticsLogic) QueryUserStatistics() (resp *types.UserStatis
resp.All.NewOrderUsers = allNewOrderUsers
resp.All.RenewalOrderUsers = allRenewalOrderUsers
}
// Get all monthly user statistics list for the past 6 months
allListData, err := l.svcCtx.UserModel.QueryMonthlyUserStatisticsList(l.ctx, now)
if err != nil {
l.Errorw("[QueryUserStatisticsLogic] QueryMonthlyUserStatisticsList error", logger.Field("error", err.Error()))
// Don't return error, just log it and continue with empty list
} else {
allList := make([]types.UserStatistics, len(allListData))
for i, data := range allListData {
allList[i] = types.UserStatistics{
Date: data.Date,
Register: data.Register,
NewOrderUsers: data.NewOrderUsers,
RenewalOrderUsers: data.RenewalOrderUsers,
}
}
resp.All.List = allList
}
return
}
func (l *QueryUserStatisticsLogic) mockRevenueStatistics() *types.UserStatisticsResponse {
now := time.Now()
// Generate daily user statistics for the past 7 days (oldest first)
// Generate daily user statistics for the current month (from 1st to current date)
monthlyList := make([]types.UserStatistics, 7)
for i := 0; i < 7; i++ {
dayDate := now.AddDate(0, 0, -(6 - i))
@@ -100,6 +135,19 @@ func (l *QueryUserStatisticsLogic) mockRevenueStatistics() *types.UserStatistics
}
}
// Generate monthly user statistics for the past 6 months (oldest first)
allList := make([]types.UserStatistics, 6)
for i := 0; i < 6; i++ {
monthDate := now.AddDate(0, -(5 - i), 0)
baseRegister := int64(1800 + ((5 - i) * 200) + ((5-i)%2)*500)
allList[i] = types.UserStatistics{
Date: monthDate.Format("2006-01"),
Register: baseRegister,
NewOrderUsers: int64(float64(baseRegister) * 0.65),
RenewalOrderUsers: int64(float64(baseRegister) * 0.35),
}
}
return &types.UserStatisticsResponse{
Today: types.UserStatistics{
Register: 28,
@@ -116,6 +164,7 @@ func (l *QueryUserStatisticsLogic) mockRevenueStatistics() *types.UserStatistics
Register: 18888,
NewOrderUsers: 0, // This field is not used in All statistics
RenewalOrderUsers: 0, // This field is not used in All statistics
List: allList,
},
}
}
@@ -0,0 +1,149 @@
package marketing
import (
"context"
"strconv"
"strings"
"time"
"github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/model/task"
"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/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
types2 "github.com/perfect-panel/server/queue/types"
"gorm.io/gorm"
)
type CreateBatchSendEmailTaskLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Create a batch send email task
func NewCreateBatchSendEmailTaskLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateBatchSendEmailTaskLogic {
return &CreateBatchSendEmailTaskLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CreateBatchSendEmailTaskLogic) CreateBatchSendEmailTask(req *types.CreateBatchSendEmailTaskRequest) (err error) {
tx := l.svcCtx.DB
var emails []string
// 通用查询器(含 user JOIN + 注册时间范围过滤)
baseQuery := func() *gorm.DB {
query := tx.Model(&user.AuthMethods{}).
Select("auth_identifier").
Joins("JOIN user ON user.id = user_auth_methods.user_id").
Where("auth_type = ?", "email")
if req.RegisterStartTime != 0 {
query = query.Where("user.created_at >= ?", req.RegisterStartTime)
}
if req.RegisterEndTime != 0 {
query = query.Where("user.created_at <= ?", req.RegisterEndTime)
}
return query
}
var query *gorm.DB
switch req.Scope {
case "all":
query = baseQuery()
case "active":
query = baseQuery().
Joins("JOIN user_subscribe ON user.id = user_subscribe.user_id").
Where("user_subscribe.status IN ?", []int64{1, 2})
case "expired":
query = baseQuery().
Joins("JOIN user_subscribe ON user.id = user_subscribe.user_id").
Where("user_subscribe.status = ?", 3)
case "none":
query = baseQuery().
Joins("LEFT JOIN user_subscribe ON user.id = user_subscribe.user_id").
Where("user_subscribe.user_id IS NULL")
}
if query != nil {
// 执行查询
err = query.Pluck("auth_identifier", &emails).Error
if err != nil {
l.Errorf("[CreateBatchSendEmailTask] Failed to fetch email addresses: %v", err.Error())
return xerr.NewErrCode(xerr.DatabaseQueryError)
}
}
// 邮箱列表为空,返回错误
if len(emails) == 0 && req.Scope != "skip" {
l.Errorf("[CreateBatchSendEmailTask] No email addresses found for the specified scope")
return xerr.NewErrMsg("No email addresses found for the specified scope")
}
// 邮箱地址去重
emails = tool.RemoveDuplicateElements(emails...)
var additionalEmails []string
// 追加额外的邮箱地址(不覆盖)
if req.Additional != "" {
additionalEmails = strings.Split(req.Additional, "\n")
}
if len(additionalEmails) == 0 && req.Scope == "skip" {
l.Errorf("[CreateBatchSendEmailTask] No additional email addresses provided for skip scope")
return xerr.NewErrMsg("No additional email addresses provided for skip scope")
}
var scheduledAt time.Time
if req.Scheduled == 0 {
scheduledAt = time.Now()
} else {
scheduledAt = time.Unix(req.Scheduled, 0)
if scheduledAt.Before(time.Now()) {
scheduledAt = time.Now()
}
}
taskInfo := &task.EmailTask{
Subject: req.Subject,
Content: req.Content,
Recipients: strings.Join(emails, "\n"),
Scope: req.Scope,
RegisterStartTime: time.Unix(req.RegisterStartTime, 0),
RegisterEndTime: time.Unix(req.RegisterEndTime, 0),
Additional: req.Additional,
Scheduled: scheduledAt,
Interval: req.Interval,
Limit: req.Limit,
Status: 0,
Errors: "",
Total: uint64(len(emails) + len(additionalEmails)),
Current: 0,
}
if err = l.svcCtx.DB.Model(&task.EmailTask{}).Create(taskInfo).Error; err != nil {
l.Errorf("[CreateBatchSendEmailTask] Failed to create email task: %v", err.Error())
return xerr.NewErrCode(xerr.DatabaseInsertError)
}
// create task
l.Infof("[CreateBatchSendEmailTask] Successfully created email task with ID: %d", taskInfo.Id)
t := asynq.NewTask(types2.ScheduledBatchSendEmail, []byte(strconv.FormatInt(taskInfo.Id, 10)))
info, err := l.svcCtx.Queue.EnqueueContext(l.ctx, t, asynq.ProcessAt(taskInfo.Scheduled))
if err != nil {
l.Errorf("[CreateBatchSendEmailTask] Failed to enqueue email task: %v", err.Error())
return xerr.NewErrCode(xerr.QueueEnqueueError)
}
l.Infof("[CreateBatchSendEmailTask] Successfully enqueued email task with ID: %s, scheduled at: %s", info.ID, taskInfo.Scheduled)
return nil
}
@@ -0,0 +1,56 @@
package marketing
import (
"context"
"github.com/perfect-panel/server/internal/model/task"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
)
type GetBatchSendEmailTaskListLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewGetBatchSendEmailTaskListLogic Get batch send email task list
func NewGetBatchSendEmailTaskListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetBatchSendEmailTaskListLogic {
return &GetBatchSendEmailTaskListLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetBatchSendEmailTaskListLogic) GetBatchSendEmailTaskList(req *types.GetBatchSendEmailTaskListRequest) (resp *types.GetBatchSendEmailTaskListResponse, err error) {
var tasks []*task.EmailTask
tx := l.svcCtx.DB.Model(&task.EmailTask{})
if req.Status != nil {
tx = tx.Where("status = ?", *req.Status)
}
if req.Scope != "" {
tx = tx.Where("scope = ?", req.Scope)
}
if req.Page == 0 {
req.Page = 1
}
if req.Size == 0 {
req.Size = 10
}
err = tx.Offset((req.Page - 1) * req.Size).Limit(req.Size).Order("created_at DESC").Find(&tasks).Error
if err != nil {
l.Errorf("failed to get email tasks: %v", err)
return nil, xerr.NewErrCode(xerr.DatabaseQueryError)
}
list := make([]types.BatchSendEmailTask, 0)
tool.DeepCopy(&list, tasks)
return &types.GetBatchSendEmailTaskListResponse{
List: list,
}, nil
}
@@ -0,0 +1,44 @@
package marketing
import (
"context"
"github.com/perfect-panel/server/internal/model/task"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
)
type GetBatchSendEmailTaskStatusLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewGetBatchSendEmailTaskStatusLogic Get batch send email task status
func NewGetBatchSendEmailTaskStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetBatchSendEmailTaskStatusLogic {
return &GetBatchSendEmailTaskStatusLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetBatchSendEmailTaskStatusLogic) GetBatchSendEmailTaskStatus(req *types.GetBatchSendEmailTaskStatusRequest) (resp *types.GetBatchSendEmailTaskStatusResponse, err error) {
tx := l.svcCtx.DB
var taskInfo *task.EmailTask
err = tx.Model(&task.EmailTask{}).Where("id = ?", req.Id).First(&taskInfo).Error
if err != nil {
l.Errorf("failed to get email task status, error: %v", err)
return nil, xerr.NewErrCode(xerr.DatabaseQueryError)
}
return &types.GetBatchSendEmailTaskStatusResponse{
Status: taskInfo.Status,
Total: int64(taskInfo.Total),
Current: int64(taskInfo.Current),
Errors: taskInfo.Errors,
}, nil
}
@@ -0,0 +1,86 @@
package marketing
import (
"context"
"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/logger"
"github.com/perfect-panel/server/pkg/xerr"
"gorm.io/gorm"
)
type GetPreSendEmailCountLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewGetPreSendEmailCountLogic Get pre-send email count
func NewGetPreSendEmailCountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPreSendEmailCountLogic {
return &GetPreSendEmailCountLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetPreSendEmailCountLogic) GetPreSendEmailCount(req *types.GetPreSendEmailCountRequest) (resp *types.GetPreSendEmailCountResponse, err error) {
tx := l.svcCtx.DB
var count int64
// 通用查询器(含 user JOIN + 注册时间范围过滤)
baseQuery := func() *gorm.DB {
query := tx.Model(&user.AuthMethods{}).
Select("auth_identifier").
Joins("JOIN user ON user.id = user_auth_methods.user_id").
Where("auth_type = ?", "email")
if req.RegisterStartTime != 0 {
query = query.Where("user.created_at >= ?", req.RegisterStartTime)
}
if req.RegisterEndTime != 0 {
query = query.Where("user.created_at <= ?", req.RegisterEndTime)
}
return query
}
var query *gorm.DB
switch req.Scope {
case "all":
query = baseQuery()
case "active":
query = baseQuery().
Joins("JOIN user_subscribe ON user.id = user_subscribe.user_id").
Where("user_subscribe.status IN ?", []int64{1, 2})
case "expired":
query = baseQuery().
Joins("JOIN user_subscribe ON user.id = user_subscribe.user_id").
Where("user_subscribe.status = ?", 3)
case "none":
query = baseQuery().
Joins("LEFT JOIN user_subscribe ON user.id = user_subscribe.user_id").
Where("user_subscribe.user_id IS NULL")
case "skip":
// Skip scope does not require a count
query = nil
default:
l.Errorf("[CreateBatchSendEmailTask] Invalid scope: %v", req.Scope)
return nil, xerr.NewErrMsg("Invalid email scope")
}
if query != nil {
if err = query.Count(&count).Error; err != nil {
l.Errorf("[GetPreSendEmailCount] Count error: %v", err)
return nil, xerr.NewErrMsg("Failed to count emails")
}
}
return &types.GetPreSendEmailCountResponse{
Count: count,
}, nil
}
@@ -0,0 +1,42 @@
package marketing
import (
"context"
"github.com/perfect-panel/server/internal/model/task"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/email"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
)
type StopBatchSendEmailTaskLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewStopBatchSendEmailTaskLogic Stop a batch send email task
func NewStopBatchSendEmailTaskLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StopBatchSendEmailTaskLogic {
return &StopBatchSendEmailTaskLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *StopBatchSendEmailTaskLogic) StopBatchSendEmailTask(req *types.StopBatchSendEmailTaskRequest) (err error) {
if email.Manager != nil {
email.Manager.RemoveWorker(req.Id)
} else {
logger.Error("[StopBatchSendEmailTaskLogic] email.Manager is nil, cannot stop task")
}
err = l.svcCtx.DB.Model(&task.EmailTask{}).Where("id = ?", req.Id).Update("status", 2).Error
if err != nil {
l.Errorf("failed to stop email task, error: %v", err)
return xerr.NewErrCode(xerr.DatabaseUpdateError)
}
return
}
@@ -55,17 +55,18 @@ func (l *GetPaymentMethodListLogic) GetPaymentMethodList(req *types.GetPaymentMe
}
}
resp.List[i] = types.PaymentMethodDetail{
Id: v.Id,
Name: v.Name,
Platform: v.Platform,
Icon: v.Icon,
Domain: v.Domain,
Config: config,
FeeMode: v.FeeMode,
FeePercent: v.FeePercent,
FeeAmount: v.FeeAmount,
Enable: *v.Enable,
NotifyURL: notifyUrl,
Id: v.Id,
Name: v.Name,
Platform: v.Platform,
Icon: v.Icon,
Domain: v.Domain,
Config: config,
FeeMode: v.FeeMode,
FeePercent: v.FeePercent,
FeeAmount: v.FeeAmount,
Enable: *v.Enable,
NotifyURL: notifyUrl,
Description: v.Description,
}
}
return
@@ -133,6 +133,7 @@ func (l *UpdateNodeLogic) UpdateNode(req *types.UpdateNodeRequest) error {
}
l.Infow("[GetNodeCountry]: Enqueue Success", logger.Field("taskID", taskInfo.ID), logger.Field("payload", string(payload)))
}
l.svcCtx.DeviceManager.Broadcast(device.SubscribeUpdate)
return nil
}
@@ -0,0 +1,51 @@
package tool
import (
"context"
"fmt"
"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/logger"
)
type GetVersionLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewGetVersionLogic Get Version
func NewGetVersionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetVersionLogic {
return &GetVersionLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetVersionLogic) GetVersion() (resp *types.VersionResponse, err error) {
version := constant.Version
buildTime := constant.BuildTime
// Normalize unknown values
if version == "unknown version" {
version = "unknown"
}
if buildTime == "unknown time" {
buildTime = "unknown"
}
// Format version based on whether it starts with 'v'
var formattedVersion string
if len(version) > 0 && version[0] == 'v' {
formattedVersion = fmt.Sprintf("%s(%s)", version[1:], buildTime)
} else {
formattedVersion = fmt.Sprintf("%s(%s) Develop", version, buildTime)
}
return &types.VersionResponse{
Version: formattedVersion,
}, nil
}
@@ -77,5 +77,10 @@ func (l *CreateUserSubscribeLogic) CreateUserSubscribe(req *types.CreateUserSubs
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "UpdateUserCache error: %v", err.Error())
}
err = l.svcCtx.SubscribeModel.ClearCache(l.ctx, userSub.SubscribeId)
if err != nil {
logger.Errorw("ClearSubscribe error", logger.Field("error", err.Error()))
}
return nil
}
@@ -32,6 +32,7 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge
Search: req.Search,
SubscribeId: req.SubscribeId,
UserSubscribeId: req.UserSubscribeId,
Order: "DESC",
})
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetUserListLogic failed: %v", err.Error())
@@ -31,11 +31,21 @@ func (l *UpdateUserAuthMethodLogic) UpdateUserAuthMethod(req *types.UpdateUserAu
l.Errorw("Get user auth method error", logger.Field("error", err.Error()), logger.Field("userId", req.UserId), logger.Field("authType", req.AuthType))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Get user auth method error: %v", err.Error())
}
userInfo, err := l.svcCtx.UserModel.FindOne(l.ctx, req.UserId)
if err != nil {
l.Errorw("Get user info error", logger.Field("error", err.Error()), logger.Field("userId", req.UserId))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Get user info error: %v", err.Error())
}
method.AuthType = req.AuthType
method.AuthIdentifier = req.AuthIdentifier
if err = l.svcCtx.UserModel.UpdateUserAuthMethods(l.ctx, method); err != nil {
l.Errorw("Update user auth method error", logger.Field("error", err.Error()), logger.Field("userId", req.UserId), logger.Field("authType", req.AuthType))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Update user auth method error: %v", err.Error())
}
if err = l.svcCtx.UserModel.UpdateUserCache(l.ctx, userInfo); err != nil {
l.Errorw("Update user cache error", logger.Field("error", err.Error()), logger.Field("userId", req.UserId))
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Update user cache error: %v", err.Error())
}
return nil
}
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
package common
import (
"context"
"encoding/json"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type GetClientLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Get Client
func NewGetClientLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetClientLogic {
return &GetClientLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetClientLogic) GetClient() (resp *types.GetSubscribeClientResponse, err error) {
data, err := l.svcCtx.ClientModel.List(l.ctx)
if err != nil {
l.Errorf("Failed to get subscribe application list: %v", err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to get subscribe application list")
}
var list []types.SubscribeClient
for _, item := range data {
var temp types.DownloadLink
if item.DownloadLink != "" {
_ = json.Unmarshal([]byte(item.DownloadLink), &temp)
}
list = append(list, types.SubscribeClient{
Id: item.Id,
Name: item.Name,
Description: item.Description,
Icon: item.Icon,
Scheme: item.Scheme,
IsDefault: item.IsDefault,
DownloadLink: temp,
})
}
resp = &types.GetSubscribeClientResponse{
Total: int64(len(list)),
List: list,
}
return
}
@@ -159,6 +159,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
// Calculate the handling fee
if amount > 0 {
feeAmount = calculateFee(amount, payment)
amount += feeAmount
}
// query user is new purchase or renewal
isNew, err := l.svcCtx.OrderModel.IsUserEligibleForNewOrder(l.ctx, u.Id)
@@ -2,6 +2,7 @@ package user
import (
"context"
"sort"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/xerr"
@@ -53,10 +54,31 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
}
userMethods = append(userMethods, item)
}
// 按照指定顺序排序:email第一位,mobile第二位,其他按原顺序
sort.Slice(userMethods, func(i, j int) bool {
return getAuthTypePriority(userMethods[i].AuthType) < getAuthTypePriority(userMethods[j].AuthType)
})
resp.AuthMethods = userMethods
return resp, nil
}
// getAuthTypePriority 获取认证类型的排序优先级
// email: 1 (第一位)
// mobile: 2 (第二位)
// 其他类型: 100+ (后续位置)
func getAuthTypePriority(authType string) int {
switch authType {
case "email":
return 1
case "mobile":
return 2
default:
return 100
}
}
// maskOpenID 脱敏 OpenID,只保留前 3 和后 3 位
func maskOpenID(openID string) string {
length := len(openID)
@@ -68,7 +68,7 @@ func (l *QueryUserSubscribeLogic) QueryUserSubscribe() (resp *types.QueryUserSub
// 计算下次重置时间
func calculateNextResetTime(sub *types.UserSubscribe) int64 {
startTime := time.UnixMilli(sub.StartTime)
resetTime := time.UnixMilli(sub.ExpireTime)
now := time.Now()
switch sub.Subscribe.ResetCycle {
case 0:
@@ -76,15 +76,15 @@ func calculateNextResetTime(sub *types.UserSubscribe) int64 {
case 1:
return time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location()).UnixMilli()
case 2:
if startTime.Day() > now.Day() {
return time.Date(now.Year(), now.Month(), startTime.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
if resetTime.Day() > now.Day() {
return time.Date(now.Year(), now.Month(), resetTime.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
} else {
return time.Date(now.Year(), now.Month()+1, startTime.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
return time.Date(now.Year(), now.Month()+1, resetTime.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
}
case 3:
targetTime := time.Date(now.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, now.Location())
targetTime := time.Date(now.Year(), resetTime.Month(), resetTime.Day(), 0, 0, 0, 0, now.Location())
if targetTime.Before(now) {
targetTime = time.Date(now.Year()+1, startTime.Month(), startTime.Day(), 0, 0, 0, 0, now.Location())
targetTime = time.Date(now.Year()+1, resetTime.Month(), resetTime.Day(), 0, 0, 0, 0, now.Location())
}
return targetTime.UnixMilli()
default:
@@ -42,6 +42,7 @@ func (l *UnbindOAuthLogic) UnbindOAuth(req *types.UnbindOAuthRequest) error {
l.Errorw("delete user auth methods failed:", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete user auth methods failed: %v", err.Error())
}
return nil
}
func (l *UnbindOAuthLogic) validator(req *types.UnbindOAuthRequest) bool {
+127
View File
@@ -0,0 +1,127 @@
package subscribe
import (
"fmt"
"net/url"
"strings"
"github.com/perfect-panel/server/adapter"
"github.com/perfect-panel/server/internal/model/client"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
func (l *SubscribeLogic) V2(req *types.SubscribeRequest) (resp *types.SubscribeResponse, err error) {
// query client list
clients, err := l.svc.ClientModel.List(l.ctx.Request.Context())
if err != nil {
l.Errorw("[SubscribeLogic] Query client list failed", logger.Field("error", err.Error()))
return nil, err
}
userAgent := strings.ToLower(l.ctx.Request.UserAgent())
var targetApp, defaultApp *client.SubscribeApplication
for _, item := range clients {
u := strings.ToLower(item.UserAgent)
if item.IsDefault {
defaultApp = item
}
if strings.Contains(userAgent, u) {
targetApp = item
break
}
}
if targetApp == nil {
l.Debugf("[SubscribeLogic] No matching client found", logger.Field("userAgent", userAgent))
if defaultApp == nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "No matching client found for user agent: %s", userAgent)
}
targetApp = defaultApp
}
// Find user subscribe by token
userSubscribe, err := l.getUserSubscribe(req.Token)
if err != nil {
l.Errorw("[SubscribeLogic] Get user subscribe failed", logger.Field("error", err.Error()), logger.Field("token", req.Token))
return nil, err
}
var subscribeStatus = false
defer func() {
l.logSubscribeActivity(subscribeStatus, userSubscribe, req)
}()
// find subscribe info
subscribeInfo, err := l.svc.SubscribeModel.FindOne(l.ctx.Request.Context(), userSubscribe.SubscribeId)
if err != nil {
l.Errorw("[SubscribeLogic] Find subscribe info failed", logger.Field("error", err.Error()), logger.Field("subscribeId", userSubscribe.SubscribeId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Find subscribe info failed: %v", err.Error())
}
// Find server list by user subscribe
servers, err := l.getServers(userSubscribe)
if err != nil {
return nil, err
}
a := adapter.NewAdapter(
targetApp.SubscribeTemplate,
adapter.WithServers(servers),
adapter.WithSiteName(l.svc.Config.Site.SiteName),
adapter.WithSubscribeName(subscribeInfo.Name),
adapter.WithOutputFormat(targetApp.OutputFormat),
adapter.WithUserInfo(adapter.User{
Password: userSubscribe.UUID,
ExpiredAt: userSubscribe.ExpireTime,
Download: userSubscribe.Download,
Upload: userSubscribe.Upload,
Traffic: userSubscribe.Traffic,
SubscribeURL: l.getSubscribeV2URL(req.Token),
}),
)
// Get client config
adapterClient, err := a.Client()
if err != nil {
l.Errorw("[SubscribeLogic] Client error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(500), "Client error: %v", err.Error())
}
bytes, err := adapterClient.Build()
if err != nil {
l.Errorw("[SubscribeLogic] Build client config failed", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(500), "Build client config failed: %v", err.Error())
}
var formats = []string{"json", "yaml", "conf"}
for _, format := range formats {
if format == strings.ToLower(targetApp.OutputFormat) {
l.ctx.Header("content-disposition", fmt.Sprintf("attachment;filename*=UTF-8''%s.%s", url.QueryEscape(l.svc.Config.Site.SiteName), format))
l.ctx.Header("Content-Type", "application/octet-stream; charset=UTF-8")
}
}
resp = &types.SubscribeResponse{
Config: bytes,
Header: fmt.Sprintf(
"upload=%d;download=%d;total=%d;expire=%d",
userSubscribe.Upload, userSubscribe.Download, userSubscribe.Traffic, userSubscribe.ExpireTime.Unix(),
),
}
return
}
func (l *SubscribeLogic) getSubscribeV2URL(token string) string {
if l.svc.Config.Subscribe.PanDomain {
return fmt.Sprintf("https://%s", l.ctx.Request.Host)
}
if l.svc.Config.Subscribe.SubscribeDomain != "" {
domains := strings.Split(l.svc.Config.Subscribe.SubscribeDomain, "\n")
return fmt.Sprintf("https://%s%s?token=%s", domains[0], l.svc.Config.Subscribe.SubscribePath, token)
}
return fmt.Sprintf("https://%s%s?token=%s&", l.ctx.Request.Host, l.svc.Config.Subscribe.SubscribePath, token)
}
+21 -1
View File
@@ -1,6 +1,7 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
@@ -11,7 +12,26 @@ import (
func PanDomainMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
if svc.Config.Subscribe.PanDomain {
if svc.Config.Subscribe.PanDomain && c.Request.URL.Path == "/" {
// intercept browser
ua := c.GetHeader("User-Agent")
if ua == "" {
c.String(http.StatusForbidden, "Access denied")
c.Abort()
return
}
browserKeywords := []string{"chrome", "firefox", "safari", "edge", "opera", "micromessenger"}
for _, keyword := range browserKeywords {
lcUA := strings.ToLower(ua)
if strings.Contains(lcUA, keyword) {
c.String(http.StatusForbidden, "Access denied")
c.Abort()
return
}
}
domain := c.Request.Host
domainArr := strings.Split(domain, ".")
domainFirst := domainArr[0]
+75
View File
@@ -0,0 +1,75 @@
package client
import (
"encoding/json"
"time"
)
type SubscribeApplication struct {
Id int64 `gorm:"primary_key"`
Name string `gorm:"type:varchar(255);default:'';not null;comment:Application Name"`
Icon string `gorm:"type:MEDIUMTEXT;default:null;comment:Application Icon"`
Description string `gorm:"type:varchar(255);default:null;comment:Application Description"`
Scheme string `gorm:"type:varchar(255);default:'';not null;comment:Scheme"`
UserAgent string `gorm:"type:varchar(255);default:'';not null;comment:User Agent"`
IsDefault bool `gorm:"type:tinyint(1);not null;default:0;comment:Is Default Application"`
SubscribeTemplate string `gorm:"type:MEDIUMTEXT;default:null;comment:Subscribe Template"`
OutputFormat string `gorm:"type:varchar(50);default:'yaml';not null;comment:Output Format"`
DownloadLink string `gorm:"type:text;not null;comment:Download Link"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
func (SubscribeApplication) TableName() string {
return "subscribe_application"
}
type DownloadLink struct {
IOS string `json:"ios,omitempty"`
Android string `json:"android,omitempty"`
Windows string `json:"windows,omitempty"`
Mac string `json:"mac,omitempty"`
Linux string `json:"linux,omitempty"`
Harmony string `json:"harmony,omitempty"`
}
// GetDownloadLink returns the download link for the specified platform.
func (d *DownloadLink) GetDownloadLink(platform string) string {
if d == nil {
return ""
}
switch platform {
case "ios":
return d.IOS
case "android":
return d.Android
case "windows":
return d.Windows
case "mac":
return d.Mac
case "linux":
return d.Linux
case "harmony":
return d.Harmony
default:
return ""
}
}
// Marshal serializes the DownloadLink to JSON format.
func (d *DownloadLink) Marshal() ([]byte, error) {
if d == nil {
var empty DownloadLink
return json.Marshal(empty)
}
return json.Marshal(d)
}
// Unmarshal parses the JSON-encoded data and stores the result in the DownloadLink.
func (d *DownloadLink) Unmarshal(data []byte) error {
if data == nil || len(data) == 0 {
*d = DownloadLink{}
return nil
}
return json.Unmarshal(data, d)
}
+81
View File
@@ -0,0 +1,81 @@
package client
import (
"context"
"gorm.io/gorm"
)
type (
Model interface {
subscribeApplicationModel
}
subscribeApplicationModel interface {
Insert(ctx context.Context, data *SubscribeApplication) error
FindOne(ctx context.Context, id int64) (*SubscribeApplication, error)
Update(ctx context.Context, data *SubscribeApplication) error
Delete(ctx context.Context, id int64) error
List(ctx context.Context) ([]*SubscribeApplication, error)
Transaction(ctx context.Context, fn func(db *gorm.DB) error) error
}
DefaultSubscribeApplicationModel struct {
*gorm.DB
}
)
func NewSubscribeApplicationModel(db *gorm.DB) Model {
return &DefaultSubscribeApplicationModel{
DB: db.Model(&SubscribeApplication{}),
}
}
func (m *DefaultSubscribeApplicationModel) Insert(ctx context.Context, data *SubscribeApplication) error {
if err := m.WithContext(ctx).Create(data).Error; err != nil {
return err
}
return nil
}
func (m *DefaultSubscribeApplicationModel) FindOne(ctx context.Context, id int64) (*SubscribeApplication, error) {
var resp SubscribeApplication
if err := m.WithContext(ctx).Where("id = ?", id).First(&resp).Error; err != nil {
return nil, err
}
return &resp, nil
}
func (m *DefaultSubscribeApplicationModel) Update(ctx context.Context, data *SubscribeApplication) error {
if _, err := m.FindOne(ctx, data.Id); err != nil {
return err
}
if err := m.WithContext(ctx).Save(data).Error; err != nil {
return err
}
return nil
}
func (m *DefaultSubscribeApplicationModel) Delete(ctx context.Context, id int64) error {
if err := m.WithContext(ctx).Delete(&SubscribeApplication{}, id).Error; err != nil {
return err
}
return nil
}
func (m *DefaultSubscribeApplicationModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error {
tx := m.WithContext(ctx).Begin()
if err := fn(tx); err != nil {
if rbErr := tx.Rollback().Error; rbErr != nil {
return rbErr
}
return err
}
return tx.Commit().Error
}
func (m *DefaultSubscribeApplicationModel) List(ctx context.Context) ([]*SubscribeApplication, error) {
var resp []*SubscribeApplication
if err := m.WithContext(ctx).Find(&resp).Error; err != nil {
return nil, err
}
return resp, nil
}
+49
View File
@@ -40,6 +40,13 @@ type Details struct {
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
type OrdersTotalWithDate struct {
Date string
AmountTotal int64
NewOrderAmount int64
RenewalOrderAmount int64
}
type customOrderLogicModel interface {
UpdateOrderStatus(ctx context.Context, orderNo string, status uint8, tx ...*gorm.DB) error
QueryOrderListByPage(ctx context.Context, page, size int, status uint8, user, subscribe int64, search string) (int64, []*Details, error)
@@ -52,6 +59,8 @@ type customOrderLogicModel interface {
QueryDateUserCounts(ctx context.Context, date time.Time) (int64, int64, error)
QueryTotalUserCounts(ctx context.Context) (int64, int64, error)
IsUserEligibleForNewOrder(ctx context.Context, userID int64) (bool, error)
QueryDailyOrdersList(ctx context.Context, date time.Time) ([]OrdersTotalWithDate, error)
QueryMonthlyOrdersList(ctx context.Context, date time.Time) ([]OrdersTotalWithDate, error)
}
// NewModel returns a model for the database table.
@@ -226,3 +235,43 @@ func (m *customOrderModel) IsUserEligibleForNewOrder(ctx context.Context, userID
})
return count == 0, err
}
// QueryDailyOrdersList Query daily orders list for the current month (from 1st to current date)
func (m *customOrderModel) QueryDailyOrdersList(ctx context.Context, date time.Time) ([]OrdersTotalWithDate, error) {
var results []OrdersTotalWithDate
err := m.QueryNoCacheCtx(ctx, &results, func(conn *gorm.DB, v interface{}) error {
firstDay := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
return conn.Model(&Order{}).
Where("status IN ? AND created_at BETWEEN ? AND ? AND method != ?", []int64{2, 5}, firstDay, date, "balance").
Select(
"DATE(created_at) as date, " +
"SUM(amount) as amount_total, " +
"SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " +
"SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount",
).
Group("DATE(created_at)").
Order("date ASC").
Scan(v).Error
})
return results, err
}
// QueryMonthlyOrdersList Query monthly orders list for the past 6 months
func (m *customOrderModel) QueryMonthlyOrdersList(ctx context.Context, date time.Time) ([]OrdersTotalWithDate, error) {
var results []OrdersTotalWithDate
err := m.QueryNoCacheCtx(ctx, &results, func(conn *gorm.DB, v interface{}) error {
sixMonthsAgo := date.AddDate(0, -5, 0)
return conn.Model(&Order{}).
Where("status IN ? AND created_at >= ? AND method != ?", []int64{2, 5}, sixMonthsAgo, "balance").
Select(
"DATE_FORMAT(created_at, '%Y-%m') as date, " +
"SUM(amount) as amount_total, " +
"SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " +
"SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount",
).
Group("DATE_FORMAT(created_at, '%Y-%m')").
Order("date ASC").
Scan(v).Error
})
return results, err
}
+3
View File
@@ -69,10 +69,13 @@ func (m *defaultServerModel) getCacheKeys(data *Server) []string {
detailsKey := fmt.Sprintf("%s%v", CacheServerDetailPrefix, data.Id)
ServerIdKey := fmt.Sprintf("%s%v", cacheServerIdPrefix, data.Id)
configIdKey := fmt.Sprintf("%s%v", config.ServerConfigCacheKey, data.Id)
userIDKey := fmt.Sprintf("%s%d", config.ServerUserListCacheKey, data.Id)
cacheKeys := []string{
ServerIdKey,
detailsKey,
configIdKey,
userIDKey,
}
return cacheKeys
}
+9
View File
@@ -138,6 +138,15 @@ type Hysteria2 struct {
}
type Tuic struct {
Port int `json:"port"`
DisableSNI bool `json:"disable_sni"`
ReduceRtt bool `json:"reduce_rtt"`
UDPRelayMode string `json:"udp_relay_mode"`
CongestionController string `json:"congestion_controller"`
SecurityConfig SecurityConfig `json:"security_config"`
}
type AnyTLS struct {
Port int `json:"port"`
SecurityConfig SecurityConfig `json:"security_config"`
}
+14 -2
View File
@@ -4,7 +4,9 @@ import (
"context"
"errors"
"fmt"
"strings"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/pkg/cache"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
@@ -58,8 +60,18 @@ func (m *defaultSubscribeModel) getCacheKeys(data *Subscribe) []string {
return []string{}
}
SubscribeIdKey := fmt.Sprintf("%s%v", cacheSubscribeIdPrefix, data.Id)
cacheKeys := []string{
SubscribeIdKey,
serverKey := make([]string, 0)
if data.Server != "" {
cacheKey := strings.Split(data.Server, ",")
for _, v := range cacheKey {
if v != "" {
serverKey = append(serverKey, fmt.Sprintf("%s%v", config.ServerUserListCacheKey, v))
}
}
}
cacheKeys := []string{SubscribeIdKey}
if len(serverKey) > 0 {
cacheKeys = append(cacheKeys, serverKey...)
}
return cacheKeys
}
+22
View File
@@ -35,6 +35,7 @@ type customSubscribeLogicModel interface {
QuerySubscribeIdsByServerIdAndServerGroupId(ctx context.Context, serverId, serverGroupId int64) ([]*Subscribe, error)
QuerySubscribeMinSortByIds(ctx context.Context, ids []int64) (int64, error)
QuerySubscribeListByIds(ctx context.Context, ids []int64) ([]*Subscribe, error)
ClearCache(ctx context.Context, id int64) error
}
// NewModel returns a model for the database table.
@@ -107,3 +108,24 @@ func (m *customSubscribeModel) QuerySubscribeListByIds(ctx context.Context, ids
})
return list, err
}
func (m *customSubscribeModel) ClearCache(ctx context.Context, id int64) error {
if id <= 0 {
return nil
}
data, err := m.FindOne(ctx, id)
if err != nil {
return err
}
cacheKeys := m.getCacheKeys(data)
cacheKeys = append(cacheKeys, m.getCacheKeys(&Subscribe{Id: id})...)
for _, key := range cacheKeys {
if err := m.CachedConn.DelCacheCtx(ctx, key); err != nil {
return err
}
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package task
import "time"
type EmailTask struct {
Id int64 `gorm:"column:id;primaryKey;autoIncrement;comment:ID"`
Subject string `gorm:"column:subject;type:varchar(255);not null;comment:Email Subject"`
Content string `gorm:"column:content;type:text;not null;comment:Email Content"`
Recipients string `gorm:"column:recipient;type:text;not null;comment:Email Recipient"`
Scope string `gorm:"column:scope;type:varchar(50);not null;comment:Email Scope"`
RegisterStartTime time.Time `gorm:"column:register_start_time;default:null;comment:Register Start Time"`
RegisterEndTime time.Time `gorm:"column:register_end_time;default:null;comment:Register End Time"`
Additional string `gorm:"column:additional;type:text;default:null;comment:Additional Information"`
Scheduled time.Time `gorm:"column:scheduled;not null;comment:Scheduled Time"`
Interval uint8 `gorm:"column:interval;not null;comment:Interval in Seconds"`
Limit uint64 `gorm:"column:limit;not null;comment:Daily send limit"`
Status uint8 `gorm:"column:status;not null;comment:Daily Status"`
Errors string `gorm:"column:errors;type:text;not null;comment:Errors"`
Total uint64 `gorm:"column:total;not null;default:0;comment:Total Number"`
Current uint64 `gorm:"column:current;not null;default:0;comment:Current Number"`
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
func (EmailTask) TableName() string {
return "email_task"
}
+29 -2
View File
@@ -3,6 +3,7 @@ package user
import (
"context"
"github.com/perfect-panel/server/pkg/logger"
"gorm.io/gorm"
)
@@ -31,24 +32,50 @@ func (m *defaultUserModel) FindUserAuthMethodByPlatform(ctx context.Context, use
}
func (m *defaultUserModel) InsertUserAuthMethods(ctx context.Context, data *AuthMethods, tx ...*gorm.DB) error {
u, err := m.FindOne(ctx, data.UserId)
if err != nil {
return err
}
return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Model(&AuthMethods{}).Create(data).Error
if err = conn.Model(&AuthMethods{}).Create(data).Error; err != nil {
return err
}
return m.ClearUserCache(ctx, u)
})
}
func (m *defaultUserModel) UpdateUserAuthMethods(ctx context.Context, data *AuthMethods, tx ...*gorm.DB) error {
u, err := m.FindOne(ctx, data.UserId)
if err != nil {
return err
}
return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Model(&AuthMethods{}).Where("user_id = ? AND auth_type = ?", data.UserId, data.AuthType).Save(data).Error
err = conn.Model(&AuthMethods{}).Where("user_id = ? AND auth_type = ?", data.UserId, data.AuthType).Save(data).Error
if err != nil {
return err
}
return m.ClearUserCache(ctx, u)
})
}
func (m *defaultUserModel) DeleteUserAuthMethods(ctx context.Context, userId int64, platform string, tx ...*gorm.DB) error {
u, err := m.FindOne(ctx, userId)
if err != nil {
return err
}
defer func() {
if err = m.ClearUserCache(context.Background(), u); err != nil {
logger.Errorf("[UserModel] clear user cache failed: %v", err.Error())
}
}()
return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
+285
View File
@@ -0,0 +1,285 @@
package user
import (
"context"
"fmt"
"github.com/perfect-panel/server/pkg/logger"
)
type CacheKeyGenerator interface {
GetCacheKeys() []string
}
type CacheManager interface {
ClearCache(ctx context.Context, keys ...string) error
ClearModelCache(ctx context.Context, models ...CacheKeyGenerator) error
}
type UserCacheManager struct {
model *defaultUserModel
}
func NewUserCacheManager(model *defaultUserModel) *UserCacheManager {
return &UserCacheManager{
model: model,
}
}
func (c *UserCacheManager) ClearCache(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
return c.model.CachedConn.DelCacheCtx(ctx, keys...)
}
func (c *UserCacheManager) ClearModelCache(ctx context.Context, models ...CacheKeyGenerator) error {
var allKeys []string
for _, model := range models {
if model != nil {
allKeys = append(allKeys, model.GetCacheKeys()...)
}
}
return c.ClearCache(ctx, allKeys...)
}
func (u *User) GetCacheKeys() []string {
if u == nil {
return []string{}
}
keys := []string{
fmt.Sprintf("%s%d", cacheUserIdPrefix, u.Id),
}
for _, auth := range u.AuthMethods {
if auth.AuthType == "email" {
keys = append(keys, fmt.Sprintf("%s%s", cacheUserEmailPrefix, auth.AuthIdentifier))
break
}
}
return keys
}
func (s *Subscribe) GetCacheKeys() []string {
if s == nil {
return []string{}
}
keys := []string{}
if s.Token != "" {
keys = append(keys, fmt.Sprintf("%s%s", cacheUserSubscribeTokenPrefix, s.Token))
}
if s.UserId != 0 {
keys = append(keys, fmt.Sprintf("%s%d", cacheUserSubscribeUserPrefix, s.UserId))
}
if s.Id != 0 {
keys = append(keys, fmt.Sprintf("%s%d", cacheUserSubscribeIdPrefix, s.Id))
}
return keys
}
func (s *Subscribe) GetExtendedCacheKeys(model *defaultUserModel) []string {
keys := s.GetCacheKeys()
if s.SubscribeId != 0 && model != nil {
serverKeys := model.getServerRelatedCacheKeys(s.SubscribeId)
keys = append(keys, serverKeys...)
}
return keys
}
func (d *Device) GetCacheKeys() []string {
if d == nil {
return []string{}
}
keys := []string{}
if d.Id != 0 {
keys = append(keys, fmt.Sprintf("%s%d", cacheUserDeviceIdPrefix, d.Id))
}
if d.Identifier != "" {
keys = append(keys, fmt.Sprintf("%s%s", cacheUserDeviceNumberPrefix, d.Identifier))
}
return keys
}
func (a *AuthMethods) GetCacheKeys() []string {
if a == nil {
return []string{}
}
keys := []string{}
if a.UserId != 0 {
keys = append(keys, fmt.Sprintf("%s%d", cacheUserIdPrefix, a.UserId))
}
if a.AuthType == "email" && a.AuthIdentifier != "" {
keys = append(keys, fmt.Sprintf("%s%s", cacheUserEmailPrefix, a.AuthIdentifier))
}
return keys
}
func (m *defaultUserModel) GetCacheManager() *UserCacheManager {
return NewUserCacheManager(m)
}
func (m *defaultUserModel) getServerRelatedCacheKeys(subscribeId int64) []string {
// 这里复用了 model.go 中的逻辑,但简化了实现
keys := []string{}
if subscribeId == 0 {
return keys
}
// 这里需要从 getSubscribeCacheKey 方法中提取服务器相关的逻辑
// 为了避免重复查询,我们可以在需要时才获取
// 或者可以将这个逻辑移到一个统一的地方
return keys
}
func (m *defaultUserModel) ClearUserCache(ctx context.Context, users ...*User) error {
cacheManager := m.GetCacheManager()
models := make([]CacheKeyGenerator, len(users))
for i, user := range users {
models[i] = user
}
return cacheManager.ClearModelCache(ctx, models...)
}
func (m *defaultUserModel) ClearSubscribeCacheByModels(ctx context.Context, subscribes ...*Subscribe) error {
cacheManager := m.GetCacheManager()
models := make([]CacheKeyGenerator, len(subscribes))
for i, subscribe := range subscribes {
models[i] = subscribe
}
return cacheManager.ClearModelCache(ctx, models...)
}
func (m *defaultUserModel) ClearDeviceCache(ctx context.Context, devices ...*Device) error {
cacheManager := m.GetCacheManager()
models := make([]CacheKeyGenerator, len(devices))
for i, device := range devices {
models[i] = device
}
return cacheManager.ClearModelCache(ctx, models...)
}
func (m *defaultUserModel) ClearAuthMethodCache(ctx context.Context, authMethods ...*AuthMethods) error {
cacheManager := m.GetCacheManager()
models := make([]CacheKeyGenerator, len(authMethods))
for i, auth := range authMethods {
models[i] = auth
}
return cacheManager.ClearModelCache(ctx, models...)
}
func (m *defaultUserModel) BatchClearRelatedCache(ctx context.Context, user *User) error {
if user == nil {
return nil
}
cacheManager := m.GetCacheManager()
var allModels []CacheKeyGenerator
allModels = append(allModels, user)
for _, auth := range user.AuthMethods {
allModels = append(allModels, &auth)
}
for _, device := range user.UserDevices {
allModels = append(allModels, &device)
}
subscribes, err := m.QueryUserSubscribe(ctx, user.Id)
if err != nil {
logger.Errorf("failed to query user subscribes for cache clearing: %v", err)
} else {
for _, sub := range subscribes {
subModel := &Subscribe{
Id: sub.Id,
UserId: sub.UserId,
Token: sub.Token,
SubscribeId: sub.SubscribeId,
}
allModels = append(allModels, subModel)
}
}
return cacheManager.ClearModelCache(ctx, allModels...)
}
func (m *defaultUserModel) CacheInvalidationHandler(ctx context.Context, operation string, modelType string, model interface{}) error {
switch operation {
case "create", "update", "delete":
switch modelType {
case "user":
if user, ok := model.(*User); ok {
return m.BatchClearRelatedCache(ctx, user)
}
case "subscribe":
if subscribe, ok := model.(*Subscribe); ok {
return m.ClearSubscribeCacheByModels(ctx, subscribe)
}
case "device":
if device, ok := model.(*Device); ok {
return m.ClearDeviceCache(ctx, device)
}
case "authmethod":
if authMethod, ok := model.(*AuthMethods); ok {
return m.ClearAuthMethodCache(ctx, authMethod)
}
}
}
return nil
}
func (m *customUserModel) GetRelatedCacheKeys(ctx context.Context, modelType string, modelId int64) ([]string, error) {
var keys []string
switch modelType {
case "user":
user, err := m.FindOne(ctx, modelId)
if err != nil {
return nil, err
}
keys = append(keys, user.GetCacheKeys()...)
auths, err := m.FindUserAuthMethods(ctx, modelId)
if err == nil {
for _, auth := range auths {
keys = append(keys, auth.GetCacheKeys()...)
}
}
subscribes, err := m.QueryUserSubscribe(ctx, modelId)
if err == nil {
for _, sub := range subscribes {
subModel := &Subscribe{
Id: sub.Id,
UserId: sub.UserId,
Token: sub.Token,
SubscribeId: sub.SubscribeId,
}
keys = append(keys, subModel.GetCacheKeys()...)
}
}
case "subscribe":
subscribe, err := m.FindOneSubscribe(ctx, modelId)
if err != nil {
return nil, err
}
keys = append(keys, subscribe.GetCacheKeys()...)
case "device":
device, err := m.FindOneDevice(ctx, modelId)
if err != nil {
return nil, err
}
keys = append(keys, device.GetCacheKeys()...)
}
return keys, nil
}
+56 -60
View File
@@ -48,29 +48,20 @@ func newUserModel(db *gorm.DB, c *redis.Client) *defaultUserModel {
func (m *defaultUserModel) batchGetCacheKeys(users ...*User) []string {
var keys []string
for _, user := range users {
keys = append(keys, m.getCacheKeys(user)...)
keys = append(keys, user.GetCacheKeys()...)
}
return keys
}
func (m *defaultUserModel) getCacheKeys(data *User) []string {
if data == nil {
return []string{}
}
userIdKey := fmt.Sprintf("%s%v", cacheUserIdPrefix, data.Id)
cacheKeys := []string{
userIdKey,
}
// email key
if len(data.AuthMethods) > 0 {
for _, auth := range data.AuthMethods {
if auth.AuthType == "email" {
cacheKeys = append(cacheKeys, fmt.Sprintf("%s%v", cacheUserEmailPrefix, auth.AuthIdentifier))
break
}
}
}
return cacheKeys
return data.GetCacheKeys()
}
func (m *defaultUserModel) clearUserCache(ctx context.Context, data ...*User) error {
return m.ClearUserCache(ctx, data...)
}
func (m *defaultUserModel) FindOneByEmail(ctx context.Context, email string) (*User, error) {
@@ -127,53 +118,58 @@ func (m *defaultUserModel) Delete(ctx context.Context, id int64, tx ...*gorm.DB)
}
return err
}
err = m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
// 使用批量相关缓存清理,包含所有相关数据的缓存
defer func() {
if clearErr := m.BatchClearRelatedCache(ctx, data); clearErr != nil {
// 记录清理缓存错误,但不阻断删除操作
}
return conn.Transaction(func(db *gorm.DB) error {
if err := db.Model(&User{}).Where("`id` = ?", id).Delete(&User{}).Error; err != nil {
return err
}
if err := db.Model(&AuthMethods{}).Where("`user_id` = ?", id).Delete(&User{}).Error; err != nil {
return err
}
if err := db.Model(&Subscribe{}).Where("`user_id` = ?", id).Delete(&User{}).Error; err != nil {
return err
}
if err := db.Model(&BalanceLog{}).Where("`user_id` = ?", id).Delete(&User{}).Error; err != nil {
return err
}
if err := db.Model(&GiftAmountLog{}).Where("`user_id` = ?", id).Delete(&User{}).Error; err != nil {
return err
}
if err := db.Model(&LoginLog{}).Where("`user_id` = ?", id).Delete(&User{}).Error; err != nil {
return err
}
if err := db.Model(&SubscribeLog{}).Where("`user_id` = ?", id).Delete(&User{}).Error; err != nil {
return err
}
if err := db.Model(&Device{}).Where("`user_id` = ?", id).Delete(&User{}).Error; err != nil {
return err
}
}()
subs, err := m.QueryUserSubscribe(ctx, id)
if err != nil {
return err
}
for _, sub := range subs {
if err := m.DeleteSubscribeById(ctx, sub.Id, db); err != nil {
return err
}
}
return m.TransactCtx(ctx, func(db *gorm.DB) error {
if len(tx) > 0 {
db = tx[0]
}
if err := db.Model(&CommissionLog{}).Where("`user_id` = ?", id).Delete(&User{}).Error; err != nil {
return err
}
return nil
})
}, m.getCacheKeys(data)...)
return err
// 删除用户相关的所有数据
if err := db.Model(&User{}).Where("`id` = ?", id).Delete(&User{}).Error; err != nil {
return err
}
if err := db.Model(&AuthMethods{}).Where("`user_id` = ?", id).Delete(&AuthMethods{}).Error; err != nil {
return err
}
if err := db.Model(&Subscribe{}).Where("`user_id` = ?", id).Delete(&Subscribe{}).Error; err != nil {
return err
}
if err := db.Model(&BalanceLog{}).Where("`user_id` = ?", id).Delete(&BalanceLog{}).Error; err != nil {
return err
}
if err := db.Model(&GiftAmountLog{}).Where("`user_id` = ?", id).Delete(&GiftAmountLog{}).Error; err != nil {
return err
}
if err := db.Model(&LoginLog{}).Where("`user_id` = ?", id).Delete(&LoginLog{}).Error; err != nil {
return err
}
if err := db.Model(&SubscribeLog{}).Where("`user_id` = ?", id).Delete(&SubscribeLog{}).Error; err != nil {
return err
}
if err := db.Model(&Device{}).Where("`user_id` = ?", id).Delete(&Device{}).Error; err != nil {
return err
}
if err := db.Model(&CommissionLog{}).Where("`user_id` = ?", id).Delete(&CommissionLog{}).Error; err != nil {
return err
}
return nil
})
}
func (m *defaultUserModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error {
+2 -4
View File
@@ -51,13 +51,12 @@ func (m *customUserModel) UpdateDevice(ctx context.Context, data *Device, tx ...
if err != nil {
return err
}
deviceIdKey := fmt.Sprintf("%s%v", cacheUserDeviceIdPrefix, old.Id)
err = m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Save(data).Error
}, deviceIdKey)
}, old.GetCacheKeys()...)
return err
}
@@ -69,12 +68,11 @@ func (m *customUserModel) DeleteDevice(ctx context.Context, id int64, tx ...*gor
}
return err
}
deviceIdKey := fmt.Sprintf("%s%v", cacheUserDeviceIdPrefix, data.Id)
err = m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Delete(&Device{}, id).Error
}, deviceIdKey)
}, data.GetCacheKeys()...)
return err
}
+66 -57
View File
@@ -6,11 +6,7 @@ import (
"fmt"
"time"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/model/server"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
@@ -63,6 +59,7 @@ type UserFilterParams struct {
UserId *int64
SubscribeId *int64
UserSubscribeId *int64
Order string // Order by id, e.g., "desc"
}
type customUserLogicModel interface {
@@ -110,12 +107,23 @@ type customUserLogicModel interface {
FilterLoginLogList(ctx context.Context, page, size int, filter *LoginLogFilterParams) ([]*LoginLog, int64, error)
ClearSubscribeCache(ctx context.Context, data ...*Subscribe) error
clearUserCache(ctx context.Context, data ...*User) error
InsertResetSubscribeLog(ctx context.Context, log *ResetSubscribeLog, tx ...*gorm.DB) error
UpdateResetSubscribeLog(ctx context.Context, log *ResetSubscribeLog, tx ...*gorm.DB) error
FindResetSubscribeLog(ctx context.Context, id int64) (*ResetSubscribeLog, error)
DeleteResetSubscribeLog(ctx context.Context, id int64, tx ...*gorm.DB) error
FilterResetSubscribeLogList(ctx context.Context, filter *FilterResetSubscribeLogParams) ([]*ResetSubscribeLog, int64, error)
QueryDailyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error)
QueryMonthlyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error)
}
type UserStatisticsWithDate struct {
Date string
Register int64
NewOrderUsers int64
RenewalOrderUsers int64
}
// NewModel returns a model for the database table.
@@ -125,56 +133,6 @@ func NewModel(conn *gorm.DB, c *redis.Client) Model {
}
}
func (m *defaultUserModel) getSubscribeCacheKey(data *Subscribe) []string {
if data == nil {
return []string{}
}
var keys []string
if data.Token != "" {
keys = append(keys, fmt.Sprintf("%s%s", cacheUserSubscribeTokenPrefix, data.Token))
}
if data.UserId != 0 {
keys = append(keys, fmt.Sprintf("%s%d", cacheUserSubscribeUserPrefix, data.UserId))
}
if data.Id != 0 {
keys = append(keys, fmt.Sprintf("%s%d", cacheUserSubscribeIdPrefix, data.Id))
}
if data.SubscribeId != 0 {
var sub *subscribe.Subscribe
err := m.QueryNoCacheCtx(context.Background(), &sub, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&subscribe.Subscribe{}).Where("id = ?", data.SubscribeId).First(&sub).Error
})
if err != nil {
logger.Error("getUserSubscribeCacheKey", logger.Field("error", err.Error()), logger.Field("subscribeId", data.SubscribeId))
return keys
}
if sub.Server != "" {
ids := tool.StringToInt64Slice(sub.Server)
for _, id := range ids {
keys = append(keys, fmt.Sprintf("%s%d", config.ServerUserListCacheKey, id))
}
}
if sub.ServerGroup != "" {
ids := tool.StringToInt64Slice(sub.ServerGroup)
var servers []*server.Server
err = m.QueryNoCacheCtx(context.Background(), &servers, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&server.Server{}).Where("group_id in ?", ids).Find(v).Error
})
if err != nil {
logger.Error("getUserSubscribeCacheKey", logger.Field("error", err.Error()), logger.Field("subscribeId", data.SubscribeId))
return keys
}
for _, s := range servers {
keys = append(keys, fmt.Sprintf("%s%d", config.ServerUserListCacheKey, s.Id))
}
}
}
return keys
}
// QueryPageList returns a list of records that meet the conditions.
func (m *customUserModel) QueryPageList(ctx context.Context, page, size int, filter *UserFilterParams) ([]*User, int64, error) {
var list []*User
@@ -196,6 +154,9 @@ func (m *customUserModel) QueryPageList(ctx context.Context, page, size int, fil
conn = conn.Joins("LEFT JOIN user_subscribe ON user.id = user_subscribe.user_id").
Where("user_subscribe.subscribe_id =? and `status` IN (0,1)", *filter.SubscribeId)
}
if filter.Order != "" {
conn = conn.Order(fmt.Sprintf("user.id %s", filter.Order))
}
}
return conn.Model(&User{}).Group("user.id").Count(&total).Limit(size).Offset((page - 1) * size).Preload("UserDevices").Preload("AuthMethods").Find(&list).Error
})
@@ -245,7 +206,15 @@ func (m *customUserModel) UpdateUserSubscribeWithTraffic(ctx context.Context, id
if err != nil {
return err
}
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
// 使用 defer 确保更新后清理缓存
defer func() {
if clearErr := m.ClearSubscribeCacheByModels(ctx, sub); clearErr != nil {
// 记录清理缓存错误
}
}()
return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
@@ -253,7 +222,7 @@ func (m *customUserModel) UpdateUserSubscribeWithTraffic(ctx context.Context, id
"download": gorm.Expr("download + ?", download),
"upload": gorm.Expr("upload + ?", upload),
}).Error
}, m.getSubscribeCacheKey(sub)...)
})
}
func (m *customUserModel) QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error) {
@@ -293,7 +262,7 @@ func (m *customUserModel) QueryAdminUsers(ctx context.Context) ([]*User, error)
}
func (m *customUserModel) UpdateUserCache(ctx context.Context, data *User) error {
return m.CachedConn.DelCacheCtx(ctx, m.getCacheKeys(data)...)
return m.ClearUserCache(ctx, data)
}
func (m *customUserModel) InsertCommissionLog(ctx context.Context, data *CommissionLog, tx ...*gorm.DB) error {
@@ -399,3 +368,43 @@ func (m *customUserModel) FilterResetSubscribeLogList(ctx context.Context, filte
return list, total, err
}
// QueryDailyUserStatisticsList Query daily user statistics list for the current month (from 1st to current date)
func (m *customUserModel) QueryDailyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error) {
var results []UserStatisticsWithDate
err := m.QueryNoCacheCtx(ctx, &results, func(conn *gorm.DB, v interface{}) error {
firstDay := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
return conn.Model(&User{}).
Select(
"DATE(created_at) as date, "+
"COUNT(*) as register, "+
"0 as new_order_users, "+
"0 as renewal_order_users",
).
Where("created_at BETWEEN ? AND ?", firstDay, date).
Group("DATE(created_at)").
Order("date ASC").
Scan(v).Error
})
return results, err
}
// QueryMonthlyUserStatisticsList Query monthly user statistics list for the past 6 months
func (m *customUserModel) QueryMonthlyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error) {
var results []UserStatisticsWithDate
err := m.QueryNoCacheCtx(ctx, &results, func(conn *gorm.DB, v interface{}) error {
sixMonthsAgo := date.AddDate(0, -5, 0)
return conn.Model(&User{}).
Select(
"DATE_FORMAT(created_at, '%Y-%m') as date, "+
"COUNT(*) as register, "+
"0 as new_order_users, "+
"0 as renewal_order_users",
).
Where("created_at >= ?", sixMonthsAgo).
Group("DATE_FORMAT(created_at, '%Y-%m')").
Order("date ASC").
Scan(v).Error
})
return results, err
}
+41 -14
View File
@@ -9,7 +9,7 @@ import (
)
func (m *defaultUserModel) UpdateUserSubscribeCache(ctx context.Context, data *Subscribe) error {
return m.CachedConn.DelCacheCtx(ctx, m.getSubscribeCacheKey(data)...)
return m.ClearSubscribeCacheByModels(ctx, data)
}
// QueryActiveSubscriptions returns the number of active subscriptions.
@@ -113,12 +113,20 @@ func (m *defaultUserModel) UpdateSubscribe(ctx context.Context, data *Subscribe,
if err != nil {
return err
}
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
// 使用 defer 确保更新后清理缓存
defer func() {
if clearErr := m.ClearSubscribeCacheByModels(ctx, old, data); clearErr != nil {
// 记录清理缓存错误
}
}()
return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Model(&Subscribe{}).Where("id = ?", data.Id).Save(data).Error
}, m.getSubscribeCacheKey(old)...)
})
}
// DeleteSubscribe deletes a record.
@@ -127,22 +135,37 @@ func (m *defaultUserModel) DeleteSubscribe(ctx context.Context, token string, tx
if err != nil {
return err
}
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
// 使用 defer 确保删除后清理缓存
defer func() {
if clearErr := m.ClearSubscribeCacheByModels(ctx, data); clearErr != nil {
// 记录清理缓存错误
}
}()
return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Where("token = ?", token).Delete(&Subscribe{}).Error
}, m.getSubscribeCacheKey(data)...)
})
}
// InsertSubscribe insert Subscribe into the database.
func (m *defaultUserModel) InsertSubscribe(ctx context.Context, data *Subscribe, tx ...*gorm.DB) error {
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
// 使用 defer 确保插入后清理相关缓存
defer func() {
if clearErr := m.ClearSubscribeCacheByModels(ctx, data); clearErr != nil {
// 记录清理缓存错误
}
}()
return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Create(data).Error
}, m.getSubscribeCacheKey(data)...)
})
}
func (m *defaultUserModel) DeleteSubscribeById(ctx context.Context, id int64, tx ...*gorm.DB) error {
@@ -150,18 +173,22 @@ func (m *defaultUserModel) DeleteSubscribeById(ctx context.Context, id int64, tx
if err != nil {
return err
}
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
// 使用 defer 确保删除后清理缓存
defer func() {
if clearErr := m.ClearSubscribeCacheByModels(ctx, data); clearErr != nil {
// 记录清理缓存错误
}
}()
return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Where("id = ?", id).Delete(&Subscribe{}).Error
}, m.getSubscribeCacheKey(data)...)
})
}
func (m *defaultUserModel) ClearSubscribeCache(ctx context.Context, data ...*Subscribe) error {
var keys []string
for _, item := range data {
keys = append(keys, m.getSubscribeCacheKey(item)...)
}
return m.CachedConn.DelCacheCtx(ctx, keys...)
return m.ClearSubscribeCacheByModels(ctx, data...)
}
+5 -40
View File
@@ -2,9 +2,6 @@ package user
import (
"time"
"gorm.io/gorm"
"gorm.io/plugin/soft_delete"
)
type User struct {
@@ -28,39 +25,7 @@ type User struct {
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
func (User) TableName() string {
return "user"
}
type OldUser struct {
Id int64 `gorm:"primaryKey"`
Email string `gorm:"index:idx_email;type:varchar(100);comment:Email"`
//Telephone string `gorm:"index:idx_telephone;type:varchar(20);default:'';comment:Telephone"`
//TelephoneAreaCode string `gorm:"index:idx_telephone;type:varchar(20);default:'';comment:TelephoneAreaCode"`
Password string `gorm:"type:varchar(100);not null;comment:User Password"`
Avatar string `gorm:"type:varchar(200);default:'';comment:User Avatar"`
Balance int64 `gorm:"default:0;comment:User Balance"` // User Balance Amount
Telegram int64 `gorm:"default:null;comment:Telegram Account"`
ReferCode string `gorm:"type:varchar(20);default:'';comment:Referral Code"`
RefererId int64 `gorm:"index:idx_referer;comment:Referrer ID"`
Commission int64 `gorm:"default:0;comment:Commission"` // Commission Amount
GiftAmount int64 `gorm:"default:0;comment:User Gift Amount"`
Enable *bool `gorm:"default:true;not null;comment:Is Account Enabled"`
IsAdmin *bool `gorm:"default:false;not null;comment:Is Admin"`
ValidEmail *bool `gorm:"default:false;not null;comment:Is Email Verified"`
EnableEmailNotify *bool `gorm:"default:false;not null;comment:Enable Email Notifications"`
EnableTelegramNotify *bool `gorm:"default:false;not null;comment:Enable Telegram Notifications"`
EnableBalanceNotify *bool `gorm:"default:false;not null;comment:Enable Balance Change Notifications"`
EnableLoginNotify *bool `gorm:"default:false;not null;comment:Enable Login Notifications"`
EnableSubscribeNotify *bool `gorm:"default:false;not null;comment:Enable Subscription Notifications"`
EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"`
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
DeletedAt gorm.DeletedAt `gorm:"default:null;comment:Deletion Time"`
IsDel soft_delete.DeletedAt `gorm:"softDelete:flag,DeletedAtField:DeletedAt;comment:1: Normal 0: Deleted"` // Using `1` and `0` to indicate
}
func (OldUser) TableName() string {
func (*User) TableName() string {
return "user"
}
@@ -83,7 +48,7 @@ type Subscribe struct {
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
func (Subscribe) TableName() string {
func (*Subscribe) TableName() string {
return "user_subscribe"
}
@@ -125,7 +90,7 @@ type CommissionLog struct {
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
}
func (CommissionLog) TableName() string {
func (*CommissionLog) TableName() string {
return "user_commission_log"
}
@@ -139,7 +104,7 @@ type AuthMethods struct {
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
func (AuthMethods) TableName() string {
func (*AuthMethods) TableName() string {
return "user_auth_methods"
}
@@ -155,7 +120,7 @@ type Device struct {
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
func (Device) TableName() string {
func (*Device) TableName() string {
return "user_device"
}
+25 -21
View File
@@ -3,6 +3,7 @@ package svc
import (
"context"
"github.com/perfect-panel/server/internal/model/client"
"github.com/perfect-panel/server/pkg/device"
"github.com/perfect-panel/server/internal/model/ads"
@@ -34,27 +35,29 @@ import (
)
type ServiceContext struct {
DB *gorm.DB
Redis *redis.Client
Config config.Config
Queue *asynq.Client
NodeCache *cache.NodeCacheClient
AuthModel auth.Model
AdsModel ads.Model
LogModel log.Model
UserModel user.Model
OrderModel order.Model
TicketModel ticket.Model
ServerModel server.Model
SystemModel system.Model
CouponModel coupon.Model
PaymentModel payment.Model
DocumentModel document.Model
SubscribeModel subscribe.Model
TrafficLogModel traffic.Model
ApplicationModel application.Model
AnnouncementModel announcement.Model
SubscribeTypeModel subscribeType.Model
DB *gorm.DB
Redis *redis.Client
Config config.Config
Queue *asynq.Client
NodeCache *cache.NodeCacheClient
AuthModel auth.Model
AdsModel ads.Model
LogModel log.Model
UserModel user.Model
OrderModel order.Model
ClientModel client.Model
TicketModel ticket.Model
ServerModel server.Model
SystemModel system.Model
CouponModel coupon.Model
PaymentModel payment.Model
DocumentModel document.Model
SubscribeModel subscribe.Model
TrafficLogModel traffic.Model
ApplicationModel application.Model
AnnouncementModel announcement.Model
SubscribeTypeModel subscribeType.Model
Restart func() error
TelegramBot *tgbotapi.BotAPI
NodeMultiplierManager *nodeMultiplier.Manager
@@ -94,6 +97,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
AuthModel: auth.NewModel(db, rds),
UserModel: user.NewModel(db, rds),
OrderModel: order.NewModel(db, rds),
ClientModel: client.NewSubscribeApplicationModel(db),
TicketModel: ticket.NewModel(db, rds),
ServerModel: server.NewModel(db, rds),
SystemModel: system.NewModel(db, rds),
+161
View File
@@ -255,6 +255,26 @@ type BatchDeleteUserRequest struct {
Ids []int64 `json:"ids" validate:"required"`
}
type BatchSendEmailTask struct {
Id int64 `json:"id"`
Subject string `json:"subject"`
Content string `json:"content"`
Recipients string `json:"recipients"`
Scope string `json:"scope"`
RegisterStartTime int64 `json:"register_start_time"`
RegisterEndTime int64 `json:"register_end_time"`
Additional string `json:"additional"`
Scheduled int64 `json:"scheduled"`
Interval uint8 `json:"interval"`
Limit uint64 `json:"limit"`
Status uint8 `json:"status"`
Errors string `json:"errors"`
Total uint64 `json:"total"`
Current uint64 `json:"current"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type BindOAuthCallbackRequest struct {
Method string `json:"method"`
Callback interface{} `json:"callback"`
@@ -372,6 +392,18 @@ type CreateApplicationVersionRequest struct {
ApplicationId int64 `json:"application_id" validate:"required"`
}
type CreateBatchSendEmailTaskRequest struct {
Subject string `json:"subject"`
Content string `json:"content"`
Scope string `json:"scope"`
RegisterStartTime int64 `json:"register_start_time,omitempty"`
RegisterEndTime int64 `json:"register_end_time,omitempty"`
Additional string `json:"additional,omitempty"`
Scheduled int64 `json:"scheduled,omitempty"`
Interval uint8 `json:"interval,omitempty"`
Limit uint64 `json:"limit,omitempty"`
}
type CreateCouponRequest struct {
Name string `json:"name" validate:"required"`
Code string `json:"code,omitempty"`
@@ -455,6 +487,18 @@ type CreateRuleGroupRequest struct {
Enable bool `json:"enable"`
}
type CreateSubscribeApplicationRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Scheme string `json:"scheme,omitempty"`
UserAgent string `json:"user_agent"`
IsDefault bool `json:"is_default"`
SubscribeTemplate string `json:"template"`
OutputFormat string `json:"output_format"`
DownloadLink DownloadLink `json:"download_link"`
}
type CreateSubscribeGroupRequest struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
@@ -586,6 +630,10 @@ type DeleteRuleGroupRequest struct {
Id int64 `json:"id" validate:"required"`
}
type DeleteSubscribeApplicationRequest struct {
Id int64 `json:"id"`
}
type DeleteSubscribeGroupRequest struct {
Id int64 `json:"id" validate:"required"`
}
@@ -617,6 +665,15 @@ type Document struct {
UpdatedAt int64 `json:"updated_at"`
}
type DownloadLink struct {
IOS string `json:"ios,omitempty"`
Android string `json:"android,omitempty"`
Windows string `json:"windows,omitempty"`
Mac string `json:"mac,omitempty"`
Linux string `json:"linux,omitempty"`
Harmony string `json:"harmony,omitempty"`
}
type EPayNotifyRequest struct {
Pid int64 `json:"pid" form:"pid"`
TradeNo string `json:"trade_no" form:"trade_no"`
@@ -706,6 +763,29 @@ type GetAvailablePaymentMethodsResponse struct {
List []PaymentMethod `json:"list"`
}
type GetBatchSendEmailTaskListRequest struct {
Page int `form:"page"`
Size int `form:"size"`
Scope string `form:"scope,omitempty"`
Status *uint8 `form:"status,omitempty"`
}
type GetBatchSendEmailTaskListResponse struct {
Total int64 `json:"total"`
List []BatchSendEmailTask `json:"list"`
}
type GetBatchSendEmailTaskStatusRequest struct {
Id int64 `json:"id"`
}
type GetBatchSendEmailTaskStatusResponse struct {
Status uint8 `json:"status"`
Current int64 `json:"current"`
Total int64 `json:"total"`
Errors string `json:"errors"`
}
type GetCouponListRequest struct {
Page int64 `form:"page" validate:"required"`
Size int64 `form:"size" validate:"required"`
@@ -837,6 +917,16 @@ type GetPaymentMethodListResponse struct {
List []PaymentMethodDetail `json:"list"`
}
type GetPreSendEmailCountRequest struct {
Scope string `json:"scope"`
RegisterStartTime int64 `json:"register_start_time,omitempty"`
RegisterEndTime int64 `json:"register_end_time,omitempty"`
}
type GetPreSendEmailCountResponse struct {
Count int64 `json:"count"`
}
type GetRuleGroupResponse struct {
Total int64 `json:"total"`
List []ServerRuleGroup `json:"list"`
@@ -867,6 +957,21 @@ type GetStatResponse struct {
Protocol []string `json:"protocol"`
}
type GetSubscribeApplicationListRequest struct {
Page int `form:"page"`
Size int `form:"size"`
}
type GetSubscribeApplicationListResponse struct {
Total int64 `json:"total"`
List []SubscribeApplication `json:"list"`
}
type GetSubscribeClientResponse struct {
Total int64 `json:"total"`
List []SubscribeClient `json:"list"`
}
type GetSubscribeDetailsRequest struct {
Id int64 `form:"id" validate:"required"`
}
@@ -1285,6 +1390,14 @@ type PreUnsubscribeResponse struct {
DeductionAmount int64 `json:"deduction_amount"`
}
type PreviewSubscribeTemplateRequest struct {
Id int64 `form:"id"`
}
type PreviewSubscribeTemplateResponse struct {
Template string `json:"template"` // 预览的模板内容
}
type PrivacyPolicyConfig struct {
PrivacyPolicy string `json:"privacy_policy"`
}
@@ -1634,6 +1747,10 @@ type SortItem struct {
Sort int64 `json:"sort" validate:"required"`
}
type StopBatchSendEmailTaskRequest struct {
Id int64 `json:"id"`
}
type StripePayment struct {
Method string `json:"method"`
ClientSecret string `json:"client_secret"`
@@ -1667,11 +1784,38 @@ type Subscribe struct {
UpdatedAt int64 `json:"updated_at"`
}
type SubscribeApplication struct {
Id int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Scheme string `json:"scheme,omitempty"`
UserAgent string `json:"user_agent"`
IsDefault bool `json:"is_default"`
SubscribeTemplate string `json:"template"`
OutputFormat string `json:"output_format"`
DownloadLink DownloadLink `json:"download_link,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type SubscribeClient struct {
Id int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Scheme string `json:"scheme,omitempty"`
IsDefault bool `json:"is_default"`
DownloadLink DownloadLink `json:"download_link,omitempty"`
}
type SubscribeConfig struct {
SingleModel bool `json:"single_model"`
SubscribePath string `json:"subscribe_path"`
SubscribeDomain string `json:"subscribe_domain"`
PanDomain bool `json:"pan_domain"`
UserAgentLimit bool `json:"user_agent_limit"`
UserAgentList string `json:"user_agent_list"`
}
type SubscribeDiscount struct {
@@ -1974,6 +2118,19 @@ type UpdateRuleGroupRequest struct {
Enable bool `json:"enable"`
}
type UpdateSubscribeApplicationRequest struct {
Id int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Scheme string `json:"scheme,omitempty"`
UserAgent string `json:"user_agent"`
IsDefault bool `json:"is_default"`
SubscribeTemplate string `json:"template"`
OutputFormat string `json:"output_format"`
DownloadLink DownloadLink `json:"download_link,omitempty"`
}
type UpdateSubscribeGroupRequest struct {
Id int64 `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
@@ -2273,6 +2430,10 @@ type VerifyEmailRequest struct {
Code string `json:"code" validate:"required"`
}
type VersionResponse struct {
Version string `json:"version"`
}
type Vless struct {
Port int `json:"port" validate:"required"`
Flow string `json:"flow" validate:"required"`