diff --git a/Dockerfile b/Dockerfile index 48c10c0..5800618 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ FROM alpine:latest # Copy CA certificates and timezone data COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ -COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai ENV TZ=Asia/Shanghai @@ -36,6 +36,7 @@ ENV TZ=Asia/Shanghai WORKDIR /app COPY --from=builder /app/ppanel /app/ppanel +COPY --from=builder /build/etc /app/etc # Expose the port (optional) EXPOSE 8080 diff --git a/apis/public/user.api b/apis/public/user.api index 6ffc69b..931b361 100644 --- a/apis/public/user.api +++ b/apis/public/user.api @@ -93,24 +93,27 @@ type ( UpdateBindEmailRequest { Email string `json:"email" validate:"required"` } - BindEmailWithPasswordRequest { - Email string `json:"email" validate:"required"` - Password string `json:"password" validate:"required"` - } - BindInviteCodeRequest { - InviteCode string `json:"invite_code" validate:"required"` - } VerifyEmailRequest { Email string `json:"email" validate:"required"` Code string `json:"code" validate:"required"` } - GetDeviceListResponse { - List []UserDevice `json:"list"` - Total int64 `json:"total"` + BindEmailWithVerificationRequest { + Email string `json:"email" validate:"required"` + Code string `json:"code" validate:"required"` } - UnbindDeviceRequest { - Id int64 `json:"id" validate:"required"` + BindEmailWithVerificationResponse { + Success bool `json:"success"` + Message string `json:"message,omitempty"` } + + GetDeviceListResponse { + List []UserDevice `json:"list"` + Total int64 `json:"total"` + } + + UnbindDeviceRequest { + Id int64 `json:"id" validate:"required"` + } ) @server ( @@ -207,20 +210,16 @@ service ppanel { @handler UpdateBindEmail put /bind_email (UpdateBindEmailRequest) - @doc "Bind Email With Password" - @handler BindEmailWithPassword - post /bind_email_with_password (BindEmailWithPasswordRequest) returns (LoginResponse) + @doc "Bind Email With Verification" + @handler BindEmailWithVerification + post /bind_email_with_verification (BindEmailWithVerificationRequest) returns (BindEmailWithVerificationResponse) - @doc "Bind Invite Code" - @handler BindInviteCode - post /bind_invite_code (BindInviteCodeRequest) + @doc "Get Device List" + @handler GetDeviceList + get /devices returns (GetDeviceListResponse) - @doc "Get Device List" - @handler GetDeviceList - get /devices returns (GetDeviceListResponse) - - @doc "Unbind Device" - @handler UnbindDevice - put /unbind_device (UnbindDeviceRequest) + @doc "Unbind Device" + @handler UnbindDevice + put /unbind_device (UnbindDeviceRequest) } diff --git a/apis/types.api b/apis/types.api index 5e62835..886b2fe 100644 --- a/apis/types.api +++ b/apis/types.api @@ -115,7 +115,7 @@ type ( AuthConfig { Mobile MobileAuthenticateConfig `json:"mobile"` Email EmailAuthticateConfig `json:"email"` - Device DeviceAuthticateConfig `json:"device"` + Device DeviceAuthticateConfig `json:"device"` Register PubilcRegisterConfig `json:"register"` } PubilcRegisterConfig { @@ -135,12 +135,14 @@ type ( EnableDomainSuffix bool `json:"enable_domain_suffix"` DomainSuffixList string `json:"domain_suffix_list"` } - DeviceAuthticateConfig { - Enable bool `json:"enable"` - ShowAds bool `json:"show_ads"` - EnableSecurity bool `json:"enable_security"` - OnlyRealDevice bool `json:"only_real_device"` - } + + DeviceAuthticateConfig { + Enable bool `json:"enable"` + ShowAds bool `json:"show_ads"` + EnableSecurity bool `json:"enable_security"` + OnlyRealDevice bool `json:"only_real_device"` + } + RegisterConfig { StopRegister bool `json:"stop_register"` EnableTrial bool `json:"enable_trial"` @@ -185,7 +187,6 @@ type ( ForcedInvite bool `json:"forced_invite"` ReferralPercentage int64 `json:"referral_percentage"` OnlyFirstPurchase bool `json:"only_first_purchase"` - GiftDays int64 `json:"gift_days"` } TelegramConfig { TelegramBotToken string `json:"telegram_bot_token"` @@ -205,8 +206,8 @@ type ( CurrencySymbol string `json:"currency_symbol"` } SubscribeDiscount { - Quantity int64 `json:"quantity"` - Discount float64 `json:"discount"` + Quantity int64 `json:"quantity"` + Discount int64 `json:"discount"` } Subscribe { Id int64 `json:"id"` @@ -673,6 +674,7 @@ type ( List []SubscribeGroup `json:"list"` Total int64 `json:"total"` } + GetUserSubscribeTrafficLogsRequest { Page int `form:"page"` Size int `form:"size"` diff --git a/doc/config-zh.md b/doc/config-zh.md new file mode 100644 index 0000000..55b7adf --- /dev/null +++ b/doc/config-zh.md @@ -0,0 +1,161 @@ +# PPanel 配置指南 + +本文件为 PPanel 应用程序的配置文件提供全面指南。配置文件采用 YAML 格式,定义了服务器、日志、数据库、Redis 和管理员访问的相关设置。 + +## 1. 配置文件概述 + +- **默认路径**:`./etc/ppanel.yaml` +- **自定义路径**:通过启动参数 `--config` 指定配置文件路径。 +- **格式**:YAML 格式,支持注释,文件名需以 `.yaml` 结尾。 + +## 2. 配置文件结构 + +以下是配置文件示例,包含默认值和说明: + +```yaml +# PPanel 配置文件 +Host: "0.0.0.0" # 服务监听地址 +Port: 8080 # 服务监听端口 +Debug: false # 是否开启调试模式(禁用后台日志) +JwtAuth: # JWT 认证配置 + AccessSecret: "" # 访问令牌密钥(为空时随机生成) + AccessExpire: 604800 # 访问令牌过期时间(秒) +Logger: # 日志配置 + ServiceName: "" # 日志服务标识名称 + Mode: "console" # 日志输出模式(console、file、volume) + Encoding: "json" # 日志格式(json、plain) + TimeFormat: "2006-01-02T15:04:05.000Z07:00" # 自定义时间格式 + Path: "logs" # 日志文件目录 + Level: "info" # 日志级别(info、error、severe) + Compress: false # 是否压缩日志文件 + KeepDays: 7 # 日志保留天数 + StackCooldownMillis: 100 # 堆栈日志冷却时间(毫秒) + MaxBackups: 3 # 最大日志备份数 + MaxSize: 50 # 最大日志文件大小(MB) + Rotation: "daily" # 日志轮转策略(daily、size) +MySQL: # MySQL 数据库配置 + Addr: "" # MySQL 地址(必填) + Username: "" # MySQL 用户名(必填) + Password: "" # MySQL 密码(必填) + Dbname: "" # MySQL 数据库名(必填) + Config: "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai" # MySQL 连接参数 + MaxIdleConns: 10 # 最大空闲连接数 + MaxOpenConns: 100 # 最大打开连接数 + LogMode: "info" # 日志级别(debug、error、warn、info) + LogZap: true # 是否使用 Zap 记录 SQL 日志 + SlowThreshold: 1000 # 慢查询阈值(毫秒) +Redis: # Redis 配置 + Host: "localhost:6379" # Redis 地址 + Pass: "" # Redis 密码 + DB: 0 # Redis 数据库索引 +Administer: # 管理员登录配置 + Email: "admin@ppanel.dev" # 管理员登录邮箱 + Password: "password" # 管理员登录密码 +``` + +## 3. 配置项说明 + +### 3.1 服务器设置 + +- **`Host`**:服务监听的地址。 + - 默认:`0.0.0.0`(监听所有网络接口)。 +- **`Port`**:服务监听的端口。 + - 默认:`8080`。 +- **`Debug`**:是否开启调试模式,开启后禁用后台日志功能。 + - 默认:`false`。 + +### 3.2 JWT 认证 (`JwtAuth`) + +- **`AccessSecret`**:访问令牌的密钥。 + - 默认:为空时随机生成。 +- **`AccessExpire`**:令牌过期时间(秒)。 + - 默认:`604800`(7天)。 + +### 3.3 日志配置 (`Logger`) + +- **`ServiceName`**:日志的服务标识名称,在 `volume` 模式下用作日志文件名。 + - 默认:`""`。 +- **`Mode`**:日志输出方式。 + - 选项:`console`(标准输出/错误输出)、`file`(写入指定目录)、`volume`(Docker 卷)。 + - 默认:`console`。 +- **`Encoding`**:日志格式。 + - 选项:`json`(结构化 JSON)、`plain`(纯文本,带颜色)。 + - 默认:`json`。 +- **`TimeFormat`**:日志时间格式。 + - 默认:`2006-01-02T15:04:05.000Z07:00`。 +- **`Path`**:日志文件存储目录。 + - 默认:`logs`。 +- **`Level`**:日志过滤级别。 + - 选项:`info`(记录所有日志)、`error`(仅错误和严重日志)、`severe`(仅严重日志)。 + - 默认:`info`。 +- **`Compress`**:是否压缩日志文件(仅在 `file` 模式下生效)。 + - 默认:`false`。 +- **`KeepDays`**:日志文件保留天数。 + - 默认:`7`。 +- **`StackCooldownMillis`**:堆栈日志冷却时间(毫秒),防止日志过多。 + - 默认:`100`。 +- **`MaxBackups`**:最大日志备份数量(仅在 `size` 轮转时生效)。 + - 默认:`3`。 +- **`MaxSize`**:日志文件最大大小(MB,仅在 `size` 轮转时生效)。 + - 默认:`50`。 +- **`Rotation`**:日志轮转策略。 + - 选项:`daily`(按天轮转)、`size`(按大小轮转)。 + - 默认:`daily`。 + +### 3.4 MySQL 数据库 (`MySQL`) + +- **`Addr`**:MySQL 服务器地址。 + - 必填。 +- **`Username`**:MySQL 用户名。 + - 必填。 +- **`Password`**:MySQL 密码。 + - 必填。 +- **`Dbname`**:MySQL 数据库名。 + - 必填。 +- **`Config`**:MySQL 连接参数。 + - 默认:`charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai`。 +- **`MaxIdleConns`**:最大空闲连接数。 + - 默认:`10`。 +- **`MaxOpenConns`**:最大打开连接数。 + - 默认:`100`。 +- **`LogMode`**:SQL 日志级别。 + - 选项:`debug`、`error`、`warn`、`info`。 + - 默认:`info`。 +- **`LogZap`**:是否使用 Zap 记录 SQL 日志。 + - 默认:`true`。 +- **`SlowThreshold`**:慢查询阈值(毫秒)。 + - 默认:`1000`。 + +### 3.5 Redis 配置 (`Redis`) + +- **`Host`**:Redis 服务器地址。 + - 默认:`localhost:6379`。 +- **`Pass`**:Redis 密码。 + - 默认:`""`(无密码)。 +- **`DB`**:Redis 数据库索引。 + - 默认:`0`。 + +### 3.6 管理员登录 (`Administer`) + +- **`Email`**:管理员登录邮箱。 + - 默认:`admin@ppanel.dev`。 +- **`Password`**:管理员登录密码。 + - 默认:`password`。 + +## 4. 环境变量 + +以下环境变量可用于覆盖配置文件中的设置: + +| 环境变量 | 配置项 | 示例值 | +|----------------|----------|----------------------------------------------| +| `PPANEL_DB` | MySQL 配置 | `root:password@tcp(localhost:3306)/vpnboard` | +| `PPANEL_REDIS` | Redis 配置 | `redis://localhost:6379` | + +## 5. 最佳实践 + +- **安全性**:生产环境中避免使用默认的 `Administer` 凭据,更新 `Email` 和 `Password` 为安全值。 +- **日志**:生产环境中建议使用 `file` 或 `volume` 模式持久化日志,将 `Level` 设置为 `error` 或 `severe` 以减少日志量。 +- **数据库**:确保 `MySQL` 和 `Redis` 凭据安全,避免在版本控制中暴露。 +- **JWT**:为 `JwtAuth` 的 `AccessSecret` 设置强密钥以增强安全性。 + +如需进一步帮助,请参考 PPanel 官方文档或联系支持团队。 \ No newline at end of file diff --git a/doc/config.md b/doc/config.md new file mode 100644 index 0000000..99b6b3c --- /dev/null +++ b/doc/config.md @@ -0,0 +1,164 @@ +# PPanel Configuration Guide + +This document provides a comprehensive guide to the configuration file for the PPanel application. The configuration +file is in YAML format and defines settings for the server, logging, database, Redis, and admin access. + +## 1. Configuration File Overview + +- **Default Path**: `./etc/ppanel.yaml` +- **Custom Path**: Specify a custom path using the `--config` startup parameter. +- **Format**: YAML, supports comments, and must be named with a `.yaml` extension. + +## 2. Configuration File Structure + +Below is an example of the configuration file with default values and explanations: + +```yaml +# PPanel Configuration +Host: "0.0.0.0" # Server listening address +Port: 8080 # Server listening port +Debug: false # Enable debug mode (disables background logging) +JwtAuth: # JWT authentication settings + AccessSecret: "" # Access token secret (randomly generated if empty) + AccessExpire: 604800 # Access token expiration (seconds) +Logger: # Logging configuration + ServiceName: "" # Service name for log identification + Mode: "console" # Log output mode (console, file, volume) + Encoding: "json" # Log format (json, plain) + TimeFormat: "2006-01-02T15:04:05.000Z07:00" # Custom time format + Path: "logs" # Log file directory + Level: "info" # Log level (info, error, severe) + Compress: false # Enable log compression + KeepDays: 7 # Log retention period (days) + StackCooldownMillis: 100 # Stack trace cooldown (milliseconds) + MaxBackups: 3 # Maximum number of log backups + MaxSize: 50 # Maximum log file size (MB) + Rotation: "daily" # Log rotation strategy (daily, size) +MySQL: # MySQL database configuration + Addr: "" # MySQL address (required) + Username: "" # MySQL username (required) + Password: "" # MySQL password (required) + Dbname: "" # MySQL database name (required) + Config: "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai" # MySQL connection parameters + MaxIdleConns: 10 # Maximum idle connections + MaxOpenConns: 100 # Maximum open connections + LogMode: "info" # Log level (debug, error, warn, info) + LogZap: true # Enable Zap logging for SQL + SlowThreshold: 1000 # Slow query threshold (milliseconds) +Redis: # Redis configuration + Host: "localhost:6379" # Redis address + Pass: "" # Redis password + DB: 0 # Redis database index +Administer: # Admin login configuration + Email: "admin@ppanel.dev" # Admin login email + Password: "password" # Admin login password +``` + +## 3. Configuration Details + +### 3.1 Server Settings + +- **`Host`**: Address the server listens on. + - Default: `0.0.0.0` (all network interfaces). +- **`Port`**: Port the server listens on. + - Default: `8080`. +- **`Debug`**: Enables debug mode, disabling background logging. + - Default: `false`. + +### 3.2 JWT Authentication (`JwtAuth`) + +- **`AccessSecret`**: Secret key for access tokens. + - Default: Randomly generated if not specified. +- **`AccessExpire`**: Token expiration time in seconds. + - Default: `604800` (7 days). + +### 3.3 Logging (`Logger`) + +- **`ServiceName`**: Identifier for logs, used as the log filename in `volume` mode. + - Default: `""`. +- **`Mode`**: Log output destination. + - Options: `console` (stdout/stderr), `file` (to a directory), `volume` (Docker volume). + - Default: `console`. +- **`Encoding`**: Log format. + - Options: `json` (structured JSON), `plain` (plain text with colors). + - Default: `json`. +- **`TimeFormat`**: Custom time format for logs. + - Default: `2006-01-02T15:04:05.000Z07:00`. +- **`Path`**: Directory for log files. + - Default: `logs`. +- **`Level`**: Log filtering level. + - Options: `info` (all logs), `error` (error and severe), `severe` (severe only). + - Default: `info`. +- **`Compress`**: Enable compression for log files (only in `file` mode). + - Default: `false`. +- **`KeepDays`**: Retention period for log files (in days). + - Default: `7`. +- **`StackCooldownMillis`**: Cooldown for stack trace logging to prevent log flooding. + - Default: `100`. +- **`MaxBackups`**: Maximum number of log backups (for `size` rotation). + - Default: `3`. +- **`MaxSize`**: Maximum log file size in MB (for `size` rotation). + - Default: `50`. +- **`Rotation`**: Log rotation strategy. + - Options: `daily` (rotate daily), `size` (rotate by size). + - Default: `daily`. + +### 3.4 MySQL Database (`MySQL`) + +- **`Addr`**: MySQL server address. + - Required. +- **`Username`**: MySQL username. + - Required. +- **`Password`**: MySQL password. + - Required. +- **`Dbname`**: MySQL database name. + - Required. +- **`Config`**: MySQL connection parameters. + - Default: `charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai`. +- **`MaxIdleConns`**: Maximum idle connections. + - Default: `10`. +- **`MaxOpenConns`**: Maximum open connections. + - Default: `100`. +- **`LogMode`**: SQL logging level. + - Options: `debug`, `error`, `warn`, `info`. + - Default: `info`. +- **`LogZap`**: Enable Zap logging for SQL queries. + - Default: `true`. +- **`SlowThreshold`**: Threshold for slow query logging (in milliseconds). + - Default: `1000`. + +### 3.5 Redis (`Redis`) + +- **`Host`**: Redis server address. + - Default: `localhost:6379`. +- **`Pass`**: Redis password. + - Default: `""` (no password). +- **`DB`**: Redis database index. + - Default: `0`. + +### 3.6 Admin Login (`Administer`) + +- **`Email`**: Admin login email. + - Default: `admin@ppanel.dev`. +- **`Password`**: Admin login password. + - Default: `password`. + +## 4. Environment Variables + +The following environment variables can be used to override configuration settings: + +| Environment Variable | Configuration Section | Example Value | +|----------------------|-----------------------|----------------------------------------------| +| `PPANEL_DB` | MySQL | `root:password@tcp(localhost:3306)/vpnboard` | +| `PPANEL_REDIS` | Redis | `redis://localhost:6379` | + +## 5. Best Practices + +- **Security**: Avoid using default `Administer` credentials in production. Update `Email` and `Password` to secure + values. +- **Logging**: Use `file` or `volume` mode for production to persist logs. Adjust `Level` to `error` or `severe` to + reduce log volume. +- **Database**: Ensure `MySQL` and `Redis` credentials are secure and not exposed in version control. +- **JWT**: Specify a strong `AccessSecret` for `JwtAuth` to enhance security. + +For further assistance, refer to the official PPanel documentation or contact support. \ No newline at end of file diff --git a/doc/image/architecture-en.png b/doc/image/architecture-en.png new file mode 100644 index 0000000..978774c Binary files /dev/null and b/doc/image/architecture-en.png differ diff --git a/doc/image/architecture-zh.png b/doc/image/architecture-zh.png new file mode 100644 index 0000000..7923f60 Binary files /dev/null and b/doc/image/architecture-zh.png differ diff --git a/doc/install-zh.md b/doc/install-zh.md new file mode 100644 index 0000000..10d8292 --- /dev/null +++ b/doc/install-zh.md @@ -0,0 +1,133 @@ +### 安装说明 +#### 前置系统要求 +- Mysql 5.7+ (推荐使用8.0) +- Redis 6.0+ (推荐使用7.0) + +#### 二进制安装 +1.确定系统架构,并下载对应的二进制文件 + +下载地址:`https://github.com/perfect-panel/server/releases` + +示例说明:系统:Linux amd64,用户:root,当前目录:/root + +- 下载二进制文件 + +```shell +$ wget https://github.com/perfect-panel/server/releases/download/v1.0.0/ppanel-server-linux-amd64.tar.gz +``` + +- 解压二进制文件 + +```shell +$ tar -zxvf ppanel-server-linux-amd64.tar.gz +``` + +- 进入解压后的目录 + +```shell +$ cd ppanel-server-linux-amd64 +``` + +- 赋予二进制文件执行权限 + +```shell +$ chmod +x ppanel-server +``` + +- 创建 systemd 服务文件 + +```shell +$ cat > /etc/systemd/system/ppanel.service < /etc/systemd/system/ppanel.service <:8080/init` to **initialize the system configuration**. + +#### NGINX Reverse Proxy Configuration + +Below is an example configuration to proxy the ppanel service to the domain api.ppanel.dev: + +```nginx +server { + listen 80; + server_name ppanel.dev; + + location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header REMOTE-HOST $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_http_version 1.1; + + add_header X-Cache $upstream_cache_status; + + # Set Nginx Cache + set $static_file_cache 0; + if ($uri ~* "\.(gif|png|jpg|css|js|woff|woff2)$") { + set $static_file_cache 1; + expires 1m; + } + if ($static_file_cache = 0) { + add_header Cache-Control no-cache; + } + } +} +``` + +If using Cloudflare as a proxy service, you need to retrieve the user's real IP address. Add the following to the http section of the NGINX configuration file: + +- Dependency: `ngx_http_realip_module`. Check if your NGINX build includes this module by running `nginx -V`. If not, you will need to recompile NGINX with this module. + +```nginx +# Cloudflare Start +set_real_ip_from 0.0.0.0/0; +real_ip_header X-Forwarded-For; +real_ip_recursive on; +# Cloudflare End +``` \ No newline at end of file diff --git a/go.mod b/go.mod index 04daadd..3e110be 100644 --- a/go.mod +++ b/go.mod @@ -44,7 +44,7 @@ require ( go.opentelemetry.io/otel/sdk v1.29.0 go.opentelemetry.io/otel/trace v1.29.0 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.32.0 + golang.org/x/crypto v0.35.0 golang.org/x/oauth2 v0.25.0 golang.org/x/time v0.6.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df @@ -138,8 +138,8 @@ require ( golang.org/x/arch v0.13.0 // indirect golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect diff --git a/go.sum b/go.sum index 9e9f2c5..6ab9d74 100644 --- a/go.sum +++ b/go.sum @@ -402,8 +402,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4= golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= @@ -463,8 +463,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -478,8 +478,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/initialize/migrate/database/02115_ads.up.sql b/initialize/migrate/database/02115_ads.up.sql index 341bc84..39cfaf2 100644 --- a/initialize/migrate/database/02115_ads.up.sql +++ b/initialize/migrate/database/02115_ads.up.sql @@ -1,2 +1,20 @@ -ALTER TABLE `ads` - ADD COLUMN `description` VARCHAR(255) DEFAULT '' COMMENT 'Description'; +-- 只有当 ads 表中不存在 description 字段时才添加 +SET +@col_exists := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ads' + AND COLUMN_NAME = 'description' +); + +SET +@query := IF( + @col_exists = 0, + 'ALTER TABLE `ads` ADD COLUMN `description` VARCHAR(255) DEFAULT '''' COMMENT ''Description'';', + 'SELECT "Column `description` already exists"' +); + +PREPARE stmt FROM @query; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/initialize/migrate/database/02117_site_custom_data.down.sql b/initialize/migrate/database/02117_site_custom_data.down.sql new file mode 100644 index 0000000..c8581e8 --- /dev/null +++ b/initialize/migrate/database/02117_site_custom_data.down.sql @@ -0,0 +1,7 @@ +INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +SELECT 'site', 'CustomData', '{ + "kr_website_id": "" +}', 'string', 'Custom Data', '2025-04-22 14:25:16.637', '2025-10-14 15:47:19.187' + WHERE NOT EXISTS ( + SELECT 1 FROM `system` WHERE `category` = 'site' AND `key` = 'CustomData' +); diff --git a/initialize/migrate/database/02117_site_custom_data.up.sql b/initialize/migrate/database/02117_site_custom_data.up.sql new file mode 100644 index 0000000..c8581e8 --- /dev/null +++ b/initialize/migrate/database/02117_site_custom_data.up.sql @@ -0,0 +1,7 @@ +INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +SELECT 'site', 'CustomData', '{ + "kr_website_id": "" +}', 'string', 'Custom Data', '2025-04-22 14:25:16.637', '2025-10-14 15:47:19.187' + WHERE NOT EXISTS ( + SELECT 1 FROM `system` WHERE `category` = 'site' AND `key` = 'CustomData' +); diff --git a/initialize/migrate/database/02118_traffic_log_idx.down.sql b/initialize/migrate/database/02118_traffic_log_idx.down.sql new file mode 100644 index 0000000..25598dd --- /dev/null +++ b/initialize/migrate/database/02118_traffic_log_idx.down.sql @@ -0,0 +1 @@ +ALTER TABLE traffic_log DROP INDEX idx_timestamp; diff --git a/initialize/migrate/database/02118_traffic_log_idx.up.sql b/initialize/migrate/database/02118_traffic_log_idx.up.sql new file mode 100644 index 0000000..cdd308f --- /dev/null +++ b/initialize/migrate/database/02118_traffic_log_idx.up.sql @@ -0,0 +1 @@ +ALTER TABLE traffic_log ADD INDEX idx_timestamp (timestamp); diff --git a/initialize/migrate/database/02119_user_algo.down.sql b/initialize/migrate/database/02119_user_algo.down.sql new file mode 100644 index 0000000..41644c2 --- /dev/null +++ b/initialize/migrate/database/02119_user_algo.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE `user` +DROP COLUMN `algo`, + DROP COLUMN `salt`; diff --git a/initialize/migrate/database/02119_user_algo.up.sql b/initialize/migrate/database/02119_user_algo.up.sql new file mode 100644 index 0000000..4a79ef2 --- /dev/null +++ b/initialize/migrate/database/02119_user_algo.up.sql @@ -0,0 +1,35 @@ +-- 添加 algo 列(如果不存在) +SET @dbname = DATABASE(); +SET @tablename = 'user'; +SET @colname = 'algo'; +SET @sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE `user` ADD COLUMN `algo` VARCHAR(20) NOT NULL DEFAULT ''default'' COMMENT ''Encryption Algorithm'' AFTER `password`;', + 'SELECT "Column `algo` already exists";' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @dbname + AND TABLE_NAME = @tablename + AND COLUMN_NAME = @colname +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 添加 salt 列(如果不存在) +SET @colname = 'salt'; +SET @sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE `user` ADD COLUMN `salt` VARCHAR(20) NOT NULL DEFAULT ''default'' COMMENT ''Password Salt'' AFTER `algo`;', + 'SELECT "Column `salt` already exists";' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @dbname + AND TABLE_NAME = @tablename + AND COLUMN_NAME = @colname +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/initialize/version.go b/initialize/version.go index 9eac7d4..14d0875 100644 --- a/initialize/version.go +++ b/initialize/version.go @@ -2,6 +2,7 @@ package initialize import ( "errors" + "time" "github.com/perfect-panel/server/internal/model/user" "gorm.io/gorm" @@ -16,6 +17,7 @@ func Migrate(ctx *svc.ServiceContext) { mc := orm.Mysql{ Config: ctx.Config.MySQL, } + now := time.Now() if err := migrate.Migrate(mc.Dsn()).Up(); err != nil { if errors.Is(err, migrate.NoChange) { logger.Info("[Migrate] database not change") @@ -23,6 +25,8 @@ func Migrate(ctx *svc.ServiceContext) { } logger.Errorf("[Migrate] Up error: %v", err.Error()) panic(err) + } else { + logger.Info("[Migrate] Database change, took " + time.Since(now).String()) } // if not found admin user err := ctx.DB.Transaction(func(tx *gorm.DB) error { diff --git a/internal/config/config.go b/internal/config/config.go index d24defc..e47b56c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -203,7 +203,7 @@ type InviteConfig struct { ForcedInvite bool `yaml:"ForcedInvite" default:"false"` ReferralPercentage int64 `yaml:"ReferralPercentage" default:"0"` OnlyFirstPurchase bool `yaml:"OnlyFirstPurchase" default:"false"` - GiftDays int64 `yaml:"GiftDays" default:"3"` + GiftDays int64 `yaml:"GiftDays" default:"0"` } type Telegram struct { diff --git a/internal/handler/public/user/bindEmailWithPasswordHandler.go b/internal/handler/public/user/bindEmailWithVerificationHandler.go similarity index 61% rename from internal/handler/public/user/bindEmailWithPasswordHandler.go rename to internal/handler/public/user/bindEmailWithVerificationHandler.go index 333a98c..ff3209b 100644 --- a/internal/handler/public/user/bindEmailWithPasswordHandler.go +++ b/internal/handler/public/user/bindEmailWithVerificationHandler.go @@ -8,10 +8,10 @@ import ( "github.com/perfect-panel/server/pkg/result" ) -// Bind Email With Password -func BindEmailWithPasswordHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { +// Bind Email With Verification +func BindEmailWithVerificationHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { - var req types.BindEmailWithPasswordRequest + var req types.BindEmailWithVerificationRequest _ = c.ShouldBind(&req) validateErr := svcCtx.Validate(&req) if validateErr != nil { @@ -19,8 +19,8 @@ func BindEmailWithPasswordHandler(svcCtx *svc.ServiceContext) func(c *gin.Contex return } - l := user.NewBindEmailWithPasswordLogic(c.Request.Context(), svcCtx) - resp, err := l.BindEmailWithPassword(&req) + l := user.NewBindEmailWithVerificationLogic(c.Request.Context(), svcCtx) + resp, err := l.BindEmailWithVerification(&req) result.HttpResult(c, resp, err) } -} +} \ No newline at end of file diff --git a/internal/handler/routes.go b/internal/handler/routes.go index e684fb4..d42ac7e 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -780,12 +780,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Update Bind Email publicUserGroupRouter.PUT("/bind_email", publicUser.UpdateBindEmailHandler(serverCtx)) - // Bind Email With Password - publicUserGroupRouter.POST("/bind_email_with_password", publicUser.BindEmailWithPasswordHandler(serverCtx)) - - // Bind Invite Code - publicUserGroupRouter.POST("/bind_invite_code", publicUser.BindInviteCodeHandler(serverCtx)) - // Update Bind Mobile publicUserGroupRouter.PUT("/bind_mobile", publicUser.UpdateBindMobileHandler(serverCtx)) @@ -867,10 +861,10 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { serverGroupRouter.GET("/user", server.GetServerUserListHandler(serverCtx)) } - serverGroupRouterV2 := router.Group("/v2/server") + serverV2GroupRouter := router.Group("/v2/server") { // Get Server Protocol Config - serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx)) + serverV2GroupRouter.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx)) } } diff --git a/internal/handler/server/queryServerProtocolConfigHandler.go b/internal/handler/server/queryServerProtocolConfigHandler.go index 5d382a6..a2786b9 100644 --- a/internal/handler/server/queryServerProtocolConfigHandler.go +++ b/internal/handler/server/queryServerProtocolConfigHandler.go @@ -36,6 +36,12 @@ func QueryServerProtocolConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Co fmt.Printf("[QueryServerProtocolConfigHandler] - ShouldBindQuery request: %+v\n", req) + if svcCtx.Config.Node.NodeSecret != req.SecretKey { + c.String(http.StatusUnauthorized, "Unauthorized") + c.Abort() + return + } + l := server.NewQueryServerProtocolConfigLogic(c.Request.Context(), svcCtx) resp, err := l.QueryServerProtocolConfig(&req) result.HttpResult(c, resp, err) diff --git a/internal/logic/admin/user/createUserLogic.go b/internal/logic/admin/user/createUserLogic.go index 5f6c858..0fb6b43 100644 --- a/internal/logic/admin/user/createUserLogic.go +++ b/internal/logic/admin/user/createUserLogic.go @@ -40,6 +40,7 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserRequest) error { pwd := tool.EncodePassWord(req.Password) newUser := &user.User{ Password: pwd, + Algo: "default", ReferralPercentage: req.ReferralPercentage, OnlyFirstPurchase: &req.OnlyFirstPurchase, ReferCode: req.ReferCode, diff --git a/internal/logic/admin/user/updateUserBasicInfoLogic.go b/internal/logic/admin/user/updateUserBasicInfoLogic.go index 9f57f75..faa7930 100644 --- a/internal/logic/admin/user/updateUserBasicInfoLogic.go +++ b/internal/logic/admin/user/updateUserBasicInfoLogic.go @@ -129,6 +129,7 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi return errors.Wrapf(xerr.NewErrCodeMsg(503, "Demo mode does not allow modification of the admin user password"), "UpdateUserBasicInfo failed: cannot update admin user password in demo mode") } userInfo.Password = tool.EncodePassWord(req.Password) + userInfo.Algo = "default" } err = l.svcCtx.UserModel.Update(l.ctx, userInfo) diff --git a/internal/logic/auth/deviceLoginLogic.go b/internal/logic/auth/deviceLoginLogic.go index 2e2b6ae..10f0949 100644 --- a/internal/logic/auth/deviceLoginLogic.go +++ b/internal/logic/auth/deviceLoginLogic.go @@ -19,13 +19,14 @@ import ( "gorm.io/gorm" ) +// DeviceLoginLogic 设备登录逻辑结构体 type DeviceLoginLogic struct { logger.Logger ctx context.Context svcCtx *svc.ServiceContext } -// Device Login +// NewDeviceLoginLogic 创建设备登录逻辑实例 func NewDeviceLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeviceLoginLogic { return &DeviceLoginLogic{ Logger: logger.WithContext(ctx), @@ -34,14 +35,17 @@ func NewDeviceLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Devic } } +// DeviceLogin 设备登录主要逻辑 func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *types.LoginResponse, err error) { + // 检查设备登录是否启用 if !l.svcCtx.Config.Device.Enable { return nil, xerr.NewErrMsg("Device login is disabled") } loginStatus := false var userInfo *user.User - // Record login status + + // 延迟执行:记录登录状态日志 defer func() { if userInfo != nil && userInfo.Id != 0 { loginLog := log.Login{ @@ -67,14 +71,61 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ } }() - // Check if device exists by identifier + // 根据设备标识符查找设备信息 deviceInfo, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - // Device not found, create new user and device - userInfo, err = l.registerUserAndDevice(req) - if err != nil { - return nil, err + // 设备未找到,但需要检查认证方法是否已存在 + authMethod, authErr := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "device", req.Identifier) + if authErr != nil && !errors.Is(authErr, gorm.ErrRecordNotFound) { + l.Errorw("query auth method failed", + logger.Field("identifier", req.Identifier), + logger.Field("error", authErr.Error()), + ) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query auth method failed: %v", authErr.Error()) + } + + if authMethod != nil { + // 认证方法存在但设备记录不存在,可能是数据不一致,获取用户信息并重新创建设备记录 + userInfo, err = l.svcCtx.UserModel.FindOne(l.ctx, authMethod.UserId) + if err != nil { + l.Errorw("query user by auth method failed", + logger.Field("user_id", authMethod.UserId), + logger.Field("identifier", req.Identifier), + logger.Field("error", err.Error()), + ) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user failed: %v", err.Error()) + } + + // 重新创建缺失的设备记录 + deviceInfo := &user.Device{ + Ip: req.IP, + UserId: userInfo.Id, + UserAgent: req.UserAgent, + Identifier: req.Identifier, + Enabled: true, + Online: false, + } + if err := l.svcCtx.UserModel.InsertDevice(l.ctx, deviceInfo); err != nil { + l.Errorw("failed to recreate device record", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", req.Identifier), + logger.Field("error", err.Error()), + ) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "recreate device record failed: %v", err) + } + + l.Infow("found existing auth method without device record, recreated device record", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", req.Identifier), + logger.Field("device_id", deviceInfo.Id), + ) + } else { + // 设备和认证方法都不存在,创建新用户和设备 + userInfo, err = l.registerUserAndDevice(req) + if err != nil { + return nil, err + } } } else { l.Errorw("query device failed", @@ -84,7 +135,7 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device failed: %v", err.Error()) } } else { - // Device found, get user info + // 设备已存在,获取用户信息 userInfo, err = l.svcCtx.UserModel.FindOne(l.ctx, deviceInfo.UserId) if err != nil { l.Errorw("query user failed", @@ -95,10 +146,10 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ } } - // Generate session id + // 生成会话ID sessionId := uuidx.NewUUID().String() - // Generate token + // 生成JWT令牌 token, err := jwt.NewJwtToken( l.svcCtx.Config.JwtAuth.AccessSecret, time.Now().Unix(), @@ -115,7 +166,7 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error()) } - // Store session id in redis + // 将会话ID存储到Redis中 sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil { l.Errorw("set session id error", @@ -131,6 +182,7 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ }, nil } +// registerUserAndDevice 注册新用户和设备 func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) (*user.User, error) { l.Infow("device not found, creating new user and device", logger.Field("identifier", req.Identifier), @@ -138,8 +190,9 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) ) var userInfo *user.User + // 使用数据库事务确保数据一致性 err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error { - // Create new user + // 创建新用户 userInfo = &user.User{ OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase, } @@ -150,7 +203,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create user failed: %v", err) } - // Update refer code + // 更新用户邀请码 userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id) if err := db.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil { l.Errorw("failed to update refer code", @@ -160,7 +213,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err) } - // Create device auth method + // 创建设备认证方式记录 authMethod := &user.AuthMethods{ UserId: userInfo.Id, AuthType: "device", @@ -176,7 +229,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create device auth method failed: %v", err) } - // Insert device record + // 插入设备记录 deviceInfo := &user.Device{ Ip: req.IP, UserId: userInfo.Id, @@ -194,7 +247,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert device failed: %v", err) } - // Activate trial if enabled + // 如果启用了试用,则激活试用订阅 if l.svcCtx.Config.Register.EnableTrial { if err := l.activeTrial(userInfo.Id, db); err != nil { return err @@ -218,7 +271,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) logger.Field("refer_code", userInfo.ReferCode), ) - // Register log + // 记录注册日志 registerLog := log.Register{ AuthMethod: "device", Identifier: req.Identifier, @@ -244,7 +297,9 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) return userInfo, nil } +// activeTrial 激活试用订阅 func (l *DeviceLoginLogic) activeTrial(userId int64, db *gorm.DB) error { + // 查找试用订阅模板 sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, l.svcCtx.Config.Register.TrialSubscribe) if err != nil { l.Errorw("failed to find trial subscription template", @@ -255,11 +310,13 @@ func (l *DeviceLoginLogic) activeTrial(userId int64, db *gorm.DB) error { return err } + // 计算试用期时间 startTime := time.Now() expireTime := tool.AddTime(l.svcCtx.Config.Register.TrialTimeUnit, l.svcCtx.Config.Register.TrialTime, startTime) subscribeToken := uuidx.SubscribeToken(fmt.Sprintf("Trial-%v", userId)) subscribeUUID := uuidx.NewUUID().String() + // 创建用户订阅记录 userSub := &user.Subscribe{ UserId: userId, OrderId: 0, diff --git a/internal/logic/auth/resetPasswordLogic.go b/internal/logic/auth/resetPasswordLogic.go index c4e2ece..aef245a 100644 --- a/internal/logic/auth/resetPasswordLogic.go +++ b/internal/logic/auth/resetPasswordLogic.go @@ -104,7 +104,8 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res // Update password userInfo.Password = tool.EncodePassWord(req.Password) - if err := l.svcCtx.UserModel.Update(l.ctx, userInfo); err != nil { + userInfo.Algo = "default" + if err = l.svcCtx.UserModel.Update(l.ctx, userInfo); err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update user info failed: %v", err.Error()) } diff --git a/internal/logic/auth/telephoneLoginLogic.go b/internal/logic/auth/telephoneLoginLogic.go index 737157f..8a54ff5 100644 --- a/internal/logic/auth/telephoneLoginLogic.go +++ b/internal/logic/auth/telephoneLoginLogic.go @@ -98,7 +98,7 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r if req.TelephoneCode == "" { // Verify password - if !tool.VerifyPassWord(req.Password, userInfo.Password) { + if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) { return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password") } } else { diff --git a/internal/logic/auth/telephoneResetPasswordLogic.go b/internal/logic/auth/telephoneResetPasswordLogic.go index f119c55..5cb47cc 100644 --- a/internal/logic/auth/telephoneResetPasswordLogic.go +++ b/internal/logic/auth/telephoneResetPasswordLogic.go @@ -78,6 +78,7 @@ func (l *TelephoneResetPasswordLogic) TelephoneResetPassword(req *types.Telephon // Generate password pwd := tool.EncodePassWord(req.Password) userInfo.Password = pwd + userInfo.Algo = "default" err = l.svcCtx.UserModel.Update(l.ctx, userInfo) if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "update user password failed: %v", err.Error()) diff --git a/internal/logic/auth/telephoneUserRegisterLogic.go b/internal/logic/auth/telephoneUserRegisterLogic.go index aa2dde9..af16811 100644 --- a/internal/logic/auth/telephoneUserRegisterLogic.go +++ b/internal/logic/auth/telephoneUserRegisterLogic.go @@ -107,6 +107,7 @@ func (l *TelephoneUserRegisterLogic) TelephoneUserRegister(req *types.TelephoneR pwd := tool.EncodePassWord(req.Password) userInfo := &user.User{ Password: pwd, + Algo: "default", OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase, AuthMethods: []user.AuthMethods{ { diff --git a/internal/logic/auth/userRegisterLogic.go b/internal/logic/auth/userRegisterLogic.go index 0b9da43..cf959a9 100644 --- a/internal/logic/auth/userRegisterLogic.go +++ b/internal/logic/auth/userRegisterLogic.go @@ -90,6 +90,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp * pwd := tool.EncodePassWord(req.Password) userInfo := &user.User{ Password: pwd, + Algo: "default", OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase, } if referer != nil { diff --git a/internal/logic/notify/ePayNotifyLogic.go b/internal/logic/notify/ePayNotifyLogic.go index 8def591..efdd127 100644 --- a/internal/logic/notify/ePayNotifyLogic.go +++ b/internal/logic/notify/ePayNotifyLogic.go @@ -57,7 +57,7 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error { return err } // Verify sign - client := epay.NewClient(config.Pid, config.Url, config.Key) + client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type) if !client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) && !l.svcCtx.Config.Debug { l.Logger.Error("[EPayNotify] Verify sign failed") return nil diff --git a/internal/logic/public/order/closeOrderLogic.go b/internal/logic/public/order/closeOrderLogic.go index fb1cfc8..ced53b8 100644 --- a/internal/logic/public/order/closeOrderLogic.go +++ b/internal/logic/public/order/closeOrderLogic.go @@ -61,7 +61,18 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error { ) return err } - // All orders now have associated users, no special handling needed for guest orders + // If User ID is 0, it means that the order is a guest order and does not need to be refunded, the order can be deleted directly + if orderInfo.UserId == 0 { + err = tx.Model(&order.Order{}).Where("order_no = ?", req.OrderNo).Delete(&order.Order{}).Error + if err != nil { + l.Errorw("[CloseOrder] Delete order failed", + logger.Field("error", err.Error()), + logger.Field("orderNo", req.OrderNo), + ) + return err + } + return nil + } // refund deduction amount to user deduction balance if orderInfo.GiftAmount > 0 { userInfo, err := l.svcCtx.UserModel.FindOne(l.ctx, orderInfo.UserId) diff --git a/internal/logic/public/order/getDiscount.go b/internal/logic/public/order/getDiscount.go index 2278005..34c16a9 100644 --- a/internal/logic/public/order/getDiscount.go +++ b/internal/logic/public/order/getDiscount.go @@ -3,7 +3,7 @@ package order import "github.com/perfect-panel/server/internal/types" func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64 { - var finalDiscount float64 = 1.0 + var finalDiscount int64 = 100 for _, discount := range discounts { if inputMonths >= discount.Quantity && discount.Discount < finalDiscount { @@ -11,5 +11,5 @@ func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64 } } - return finalDiscount + return float64(finalDiscount) / float64(100) } diff --git a/internal/logic/public/portal/getSubscriptionLogic.go b/internal/logic/public/portal/getSubscriptionLogic.go index 022b737..ad0bb9d 100644 --- a/internal/logic/public/portal/getSubscriptionLogic.go +++ b/internal/logic/public/portal/getSubscriptionLogic.go @@ -29,6 +29,43 @@ func NewGetSubscriptionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *G } } +func (l *GetSubscriptionLogic) GetSubscription1(req *types.GetSubscriptionRequest) (resp *types.GetSubscriptionResponse, err error) { + resp = &types.GetSubscriptionResponse{ + List: make([]types.Subscribe, 0), + } + // Get the subscription list + _, data, err := l.svcCtx.SubscribeModel.FilterList(l.ctx, &subscribe.FilterParams{ + Page: 1, + Size: 9999, + Show: true, + Language: req.Language, + DefaultLanguage: true, + }) + if err != nil { + l.Errorw("[Site GetSubscription]", logger.Field("err", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get subscription list error: %v", err.Error()) + } + list := make([]types.Subscribe, len(data)) + for i, item := range data { + var sub types.Subscribe + tool.DeepCopy(&sub, item) + if item.Discount != "" { + var discount []types.SubscribeDiscount + _ = json.Unmarshal([]byte(item.Discount), &discount) + sub.Discount = discount + list[i] = sub + } + list[i] = sub + } + resp.List = list + return +} + +// CountNodesByIdsAndTags 根据节点ID和标签计算启用的节点数量 +func (l *GetSubscriptionLogic) CountNodesByIdsAndTags(ctx context.Context, nodeIds []int64, tags []string) (int64, error) { + return l.svcCtx.NodeModel.CountNodesByIdsAndTags(ctx, nodeIds, tags) +} + func (l *GetSubscriptionLogic) GetSubscription(req *types.GetSubscriptionRequest) (resp *types.GetSubscriptionResponse, err error) { resp = &types.GetSubscriptionResponse{ List: make([]types.Subscribe, 0), @@ -54,7 +91,7 @@ func (l *GetSubscriptionLogic) GetSubscription(req *types.GetSubscriptionRequest _ = json.Unmarshal([]byte(item.Discount), &discount) sub.Discount = discount } - + // 计算节点数量 nodeIds := tool.StringToInt64Slice(item.Nodes) var nodeTags []string @@ -66,7 +103,7 @@ func (l *GetSubscriptionLogic) GetSubscription(req *types.GetSubscriptionRequest } } } - + nodeCount, err := l.svcCtx.NodeModel.CountNodesByIdsAndTags(l.ctx, nodeIds, nodeTags) if err != nil { l.Logger.Error("[GetSubscription] count nodes failed: ", logger.Field("error", err.Error())) @@ -74,7 +111,7 @@ func (l *GetSubscriptionLogic) GetSubscription(req *types.GetSubscriptionRequest } else { sub.NodeCount = nodeCount } - + list[i] = sub } resp.List = list diff --git a/internal/logic/public/portal/purchaseCheckoutLogic.go b/internal/logic/public/portal/purchaseCheckoutLogic.go index f72ed4f..f016a49 100644 --- a/internal/logic/public/portal/purchaseCheckoutLogic.go +++ b/internal/logic/public/portal/purchaseCheckoutLogic.go @@ -267,7 +267,7 @@ func (l *PurchaseCheckoutLogic) epayPayment(config *payment.Payment, info *order return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Unmarshal error: %s", err.Error()) } // Initialize EPay client with merchant credentials - client := epay.NewClient(epayConfig.Pid, epayConfig.Url, epayConfig.Key) + client := epay.NewClient(epayConfig.Pid, epayConfig.Url, epayConfig.Key, epayConfig.Type) // Convert order amount to CNY using current exchange rate amount, err := l.queryExchangeRate("CNY", info.Amount) @@ -309,7 +309,7 @@ func (l *PurchaseCheckoutLogic) CryptoSaaSPayment(config *payment.Payment, info return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Unmarshal error: %s", err.Error()) } // Initialize EPay client with merchant credentials - client := epay.NewClient(epayConfig.AccountID, epayConfig.Endpoint, epayConfig.SecretKey) + client := epay.NewClient(epayConfig.AccountID, epayConfig.Endpoint, epayConfig.SecretKey, epayConfig.Type) // Convert order amount to CNY using current exchange rate amount, err := l.queryExchangeRate("CNY", info.Amount) @@ -347,6 +347,11 @@ func (l *PurchaseCheckoutLogic) queryExchangeRate(to string, src int64) (amount // Convert cents to decimal amount amount = float64(src) / float64(100) + if l.svcCtx.ExchangeRate != 0 && to == "CNY" { + amount = amount * l.svcCtx.ExchangeRate + return amount, nil + } + // Retrieve system currency configuration currency, err := l.svcCtx.SystemModel.GetCurrencyConfig(l.ctx) if err != nil { diff --git a/internal/logic/public/portal/purchaseLogic.go b/internal/logic/public/portal/purchaseLogic.go index 2c0cd69..322f94c 100644 --- a/internal/logic/public/portal/purchaseLogic.go +++ b/internal/logic/public/portal/purchaseLogic.go @@ -83,6 +83,12 @@ func (l *PurchaseLogic) Purchase(req *types.PortalPurchaseRequest) (resp *types. if couponInfo.Count != 0 && couponInfo.Count <= couponInfo.UsedCount { return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponInsufficientUsage), "coupon used") } + // Check expiration time + expireTime := time.Unix(couponInfo.ExpireTime, 0) + if time.Now().After(expireTime) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponExpired), "coupon expired") + } + couponSub := tool.StringToInt64Slice(couponInfo.Subscribe) if len(couponSub) > 0 && !tool.Contains(couponSub, req.SubscribeId) { return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponNotApplicable), "coupon not match") diff --git a/internal/logic/public/portal/tool.go b/internal/logic/public/portal/tool.go index 0f79310..c2d2bbd 100644 --- a/internal/logic/public/portal/tool.go +++ b/internal/logic/public/portal/tool.go @@ -7,14 +7,14 @@ import ( ) func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64 { - var finalDiscount float64 = 1.0 + var finalDiscount int64 = 100 for _, discount := range discounts { if inputMonths >= discount.Quantity && discount.Discount < finalDiscount { finalDiscount = discount.Discount } } - return finalDiscount + return float64(finalDiscount) / float64(100) } func calculateCoupon(amount int64, couponInfo *coupon.Coupon) int64 { diff --git a/internal/logic/public/subscribe/querySubscribeListLogic.go b/internal/logic/public/subscribe/querySubscribeListLogic.go index 7cf40b0..2208559 100644 --- a/internal/logic/public/subscribe/querySubscribeListLogic.go +++ b/internal/logic/public/subscribe/querySubscribeListLogic.go @@ -3,7 +3,6 @@ package subscribe import ( "context" "encoding/json" - "strings" "github.com/perfect-panel/server/internal/model/subscribe" "github.com/perfect-panel/server/internal/svc" @@ -54,35 +53,8 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi var discount []types.SubscribeDiscount _ = json.Unmarshal([]byte(item.Discount), &discount) sub.Discount = discount + list[i] = sub } - - // 计算节点数量 - var nodeIds []int64 - var tags []string - - // 解析节点ID - if item.Nodes != "" { - nodeIds = tool.StringToInt64Slice(item.Nodes) - } - - // 解析标签 - if item.NodeTags != "" { - tagStrs := strings.Split(item.NodeTags, ",") - for _, tag := range tagStrs { - if tag != "" { - tags = append(tags, tag) - } - } - } - - // 获取节点数量 - nodeCount, err := l.svcCtx.NodeModel.CountNodesByIdsAndTags(l.ctx, nodeIds, tags) - if err != nil { - l.Errorw("[QuerySubscribeListLogic] Count nodes failed", logger.Field("error", err.Error()), logger.Field("subscribeId", item.Id)) - nodeCount = 0 // 出错时设置为0 - } - sub.NodeCount = nodeCount - list[i] = sub } resp.List = list diff --git a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go index 1d9769d..2ad05b8 100644 --- a/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go +++ b/internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go @@ -141,7 +141,6 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u Name: n.Name, Uuid: userSub.UUID, Protocol: n.Protocol, - Protocols: server.Protocols, Port: n.Port, Address: n.Address, Tags: strings.Split(n.Tags, ","), diff --git a/internal/logic/public/user/bindEmailWithPasswordLogic.go b/internal/logic/public/user/bindEmailWithPasswordLogic.go deleted file mode 100644 index 20ff2e0..0000000 --- a/internal/logic/public/user/bindEmailWithPasswordLogic.go +++ /dev/null @@ -1,175 +0,0 @@ -package user - -import ( - "context" - "fmt" - "time" - - "github.com/perfect-panel/server/internal/logic/auth" - "github.com/perfect-panel/server/pkg/constant" - "github.com/perfect-panel/server/pkg/tool" - - "github.com/perfect-panel/server/internal/config" - "github.com/perfect-panel/server/internal/model/user" - "github.com/perfect-panel/server/pkg/jwt" - "github.com/perfect-panel/server/pkg/uuidx" - "github.com/perfect-panel/server/pkg/xerr" - "github.com/pkg/errors" - "gorm.io/gorm" - - "github.com/perfect-panel/server/internal/svc" - "github.com/perfect-panel/server/internal/types" - "github.com/perfect-panel/server/pkg/logger" -) - -type BindEmailWithPasswordLogic struct { - logger.Logger - ctx context.Context - svcCtx *svc.ServiceContext -} - -// NewBindEmailWithPasswordLogic Bind Email With Password -func NewBindEmailWithPasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BindEmailWithPasswordLogic { - return &BindEmailWithPasswordLogic{ - Logger: logger.WithContext(ctx), - ctx: ctx, - svcCtx: svcCtx, - } -} - -func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailWithPasswordRequest) (*types.LoginResponse, error) { - // 获取当前设备用户 - currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) - if !ok { - logger.Error("current user is not found in context") - return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") - } - - // 验证邮箱和密码是否匹配现有用户 - emailUser, err := l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "email not registered: %v", req.Email) - } - logger.WithContext(l.ctx).Error(err) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user by email failed: %v", err.Error()) - } - - // 验证密码 - if !tool.VerifyPassWord(req.Password, emailUser.Password) { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "password incorrect") - } - - // 检查当前用户是否已经绑定了邮箱 - currentEmailMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", currentUser.Id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId error") - } - - // 最终用户ID(可能是当前用户或邮箱用户) - finalUserId := currentUser.Id - - // 如果当前用户已经绑定了邮箱,检查是否是同一个邮箱 - if currentEmailMethod.Id > 0 { - // 如果绑定的是同一个邮箱,直接生成Token返回 - if currentEmailMethod.AuthIdentifier == req.Email { - l.Infow("user is binding the same email that is already bound", - logger.Field("user_id", currentUser.Id), - logger.Field("email", req.Email), - ) - // 直接使用当前用户ID生成Token - } else { - // 如果是不同的邮箱,不允许重复绑定 - return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "current user already has email bound") - } - } else { - // 检查该邮箱是否已经被其他用户绑定 - existingEmailMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error") - } - - // 如果邮箱已经被其他用户绑定,需要进行数据迁移 - if existingEmailMethod.Id > 0 && existingEmailMethod.UserId != currentUser.Id { - // 调用设备绑定逻辑,这会触发数据迁移 - bindLogic := auth.NewBindDeviceLogic(l.ctx, l.svcCtx) - - // 获取当前用户的设备标识符 - deviceMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "device", currentUser.Id) - if err != nil { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId device error") - } - - if deviceMethod.Id == 0 { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "current user has no device identifier") - } - - // 执行设备重新绑定,这会触发数据迁移 - if err := bindLogic.BindDeviceToUser(deviceMethod.AuthIdentifier, "", "", emailUser.Id); err != nil { - l.Errorw("failed to bind device to email user", - logger.Field("current_user_id", currentUser.Id), - logger.Field("email_user_id", emailUser.Id), - logger.Field("device_identifier", deviceMethod.AuthIdentifier), - logger.Field("error", err.Error()), - ) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "bind device to email user failed") - } - - l.Infow("successfully bound device to email user with data migration", - logger.Field("current_user_id", currentUser.Id), - logger.Field("email_user_id", emailUser.Id), - logger.Field("device_identifier", deviceMethod.AuthIdentifier), - ) - - // 数据迁移后,使用邮箱用户的ID - finalUserId = emailUser.Id - } else { - // 邮箱未被绑定,直接为当前用户创建邮箱绑定 - emailMethod := &user.AuthMethods{ - UserId: currentUser.Id, - AuthType: "email", - AuthIdentifier: req.Email, - Verified: true, // 通过密码验证,直接设为已验证 - } - - if err := l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, emailMethod); err != nil { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error") - } - - l.Infow("successfully bound email to current user", - logger.Field("user_id", currentUser.Id), - logger.Field("email", req.Email), - ) - } - } - - // 生成新的Token - sessionId := uuidx.NewUUID().String() - loginType := "device" - if l.ctx.Value(constant.LoginType) != nil { - loginType = l.ctx.Value(constant.LoginType).(string) - } - - token, err := jwt.NewJwtToken( - l.svcCtx.Config.JwtAuth.AccessSecret, - time.Now().Unix(), - l.svcCtx.Config.JwtAuth.AccessExpire, - jwt.WithOption("UserId", finalUserId), - jwt.WithOption("SessionId", sessionId), - jwt.WithOption("LoginType", loginType), - ) - if err != nil { - l.Logger.Error("[BindEmailWithPassword] token generate error", logger.Field("error", err.Error())) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error()) - } - - // 设置session缓存 - sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) - if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, finalUserId, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error()) - } - - return &types.LoginResponse{ - Token: token, - }, nil -} diff --git a/internal/logic/public/user/bindEmailWithVerificationLogic.go b/internal/logic/public/user/bindEmailWithVerificationLogic.go new file mode 100644 index 0000000..9ba37f5 --- /dev/null +++ b/internal/logic/public/user/bindEmailWithVerificationLogic.go @@ -0,0 +1,341 @@ +package user + +import ( + "context" + "fmt" + "time" + + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/jwt" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type BindEmailWithVerificationLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// NewBindEmailWithVerificationLogic Bind Email With Verification +func NewBindEmailWithVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BindEmailWithVerificationLogic { + return &BindEmailWithVerificationLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.BindEmailWithVerificationRequest) (*types.BindEmailWithVerificationResponse, error) { + // 获取当前用户 + u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok { + logger.Error("current user is not found in context") + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + + // 获取当前用户的设备标识符 + deviceIdentifier, err := l.getCurrentUserDeviceIdentifier(l.ctx, u.Id) + if err != nil { + l.Errorw("获取用户设备标识符失败", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "获取用户设备信息失败: %v", err) + } + + // 检查邮箱是否已被其他用户绑定 + existingMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + l.Errorw("查询邮箱绑定状态失败", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "查询邮箱绑定状态失败") + } + + var emailUserId int64 + + if existingMethod != nil { + // 邮箱已存在,使用现有的邮箱用户 + emailUserId = existingMethod.UserId + l.Infow("邮箱已存在,将设备转移到现有邮箱用户", + logger.Field("email", req.Email), + logger.Field("email_user_id", emailUserId)) + } else { + // 邮箱不存在,创建新的邮箱用户 + emailUserId, err = l.createEmailUser(req.Email) + if err != nil { + l.Errorw("创建邮箱用户失败", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "创建邮箱用户失败: %v", err) + } + l.Infow("创建新的邮箱用户", + logger.Field("email", req.Email), + logger.Field("email_user_id", emailUserId)) + } + + // 执行设备转移到邮箱用户 + return l.transferDeviceToEmailUser(u.Id, emailUserId, deviceIdentifier) +} + +// getCurrentUserDeviceIdentifier 获取当前用户的设备标识符 +func (l *BindEmailWithVerificationLogic) getCurrentUserDeviceIdentifier(ctx context.Context, userId int64) (string, error) { + authMethods, err := l.svcCtx.UserModel.FindUserAuthMethods(ctx, userId) + if err != nil { + return "", err + } + + // 查找设备认证方式 + for _, method := range authMethods { + if method.AuthType == "device" { + return method.AuthIdentifier, nil + } + } + + return "", errors.New("用户没有设备认证方式") +} + +// checkIfPureDeviceUser 检查用户是否为纯设备用户(只有设备认证方式) +func (l *BindEmailWithVerificationLogic) checkIfPureDeviceUser(ctx context.Context, userId int64) (bool, string, error) { + authMethods, err := l.svcCtx.UserModel.FindUserAuthMethods(ctx, userId) + if err != nil { + l.Errorw("查询用户认证方式失败", logger.Field("error", err.Error()), logger.Field("user_id", userId)) + return false, "", errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "查询用户认证方式失败") + } + + // 检查是否只有一个设备认证方式 + if len(authMethods) == 1 && authMethods[0].AuthType == "device" { + return true, authMethods[0].AuthIdentifier, nil + } + + return false, "", nil +} + +// transferDeviceToEmailUser 将设备从设备用户转移到邮箱用户 +func (l *BindEmailWithVerificationLogic) transferDeviceToEmailUser(deviceUserId, emailUserId int64, deviceIdentifier string) (*types.BindEmailWithVerificationResponse, error) { + l.Infow("开始设备转移", + logger.Field("device_user_id", deviceUserId), + logger.Field("email_user_id", emailUserId), + logger.Field("device_identifier", deviceIdentifier)) + + // 1. 先获取当前用户的SessionId,用于后续清理 + currentSessionId := "" + if sessionIdValue := l.ctx.Value(constant.CtxKeySessionID); sessionIdValue != nil { + currentSessionId = sessionIdValue.(string) + } + + // 2. 在事务中执行设备转移 + err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error { + // 1. 检查目标邮箱用户状态 + _, err := l.svcCtx.UserModel.FindOne(l.ctx, emailUserId) + if err != nil { + l.Errorw("查询邮箱用户失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId)) + return err + } + + // 2. 检查设备是否已经关联到目标用户 + existingDevice, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, deviceIdentifier) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + l.Errorw("查询设备信息失败", logger.Field("error", err.Error()), logger.Field("device_identifier", deviceIdentifier)) + return err + } + + if existingDevice != nil && existingDevice.UserId == emailUserId { + // 设备已经关联到目标用户,直接生成token + l.Infow("设备已关联到目标用户", logger.Field("device_id", existingDevice.Id)) + return nil + } + + // 3. 处理设备冲突 - 删除目标用户的现有设备记录(如果存在) + if existingDevice != nil && existingDevice.UserId != emailUserId { + l.Infow("删除冲突的设备记录", logger.Field("existing_device_id", existingDevice.Id), logger.Field("existing_user_id", existingDevice.UserId)) + if err := db.Where("identifier = ? AND user_id = ?", deviceIdentifier, existingDevice.UserId).Delete(&user.Device{}).Error; err != nil { + l.Errorw("删除冲突设备记录失败", logger.Field("error", err.Error())) + return err + } + } + + // 4. 更新user_auth_methods表 - 将设备认证方式转移到邮箱用户 + if err := db.Model(&user.AuthMethods{}). + Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", deviceUserId, "device", deviceIdentifier). + Update("user_id", emailUserId).Error; err != nil { + l.Errorw("更新设备认证方式失败", logger.Field("error", err.Error())) + return err + } + + // 5. 更新user_device表 - 将设备记录转移到邮箱用户 + if err := db.Model(&user.Device{}). + Where("user_id = ? AND identifier = ?", deviceUserId, deviceIdentifier). + Update("user_id", emailUserId).Error; err != nil { + l.Errorw("更新设备记录失败", logger.Field("error", err.Error())) + return err + } + + // 6. 检查原始设备用户是否还有其他认证方式,如果没有则删除该用户 + var remainingAuthMethods []user.AuthMethods + if err := db.Where("user_id = ?", deviceUserId).Find(&remainingAuthMethods).Error; err != nil { + l.Errorw("查询原始用户剩余认证方式失败", logger.Field("error", err.Error()), logger.Field("device_user_id", deviceUserId)) + return err + } + + if len(remainingAuthMethods) == 0 { + // 获取原始用户信息用于清除缓存 + deviceUser, _ := l.svcCtx.UserModel.FindOne(l.ctx, deviceUserId) + + // 原始用户没有其他认证方式,可以安全删除 + if err := db.Where("id = ?", deviceUserId).Delete(&user.User{}).Error; err != nil { + l.Errorw("删除原始设备用户失败", logger.Field("error", err.Error()), logger.Field("device_user_id", deviceUserId)) + return err + } + + // 清除已删除用户的缓存 + if deviceUser != nil { + l.svcCtx.UserModel.ClearUserCache(l.ctx, deviceUser) + } + + l.Infow("已删除原始设备用户", logger.Field("device_user_id", deviceUserId)) + } else { + l.Infow("原始用户还有其他认证方式,保留用户记录", + logger.Field("device_user_id", deviceUserId), + logger.Field("remaining_auth_count", len(remainingAuthMethods))) + } + + l.Infow("设备转移成功", + logger.Field("device_user_id", deviceUserId), + logger.Field("email_user_id", emailUserId), + logger.Field("device_identifier", deviceIdentifier)) + + return nil + }) + + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "设备转移失败: %v", err) + } + + // 3. 清理原用户的SessionId缓存(使旧token失效) + if currentSessionId != "" { + sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, currentSessionId) + if err := l.svcCtx.Redis.Del(l.ctx, sessionKey).Err(); err != nil { + l.Errorw("清理原SessionId缓存失败", logger.Field("error", err.Error()), logger.Field("session_id", currentSessionId)) + // 不返回错误,继续执行 + } else { + l.Infow("已清理原SessionId缓存", logger.Field("session_id", currentSessionId)) + } + } + + // 4. 生成新的JWT token + token, err := l.generateTokenForUser(emailUserId) + if err != nil { + return nil, err + } + + // 5. 清除邮箱用户缓存(确保获取最新数据) + emailUser, _ := l.svcCtx.UserModel.FindOne(l.ctx, emailUserId) + if emailUser != nil { + l.svcCtx.UserModel.ClearUserCache(l.ctx, emailUser) + } + + // 6. 清除设备相关缓存 + l.clearDeviceRelatedCache(deviceIdentifier, deviceUserId, emailUserId) + + return &types.BindEmailWithVerificationResponse{ + Success: true, + Message: "设备关联成功", + Token: token, + UserId: emailUserId, + }, nil +} + +// generateTokenForUser 为指定用户生成JWT token +func (l *BindEmailWithVerificationLogic) generateTokenForUser(userId int64) (string, error) { + // 生成JWT token + now := time.Now().Unix() + accessExpire := l.svcCtx.Config.JwtAuth.AccessExpire + sessionId := fmt.Sprintf("device_transfer_%d_%d", userId, now) + + jwtToken, err := jwt.NewJwtToken( + l.svcCtx.Config.JwtAuth.AccessSecret, + now, + accessExpire, + jwt.WithOption("UserId", userId), + jwt.WithOption("SessionId", sessionId), + ) + if err != nil { + l.Errorw("生成JWT token失败", logger.Field("error", err.Error()), logger.Field("user_id", userId)) + return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "生成token失败: %v", err) + } + + // 设置session缓存 + sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) + if err := l.svcCtx.Redis.Set(l.ctx, sessionKey, userId, time.Duration(accessExpire)*time.Second).Err(); err != nil { + l.Errorw("设置session缓存失败", logger.Field("error", err.Error()), logger.Field("user_id", userId)) + // session缓存失败不影响token生成,只记录错误 + } + + l.Infow("为用户生成token成功", logger.Field("user_id", userId)) + return jwtToken, nil +} + +// createEmailUser 创建新的邮箱用户 +func (l *BindEmailWithVerificationLogic) createEmailUser(email string) (int64, error) { + var newUserId int64 + + err := l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error { + // 1. 创建新用户 + enabled := true + newUser := &user.User{ + Enable: &enabled, // 启用状态 + } + if err := tx.Create(newUser).Error; err != nil { + l.Errorw("创建用户失败", logger.Field("error", err.Error())) + return err + } + + newUserId = newUser.Id + l.Infow("创建新用户成功", logger.Field("user_id", newUserId)) + + // 2. 创建邮箱认证方法 + emailAuth := &user.AuthMethods{ + UserId: newUserId, + AuthType: "email", + AuthIdentifier: email, + Verified: true, // 直接设置为已验证 + } + if err := tx.Create(emailAuth).Error; err != nil { + l.Errorw("创建邮箱认证方法失败", logger.Field("error", err.Error())) + return err + } + + l.Infow("创建邮箱认证方法成功", + logger.Field("user_id", newUserId), + logger.Field("email", email)) + + return nil + }) + + if err != nil { + return 0, err + } + + return newUserId, nil +} + +// clearDeviceRelatedCache 清除设备相关缓存 +func (l *BindEmailWithVerificationLogic) clearDeviceRelatedCache(deviceIdentifier string, oldUserId, newUserId int64) { + // 清除设备相关的缓存键 + deviceCacheKeys := []string{ + fmt.Sprintf("device:%s", deviceIdentifier), + fmt.Sprintf("user_device:%d", oldUserId), + fmt.Sprintf("user_device:%d", newUserId), + fmt.Sprintf("user_auth:%d", oldUserId), + fmt.Sprintf("user_auth:%d", newUserId), + } + + for _, key := range deviceCacheKeys { + if err := l.svcCtx.Redis.Del(l.ctx, key).Err(); err != nil { + l.Errorw("清除设备缓存失败", logger.Field("error", err.Error()), logger.Field("cache_key", key)) + } else { + l.Infow("已清除设备缓存", logger.Field("cache_key", key)) + } + } +} diff --git a/internal/logic/public/user/unbindDeviceLogic.go b/internal/logic/public/user/unbindDeviceLogic.go index 57218cc..fcbe316 100644 --- a/internal/logic/public/user/unbindDeviceLogic.go +++ b/internal/logic/public/user/unbindDeviceLogic.go @@ -41,7 +41,11 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error { return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "device not belong to user") } - return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error { + // 保存设备信息用于后续缓存清理 + deviceIdentifier := device.Identifier + userId := device.UserId + + err = l.svcCtx.DB.Transaction(func(tx *gorm.DB) error { var deleteDevice user.Device err = tx.Model(&deleteDevice).Where("id = ?", req.Id).First(&deleteDevice).Error if err != nil { @@ -55,6 +59,9 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error { err = tx.Model(&userAuth).Where("auth_identifier = ? and auth_type = ?", deleteDevice.Identifier, "device").First(&userAuth).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { + l.Infow("设备认证方法不存在,可能已被删除", + logger.Field("device_identifier", deleteDevice.Identifier), + logger.Field("user_id", userId)) return nil } return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find device online record err: %v", err) @@ -64,9 +71,69 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error { if err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete device online record err: %v", err) } - sessionId := l.ctx.Value(constant.CtxKeySessionID) - sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) - l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey) + + l.Infow("设备解绑成功", + logger.Field("device_id", req.Id), + logger.Field("device_identifier", deviceIdentifier), + logger.Field("user_id", userId)) + return nil }) + + if err != nil { + return err + } + + // 事务成功后进行缓存清理 + l.clearUnbindDeviceCache(deviceIdentifier, userId, userInfo) + + return nil +} + +// clearUnbindDeviceCache 清除设备解绑相关的缓存 +func (l *UnbindDeviceLogic) clearUnbindDeviceCache(deviceIdentifier string, userId int64, userInfo *user.User) { + // 1. 清除当前SessionId缓存(使当前token失效) + if sessionId := l.ctx.Value(constant.CtxKeySessionID); sessionId != nil { + sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId) + if err := l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey).Err(); err != nil { + l.Errorw("清理SessionId缓存失败", + logger.Field("error", err.Error()), + logger.Field("session_id", sessionId)) + } else { + l.Infow("已清理SessionId缓存", logger.Field("session_id", sessionId)) + } + } + + // 2. 清除用户缓存 + if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); err != nil { + l.Errorw("清理用户缓存失败", + logger.Field("error", err.Error()), + logger.Field("user_id", userId)) + } else { + l.Infow("已清理用户缓存", logger.Field("user_id", userId)) + } + + // 3. 清除设备相关缓存 + l.clearDeviceRelatedCache(deviceIdentifier, userId) +} + +// clearDeviceRelatedCache 清除设备相关缓存 +func (l *UnbindDeviceLogic) clearDeviceRelatedCache(deviceIdentifier string, userId int64) { + // 清除设备相关的缓存键 + deviceCacheKeys := []string{ + fmt.Sprintf("device:%s", deviceIdentifier), + fmt.Sprintf("user_device:%d", userId), + fmt.Sprintf("user_auth:%d", userId), + fmt.Sprintf("device_auth:%s", deviceIdentifier), + } + + for _, key := range deviceCacheKeys { + if err := l.svcCtx.Redis.Del(l.ctx, key).Err(); err != nil { + l.Errorw("清除设备缓存失败", + logger.Field("error", err.Error()), + logger.Field("cache_key", key)) + } else { + l.Infow("已清除设备缓存", logger.Field("cache_key", key)) + } + } } diff --git a/internal/logic/public/user/updateBindEmailLogic.go b/internal/logic/public/user/updateBindEmailLogic.go index b789d05..f56ff8c 100644 --- a/internal/logic/public/user/updateBindEmailLogic.go +++ b/internal/logic/public/user/updateBindEmailLogic.go @@ -30,50 +30,37 @@ func NewUpdateBindEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *U } } -// UpdateBindEmail 更新用户绑定的邮箱地址 -// 该方法用于用户更新或绑定新的邮箱地址,支持首次绑定和修改已绑定邮箱 func (l *UpdateBindEmailLogic) UpdateBindEmail(req *types.UpdateBindEmailRequest) error { - // 从上下文中获取当前用户信息 u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) if !ok { logger.Error("current user is not found in context") return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } - - // 查询当前用户是否已有邮箱认证方式 method, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", u.Id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error") } - - // 检查要绑定的邮箱是否已被其他用户使用 m, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error") } - - // 如果邮箱已被绑定,返回错误 + // email already bind if m.Id > 0 { return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "email already bind") } - - // 如果用户还没有邮箱认证方式,创建新的认证记录 if method.Id == 0 { method = &user.AuthMethods{ - UserId: u.Id, // 用户ID - AuthType: "email", // 认证类型为邮箱 - AuthIdentifier: req.Email, // 邮箱地址 - Verified: false, // 初始状态为未验证 + UserId: u.Id, + AuthType: "email", + AuthIdentifier: req.Email, + Verified: false, } - // 插入新的认证方式记录 if err := l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, method); err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error") } } else { - // 如果用户已有邮箱认证方式,更新邮箱地址 - method.Verified = false // 重置验证状态 - method.AuthIdentifier = req.Email // 更新邮箱地址 - // 更新认证方式记录 + method.Verified = false + method.AuthIdentifier = req.Email if err := l.svcCtx.UserModel.UpdateUserAuthMethods(l.ctx, method); err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateUserAuthMethods error") } diff --git a/internal/model/payment/payment.go b/internal/model/payment/payment.go index 46ee0a0..ad2f046 100644 --- a/internal/model/payment/payment.go +++ b/internal/model/payment/payment.go @@ -85,9 +85,10 @@ func (l *AlipayF2FConfig) Unmarshal(data []byte) error { } type EPayConfig struct { - Pid string `json:"pid"` - Url string `json:"url"` - Key string `json:"key"` + Pid string `json:"pid"` + Url string `json:"url"` + Key string `json:"key"` + Type string `json:"type"` } func (l *EPayConfig) Marshal() ([]byte, error) { @@ -109,6 +110,7 @@ type CryptoSaaSConfig struct { Endpoint string `json:"endpoint"` AccountID string `json:"account_id"` SecretKey string `json:"secret_key"` + Type string `json:"type"` } func (l *CryptoSaaSConfig) Marshal() ([]byte, error) { diff --git a/internal/model/subscribe/subscribe.go b/internal/model/subscribe/subscribe.go index 6bdf9eb..a80ea63 100644 --- a/internal/model/subscribe/subscribe.go +++ b/internal/model/subscribe/subscribe.go @@ -71,8 +71,8 @@ func (s *Subscribe) BeforeUpdate(tx *gorm.DB) error { } type Discount struct { - Months int64 `json:"months"` - Discount float64 `json:"discount"` + Months int64 `json:"months"` + Discount int64 `json:"discount"` } type Group struct { diff --git a/internal/model/user/authMethod.go b/internal/model/user/authMethod.go index 18ce951..2655531 100644 --- a/internal/model/user/authMethod.go +++ b/internal/model/user/authMethod.go @@ -20,7 +20,10 @@ func (m *defaultUserModel) FindUserAuthMethodByOpenID(ctx context.Context, metho err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, v interface{}) error { return conn.Model(&AuthMethods{}).Where("auth_type = ? AND auth_identifier = ?", method, openID).First(&data).Error }) - return &data, err + if err != nil { + return nil, err + } + return &data, nil } func (m *defaultUserModel) FindUserAuthMethodByPlatform(ctx context.Context, userId int64, platform string) (*AuthMethods, error) { diff --git a/internal/model/user/model.go b/internal/model/user/model.go index d5fa385..dea27cd 100644 --- a/internal/model/user/model.go +++ b/internal/model/user/model.go @@ -77,7 +77,6 @@ type customUserLogicModel interface { QueryUserSubscribe(ctx context.Context, userId int64, status ...int64) ([]*SubscribeDetails, error) FindOneSubscribeDetailsById(ctx context.Context, id int64) (*SubscribeDetails, error) FindOneUserSubscribe(ctx context.Context, id int64) (*SubscribeDetails, error) - FindActiveSubscribe(ctx context.Context, userId int64) (*Subscribe, error) FindUsersSubscribeBySubscribeId(ctx context.Context, subscribeId int64) ([]*Subscribe, error) UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, tx ...*gorm.DB) error QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error) @@ -87,6 +86,7 @@ type customUserLogicModel interface { UpdateUserCache(ctx context.Context, data *User) error UpdateUserSubscribeCache(ctx context.Context, data *Subscribe) error QueryActiveSubscriptions(ctx context.Context, subscribeId ...int64) (map[int64]int64, error) + FindActiveSubscribe(ctx context.Context, userId int64) (*Subscribe, error) FindUserAuthMethods(ctx context.Context, userId int64) ([]*AuthMethods, error) InsertUserAuthMethods(ctx context.Context, data *AuthMethods, tx ...*gorm.DB) error UpdateUserAuthMethods(ctx context.Context, data *AuthMethods, tx ...*gorm.DB) error @@ -288,6 +288,20 @@ func (m *customUserModel) QueryDailyUserStatisticsList(ctx context.Context, date return results, err } +// FindActiveSubscribe 查找用户的活跃订阅 +func (m *customUserModel) FindActiveSubscribe(ctx context.Context, userId int64) (*Subscribe, error) { + var subscribe Subscribe + err := m.QueryNoCacheCtx(ctx, &subscribe, func(conn *gorm.DB, v interface{}) error { + return conn.Where("user_id = ? AND status IN (0, 1) AND expire_time > ?", userId, time.Now()). + Order("expire_time DESC"). + First(v).Error + }) + if err != nil { + return nil, err + } + return &subscribe, nil +} + // 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 diff --git a/internal/model/user/subscribe.go b/internal/model/user/subscribe.go index a4345c5..362fb99 100644 --- a/internal/model/user/subscribe.go +++ b/internal/model/user/subscribe.go @@ -198,24 +198,3 @@ func (m *defaultUserModel) DeleteSubscribeById(ctx context.Context, id int64, tx func (m *defaultUserModel) ClearSubscribeCache(ctx context.Context, data ...*Subscribe) error { return m.ClearSubscribeCacheByModels(ctx, data...) } - -// FindActiveSubscribe finds the user's active subscription -func (m *defaultUserModel) FindActiveSubscribe(ctx context.Context, userId int64) (*Subscribe, error) { - var data Subscribe - err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, v interface{}) error { - now := time.Now() - return conn.Model(&Subscribe{}). - Where("user_id = ? AND status = ? AND expire_time > ?", userId, 1, now). - Order("expire_time DESC"). - First(&data).Error - }) - - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, err - } - - return &data, nil -} diff --git a/internal/model/user/user.go b/internal/model/user/user.go index 603f98e..e8f802a 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -4,27 +4,30 @@ import ( "time" ) +// User 用户模型结构体 type User struct { - Id int64 `gorm:"primaryKey"` - Password string `gorm:"type:varchar(100);not null;comment:User Password"` - Avatar string `gorm:"type:MEDIUMTEXT;comment:User Avatar"` - Balance int64 `gorm:"default:0;comment:User Balance"` // User Balance Amount - 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 - ReferralPercentage uint8 `gorm:"default:0;comment:Referral"` // Referral Percentage - OnlyFirstPurchase *bool `gorm:"default:true;not null;comment:Only First Purchase"` // Only First Purchase Referral - 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"` - 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"` - AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"` - UserDevices []Device `gorm:"foreignKey:UserId;references:Id"` - CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` - UpdatedAt time.Time `gorm:"comment:Update Time"` + Id int64 `gorm:"primaryKey"` // 用户主键ID + Password string `gorm:"type:varchar(100);not null;comment:User Password"` // 用户密码(加密存储) + Algo string `gorm:"type:varchar(20);default:'default';comment:Encryption Algorithm"` // 密码加密算法 + Salt string `gorm:"type:varchar(20);default:null;comment:Password Salt"` // 密码盐值 + Avatar string `gorm:"type:MEDIUMTEXT;comment:User Avatar"` // 用户头像 + Balance int64 `gorm:"default:0;comment:User Balance"` // 用户余额(以分为单位) + ReferCode string `gorm:"type:varchar(20);default:'';comment:Referral Code"` // 用户推荐码 + RefererId int64 `gorm:"index:idx_referer;comment:Referrer ID"` // 推荐人ID + Commission int64 `gorm:"default:0;comment:Commission"` // 佣金金额 + ReferralPercentage uint8 `gorm:"default:0;comment:Referral"` // 推荐奖励百分比 + OnlyFirstPurchase *bool `gorm:"default:true;not null;comment:Only First Purchase"` // 是否仅首次购买给推荐奖励 + 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"` // 是否为管理员 + 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"` // 是否启用交易通知 + AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"` // 用户认证方式列表 + UserDevices []Device `gorm:"foreignKey:UserId;references:Id"` // 用户设备列表 + CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` // 创建时间 + UpdatedAt time.Time `gorm:"comment:Update Time"` // 更新时间 } func (*User) TableName() string { diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index 184dc69..be05079 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -32,10 +32,12 @@ import ( ) type ServiceContext struct { - DB *gorm.DB - Redis *redis.Client - Config config.Config - Queue *asynq.Client + DB *gorm.DB + Redis *redis.Client + Config config.Config + Queue *asynq.Client + ExchangeRate float64 + //NodeCache *cache.NodeCacheClient AuthModel auth.Model AdsModel ads.Model @@ -82,10 +84,11 @@ func NewServiceContext(c config.Config) *ServiceContext { } authLimiter := limit.NewPeriodLimit(86400, 15, rds, config.SendCountLimitKeyPrefix, limit.Align()) srv := &ServiceContext{ - DB: db, - Redis: rds, - Config: c, - Queue: NewAsynqClient(c), + DB: db, + Redis: rds, + Config: c, + Queue: NewAsynqClient(c), + ExchangeRate: 1.0, //NodeCache: cache.NewNodeCacheClient(rds), AuthLimiter: authLimiter, AdsModel: ads.NewModel(db, rds), diff --git a/internal/types/types.go b/internal/types/types.go index 3a44525..3be24d2 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -178,15 +178,6 @@ type BatchSendEmailTask struct { UpdatedAt int64 `json:"updated_at"` } -type BindEmailWithPasswordRequest struct { - Email string `json:"email" validate:"required"` - Password string `json:"password" validate:"required"` -} - -type BindInviteCodeRequest struct { - InviteCode string `json:"invite_code" validate:"required"` -} - type BindOAuthCallbackRequest struct { Method string `json:"method"` Callback interface{} `json:"callback"` @@ -201,6 +192,10 @@ type BindOAuthResponse struct { Redirect string `json:"redirect"` } +type BindInviteCodeRequest struct { + InviteCode string `json:"invite_code" validate:"required"` +} + type BindTelegramResponse struct { Url string `json:"url"` ExpiredAt int64 `json:"expired_at"` @@ -1179,7 +1174,6 @@ type InviteConfig struct { ForcedInvite bool `json:"forced_invite"` ReferralPercentage int64 `json:"referral_percentage"` OnlyFirstPurchase bool `json:"only_first_purchase"` - GiftDays int64 `json:"gift_days"` } type KickOfflineRequest struct { @@ -2076,8 +2070,8 @@ type SubscribeConfig struct { } type SubscribeDiscount struct { - Quantity int64 `json:"quantity"` - Discount float64 `json:"discount"` + Quantity int64 `json:"quantity"` + Discount int64 `json:"discount"` } type SubscribeGroup struct { @@ -2645,7 +2639,6 @@ type UserSubscribeNodeInfo struct { Name string `json:"name"` Uuid string `json:"uuid"` Protocol string `json:"protocol"` - Protocols string `json:"protocols"` Port uint16 `json:"port"` Address string `json:"address"` Tags []string `json:"tags"` @@ -2702,6 +2695,18 @@ type VerifyEmailRequest struct { Code string `json:"code" validate:"required"` } +type BindEmailWithVerificationRequest struct { + Email string `json:"email" validate:"required"` + Code string `json:"code" validate:"required"` +} + +type BindEmailWithVerificationResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Token string `json:"token,omitempty"` // 设备关联后的新Token + UserId int64 `json:"user_id,omitempty"` // 目标用户ID +} + type VersionResponse struct { Version string `json:"version"` } diff --git a/pkg/payment/epay/epay.go b/pkg/payment/epay/epay.go index ae1315e..8933d9f 100644 --- a/pkg/payment/epay/epay.go +++ b/pkg/payment/epay/epay.go @@ -14,9 +14,10 @@ import ( ) type Client struct { - Pid string - Url string - Key string + Pid string + Url string + Key string + Type string } type Order struct { @@ -37,11 +38,12 @@ type queryOrderStatusResponse struct { Status int `json:"status"` } -func NewClient(pid, url, key string) *Client { +func NewClient(pid, url, key string, Type string) *Client { return &Client{ - Pid: pid, - Url: url, - Key: key, + Pid: pid, + Url: url, + Key: key, + Type: Type, } } @@ -53,6 +55,7 @@ func (c *Client) CreatePayUrl(order Order) string { params.Set("notify_url", order.NotifyUrl) params.Set("out_trade_no", order.OrderNo) params.Set("pid", c.Pid) + params.Set("type", c.Type) params.Set("return_url", order.ReturnUrl) // Generate the sign using the CreateSign function @@ -117,6 +120,7 @@ func (c *Client) structToMap(order Order) map[string]string { result["notify_url"] = order.NotifyUrl result["out_trade_no"] = order.OrderNo result["pid"] = c.Pid + result["type"] = c.Type result["return_url"] = order.ReturnUrl return result } diff --git a/pkg/payment/epay/epay_test.go b/pkg/payment/epay/epay_test.go index a3c6884..87265e6 100644 --- a/pkg/payment/epay/epay_test.go +++ b/pkg/payment/epay/epay_test.go @@ -3,7 +3,7 @@ package epay import "testing" func TestEpay(t *testing.T) { - client := NewClient("", "http://127.0.0.1", "") + client := NewClient("", "http://127.0.0.1", "", "") order := Order{ Name: "测试", OrderNo: "123456789", @@ -19,7 +19,7 @@ func TestEpay(t *testing.T) { func TestQueryOrderStatus(t *testing.T) { t.Skipf("Skip TestQueryOrderStatus test") - client := NewClient("Pid", "Url", "Key") + client := NewClient("Pid", "Url", "Key", "Type") orderNo := "123456789" status := client.QueryOrderStatus(orderNo) t.Logf("OrderNo: %s, Status: %v\n", orderNo, status) @@ -40,7 +40,7 @@ func TestVerifySign(t *testing.T) { } key := "LbTabbB580zWyhXhyyww7wwvy5u8k0wl" - c := NewClient("Pid", "Url", key) + c := NewClient("Pid", "Url", key, "Type") if c.VerifySign(params) { t.Logf("Sign verification success!") } else { diff --git a/pkg/payment/platform.go b/pkg/payment/platform.go index 42b8815..7ad12ea 100644 --- a/pkg/payment/platform.go +++ b/pkg/payment/platform.go @@ -65,9 +65,10 @@ func GetSupportedPlatforms() []types.PlatformInfo { Platform: EPay.String(), PlatformUrl: "", PlatformFieldDescription: map[string]string{ - "pid": "PID", - "url": "URL", - "key": "Key", + "pid": "PID", + "url": "URL", + "key": "Key", + "type": "Type", }, }, { diff --git a/pkg/tool/encryption.go b/pkg/tool/encryption.go index e4f205e..f51f61a 100644 --- a/pkg/tool/encryption.go +++ b/pkg/tool/encryption.go @@ -2,12 +2,14 @@ package tool import ( "crypto/md5" + "crypto/sha256" "crypto/sha512" "encoding/hex" "fmt" "strings" "github.com/anaskhan96/go-password-encoder" + "golang.org/x/crypto/bcrypt" ) var options = &password.Options{SaltLen: 16, Iterations: 100, KeyLen: 32, HashFunction: sha512.New} @@ -32,3 +34,24 @@ func Md5Encode(str string, isUpper bool) string { } return res } + +func MultiPasswordVerify(algo, salt, password, hash string) bool { + switch algo { + case "md5": + sum := md5.Sum([]byte(password)) + return hex.EncodeToString(sum[:]) == hash + case "sha256": + sum := sha256.Sum256([]byte(password)) + return hex.EncodeToString(sum[:]) == hash + case "md5salt": + sum := md5.Sum([]byte(password + salt)) + return hex.EncodeToString(sum[:]) == hash + case "default": // PPanel's default algorithm + return VerifyPassWord(password, hash) + case "bcrypt": + // Bcrypt (corresponding to PHP's password_hash/password_verify) + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil + } + return false +} diff --git a/pkg/tool/encryption_test.go b/pkg/tool/encryption_test.go index 8841072..8e420cf 100644 --- a/pkg/tool/encryption_test.go +++ b/pkg/tool/encryption_test.go @@ -1,7 +1,15 @@ package tool -import "testing" +import ( + "testing" +) func TestEncodePassWord(t *testing.T) { t.Logf("EncodePassWord: %v", EncodePassWord("password")) } + +func TestMultiPasswordVerify(t *testing.T) { + pwd := "$2y$10$WFO17pdtohfeBILjEChoGeVxpDG.u9kVCKhjDAeEeNmCjIlj3tDRy" + status := MultiPasswordVerify("bcrypt", "", "admin1", pwd) + t.Logf("MultiPasswordVerify: %v", status) +} diff --git a/pkg/xerr/errCode.go b/pkg/xerr/errCode.go index b708ac4..c9377c4 100644 --- a/pkg/xerr/errCode.go +++ b/pkg/xerr/errCode.go @@ -57,6 +57,7 @@ const ( CouponAlreadyUsed uint32 = 50002 // Coupon has already been used CouponNotApplicable uint32 = 50003 // Coupon does not match the order or conditions CouponInsufficientUsage uint32 = 50004 // Coupon has insufficient remaining uses + CouponExpired uint32 = 50005 // Coupon is expired ) // Subscribe diff --git a/pkg/xerr/errMsg.go b/pkg/xerr/errMsg.go index f4f73a3..f688854 100644 --- a/pkg/xerr/errMsg.go +++ b/pkg/xerr/errMsg.go @@ -46,6 +46,7 @@ func init() { CouponAlreadyUsed: "Coupon has already been used", CouponNotApplicable: "Coupon does not match the order or conditions", CouponInsufficientUsage: "Coupon has insufficient remaining uses", + CouponExpired: "Coupon is expired", // Subscribe SubscribeExpired: "Subscribe is expired", diff --git a/queue/handler/routes.go b/queue/handler/routes.go index edf2293..2e96219 100644 --- a/queue/handler/routes.go +++ b/queue/handler/routes.go @@ -3,7 +3,6 @@ package handler import ( "github.com/hibiken/asynq" "github.com/perfect-panel/server/internal/svc" - countrylogic "github.com/perfect-panel/server/queue/logic/country" orderLogic "github.com/perfect-panel/server/queue/logic/order" smslogic "github.com/perfect-panel/server/queue/logic/sms" "github.com/perfect-panel/server/queue/logic/subscription" @@ -15,8 +14,6 @@ import ( ) func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) { - // get country task - mux.Handle(types.ForthwithGetCountry, countrylogic.NewGetNodeCountryLogic(serverCtx)) // Send email task mux.Handle(types.ForthwithSendEmail, emailLogic.NewSendEmailLogic(serverCtx)) // Send sms task diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 79d1c0b..55dc284 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -179,8 +179,8 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O return err } - // Handle referral reward in separate goroutine to avoid blocking - go l.handleReferralReward(context.Background(), userInfo, orderInfo) + // Handle commission in separate goroutine to avoid blocking + go l.handleCommission(context.Background(), userInfo, orderInfo) // Clear cache l.clearServerCache(ctx, sub) @@ -192,12 +192,12 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O return nil } -// getUserOrCreate retrieves an existing user or creates a new user based on order details +// getUserOrCreate retrieves an existing user or creates a new guest user based on order details func (l *ActivateOrderLogic) getUserOrCreate(ctx context.Context, orderInfo *order.Order) (*user.User, error) { if orderInfo.UserId != 0 { return l.getExistingUser(ctx, orderInfo.UserId) } - return l.createUserFromTempOrder(ctx, orderInfo) + return l.createGuestUser(ctx, orderInfo) } // getExistingUser retrieves user information by user ID @@ -213,9 +213,9 @@ func (l *ActivateOrderLogic) getExistingUser(ctx context.Context, userId int64) return userInfo, nil } -// createUserFromTempOrder creates a new user account using temporary order information -// stored in Redis cache. All users created this way are formal users, not guests. -func (l *ActivateOrderLogic) createUserFromTempOrder(ctx context.Context, orderInfo *order.Order) (*user.User, error) { +// createGuestUser creates a new user account for guest orders using temporary order information +// stored in Redis cache +func (l *ActivateOrderLogic) createGuestUser(ctx context.Context, orderInfo *order.Order) (*user.User, error) { tempOrder, err := l.getTempOrderInfo(ctx, orderInfo.OrderNo) if err != nil { return nil, err @@ -223,6 +223,7 @@ func (l *ActivateOrderLogic) createUserFromTempOrder(ctx context.Context, orderI userInfo := &user.User{ Password: tool.EncodePassWord(tempOrder.Password), + Algo: "default", AuthMethods: []user.AuthMethods{ { AuthType: tempOrder.AuthType, @@ -253,7 +254,7 @@ func (l *ActivateOrderLogic) createUserFromTempOrder(ctx context.Context, orderI // Handle referrer relationship l.handleReferrer(ctx, userInfo, tempOrder.InviteCode) - logger.WithContext(ctx).Info("Create user success", + logger.WithContext(ctx).Info("Create guest user success", logger.Field("user_id", userInfo.Id), logger.Field("identifier", tempOrder.Identifier), logger.Field("auth_type", tempOrder.AuthType), @@ -349,12 +350,10 @@ func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderIn return userSub, nil } -// handleReferralReward processes referral rewards for the referrer if applicable. +// handleCommission processes referral commission for the referrer if applicable. // This runs asynchronously to avoid blocking the main order processing flow. -// If referral percentage > 0: commission reward -// If referral percentage = 0: gift days to both parties -func (l *ActivateOrderLogic) handleReferralReward(ctx context.Context, userInfo *user.User, orderInfo *order.Order) { - if !l.shouldProcessReferralReward(userInfo, orderInfo.IsNew) { +func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *user.User, orderInfo *order.Order) { + if !l.shouldProcessCommission(userInfo, orderInfo.IsNew) { return } @@ -374,25 +373,13 @@ func (l *ActivateOrderLogic) handleReferralReward(ctx context.Context, userInfo referralPercentage = uint8(l.svc.Config.Invite.ReferralPercentage) } - // Check if this is commission reward or gift days reward - if referralPercentage > 0 { - // Commission reward mode - l.processCommissionReward(ctx, referer, orderInfo, referralPercentage) - } else { - // Gift days reward mode - l.processGiftDaysReward(ctx, referer, userInfo, orderInfo) - } -} - -// processCommissionReward handles commission-based rewards -func (l *ActivateOrderLogic) processCommissionReward(ctx context.Context, referer *user.User, orderInfo *order.Order, percentage uint8) { // Order commission calculation: (Order Amount - Order Fee) * Referral Percentage - amount := l.calculateCommission(orderInfo.Amount-orderInfo.FeeAmount, percentage) + amount := l.calculateCommission(orderInfo.Amount-orderInfo.FeeAmount, referralPercentage) // Use transaction for commission updates - err := l.svc.DB.Transaction(func(tx *gorm.DB) error { + err = l.svc.DB.Transaction(func(tx *gorm.DB) error { referer.Commission += amount - if err := l.svc.UserModel.Update(ctx, referer, tx); err != nil { + if err = l.svc.UserModel.Update(ctx, referer, tx); err != nil { return err } @@ -434,73 +421,9 @@ func (l *ActivateOrderLogic) processCommissionReward(ctx context.Context, refere } } -// processGiftDaysReward handles gift days rewards for both parties -func (l *ActivateOrderLogic) processGiftDaysReward(ctx context.Context, referer *user.User, referee *user.User, orderInfo *order.Order) { - giftDays := l.svc.Config.Invite.GiftDays - if giftDays <= 0 { - giftDays = 3 // Default to 3 days - } - - // Get the subscription info to determine the unit time - sub, err := l.getSubscribeInfo(ctx, orderInfo.SubscribeId) - if err != nil { - logger.WithContext(ctx).Error("Get subscribe info failed for gift days", - logger.Field("error", err.Error()), - logger.Field("subscribe_id", orderInfo.SubscribeId), - ) - return - } - - // Grant gift days to both referer and referee - l.grantGiftDays(ctx, referer, giftDays, sub.UnitTime, "referer") - l.grantGiftDays(ctx, referee, giftDays, sub.UnitTime, "referee") -} - -// grantGiftDays grants gift days to a user by extending their subscription -func (l *ActivateOrderLogic) grantGiftDays(ctx context.Context, user *user.User, days int64, unitTime string, role string) { - // Find user's active subscription - userSub, err := l.svc.UserModel.FindActiveSubscribe(ctx, user.Id) - if err != nil { - logger.WithContext(ctx).Error("Find user active subscription failed for gift days", - logger.Field("error", err.Error()), - logger.Field("user_id", user.Id), - logger.Field("role", role), - ) - return - } - - if userSub == nil { - logger.WithContext(ctx).Info("User has no active subscription for gift days", - logger.Field("user_id", user.Id), - logger.Field("role", role), - ) - return - } - - // Extend subscription by gift days - userSub.ExpireTime = tool.AddTime("day", days, userSub.ExpireTime) - - err = l.svc.UserModel.UpdateSubscribe(ctx, userSub) - if err != nil { - logger.WithContext(ctx).Error("Update user subscription for gift days failed", - logger.Field("error", err.Error()), - logger.Field("user_id", user.Id), - logger.Field("role", role), - ) - return - } - - logger.WithContext(ctx).Info("Gift days granted successfully", - logger.Field("user_id", user.Id), - logger.Field("role", role), - logger.Field("days", days), - logger.Field("new_expire_time", userSub.ExpireTime), - ) -} - -// shouldProcessReferralReward determines if referral reward should be processed based on +// shouldProcessCommission determines if commission should be processed based on // referrer existence, commission settings, and order type -func (l *ActivateOrderLogic) shouldProcessReferralReward(userInfo *user.User, isFirstPurchase bool) bool { +func (l *ActivateOrderLogic) shouldProcessCommission(userInfo *user.User, isFirstPurchase bool) bool { if userInfo == nil || userInfo.RefererId == 0 { return false } @@ -582,8 +505,8 @@ func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order // Clear cache l.clearServerCache(ctx, sub) - // Handle referral reward - go l.handleReferralReward(context.Background(), userInfo, orderInfo) + // Handle commission + go l.handleCommission(context.Background(), userInfo, orderInfo) // Send notifications l.sendNotifications(ctx, orderInfo, userInfo, sub, userSub, telegram.RenewalNotify) diff --git a/queue/logic/task/rateLogic.go b/queue/logic/task/rateLogic.go new file mode 100644 index 0000000..2e33fae --- /dev/null +++ b/queue/logic/task/rateLogic.go @@ -0,0 +1,52 @@ +package task + +import ( + "context" + + "github.com/hibiken/asynq" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/exchangeRate" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/tool" +) + +type RateLogic struct { + svcCtx *svc.ServiceContext +} + +func NewRateLogic(svcCtx *svc.ServiceContext) *RateLogic { + return &RateLogic{ + svcCtx: svcCtx, + } +} + +func (l *RateLogic) ProcessTask(ctx context.Context, _ *asynq.Task) error { + // Retrieve system currency configuration + currency, err := l.svcCtx.SystemModel.GetCurrencyConfig(ctx) + if err != nil { + logger.Errorw("[PurchaseCheckout] GetCurrencyConfig error", logger.Field("error", err.Error())) + return err + } + // Parse currency configuration + configs := struct { + CurrencyUnit string + CurrencySymbol string + AccessKey string + }{} + tool.SystemConfigSliceReflectToStruct(currency, &configs) + + // Skip conversion if no exchange rate API key configured + if configs.AccessKey == "" { + logger.Debugf("[RateLogic] skip exchange rate, no access key configured") + return nil + } + // Update exchange rates + result, err := exchangeRate.GetExchangeRete(configs.CurrencyUnit, "CNY", configs.AccessKey, 1) + if err != nil { + logger.Errorw("[RateLogic] GetExchangeRete error", logger.Field("error", err.Error())) + return err + } + l.svcCtx.ExchangeRate = result + logger.WithContext(ctx).Infof("[RateLogic] GetExchangeRete success, result: %+v", result) + return nil +} diff --git a/queue/logic/traffic/trafficStatLogic.go b/queue/logic/traffic/trafficStatLogic.go index 81a422b..e5357a8 100644 --- a/queue/logic/traffic/trafficStatLogic.go +++ b/queue/logic/traffic/trafficStatLogic.go @@ -167,7 +167,7 @@ func (l *StatLogic) ProcessTask(ctx context.Context, _ *asynq.Task) error { // Delete old traffic logs if l.svc.Config.Log.AutoClear { - err = tx.WithContext(ctx).Model(&traffic.TrafficLog{}).Where("created_at <= ?", end.AddDate(0, 0, int(-l.svc.Config.Log.ClearDays))).Delete(&traffic.TrafficLog{}).Error + err = tx.WithContext(ctx).Model(&traffic.TrafficLog{}).Where("timestamp <= ?", end.AddDate(0, 0, int(-l.svc.Config.Log.ClearDays))).Delete(&traffic.TrafficLog{}).Error if err != nil { logger.Errorf("[Traffic Stat Queue] Delete server traffic log failed: %v", err.Error()) } diff --git a/queue/types/task.go b/queue/types/task.go index 0115f28..85da331 100644 --- a/queue/types/task.go +++ b/queue/types/task.go @@ -6,4 +6,7 @@ const ( // ForthwithQuotaTask create quota task immediately ForthwithQuotaTask = "forthwith:quota:task" + + // SchedulerExchangeRate fetch exchange rate task + SchedulerExchangeRate = "scheduler:exchange:rate" ) diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index 377dca3..bf131e8 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -46,6 +46,12 @@ func (m *Service) Start() { logger.Errorf("register traffic stat task failed: %s", err.Error()) } + // schedule update exchange rate task: every day at 01:00 + rateTask := asynq.NewTask(types.ForthwithQuotaTask, nil) + if _, err := m.server.Register("0 1 * * *", rateTask, asynq.MaxRetry(3)); err != nil { + logger.Errorf("register update exchange rate task failed: %s", err.Error()) + } + if err := m.server.Run(); err != nil { logger.Errorf("run scheduler failed: %s", err.Error()) }