* 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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
89 changed files with 4542 additions and 523 deletions

View File

@ -26,7 +26,7 @@ jobs:
- name: Build Docker image
run: docker build -t ${{ secrets.DOCKER_USERNAME }}/ppanel-server-dev:${{ env.COMMIT_ID }} .
run: docker build --build-arg VERSION=${{ env.COMMIT_ID }} -t ${{ secrets.DOCKER_USERNAME }}/ppanel-server-dev:${{ env.COMMIT_ID }} .
- name: Push Docker image
run: docker push ${{ secrets.DOCKER_USERNAME }}/ppanel-server-dev:${{ env.COMMIT_ID }}
@ -47,4 +47,4 @@ jobs:
fi
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
docker run -d --restart=always --log-driver=journald --name ppanel-server-dev -p 8080:8080 -v /www/wwwroot/api/etc:/app/etc --restart=always -d ${{ secrets.DOCKER_USERNAME }}/ppanel-server-dev:${{ env.COMMIT_ID }}
docker run -d --restart=always --log-driver=journald --name ppanel-server-dev -p 8080:8080 -v /www/wwwroot/api/etc:/app/etc -v /www/wwwroot/api/logs:/app/logs --restart=always -d ${{ secrets.DOCKER_USERNAME }}/ppanel-server-dev:${{ env.COMMIT_ID }}

View File

@ -20,7 +20,7 @@ RUN go mod download
COPY . .
# Build the binary with version and build time
RUN BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && \
RUN BUILD_TIME=$(date -u +"%Y-%m-%d %H:%M:%S") && \
go build -ldflags="-s -w -X 'github.com/perfect-panel/server/pkg/constant.Version=${VERSION}' -X 'github.com/perfect-panel/server/pkg/constant.BuildTime=${BUILD_TIME}'" -o /app/ppanel ppanel.go
# Final minimal image

137
adapter/adapter.go Normal file
View File

@ -0,0 +1,137 @@
package adapter
import (
"encoding/json"
"github.com/perfect-panel/server/internal/model/server"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/random"
)
type Adapter struct {
SiteName string // 站点名称
Servers []*server.Server // 服务器列表
UserInfo User // 用户信息
ClientTemplate string // 客户端配置模板
OutputFormat string // 输出格式,默认是 base64
SubscribeName string // 订阅名称
}
type Option func(*Adapter)
// WithServers 设置服务器列表
func WithServers(servers []*server.Server) Option {
return func(opts *Adapter) {
opts.Servers = servers
}
}
// WithUserInfo 设置用户信息
func WithUserInfo(user User) Option {
return func(opts *Adapter) {
opts.UserInfo = user
}
}
// WithOutputFormat 设置输出格式
func WithOutputFormat(format string) Option {
return func(opts *Adapter) {
opts.OutputFormat = format
}
}
// WithSiteName 设置站点名称
func WithSiteName(name string) Option {
return func(opts *Adapter) {
opts.SiteName = name
}
}
// WithSubscribeName 设置订阅名称
func WithSubscribeName(name string) Option {
return func(opts *Adapter) {
opts.SubscribeName = name
}
}
func NewAdapter(tpl string, opts ...Option) *Adapter {
adapter := &Adapter{
Servers: []*server.Server{},
UserInfo: User{},
ClientTemplate: tpl,
OutputFormat: "base64", // 默认输出格式
}
for _, opt := range opts {
opt(adapter)
}
return adapter
}
func (adapter *Adapter) Client() (*Client, error) {
client := &Client{
SiteName: adapter.SiteName,
SubscribeName: adapter.SubscribeName,
ClientTemplate: adapter.ClientTemplate,
OutputFormat: adapter.OutputFormat,
Proxies: []Proxy{},
UserInfo: adapter.UserInfo,
}
proxies, err := adapter.Proxies(adapter.Servers)
if err != nil {
return nil, err
}
client.Proxies = proxies
return client, nil
}
func (adapter *Adapter) Proxies(servers []*server.Server) ([]Proxy, error) {
var proxies []Proxy
for _, srv := range servers {
switch srv.RelayMode {
case server.RelayModeAll:
var relays []server.NodeRelay
if err := json.Unmarshal([]byte(srv.RelayNode), &relays); err != nil {
logger.Errorw("Unmarshal RelayNode", logger.Field("error", err.Error()), logger.Field("node", srv.Name), logger.Field("relayNode", srv.RelayNode))
continue
}
for _, relay := range relays {
proxy, err := adapterProxy(*srv, relay.Host, uint64(relay.Port))
if err != nil {
logger.Errorw("Adapter Proxy", logger.Field("error", err.Error()), logger.Field("node", srv.Name), logger.Field("relayNode", relay))
continue
}
proxies = append(proxies, proxy)
}
case server.RelayModeRandom:
var relays []server.NodeRelay
if err := json.Unmarshal([]byte(srv.RelayNode), &relays); err != nil {
logger.Errorw("Unmarshal RelayNode", logger.Field("error", err.Error()), logger.Field("node", srv.Name), logger.Field("relayNode", srv.RelayNode))
continue
}
randNum := random.RandomInRange(0, len(relays)-1)
relay := relays[randNum]
proxy, err := adapterProxy(*srv, relay.Host, uint64(relay.Port))
if err != nil {
logger.Errorw("Adapter Proxy", logger.Field("error", err.Error()), logger.Field("node", srv.Name), logger.Field("relayNode", relay))
continue
}
proxies = append(proxies, proxy)
case server.RelayModeNone:
proxy, err := adapterProxy(*srv, srv.ServerAddr, 0)
if err != nil {
logger.Errorw("Adapter Proxy", logger.Field("error", err.Error()), logger.Field("node", srv.Name), logger.Field("serverAddr", srv.ServerAddr))
continue
}
proxies = append(proxies, proxy)
default:
logger.Errorw("Unknown RelayMode", logger.Field("node", srv.Name), logger.Field("relayMode", srv.RelayMode))
}
}
return proxies, nil
}

34
adapter/adapter_test.go Normal file
View File

@ -0,0 +1,34 @@
package adapter
import (
"testing"
"time"
)
func TestAdapter_Client(t *testing.T) {
servers := getServers()
if len(servers) == 0 {
t.Errorf("[Test] No servers found")
return
}
a := NewAdapter(tpl, WithServers(servers), WithUserInfo(User{
Password: "test-password",
ExpiredAt: time.Now().AddDate(1, 0, 0),
Download: 0,
Upload: 0,
Traffic: 1000,
SubscribeURL: "https://example.com/subscribe",
}))
client, err := a.Client()
if err != nil {
t.Errorf("[Test] Failed to get client: %v", err.Error())
return
}
bytes, err := client.Build()
if err != nil {
t.Errorf("[Test] Failed to build client config: %v", err.Error())
return
}
t.Logf("[Test] Client config built successfully: %s", string(bytes))
}

112
adapter/client.go Normal file
View File

@ -0,0 +1,112 @@
package adapter
import (
"bytes"
"encoding/base64"
"reflect"
"text/template"
"time"
"github.com/Masterminds/sprig/v3"
)
type Proxy struct {
Name string
Server string
Port uint64
Type string
Tags []string
// Security Options
Security string
SNI string // Server Name Indication for TLS
AllowInsecure bool // Allow insecure connections (skip certificate verification)
Fingerprint string // Client fingerprint for TLS connections
RealityServerAddr string // Reality server address
RealityServerPort int // Reality server port
RealityPrivateKey string // Reality private key for authentication
RealityPublicKey string // Reality public key for authentication
RealityShortId string // Reality short ID for authentication
// Transport Options
Transport string // Transport protocol (e.g., ws, http, grpc)
Host string // For WebSocket/HTTP/HTTPS
Path string // For HTTP/HTTPS
ServiceName string // For gRPC
// Shadowsocks Options
Method string
ServerKey string // For Shadowsocks 2022
// Vmess/Vless/Trojan Options
Flow string // Flow for Vmess/Vless/Trojan
// Hysteria2 Options
HopPorts string // Comma-separated list of hop ports
HopInterval int // Interval for hop ports in seconds
ObfsPassword string // Obfuscation password for Hysteria2
// Tuic Options
DisableSNI bool // Disable SNI
ReduceRtt bool // Reduce RTT
UDPRelayMode string // UDP relay mode (e.g., "full", "partial")
CongestionController string // Congestion controller (e.g., "cubic", "bbr")
}
type User struct {
Password string
ExpiredAt time.Time
Download int64
Upload int64
Traffic int64
SubscribeURL string
}
type Client struct {
SiteName string // Name of the site
SubscribeName string // Name of the subscription
ClientTemplate string // Template for the entire client configuration
OutputFormat string // json, yaml, etc.
Proxies []Proxy // List of proxy configurations
UserInfo User // User information
}
func (c *Client) Build() ([]byte, error) {
var buf bytes.Buffer
tmpl, err := template.New("client").Funcs(sprig.TxtFuncMap()).Parse(c.ClientTemplate)
if err != nil {
return nil, err
}
proxies := make([]map[string]interface{}, len(c.Proxies))
for i, p := range c.Proxies {
proxies[i] = StructToMap(p)
}
err = tmpl.Execute(&buf, map[string]interface{}{
"SiteName": c.SiteName,
"SubscribeName": c.SubscribeName,
"OutputFormat": c.OutputFormat,
"Proxies": proxies,
"UserInfo": c.UserInfo,
})
if err != nil {
return nil, err
}
result := buf.String()
if c.OutputFormat == "base64" {
encoded := base64.StdEncoding.EncodeToString([]byte(result))
return []byte(encoded), nil
}
return buf.Bytes(), nil
}
func StructToMap(obj interface{}) map[string]interface{} {
m := make(map[string]interface{})
v := reflect.ValueOf(obj)
t := reflect.TypeOf(obj)
for i := 0; i < v.NumField(); i++ {
field := t.Field(i)
m[field.Name] = v.Field(i).Interface()
}
return m
}

153
adapter/client_test.go Normal file
View File

@ -0,0 +1,153 @@
package adapter
import (
"testing"
"time"
)
var tpl = `
{{- range $n := .Proxies }}
{{- $dn := urlquery (default "node" $n.Name) -}}
{{- $sni := default $n.Host $n.SNI -}}
{{- if eq $n.Type "shadowsocks" -}}
{{- $userinfo := b64enc (print $n.Method ":" $.UserInfo.Password) -}}
{{- printf "ss://%s@%s:%v#%s" $userinfo $n.Host $n.Port $dn -}}
{{- "\n" -}}
{{- end -}}
{{- if eq $n.Type "trojan" -}}
{{- $qs := "security=tls" -}}
{{- if $sni }}{{ $qs = printf "%s&sni=%s" $qs (urlquery $sni) }}{{ end -}}
{{- if $n.AllowInsecure }}{{ $qs = printf "%s&allowInsecure=%v" $qs $n.AllowInsecure }}{{ end -}}
{{- if $n.Fingerprint }}{{ $qs = printf "%s&fp=%s" $qs (urlquery $n.Fingerprint) }}{{ end -}}
{{- printf "trojan://%s@%s:%v?%s#%s" $.UserInfo.Password $n.Host $n.Port $qs $dn -}}
{{- "\n" -}}
{{- end -}}
{{- if eq $n.Type "vless" -}}
{{- $qs := "encryption=none" -}}
{{- if $n.RealityPublicKey -}}
{{- $qs = printf "%s&security=reality" $qs -}}
{{- $qs = printf "%s&pbk=%s" $qs (urlquery $n.RealityPublicKey) -}}
{{- if $n.RealityShortId }}{{ $qs = printf "%s&sid=%s" $qs (urlquery $n.RealityShortId) }}{{ end -}}
{{- else -}}
{{- if or $n.SNI $n.Fingerprint $n.AllowInsecure }}
{{- $qs = printf "%s&security=tls" $qs -}}
{{- end -}}
{{- end -}}
{{- if $n.SNI }}{{ $qs = printf "%s&sni=%s" $qs (urlquery $n.SNI) }}{{ end -}}
{{- if $n.AllowInsecure }}{{ $qs = printf "%s&allowInsecure=%v" $qs $n.AllowInsecure }}{{ end -}}
{{- if $n.Fingerprint }}{{ $qs = printf "%s&fp=%s" $qs (urlquery $n.Fingerprint) }}{{ end -}}
{{- if $n.Network }}{{ $qs = printf "%s&type=%s" $qs $n.Network }}{{ end -}}
{{- if $n.Path }}{{ $qs = printf "%s&path=%s" $qs (urlquery $n.Path) }}{{ end -}}
{{- if $n.ServiceName }}{{ $qs = printf "%s&serviceName=%s" $qs (urlquery $n.ServiceName) }}{{ end -}}
{{- if $n.Flow }}{{ $qs = printf "%s&flow=%s" $qs (urlquery $n.Flow) }}{{ end -}}
{{- printf "vless://%s@%s:%v?%s#%s" $n.ServerKey $n.Host $n.Port $qs $dn -}}
{{- "\n" -}}
{{- end -}}
{{- if eq $n.Type "vmess" -}}
{{- $obj := dict
"v" "2"
"ps" $n.Name
"add" $n.Host
"port" $n.Port
"id" $n.ServerKey
"aid" 0
"net" (or $n.Network "tcp")
"type" "none"
"path" (or $n.Path "")
"host" $n.Host
-}}
{{- if or $n.SNI $n.Fingerprint $n.AllowInsecure }}{{ set $obj "tls" "tls" }}{{ end -}}
{{- if $n.SNI }}{{ set $obj "sni" $n.SNI }}{{ end -}}
{{- if $n.Fingerprint }}{{ set $obj "fp" $n.Fingerprint }}{{ end -}}
{{- printf "vmess://%s" (b64enc (toJson $obj)) -}}
{{- "\n" -}}
{{- end -}}
{{- if or (eq $n.Type "hysteria2") (eq $n.Type "hy2") -}}
{{- $qs := "" -}}
{{- if $n.SNI }}{{ $qs = printf "sni=%s" (urlquery $n.SNI) }}{{ end -}}
{{- if $n.AllowInsecure }}{{ $qs = printf "%s&insecure=%v" $qs $n.AllowInsecure }}{{ end -}}
{{- if $n.ObfsPassword }}{{ $qs = printf "%s&obfs-password=%s" $qs (urlquery $n.ObfsPassword) }}{{ end -}}
{{- printf "hy2://%s@%s:%v%s#%s"
$.UserInfo.Password
$n.Host
$n.Port
(ternary (gt (len $qs) 0) (print "?" $qs) "")
$dn -}}
{{- "\n" -}}
{{- end -}}
{{- if eq $n.Type "tuic" -}}
{{- $qs := "" -}}
{{- if $n.SNI }}{{ $qs = printf "sni=%s" (urlquery $n.SNI) }}{{ end -}}
{{- if $n.AllowInsecure }}{{ $qs = printf "%s&insecure=%v" $qs $n.AllowInsecure }}{{ end -}}
{{- printf "tuic://%s:%s@%s:%v%s#%s"
$n.ServerKey
$.UserInfo.Password
$n.Host
$n.Port
(ternary (gt (len $qs) 0) (print "?" $qs) "")
$dn -}}
{{- "\n" -}}
{{- end -}}
{{- if eq $n.Type "anytls" -}}
{{- $qs := "" -}}
{{- if $n.SNI }}{{ $qs = printf "sni=%s" (urlquery $n.SNI) }}{{ end -}}
{{- printf "anytls://%s@%s:%v%s#%s"
$.UserInfo.Password
$n.Host
$n.Port
(ternary (gt (len $qs) 0) (print "?" $qs) "")
$dn -}}
{{- "\n" -}}
{{- end -}}
{{- end }}
`
func TestClient_Build(t *testing.T) {
client := &Client{
SiteName: "TestSite",
SubscribeName: "TestSubscribe",
ClientTemplate: tpl,
Proxies: []Proxy{
{
Name: "TestShadowSocks",
Type: "shadowsocks",
Host: "127.0.0.1",
Port: 1234,
Method: "aes-256-gcm",
},
{
Name: "TestTrojan",
Type: "trojan",
Host: "example.com",
Port: 443,
AllowInsecure: true,
Security: "tls",
Transport: "tcp",
SNI: "v1-dy.ixigua.com",
},
},
UserInfo: User{
Password: "testpassword",
ExpiredAt: time.Now().Add(24 * time.Hour),
Download: 1000000,
Upload: 500000,
Traffic: 1500000,
SubscribeURL: "https://example.com/subscribe",
},
}
buf, err := client.Build()
if err != nil {
t.Fatalf("Failed to build client: %v", err)
}
t.Logf("[测试] 输出: %s", buf)
}

113
adapter/utils.go Normal file
View File

@ -0,0 +1,113 @@
package adapter
import (
"encoding/json"
"fmt"
"strings"
"github.com/perfect-panel/server/internal/model/server"
"github.com/perfect-panel/server/pkg/tool"
)
func adapterProxy(svr server.Server, host string, port uint64) (Proxy, error) {
tags := strings.Split(svr.Tags, ",")
if len(tags) > 0 {
tags = tool.RemoveDuplicateElements(tags...)
}
node := Proxy{
Name: svr.Name,
Host: host,
Port: port,
Type: svr.Protocol,
Tags: tags,
}
switch svr.Protocol {
case "shadowsocks":
var ss server.Shadowsocks
if err := json.Unmarshal([]byte(svr.Config), &ss); err != nil {
return node, fmt.Errorf("unmarshal shadowsocks config: %v", err.Error())
}
if port == 0 {
node.Port = uint64(ss.Port)
}
node.Method = ss.Method
node.ServerKey = ss.ServerKey
case "vless":
var vless server.Vless
if err := json.Unmarshal([]byte(svr.Config), &vless); err != nil {
return node, fmt.Errorf("unmarshal vless config: %v", err.Error())
}
if port == 0 {
node.Port = uint64(vless.Port)
}
node.Flow = vless.Flow
node.Transport = vless.Transport
tool.DeepCopy(&node, vless.TransportConfig)
node.Security = vless.Security
tool.DeepCopy(&node, vless.SecurityConfig)
case "vmess":
var vmess server.Vmess
if err := json.Unmarshal([]byte(svr.Config), &vmess); err != nil {
return node, fmt.Errorf("unmarshal vmess config: %v", err.Error())
}
if port == 0 {
node.Port = uint64(vmess.Port)
}
node.Flow = vmess.Flow
node.Transport = vmess.Transport
tool.DeepCopy(&node, vmess.TransportConfig)
node.Security = vmess.Security
tool.DeepCopy(&node, vmess.SecurityConfig)
case "trojan":
var trojan server.Trojan
if err := json.Unmarshal([]byte(svr.Config), &trojan); err != nil {
return node, fmt.Errorf("unmarshal trojan config: %v", err.Error())
}
if port == 0 {
node.Port = uint64(trojan.Port)
}
node.Flow = trojan.Flow
node.Transport = trojan.Transport
tool.DeepCopy(&node, trojan.TransportConfig)
node.Security = trojan.Security
tool.DeepCopy(&node, trojan.SecurityConfig)
case "hysteria2":
var hysteria2 server.Hysteria2
if err := json.Unmarshal([]byte(svr.Config), &hysteria2); err != nil {
return node, fmt.Errorf("unmarshal hysteria2 config: %v", err.Error())
}
if port == 0 {
node.Port = uint64(hysteria2.Port)
}
node.HopPorts = hysteria2.HopPorts
node.HopInterval = hysteria2.HopInterval
node.ObfsPassword = hysteria2.ObfsPassword
tool.DeepCopy(&node, hysteria2.SecurityConfig)
case "tuic":
var tuic server.Tuic
if err := json.Unmarshal([]byte(svr.Config), &tuic); err != nil {
return node, fmt.Errorf("unmarshal tuic config: %v", err.Error())
}
if port == 0 {
node.Port = uint64(tuic.Port)
}
node.DisableSNI = tuic.DisableSNI
node.ReduceRtt = tuic.ReduceRtt
node.UDPRelayMode = tuic.UDPRelayMode
node.CongestionController = tuic.CongestionController
case "anytls":
var anytls server.AnyTLS
if err := json.Unmarshal([]byte(svr.Config), &anytls); err != nil {
return node, fmt.Errorf("unmarshal anytls config: %v", err.Error())
}
if port == 0 {
node.Port = uint64(anytls.Port)
}
tool.DeepCopy(&node, anytls.SecurityConfig)
default:
return node, fmt.Errorf("unsupported protocol: %s", svr.Protocol)
}
return node, nil
}

46
adapter/utils_test.go Normal file
View File

@ -0,0 +1,46 @@
package adapter
import (
"testing"
"github.com/perfect-panel/server/internal/model/server"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestAdapterProxy(t *testing.T) {
servers := getServers()
if len(servers) == 0 {
t.Fatal("no servers found")
}
for _, srv := range servers {
proxy, err := adapterProxy(*srv, "example.com", 0)
if err != nil {
t.Errorf("failed to adapt server %s: %v", srv.Name, err)
}
t.Logf("[测试] 适配服务器 %s 成功: %+v", srv.Name, proxy)
}
}
func getServers() []*server.Server {
db, err := connectMySQL("root:mylove520@tcp(localhost:3306)/perfectlink?charset=utf8mb4&parseTime=True&loc=Local")
if err != nil {
return nil
}
var servers []*server.Server
if err = db.Model(&server.Server{}).Find(&servers).Error; err != nil {
return nil
}
return servers
}
func connectMySQL(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(mysql.New(mysql.Config{
DSN: dsn,
}), &gorm.Config{})
if err != nil {
return nil, err
}
return db, nil
}

View File

@ -0,0 +1,96 @@
syntax = "v1"
info (
title: "Application API"
desc: "API for ppanel"
author: "Tension"
email: "tension@ppanel.com"
version: "0.0.1"
)
import "../types.api"
type (
SubscribeApplication {
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"`
}
GetSubscribeApplicationListResponse {
Total int64 `json:"total"`
List []SubscribeApplication `json:"list"`
}
CreateSubscribeApplicationRequest {
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"`
}
UpdateSubscribeApplicationRequest {
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"`
}
DeleteSubscribeApplicationRequest {
Id int64 `json:"id"`
}
GetSubscribeApplicationListRequest {
Page int `form:"page"`
Size int `form:"size"`
}
PreviewSubscribeTemplateRequest {
Id int64 `form:"id"`
}
PreviewSubscribeTemplateResponse {
Template string `json:"template"` // 预览的模板内容
}
)
@server (
prefix: v1/admin/application
group: admin/application
middleware: AuthMiddleware
)
service ppanel {
@doc "Create subscribe application"
@handler CreateSubscribeApplication
post / (CreateSubscribeApplicationRequest) returns (SubscribeApplication)
@doc "Update subscribe application"
@handler UpdateSubscribeApplication
put /subscribe_application (UpdateSubscribeApplicationRequest) returns (SubscribeApplication)
@doc "Get subscribe application list"
@handler GetSubscribeApplicationList
get /subscribe_application_list (GetSubscribeApplicationListRequest) returns (GetSubscribeApplicationListResponse)
@doc "Delete subscribe application"
@handler DeleteSubscribeApplication
delete /subscribe_application (DeleteSubscribeApplicationRequest)
@doc "Preview Template"
@handler PreviewSubscribeTemplate
get /preview (PreviewSubscribeTemplateRequest) returns (PreviewSubscribeTemplateResponse)
}

100
apis/admin/marketing.api Normal file
View File

@ -0,0 +1,100 @@
syntax = "v1"
info (
title: "Marketing API"
desc: "API for ppanel"
author: "Tension"
email: "tension@ppanel.com"
version: "0.0.1"
)
type (
CreateBatchSendEmailTaskRequest {
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"`
}
BatchSendEmailTask {
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"`
}
GetBatchSendEmailTaskListRequest {
Page int `form:"page"`
Size int `form:"size"`
Scope string `form:"scope,omitempty"`
Status *uint8 `form:"status,omitempty"`
}
GetBatchSendEmailTaskListResponse {
Total int64 `json:"total"`
List []BatchSendEmailTask `json:"list"`
}
StopBatchSendEmailTaskRequest {
Id int64 `json:"id"`
}
GetPreSendEmailCountRequest {
Scope string `json:"scope"`
RegisterStartTime int64 `json:"register_start_time,omitempty"`
RegisterEndTime int64 `json:"register_end_time,omitempty"`
}
GetPreSendEmailCountResponse {
Count int64 `json:"count"`
}
GetBatchSendEmailTaskStatusRequest {
Id int64 `json:"id"`
}
GetBatchSendEmailTaskStatusResponse {
Status uint8 `json:"status"`
Current int64 `json:"current"`
Total int64 `json:"total"`
Errors string `json:"errors"`
}
)
@server (
prefix: v1/admin/marketing
group: admin/marketing
middleware: AuthMiddleware
)
service ppanel {
@doc "Create a batch send email task"
@handler CreateBatchSendEmailTask
post /email/batch/send (CreateBatchSendEmailTaskRequest)
@doc "Get batch send email task list"
@handler GetBatchSendEmailTaskList
get /email/batch/list (GetBatchSendEmailTaskListRequest) returns (GetBatchSendEmailTaskListResponse)
@doc "Stop a batch send email task"
@handler StopBatchSendEmailTask
post /email/batch/stop (StopBatchSendEmailTaskRequest)
@doc "Get pre-send email count"
@handler GetPreSendEmailCount
post /email/batch/pre-send-count (GetPreSendEmailCountRequest) returns (GetPreSendEmailCountResponse)
@doc "Get batch send email task status"
@handler GetBatchSendEmailTaskStatus
post /email/batch/status (GetBatchSendEmailTaskStatusRequest) returns (GetBatchSendEmailTaskStatusResponse)
}

View File

@ -87,23 +87,23 @@ type (
Sort []SortItem `json:"sort"`
}
CreateRuleGroupRequest {
Name string `json:"name" validate:"required"`
Icon string `json:"icon"`
Type string `json:"type"`
Tags []string `json:"tags"`
Rules string `json:"rules"`
Default bool `json:"default"`
Enable bool `json:"enable"`
Name string `json:"name" validate:"required"`
Icon string `json:"icon"`
Type string `json:"type"`
Tags []string `json:"tags"`
Rules string `json:"rules"`
Default bool `json:"default"`
Enable bool `json:"enable"`
}
UpdateRuleGroupRequest {
Id int64 `json:"id" validate:"required"`
Icon string `json:"icon"`
Type string `json:"type"`
Name string `json:"name" validate:"required"`
Tags []string `json:"tags"`
Rules string `json:"rules"`
Default bool `json:"default"`
Enable bool `json:"enable"`
Id int64 `json:"id" validate:"required"`
Icon string `json:"icon"`
Type string `json:"type"`
Name string `json:"name" validate:"required"`
Tags []string `json:"tags"`
Rules string `json:"rules"`
Default bool `json:"default"`
Enable bool `json:"enable"`
}
DeleteRuleGroupRequest {
Id int64 `json:"id" validate:"required"`

View File

@ -14,6 +14,9 @@ type (
LogResponse {
List interface{} `json:"list"`
}
VersionResponse {
Version string `json:"version"`
}
)
@server (
@ -29,5 +32,9 @@ service ppanel {
@doc "Restart System"
@handler RestartSystem
get /restart
@doc "Get Version"
@handler GetVersion
get /version returns (VersionResponse)
}

View File

@ -78,6 +78,19 @@ type (
CheckVerificationCodeRespone {
Status bool `json:"status"`
}
SubscribeClient {
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"`
}
GetSubscribeClientResponse {
Total int64 `json:"total"`
List []SubscribeClient `json:"list"`
}
)
@server (
@ -120,5 +133,9 @@ service ppanel {
@doc "Check verification code"
@handler CheckVerificationCode
post /check_verification_code (CheckVerificationCodeRequest) returns (CheckVerificationCodeRespone)
@doc "Get Client"
@handler GetClient
get /client returns (GetSubscribeClientResponse)
}

View File

@ -24,5 +24,7 @@ import (
"./admin/auth.api"
"./admin/log.api"
"./admin/ads.api"
"./admin/marketing.api"
"./admin/application.api"
)

View File

@ -63,6 +63,8 @@ type (
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"`
}
VerifyCodeConfig {
VerifyCodeExpireTime int64 `json:"verify_code_expire_time"`
@ -515,7 +517,7 @@ type (
Id int64 `json:"id"`
Icon string `json:"icon"`
Name string `json:"name" validate:"required"`
Type string `json:"type"`
Type string `json:"type"`
Tags []string `json:"tags"`
Rules string `json:"rules"`
Enable bool `json:"enable"`
@ -753,5 +755,13 @@ type (
CreatedAt int64 `json:"created_at"`
Download int64 `json:"download"`
}
DownloadLink {
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"`
}
)

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS `email_task`;

View File

@ -0,0 +1,23 @@
DROP TABLE IF EXISTS `email_task`;
CREATE TABLE `email_task` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`subject` varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Subject',
`content` text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Content',
`recipient` text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Recipient',
`scope` varchar(50) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Email Scope',
`register_start_time` datetime(3) DEFAULT NULL COMMENT 'Register Start Time',
`register_end_time` datetime(3) DEFAULT NULL COMMENT 'Register End Time',
`additional` text COLLATE utf8mb4_general_ci COMMENT 'Additional Information',
`scheduled` datetime(3) NOT NULL COMMENT 'Scheduled Time',
`interval` tinyint unsigned NOT NULL COMMENT 'Interval in Seconds',
`limit` bigint unsigned NOT NULL COMMENT 'Daily send limit',
`status` tinyint unsigned NOT NULL COMMENT 'Daily Status',
`errors` text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Errors',
`total` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Total Number',
`current` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Current Number',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
SET FOREIGN_KEY_CHECKS = 1;

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS `subscribe_application`;

File diff suppressed because one or more lines are too long

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

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))

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))
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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))

View File

@ -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,
},
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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())

View File

@ -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

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
}

View File

@ -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)

View File

@ -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)

View File

@ -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:

View File

@ -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 {

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)
}

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]

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)
}

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
}

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
}

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
}

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"`
}

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
}

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
}

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"
}

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]

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
}

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 {

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
}

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
}

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...)
}

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"
}

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),

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"`

View File

@ -14,7 +14,6 @@ func buildTrojan(data proxy.Proxy, password string) string {
fmt.Sprintf("%s=trojan", data.Name),
data.Server,
fmt.Sprintf("%d", data.Port),
"auto",
password,
"fast-open=false",
"udp=true",

134
pkg/email/manager.go Normal file
View File

@ -0,0 +1,134 @@
package email
import (
"context"
"sync"
"time"
"github.com/perfect-panel/server/pkg/logger"
"gorm.io/gorm"
)
var (
Manager *WorkerManager // 全局调度器实例
once sync.Once // 确保 Scheduler 只被初始化一次
limit sync.RWMutex // 控制并发限制
)
type WorkerManager struct {
db *gorm.DB // 数据库连接
sender Sender // 邮件发送器接口
mutex sync.RWMutex // 读写互斥锁,确保线程安全
workers map[int64]*Worker // 存储所有 Worker 实例
cancels map[int64]context.CancelFunc // 存储每个 Worker 的取消函数
}
func NewWorkerManager(db *gorm.DB, sender Sender) *WorkerManager {
if Manager != nil {
return Manager
}
once.Do(func() {
Manager = &WorkerManager{
db: db,
workers: make(map[int64]*Worker),
cancels: make(map[int64]context.CancelFunc),
sender: sender,
}
})
// 设置定时检查任务
go func() {
for {
// 每隔5分钟检查一次
select {
case <-time.After(1 * time.Minute):
checkWorker()
continue
}
}
}()
return Manager
}
// AddWorker 添加一个新的 Worker 实例
func (m *WorkerManager) AddWorker(id int64) {
m.mutex.Lock()
defer m.mutex.Unlock()
if _, exists := m.workers[id]; !exists {
ctx, cancel := context.WithCancel(context.Background())
worker := NewWorker(ctx, id, m.db, m.sender)
m.workers[id] = worker
m.cancels[id] = cancel
go worker.Start()
logger.Info("Batch Send Email",
logger.Field("message", "Added new worker"),
logger.Field("task_id", id),
)
} else {
logger.Info("Batch Send Email",
logger.Field("message", "Worker already exists"),
logger.Field("task_id", id),
)
}
}
// GetWorker 获取指定任务的 Worker 实例
func (m *WorkerManager) GetWorker(id int64) *Worker {
m.mutex.RLock()
defer m.mutex.RUnlock()
if worker, exists := m.workers[id]; exists {
return worker
} else {
logger.Error("Batch Send Email",
logger.Field("message", "Worker not found"),
logger.Field("task_id", id),
)
return nil
}
}
// RemoveWorker 移除指定任务的 Worker 实例
func (m *WorkerManager) RemoveWorker(id int64) {
m.mutex.Lock()
defer m.mutex.Unlock()
if _, exists := m.workers[id]; exists {
delete(m.workers, id)
if cancelFunc, ok := m.cancels[id]; ok {
cancelFunc() // 调用取消函数
delete(m.cancels, id)
}
logger.Info("Batch Send Email",
logger.Field("message", "Removed worker"),
logger.Field("task_id", id),
)
} else {
logger.Error("Batch Send Email",
logger.Field("message", "Worker not found for removal"),
logger.Field("task_id", id),
)
}
}
func checkWorker() {
if Manager == nil {
// 如果 Manager 未初始化,直接返回
return
}
Manager.mutex.Lock()
defer Manager.mutex.Unlock()
for id, worker := range Manager.workers {
if worker.IsRunning() == 2 {
// 如果Worker已完成移除它
delete(Manager.workers, id)
if cancelFunc, ok := Manager.cancels[id]; ok {
cancelFunc() // 调用取消函数
delete(Manager.cancels, id)
}
logger.Info("Batch Send Email",
logger.Field("message", "Removed completed worker"),
logger.Field("task_id", id),
)
}
}
}

164
pkg/email/worker.go Normal file
View File

@ -0,0 +1,164 @@
package email
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/perfect-panel/server/internal/model/task"
"github.com/perfect-panel/server/pkg/logger"
"gorm.io/gorm"
)
type ErrorInfo struct {
Error string `json:"error"`
Email string `json:"email"`
Time int64 `json:"time"`
}
type Worker struct {
id int64 // 任务ID
db *gorm.DB // 数据库连接
ctx context.Context // 上下文
sender Sender // 邮件发送器接口
status uint8 // 任务状态0 表示未运行1 表示运行中 2 表示已完成
}
func NewWorker(ctx context.Context, id int64, db *gorm.DB, sender Sender) *Worker {
return &Worker{
id: id,
db: db,
ctx: ctx,
sender: sender,
}
}
// GetID 获取Worker的任务ID
func (w *Worker) GetID() int64 {
return w.id
}
// IsRunning 检查Worker是否正在运行
func (w *Worker) IsRunning() uint8 {
return w.status
}
// Start 启动Worker开始处理任务
func (w *Worker) Start() {
// 检查并发限制
limit.Lock()
defer limit.Unlock()
tx := w.db.WithContext(w.ctx)
var taskInfo task.EmailTask
if err := tx.Model(&task.EmailTask{}).Where("id = ?", w.id).First(&taskInfo).Error; err != nil {
logger.Error("Batch Send Email",
logger.Field("message", "Failed to find task"),
logger.Field("error", err.Error()),
logger.Field("task_id", w.id),
)
w.status = 2 // 设置状态为已完成
return
}
if taskInfo.Status != 0 {
logger.Error("Batch Send Email",
logger.Field("message", "Task already completed or in progress"),
logger.Field("task_id", w.id),
)
w.status = 2 // 设置状态为已完成
return
}
if taskInfo.Recipients == "" && taskInfo.Additional == "" {
logger.Error("Batch Send Email",
logger.Field("message", "No recipients or additional emails provided"),
logger.Field("task_id", w.id),
)
w.status = 2 // 设置状态为已完成
return
}
w.status = 1 // 设置状态为运行中
var recipients []string
// 解析收件人
if taskInfo.Recipients != "" {
recipients = append(recipients, strings.Split(taskInfo.Recipients, "\n")...)
}
// 解析附加收件人
if taskInfo.Additional != "" {
recipients = append(recipients, strings.Split(taskInfo.Additional, "\n")...)
}
if len(recipients) == 0 {
logger.Error("Batch Send Email",
logger.Field("message", "No valid recipients found"),
logger.Field("task_id", w.id),
)
w.status = 2 // 设置状态为已完成
return
}
// 设置发送间隔时间
var intervalTime time.Duration
if taskInfo.Interval == 0 {
intervalTime = 1 * time.Second
} else {
intervalTime = time.Duration(taskInfo.Interval) * time.Second
}
var errors []ErrorInfo
var count uint64
for _, recipient := range recipients {
select {
case <-w.ctx.Done():
logger.Info("Batch Send Email",
logger.Field("message", "Worker stopped by context cancellation"),
logger.Field("task_id", w.id),
)
return
default:
}
if taskInfo.Status == 0 {
taskInfo.Status = 1 // 1 表示任务进行中
}
if err := w.sender.Send([]string{recipient}, taskInfo.Subject, taskInfo.Content); err != nil {
logger.Error("Batch Send Email",
logger.Field("message", "Failed to send email"),
logger.Field("error", err.Error()),
logger.Field("recipient", recipient),
logger.Field("task_id", w.id),
)
errors = append(errors, ErrorInfo{
Error: err.Error(),
Email: recipient,
Time: time.Now().Unix(),
})
text, _ := json.Marshal(errors)
taskInfo.Errors = string(text)
}
count++
if err := tx.Model(&task.EmailTask{}).Save(&taskInfo).Error; err != nil {
logger.Error("Batch Send Email",
logger.Field("message", "Failed to update task progress"),
logger.Field("error", err.Error()),
logger.Field("task_id", w.id),
)
w.status = 2 // 设置状态为已完成
return
}
time.Sleep(intervalTime)
}
taskInfo.Status = 2 // 设置状态为已完成
if err := tx.Model(&task.EmailTask{}).Save(&taskInfo).Error; err != nil {
logger.Error("Batch Send Email",
logger.Field("message", "Failed to finalize task"),
logger.Field("error", err.Error()),
logger.Field("task_id", w.id),
)
} else {
logger.Info("Batch Send Email",
logger.Field("message", "Task completed successfully"),
logger.Field("task_id", w.id),
logger.Field("total_sent", count),
)
}
}

View File

@ -1,6 +1,12 @@
package orm
import "testing"
import (
"testing"
"github.com/perfect-panel/server/internal/model/task"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestParseDSN(t *testing.T) {
dsn := "root:mylove520@tcp(localhost:3306)/vpnboard"
@ -16,3 +22,18 @@ func TestPing(t *testing.T) {
status := Ping(dsn)
t.Log(status)
}
func TestMysql(t *testing.T) {
db, err := gorm.Open(mysql.New(mysql.Config{
DSN: "root:mylove520@tcp(localhost:3306)/vpnboard",
}))
if err != nil {
t.Fatalf("Failed to connect to MySQL: %v", err)
}
err = db.Migrator().AutoMigrate(&task.EmailTask{})
if err != nil {
t.Fatalf("Failed to auto migrate: %v", err)
return
}
t.Log("MySQL connection and migration successful")
}

View File

@ -134,7 +134,6 @@ func RemoveStringElement(arr []string, element ...string) []string {
var result []string
for _, str := range arr {
if !Contains(element, str) {
logger.Infof("Remove Element: %s", str)
result = append(result, str)
}
}

View File

@ -27,6 +27,8 @@ import (
"apis/admin/console.api"
"apis/admin/log.api"
"apis/admin/ads.api"
"apis/admin/marketing.api"
"apis/admin/application.api"
"apis/public/user.api"
"apis/public/subscribe.api"
"apis/public/order.api"

View File

@ -36,4 +36,7 @@ func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) {
// Schedule reset traffic
mux.Handle(types.SchedulerResetTraffic, traffic.NewResetTrafficLogic(serverCtx))
// ScheduledBatchSendEmail
mux.Handle(types.ScheduledBatchSendEmail, emailLogic.NewBatchEmailLogic(serverCtx))
}

View File

@ -0,0 +1,78 @@
package emailLogic
import (
"context"
"strconv"
"github.com/hibiken/asynq"
taskModel "github.com/perfect-panel/server/internal/model/task"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/email"
"github.com/perfect-panel/server/pkg/logger"
)
type BatchEmailLogic struct {
svcCtx *svc.ServiceContext
}
type ErrorInfo struct {
Error string `json:"error"`
Email string `json:"email"`
Time int64 `json:"time"`
}
func NewBatchEmailLogic(svcCtx *svc.ServiceContext) *BatchEmailLogic {
return &BatchEmailLogic{
svcCtx: svcCtx,
}
}
func (l *BatchEmailLogic) ProcessTask(ctx context.Context, task *asynq.Task) error {
// 解析任务负载
payload := task.Payload()
if len(payload) == 0 {
logger.Error("[BatchEmailLogic] ProcessTask failed: empty payload")
return asynq.SkipRetry
}
// 转换获取任务id
taskID, err := strconv.ParseInt(string(payload), 10, 64)
if err != nil {
logger.WithContext(ctx).Error("[BatchEmailLogic] ProcessTask failed: invalid task ID",
logger.Field("error", err.Error()),
logger.Field("payload", string(payload)),
)
return asynq.SkipRetry
}
tx := l.svcCtx.DB.WithContext(ctx)
var taskInfo taskModel.EmailTask
if err = tx.Model(&taskModel.EmailTask{}).Where("id = ?", taskID).First(&taskInfo).Error; err != nil {
logger.WithContext(ctx).Error("[BatchEmailLogic] ProcessTask failed",
logger.Field("error", err.Error()),
logger.Field("taskID", taskID),
)
return asynq.SkipRetry
}
if taskInfo.Status != 0 {
logger.WithContext(ctx).Info("[BatchEmailLogic] ProcessTask skipped: task already processed",
logger.Field("taskID", taskID),
logger.Field("status", taskInfo.Status),
)
return nil
}
sender, err := email.NewSender(l.svcCtx.Config.Email.Platform, l.svcCtx.Config.Email.PlatformConfig, l.svcCtx.Config.Site.SiteName)
if err != nil {
logger.WithContext(ctx).Error("[BatchEmailLogic] NewSender failed", logger.Field("error", err.Error()))
return nil
}
manager := email.NewWorkerManager(l.svcCtx.DB, sender)
if manager == nil {
logger.WithContext(ctx).Error("[BatchEmailLogic] ProcessTask failed: worker manager is nil")
return asynq.SkipRetry
}
// 添加或获取 Worker 实例
manager.AddWorker(taskID)
return nil
}

View File

@ -490,14 +490,22 @@ func (l *ActivateOrderLogic) updateSubscriptionForRenewal(ctx context.Context, u
if userSub.ExpireTime.Before(now) {
userSub.ExpireTime = now
}
today := time.Now().Day()
resetDay := userSub.ExpireTime.Day()
// Reset traffic if enabled
if sub.RenewalReset != nil && *sub.RenewalReset {
if (sub.RenewalReset != nil && *sub.RenewalReset) || today == resetDay {
userSub.Download = 0
userSub.Upload = 0
}
if userSub.FinishedAt != nil {
if userSub.FinishedAt.Before(now) && today > resetDay {
// reset user traffic if finished at is before now
userSub.Download = 0
userSub.Upload = 0
}
userSub.FinishedAt = nil
}

View File

@ -2,17 +2,19 @@ package traffic
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/queue/types"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
@ -114,9 +116,16 @@ func (l *ResetTrafficLogic) ProcessTask(ctx context.Context, _ *asynq.Task) erro
}
}()
// Reset today's traffic data
err = l.svc.NodeCache.ResetTodayTrafficData(ctx)
if err != nil {
logger.Errorw("[ResetTodayTraffic] Failed to reset today traffic data",
logger.Field("error", err.Error()))
}
// Load last reset time from cache
var cache resetTrafficCache
err = l.svc.Redis.Get(ctx, cacheKey).Scan(&cache)
cacheData, err := l.svc.Redis.Get(ctx, cacheKey).Result()
if err != nil {
if !errors.Is(err, redis.Nil) {
logger.Errorw("[ResetTraffic] Failed to get cache", logger.Field("error", err.Error()))
@ -127,7 +136,15 @@ func (l *ResetTrafficLogic) ProcessTask(ctx context.Context, _ *asynq.Task) erro
}
logger.Infow("[ResetTraffic] Using default cache value", logger.Field("lastResetTime", cache.LastResetTime))
} else {
logger.Infow("[ResetTraffic] Cache loaded successfully", logger.Field("lastResetTime", cache.LastResetTime))
// Parse JSON data
if err := json.Unmarshal([]byte(cacheData), &cache); err != nil {
logger.Errorw("[ResetTraffic] Failed to unmarshal cache", logger.Field("error", err.Error()))
cache = resetTrafficCache{
LastResetTime: time.Now().Add(-10 * time.Minute),
}
} else {
logger.Infow("[ResetTraffic] Cache loaded successfully", logger.Field("lastResetTime", cache.LastResetTime))
}
}
// Execute reset operations in order: yearly -> monthly (1st) -> monthly (cycle)
@ -153,12 +170,17 @@ func (l *ResetTrafficLogic) ProcessTask(ctx context.Context, _ *asynq.Task) erro
updatedCache := resetTrafficCache{
LastResetTime: startTime,
}
cacheErr := l.svc.Redis.Set(ctx, cacheKey, updatedCache, 0).Err()
if cacheErr != nil {
logger.Errorw("[ResetTraffic] Failed to update cache", logger.Field("error", cacheErr.Error()))
// Don't return error here as the main task completed successfully
cacheDataBytes, marshalErr := json.Marshal(updatedCache)
if marshalErr != nil {
logger.Errorw("[ResetTraffic] Failed to marshal cache", logger.Field("error", marshalErr.Error()))
} else {
logger.Infow("[ResetTraffic] Cache updated successfully", logger.Field("newLastResetTime", startTime))
cacheErr := l.svc.Redis.Set(ctx, cacheKey, cacheDataBytes, 0).Err()
if cacheErr != nil {
logger.Errorw("[ResetTraffic] Failed to update cache", logger.Field("error", cacheErr.Error()))
// Don't return error here as the main task completed successfully
} else {
logger.Infow("[ResetTraffic] Cache updated successfully", logger.Field("newLastResetTime", startTime))
}
}
return nil
@ -187,22 +209,19 @@ func (l *ResetTrafficLogic) resetMonth(ctx context.Context) error {
var monthlyResetUsers []int64
// Check if today is the last day of current month
nextMonth := now.AddDate(0, 1, 0)
isLastDayOfMonth := nextMonth.Month() != now.Month()
isLastDayOfMonth := now.AddDate(0, 0, 1).Month() != now.Month()
query := db.Model(&user.Subscribe{}).Select("`id`").
Where("`subscribe_id` IN ?", resetMonthSubIds).
Where("`status` = ?", 1). // Only active subscriptions
Where("PERIOD_DIFF(DATE_FORMAT(CURDATE(), '%Y%m'), DATE_FORMAT(start_time, '%Y%m')) > 0"). // At least one month passed
Where("MOD(PERIOD_DIFF(DATE_FORMAT(CURDATE(), '%Y%m'), DATE_FORMAT(start_time, '%Y%m')), 1) = 0"). // Monthly cycle
Where("DATE(start_time) < CURDATE()") // Only reset subscriptions that have started
Where("`status` IN ?", []int64{1, 2}). // Only active subscriptions
Where("TIMESTAMPDIFF(MONTH, CURDATE(),DATE(expire_time)) >= 1") // At least 1 month passed
if isLastDayOfMonth {
// Last day of month: handle subscription start dates >= today
query = query.Where("DAY(start_time) >= ?", now.Day())
query = query.Where("DAY(`expire_time`) >= ?", now.Day())
} else {
// Normal case: exact day match
query = query.Where("DAY(start_time) = ?", now.Day())
query = query.Where("DAY(`expire_time`) = ?", now.Day())
}
err = query.Find(&monthlyResetUsers).Error
@ -279,7 +298,7 @@ func (l *ResetTrafficLogic) reset1st(ctx context.Context, cache resetTrafficCach
var users1stReset []int64
err = db.Model(&user.Subscribe{}).Select("`id`").
Where("`subscribe_id` IN ?", reset1stSubIds).
Where("`status` = ?", 1). // Only active subscriptions
Where("`status` IN ?", []int64{1, 2}). // Only active subscriptions
Find(&users1stReset).Error
if err != nil {
logger.Errorw("[ResetTraffic] Failed to query 1st reset users", logger.Field("error", err.Error()))
@ -341,29 +360,20 @@ func (l *ResetTrafficLogic) resetYear(ctx context.Context) error {
// Query users for yearly reset based on subscription start date anniversary
var usersYearReset []int64
// Check if today is the last day of current month
nextMonth := now.AddDate(0, 1, 0)
isLastDayOfMonth := nextMonth.Month() != now.Month()
// Check if today is February 28th (handle leap year case)
isLeapYearCase := now.Month() == 2 && now.Day() == 28
query := db.Model(&user.Subscribe{}).Select("`id`").
Where("`subscribe_id` IN ?", resetYearSubIds).
Where("MONTH(start_time) = ?", now.Month()). // Same month
Where("`status` = ?", 1). // Only active subscriptions
Where("TIMESTAMPDIFF(YEAR, DATE(start_time), CURDATE()) >= 1"). // At least 1 year passed
Where("DATE(start_time) < CURDATE()") // Only reset subscriptions that have started
Where("MONTH(expire_time) = ?", now.Month()). // Same month
Where("`status` IN ?", []int64{1, 2}). // Only active subscriptions
Where("TIMESTAMPDIFF(YEAR, CURDATE(),DATE(expire_time)) >= 1") // At least 1 year passed
if isLeapYearCase {
// February 28th: handle both Feb 28 and Feb 29 subscriptions
query = query.Where("DAY(start_time) IN (28, 29)")
} else if isLastDayOfMonth {
// Last day of month: handle subscription start dates >= today
query = query.Where("DAY(start_time) >= ?", now.Day())
query = query.Where("DAY(expire_time) IN (28, 29)")
} else {
// Normal case: exact day match
query = query.Where("DAY(start_time) = ?", now.Day())
query = query.Where("DAY(expire_time) = ?", now.Day())
}
err = query.Find(&usersYearReset).Error

View File

@ -3,10 +3,13 @@ package types
const (
// ForthwithSendEmail forthwith send email
ForthwithSendEmail = "forthwith:email:send"
// ScheduledBatchSendEmail scheduled batch send email
ScheduledBatchSendEmail = "scheduled:email:batch"
)
type (
SendEmailPayload struct {
Type string `json:"type"`
Email string `json:"to"`
Subject string `json:"subject"`
Content string `json:"content"`

View File

@ -34,9 +34,9 @@ func (m *Service) Start() {
if _, err := m.server.Register("@every 180s", totalServerDataTask); err != nil {
logger.Errorf("register total server data task failed: %s", err.Error())
}
// schedule reset traffic task: every 24 hours
// schedule reset traffic task: every day at 00:30
resetTrafficTask := asynq.NewTask(types.SchedulerResetTraffic, nil)
if _, err := m.server.Register("@every 24h", resetTrafficTask); err != nil {
if _, err := m.server.Register("30 0 * * *", resetTrafficTask); err != nil {
logger.Errorf("register reset traffic task failed: %s", err.Error())
}

View File

@ -6,9 +6,13 @@ ARCH_TYPE=$(uname -m)
if [[ "$OS_TYPE" == "Linux" ]]; then
echo "The current operating system is Linux"
if [[ "$ARCH_TYPE" == "x86_64" ]]; then
echo "Format api file"
./generate/gopure-linux-amd64 api format --dir ./apis
echo "Architecture: amd64"
./generate/gopure-linux-amd64 api go -api *.api -dir . -style goZero
elif [[ "$ARCH_TYPE" == "aarch64" ]]; then
echo "Format api file"
./generate/gopure-linux-arm64 api format --dir ./apis
echo "Architecture: arm64"
./generate/gopure-linux-arm64 api go -api *.api -dir . -style goZero
else
@ -17,9 +21,13 @@ if [[ "$OS_TYPE" == "Linux" ]]; then
elif [[ "$OS_TYPE" == "Darwin" ]]; then
echo "The current operating system is macOS"
if [[ "$ARCH_TYPE" == "x86_64" ]]; then
echo "Format api file"
./generate/gopure-darwin-amd64 api format --dir ./apis
echo "Architecture: amd64"
./generate/gopure-darwin-amd64 api go -api *.api -dir . -style goZero
elif [[ "$ARCH_TYPE" == "arm64" ]]; then
echo "Format api file"
./generate/gopure-darwin-arm64 api format --dir ./apis
echo "Architecture: arm64"
./generate/gopure-darwin-arm64 api go -api *.api -dir . -style goZero
else
@ -28,9 +36,13 @@ elif [[ "$OS_TYPE" == "Darwin" ]]; then
elif [[ "$OS_TYPE" == "CYGWIN"* || "$OS_TYPE" == "MINGW"* ]]; then
echo "The current operating system is Windows"
if [[ "$ARCH_TYPE" == "x86_64" ]]; then
echo "Format api file"
./generate/gopure-amd64.exe api format --dir ./apis
echo "Architecture: amd64"
./generate/gopure-amd64.exe api go -api *.api -dir . -style goZero
elif [[ "$ARCH_TYPE" == "arm64" ]]; then
echo "Format api file"
./generate/gopure-arm64.exe api format --dir ./apis
echo "Architecture: arm64"
./generate/gopure-arm64.exe api go -api *.api -dir . -style goZero
else