feat: 新增多密码验证支持及架构文档
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
refactor: 重构用户模型和密码验证逻辑 feat(epay): 添加支付类型支持 docs: 添加安装和配置指南文档 fix: 修复优惠券过期检查逻辑 perf: 优化设备解绑缓存清理流程 test: 添加密码验证测试用例 chore: 更新依赖版本
This commit is contained in:
parent
fde3210a88
commit
00255a7118
@ -28,7 +28,7 @@ FROM alpine:latest
|
|||||||
|
|
||||||
# Copy CA certificates and timezone data
|
# Copy CA certificates and timezone data
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai
|
||||||
|
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
@ -36,6 +36,7 @@ ENV TZ=Asia/Shanghai
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/ppanel /app/ppanel
|
COPY --from=builder /app/ppanel /app/ppanel
|
||||||
|
COPY --from=builder /build/etc /app/etc
|
||||||
|
|
||||||
# Expose the port (optional)
|
# Expose the port (optional)
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@ -93,21 +93,24 @@ type (
|
|||||||
UpdateBindEmailRequest {
|
UpdateBindEmailRequest {
|
||||||
Email string `json:"email" validate:"required"`
|
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 {
|
VerifyEmailRequest {
|
||||||
Email string `json:"email" validate:"required"`
|
Email string `json:"email" validate:"required"`
|
||||||
Code string `json:"code" validate:"required"`
|
Code string `json:"code" validate:"required"`
|
||||||
}
|
}
|
||||||
|
BindEmailWithVerificationRequest {
|
||||||
|
Email string `json:"email" validate:"required"`
|
||||||
|
Code string `json:"code" validate:"required"`
|
||||||
|
}
|
||||||
|
BindEmailWithVerificationResponse {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
GetDeviceListResponse {
|
GetDeviceListResponse {
|
||||||
List []UserDevice `json:"list"`
|
List []UserDevice `json:"list"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
UnbindDeviceRequest {
|
UnbindDeviceRequest {
|
||||||
Id int64 `json:"id" validate:"required"`
|
Id int64 `json:"id" validate:"required"`
|
||||||
}
|
}
|
||||||
@ -207,13 +210,9 @@ service ppanel {
|
|||||||
@handler UpdateBindEmail
|
@handler UpdateBindEmail
|
||||||
put /bind_email (UpdateBindEmailRequest)
|
put /bind_email (UpdateBindEmailRequest)
|
||||||
|
|
||||||
@doc "Bind Email With Password"
|
@doc "Bind Email With Verification"
|
||||||
@handler BindEmailWithPassword
|
@handler BindEmailWithVerification
|
||||||
post /bind_email_with_password (BindEmailWithPasswordRequest) returns (LoginResponse)
|
post /bind_email_with_verification (BindEmailWithVerificationRequest) returns (BindEmailWithVerificationResponse)
|
||||||
|
|
||||||
@doc "Bind Invite Code"
|
|
||||||
@handler BindInviteCode
|
|
||||||
post /bind_invite_code (BindInviteCodeRequest)
|
|
||||||
|
|
||||||
@doc "Get Device List"
|
@doc "Get Device List"
|
||||||
@handler GetDeviceList
|
@handler GetDeviceList
|
||||||
|
|||||||
@ -135,12 +135,14 @@ type (
|
|||||||
EnableDomainSuffix bool `json:"enable_domain_suffix"`
|
EnableDomainSuffix bool `json:"enable_domain_suffix"`
|
||||||
DomainSuffixList string `json:"domain_suffix_list"`
|
DomainSuffixList string `json:"domain_suffix_list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
DeviceAuthticateConfig {
|
DeviceAuthticateConfig {
|
||||||
Enable bool `json:"enable"`
|
Enable bool `json:"enable"`
|
||||||
ShowAds bool `json:"show_ads"`
|
ShowAds bool `json:"show_ads"`
|
||||||
EnableSecurity bool `json:"enable_security"`
|
EnableSecurity bool `json:"enable_security"`
|
||||||
OnlyRealDevice bool `json:"only_real_device"`
|
OnlyRealDevice bool `json:"only_real_device"`
|
||||||
}
|
}
|
||||||
|
|
||||||
RegisterConfig {
|
RegisterConfig {
|
||||||
StopRegister bool `json:"stop_register"`
|
StopRegister bool `json:"stop_register"`
|
||||||
EnableTrial bool `json:"enable_trial"`
|
EnableTrial bool `json:"enable_trial"`
|
||||||
@ -185,7 +187,6 @@ type (
|
|||||||
ForcedInvite bool `json:"forced_invite"`
|
ForcedInvite bool `json:"forced_invite"`
|
||||||
ReferralPercentage int64 `json:"referral_percentage"`
|
ReferralPercentage int64 `json:"referral_percentage"`
|
||||||
OnlyFirstPurchase bool `json:"only_first_purchase"`
|
OnlyFirstPurchase bool `json:"only_first_purchase"`
|
||||||
GiftDays int64 `json:"gift_days"`
|
|
||||||
}
|
}
|
||||||
TelegramConfig {
|
TelegramConfig {
|
||||||
TelegramBotToken string `json:"telegram_bot_token"`
|
TelegramBotToken string `json:"telegram_bot_token"`
|
||||||
@ -206,7 +207,7 @@ type (
|
|||||||
}
|
}
|
||||||
SubscribeDiscount {
|
SubscribeDiscount {
|
||||||
Quantity int64 `json:"quantity"`
|
Quantity int64 `json:"quantity"`
|
||||||
Discount float64 `json:"discount"`
|
Discount int64 `json:"discount"`
|
||||||
}
|
}
|
||||||
Subscribe {
|
Subscribe {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
@ -673,6 +674,7 @@ type (
|
|||||||
List []SubscribeGroup `json:"list"`
|
List []SubscribeGroup `json:"list"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
GetUserSubscribeTrafficLogsRequest {
|
GetUserSubscribeTrafficLogsRequest {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
|
|||||||
161
doc/config-zh.md
Normal file
161
doc/config-zh.md
Normal file
@ -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 官方文档或联系支持团队。
|
||||||
164
doc/config.md
Normal file
164
doc/config.md
Normal file
@ -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.
|
||||||
BIN
doc/image/architecture-en.png
Normal file
BIN
doc/image/architecture-en.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 553 KiB |
BIN
doc/image/architecture-zh.png
Normal file
BIN
doc/image/architecture-zh.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 548 KiB |
133
doc/install-zh.md
Normal file
133
doc/install-zh.md
Normal file
@ -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 <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=PPANEL Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/root/ppanel-server-linux-amd64/ppanel-server
|
||||||
|
Restart=always
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/root/ppanel-server-linux-amd64
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
- 重新加载 systemd 服务
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ systemctl daemon-reload
|
||||||
|
```
|
||||||
|
- 启动服务
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ systemctl start ppanel
|
||||||
|
```
|
||||||
|
##### 其他说明
|
||||||
|
1. 安装路径:二进制文件将解压到 /root/ppanel-server-linux-amd64 目录下
|
||||||
|
2. systemd 服务:
|
||||||
|
- 服务名称:ppanel
|
||||||
|
- 服务配置文件:/etc/systemd/system/ppanel.service
|
||||||
|
- 服务启动命令:systemctl start ppanel
|
||||||
|
- 服务停止命令:systemctl stop ppanel
|
||||||
|
- 服务重启命令:systemctl restart ppanel
|
||||||
|
- 服务状态命令:systemctl status ppanel
|
||||||
|
- 服务开机自启:systemctl enable ppanel
|
||||||
|
3. 设置开机自启可通过以下命令开机自启
|
||||||
|
```shell
|
||||||
|
$ systemctl enable ppanel
|
||||||
|
```
|
||||||
|
4. 服务日志:服务日志默认输出到 /root/ppanel-server-linux-amd64/ppanel.log 文件中
|
||||||
|
5. 可通过 `journalctl -u ppanel -f` 查看服务日志
|
||||||
|
6. 当配置文件为空或者不存在的情况下,服务会使用默认配置启动,配置文件路径为:`./etc/ppanel.yaml`,
|
||||||
|
请通过`http://服务器地址:8080/init` 初始化系统配置
|
||||||
|
|
||||||
|
#### NGINX 反向代理配置
|
||||||
|
|
||||||
|
以下是反向代理配置示例,将 `ppanel` 服务代理到 `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_filezbsQiET1 0;
|
||||||
|
if ( $uri ~* "\.(gif|png|jpg|css|js|woff|woff2)$" )
|
||||||
|
{
|
||||||
|
set $static_filezbsQiET1 1;
|
||||||
|
expires 1m;
|
||||||
|
}
|
||||||
|
if ( $static_filezbsQiET1 = 0 )
|
||||||
|
{
|
||||||
|
add_header Cache-Control no-cache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
如果使用cloudflare代理服务,需要获取到用户真实访问IP。请在Nginx配置文件中Http段落中加入:
|
||||||
|
|
||||||
|
- 需要依赖:**ngx_http_realip_module**模块, 使用nginx -V命令查看nginx是否已经编译该模块,没有的话需要自己编译。
|
||||||
|
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# cloudflare Start
|
||||||
|
set_real_ip_from 0.0.0.0/0;
|
||||||
|
real_ip_header X-Forwarded-For;
|
||||||
|
real_ip_recursive on;
|
||||||
|
# cloudflare END
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
142
doc/install.md
Normal file
142
doc/install.md
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
### Installation Instructions
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
- MySQL 5.7+ (recommended: 8.0)
|
||||||
|
- Redis 6.0+ (recommended: 7.0)
|
||||||
|
|
||||||
|
#### Binary Installation
|
||||||
|
|
||||||
|
1. Determine your system architecture and download the corresponding binary file.
|
||||||
|
|
||||||
|
Download URL: `https://github.com/perfect-panel/server/releases`
|
||||||
|
|
||||||
|
Example setup: OS: Linux amd64, User: root, Current directory: `/root`
|
||||||
|
|
||||||
|
- Download the binary file:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ wget https://github.com/perfect-panel/server/releases/download/v1.0.0/ppanel-server-linux-amd64.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
- Extract the binary file:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ tar -zxvf ppanel-server-linux-amd64.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
- Navigate to the extracted directory:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ cd ppanel-server-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
- Grant execution permissions to the binary:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ chmod +x ppanel
|
||||||
|
```
|
||||||
|
|
||||||
|
- Create a systemd service file:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ cat > /etc/systemd/system/ppanel.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=PPANEL Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/root/ppanel-server-linux-amd64/ppanel
|
||||||
|
Restart=always
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/root/ppanel-server-linux-amd64
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
- Reload the systemd service configuration:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ systemctl daemon-reload
|
||||||
|
```
|
||||||
|
- Start the service:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ systemctl start ppanel
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Additional Notes
|
||||||
|
|
||||||
|
1. Installation Path: The binary files will be extracted to /root/ppanel-server-linux-amd64.
|
||||||
|
|
||||||
|
2. systemd Service:
|
||||||
|
- Service Name: ppanel
|
||||||
|
|
||||||
|
- Service Configuration File: /etc/systemd/system/ppanel.service
|
||||||
|
|
||||||
|
- Service Commands:
|
||||||
|
|
||||||
|
- Start: systemctl start ppanel
|
||||||
|
|
||||||
|
- Stop: systemctl stop ppanel
|
||||||
|
|
||||||
|
- Restart: systemctl restart ppanel
|
||||||
|
|
||||||
|
- Status: systemctl status ppanel
|
||||||
|
|
||||||
|
- Enable on Boot: systemctl enable ppanel
|
||||||
|
|
||||||
|
3. Enable Auto-start: Use the following command to enable the service on boot:
|
||||||
|
```shell
|
||||||
|
$ systemctl enable ppanel
|
||||||
|
```
|
||||||
|
4. Service Logs: By default, logs are output to `/root/ppanel-server-linux-amd64/ppanel.log`.
|
||||||
|
|
||||||
|
5. You can view service logs using: `journalctl -u ppanel -f`
|
||||||
|
6. If the configuration file is missing or empty, the service will start with default settings. The configuration file path is `./etc/ppanel.yaml`. Access `http://<server_address>: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
|
||||||
|
```
|
||||||
6
go.mod
6
go.mod
@ -44,7 +44,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk v1.29.0
|
go.opentelemetry.io/otel/sdk v1.29.0
|
||||||
go.opentelemetry.io/otel/trace v1.29.0
|
go.opentelemetry.io/otel/trace v1.29.0
|
||||||
go.uber.org/zap v1.27.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/oauth2 v0.25.0
|
||||||
golang.org/x/time v0.6.0
|
golang.org/x/time v0.6.0
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
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/arch v0.13.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect
|
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect
|
||||||
golang.org/x/net v0.34.0 // indirect
|
golang.org/x/net v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.29.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/text v0.21.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/api v0.0.0-20240513163218-0867130af1f8 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc 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
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
|
|||||||
12
go.sum
12
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-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.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.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
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-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 h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4=
|
||||||
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.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.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
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.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
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.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 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
|||||||
@ -1,2 +1,20 @@
|
|||||||
ALTER TABLE `ads`
|
-- 只有当 ads 表中不存在 description 字段时才添加
|
||||||
ADD COLUMN `description` VARCHAR(255) DEFAULT '' COMMENT '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;
|
||||||
|
|||||||
@ -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'
|
||||||
|
);
|
||||||
@ -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'
|
||||||
|
);
|
||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE traffic_log DROP INDEX idx_timestamp;
|
||||||
1
initialize/migrate/database/02118_traffic_log_idx.up.sql
Normal file
1
initialize/migrate/database/02118_traffic_log_idx.up.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE traffic_log ADD INDEX idx_timestamp (timestamp);
|
||||||
3
initialize/migrate/database/02119_user_algo.down.sql
Normal file
3
initialize/migrate/database/02119_user_algo.down.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `user`
|
||||||
|
DROP COLUMN `algo`,
|
||||||
|
DROP COLUMN `salt`;
|
||||||
35
initialize/migrate/database/02119_user_algo.up.sql
Normal file
35
initialize/migrate/database/02119_user_algo.up.sql
Normal file
@ -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;
|
||||||
@ -2,6 +2,7 @@ package initialize
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -16,6 +17,7 @@ func Migrate(ctx *svc.ServiceContext) {
|
|||||||
mc := orm.Mysql{
|
mc := orm.Mysql{
|
||||||
Config: ctx.Config.MySQL,
|
Config: ctx.Config.MySQL,
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
if err := migrate.Migrate(mc.Dsn()).Up(); err != nil {
|
if err := migrate.Migrate(mc.Dsn()).Up(); err != nil {
|
||||||
if errors.Is(err, migrate.NoChange) {
|
if errors.Is(err, migrate.NoChange) {
|
||||||
logger.Info("[Migrate] database not change")
|
logger.Info("[Migrate] database not change")
|
||||||
@ -23,6 +25,8 @@ func Migrate(ctx *svc.ServiceContext) {
|
|||||||
}
|
}
|
||||||
logger.Errorf("[Migrate] Up error: %v", err.Error())
|
logger.Errorf("[Migrate] Up error: %v", err.Error())
|
||||||
panic(err)
|
panic(err)
|
||||||
|
} else {
|
||||||
|
logger.Info("[Migrate] Database change, took " + time.Since(now).String())
|
||||||
}
|
}
|
||||||
// if not found admin user
|
// if not found admin user
|
||||||
err := ctx.DB.Transaction(func(tx *gorm.DB) error {
|
err := ctx.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
|||||||
@ -203,7 +203,7 @@ type InviteConfig struct {
|
|||||||
ForcedInvite bool `yaml:"ForcedInvite" default:"false"`
|
ForcedInvite bool `yaml:"ForcedInvite" default:"false"`
|
||||||
ReferralPercentage int64 `yaml:"ReferralPercentage" default:"0"`
|
ReferralPercentage int64 `yaml:"ReferralPercentage" default:"0"`
|
||||||
OnlyFirstPurchase bool `yaml:"OnlyFirstPurchase" default:"false"`
|
OnlyFirstPurchase bool `yaml:"OnlyFirstPurchase" default:"false"`
|
||||||
GiftDays int64 `yaml:"GiftDays" default:"3"`
|
GiftDays int64 `yaml:"GiftDays" default:"0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Telegram struct {
|
type Telegram struct {
|
||||||
|
|||||||
@ -8,10 +8,10 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/result"
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bind Email With Password
|
// Bind Email With Verification
|
||||||
func BindEmailWithPasswordHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
func BindEmailWithVerificationHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req types.BindEmailWithPasswordRequest
|
var req types.BindEmailWithVerificationRequest
|
||||||
_ = c.ShouldBind(&req)
|
_ = c.ShouldBind(&req)
|
||||||
validateErr := svcCtx.Validate(&req)
|
validateErr := svcCtx.Validate(&req)
|
||||||
if validateErr != nil {
|
if validateErr != nil {
|
||||||
@ -19,8 +19,8 @@ func BindEmailWithPasswordHandler(svcCtx *svc.ServiceContext) func(c *gin.Contex
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
l := user.NewBindEmailWithPasswordLogic(c.Request.Context(), svcCtx)
|
l := user.NewBindEmailWithVerificationLogic(c.Request.Context(), svcCtx)
|
||||||
resp, err := l.BindEmailWithPassword(&req)
|
resp, err := l.BindEmailWithVerification(&req)
|
||||||
result.HttpResult(c, resp, err)
|
result.HttpResult(c, resp, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -780,12 +780,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
// Update Bind Email
|
// Update Bind Email
|
||||||
publicUserGroupRouter.PUT("/bind_email", publicUser.UpdateBindEmailHandler(serverCtx))
|
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
|
// Update Bind Mobile
|
||||||
publicUserGroupRouter.PUT("/bind_mobile", publicUser.UpdateBindMobileHandler(serverCtx))
|
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))
|
serverGroupRouter.GET("/user", server.GetServerUserListHandler(serverCtx))
|
||||||
}
|
}
|
||||||
|
|
||||||
serverGroupRouterV2 := router.Group("/v2/server")
|
serverV2GroupRouter := router.Group("/v2/server")
|
||||||
|
|
||||||
{
|
{
|
||||||
// Get Server Protocol Config
|
// Get Server Protocol Config
|
||||||
serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
|
serverV2GroupRouter.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,12 @@ func QueryServerProtocolConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Co
|
|||||||
|
|
||||||
fmt.Printf("[QueryServerProtocolConfigHandler] - ShouldBindQuery request: %+v\n", req)
|
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)
|
l := server.NewQueryServerProtocolConfigLogic(c.Request.Context(), svcCtx)
|
||||||
resp, err := l.QueryServerProtocolConfig(&req)
|
resp, err := l.QueryServerProtocolConfig(&req)
|
||||||
result.HttpResult(c, resp, err)
|
result.HttpResult(c, resp, err)
|
||||||
|
|||||||
@ -40,6 +40,7 @@ func (l *CreateUserLogic) CreateUser(req *types.CreateUserRequest) error {
|
|||||||
pwd := tool.EncodePassWord(req.Password)
|
pwd := tool.EncodePassWord(req.Password)
|
||||||
newUser := &user.User{
|
newUser := &user.User{
|
||||||
Password: pwd,
|
Password: pwd,
|
||||||
|
Algo: "default",
|
||||||
ReferralPercentage: req.ReferralPercentage,
|
ReferralPercentage: req.ReferralPercentage,
|
||||||
OnlyFirstPurchase: &req.OnlyFirstPurchase,
|
OnlyFirstPurchase: &req.OnlyFirstPurchase,
|
||||||
ReferCode: req.ReferCode,
|
ReferCode: req.ReferCode,
|
||||||
|
|||||||
@ -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")
|
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.Password = tool.EncodePassWord(req.Password)
|
||||||
|
userInfo.Algo = "default"
|
||||||
}
|
}
|
||||||
|
|
||||||
err = l.svcCtx.UserModel.Update(l.ctx, userInfo)
|
err = l.svcCtx.UserModel.Update(l.ctx, userInfo)
|
||||||
|
|||||||
@ -19,13 +19,14 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DeviceLoginLogic 设备登录逻辑结构体
|
||||||
type DeviceLoginLogic struct {
|
type DeviceLoginLogic struct {
|
||||||
logger.Logger
|
logger.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// Device Login
|
// NewDeviceLoginLogic 创建设备登录逻辑实例
|
||||||
func NewDeviceLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeviceLoginLogic {
|
func NewDeviceLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeviceLoginLogic {
|
||||||
return &DeviceLoginLogic{
|
return &DeviceLoginLogic{
|
||||||
Logger: logger.WithContext(ctx),
|
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) {
|
func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *types.LoginResponse, err error) {
|
||||||
|
// 检查设备登录是否启用
|
||||||
if !l.svcCtx.Config.Device.Enable {
|
if !l.svcCtx.Config.Device.Enable {
|
||||||
return nil, xerr.NewErrMsg("Device login is disabled")
|
return nil, xerr.NewErrMsg("Device login is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
loginStatus := false
|
loginStatus := false
|
||||||
var userInfo *user.User
|
var userInfo *user.User
|
||||||
// Record login status
|
|
||||||
|
// 延迟执行:记录登录状态日志
|
||||||
defer func() {
|
defer func() {
|
||||||
if userInfo != nil && userInfo.Id != 0 {
|
if userInfo != nil && userInfo.Id != 0 {
|
||||||
loginLog := log.Login{
|
loginLog := log.Login{
|
||||||
@ -67,15 +71,62 @@ 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)
|
deviceInfo, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
// Device not found, create new user and device
|
// 设备未找到,但需要检查认证方法是否已存在
|
||||||
|
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)
|
userInfo, err = l.registerUserAndDevice(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
l.Errorw("query device failed",
|
l.Errorw("query device failed",
|
||||||
logger.Field("identifier", req.Identifier),
|
logger.Field("identifier", req.Identifier),
|
||||||
@ -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())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device failed: %v", err.Error())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Device found, get user info
|
// 设备已存在,获取用户信息
|
||||||
userInfo, err = l.svcCtx.UserModel.FindOne(l.ctx, deviceInfo.UserId)
|
userInfo, err = l.svcCtx.UserModel.FindOne(l.ctx, deviceInfo.UserId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("query user failed",
|
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()
|
sessionId := uuidx.NewUUID().String()
|
||||||
|
|
||||||
// Generate token
|
// 生成JWT令牌
|
||||||
token, err := jwt.NewJwtToken(
|
token, err := jwt.NewJwtToken(
|
||||||
l.svcCtx.Config.JwtAuth.AccessSecret,
|
l.svcCtx.Config.JwtAuth.AccessSecret,
|
||||||
time.Now().Unix(),
|
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())
|
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)
|
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 {
|
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",
|
l.Errorw("set session id error",
|
||||||
@ -131,6 +182,7 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registerUserAndDevice 注册新用户和设备
|
||||||
func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) (*user.User, error) {
|
func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest) (*user.User, error) {
|
||||||
l.Infow("device not found, creating new user and device",
|
l.Infow("device not found, creating new user and device",
|
||||||
logger.Field("identifier", req.Identifier),
|
logger.Field("identifier", req.Identifier),
|
||||||
@ -138,8 +190,9 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
|
|||||||
)
|
)
|
||||||
|
|
||||||
var userInfo *user.User
|
var userInfo *user.User
|
||||||
|
// 使用数据库事务确保数据一致性
|
||||||
err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error {
|
err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error {
|
||||||
// Create new user
|
// 创建新用户
|
||||||
userInfo = &user.User{
|
userInfo = &user.User{
|
||||||
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
|
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)
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create user failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update refer code
|
// 更新用户邀请码
|
||||||
userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id)
|
userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id)
|
||||||
if err := db.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil {
|
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",
|
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)
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create device auth method
|
// 创建设备认证方式记录
|
||||||
authMethod := &user.AuthMethods{
|
authMethod := &user.AuthMethods{
|
||||||
UserId: userInfo.Id,
|
UserId: userInfo.Id,
|
||||||
AuthType: "device",
|
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)
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create device auth method failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert device record
|
// 插入设备记录
|
||||||
deviceInfo := &user.Device{
|
deviceInfo := &user.Device{
|
||||||
Ip: req.IP,
|
Ip: req.IP,
|
||||||
UserId: userInfo.Id,
|
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)
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert device failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate trial if enabled
|
// 如果启用了试用,则激活试用订阅
|
||||||
if l.svcCtx.Config.Register.EnableTrial {
|
if l.svcCtx.Config.Register.EnableTrial {
|
||||||
if err := l.activeTrial(userInfo.Id, db); err != nil {
|
if err := l.activeTrial(userInfo.Id, db); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -218,7 +271,7 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
|
|||||||
logger.Field("refer_code", userInfo.ReferCode),
|
logger.Field("refer_code", userInfo.ReferCode),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register log
|
// 记录注册日志
|
||||||
registerLog := log.Register{
|
registerLog := log.Register{
|
||||||
AuthMethod: "device",
|
AuthMethod: "device",
|
||||||
Identifier: req.Identifier,
|
Identifier: req.Identifier,
|
||||||
@ -244,7 +297,9 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
|
|||||||
return userInfo, nil
|
return userInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// activeTrial 激活试用订阅
|
||||||
func (l *DeviceLoginLogic) activeTrial(userId int64, db *gorm.DB) error {
|
func (l *DeviceLoginLogic) activeTrial(userId int64, db *gorm.DB) error {
|
||||||
|
// 查找试用订阅模板
|
||||||
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, l.svcCtx.Config.Register.TrialSubscribe)
|
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, l.svcCtx.Config.Register.TrialSubscribe)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("failed to find trial subscription template",
|
l.Errorw("failed to find trial subscription template",
|
||||||
@ -255,11 +310,13 @@ func (l *DeviceLoginLogic) activeTrial(userId int64, db *gorm.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计算试用期时间
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
expireTime := tool.AddTime(l.svcCtx.Config.Register.TrialTimeUnit, l.svcCtx.Config.Register.TrialTime, startTime)
|
expireTime := tool.AddTime(l.svcCtx.Config.Register.TrialTimeUnit, l.svcCtx.Config.Register.TrialTime, startTime)
|
||||||
subscribeToken := uuidx.SubscribeToken(fmt.Sprintf("Trial-%v", userId))
|
subscribeToken := uuidx.SubscribeToken(fmt.Sprintf("Trial-%v", userId))
|
||||||
subscribeUUID := uuidx.NewUUID().String()
|
subscribeUUID := uuidx.NewUUID().String()
|
||||||
|
|
||||||
|
// 创建用户订阅记录
|
||||||
userSub := &user.Subscribe{
|
userSub := &user.Subscribe{
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
OrderId: 0,
|
OrderId: 0,
|
||||||
|
|||||||
@ -104,7 +104,8 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
|
|||||||
|
|
||||||
// Update password
|
// Update password
|
||||||
userInfo.Password = tool.EncodePassWord(req.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())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update user info failed: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -98,7 +98,7 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
|
|||||||
|
|
||||||
if req.TelephoneCode == "" {
|
if req.TelephoneCode == "" {
|
||||||
// Verify password
|
// 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")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -78,6 +78,7 @@ func (l *TelephoneResetPasswordLogic) TelephoneResetPassword(req *types.Telephon
|
|||||||
// Generate password
|
// Generate password
|
||||||
pwd := tool.EncodePassWord(req.Password)
|
pwd := tool.EncodePassWord(req.Password)
|
||||||
userInfo.Password = pwd
|
userInfo.Password = pwd
|
||||||
|
userInfo.Algo = "default"
|
||||||
err = l.svcCtx.UserModel.Update(l.ctx, userInfo)
|
err = l.svcCtx.UserModel.Update(l.ctx, userInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "update user password failed: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "update user password failed: %v", err.Error())
|
||||||
|
|||||||
@ -107,6 +107,7 @@ func (l *TelephoneUserRegisterLogic) TelephoneUserRegister(req *types.TelephoneR
|
|||||||
pwd := tool.EncodePassWord(req.Password)
|
pwd := tool.EncodePassWord(req.Password)
|
||||||
userInfo := &user.User{
|
userInfo := &user.User{
|
||||||
Password: pwd,
|
Password: pwd,
|
||||||
|
Algo: "default",
|
||||||
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
|
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
|
||||||
AuthMethods: []user.AuthMethods{
|
AuthMethods: []user.AuthMethods{
|
||||||
{
|
{
|
||||||
|
|||||||
@ -90,6 +90,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
|||||||
pwd := tool.EncodePassWord(req.Password)
|
pwd := tool.EncodePassWord(req.Password)
|
||||||
userInfo := &user.User{
|
userInfo := &user.User{
|
||||||
Password: pwd,
|
Password: pwd,
|
||||||
|
Algo: "default",
|
||||||
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
|
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
|
||||||
}
|
}
|
||||||
if referer != nil {
|
if referer != nil {
|
||||||
|
|||||||
@ -57,7 +57,7 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Verify sign
|
// 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 {
|
if !client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) && !l.svcCtx.Config.Debug {
|
||||||
l.Logger.Error("[EPayNotify] Verify sign failed")
|
l.Logger.Error("[EPayNotify] Verify sign failed")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -61,7 +61,18 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
|
|||||||
)
|
)
|
||||||
return err
|
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
|
// refund deduction amount to user deduction balance
|
||||||
if orderInfo.GiftAmount > 0 {
|
if orderInfo.GiftAmount > 0 {
|
||||||
userInfo, err := l.svcCtx.UserModel.FindOne(l.ctx, orderInfo.UserId)
|
userInfo, err := l.svcCtx.UserModel.FindOne(l.ctx, orderInfo.UserId)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package order
|
|||||||
import "github.com/perfect-panel/server/internal/types"
|
import "github.com/perfect-panel/server/internal/types"
|
||||||
|
|
||||||
func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64 {
|
func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64 {
|
||||||
var finalDiscount float64 = 1.0
|
var finalDiscount int64 = 100
|
||||||
|
|
||||||
for _, discount := range discounts {
|
for _, discount := range discounts {
|
||||||
if inputMonths >= discount.Quantity && discount.Discount < finalDiscount {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
func (l *GetSubscriptionLogic) GetSubscription(req *types.GetSubscriptionRequest) (resp *types.GetSubscriptionResponse, err error) {
|
||||||
resp = &types.GetSubscriptionResponse{
|
resp = &types.GetSubscriptionResponse{
|
||||||
List: make([]types.Subscribe, 0),
|
List: make([]types.Subscribe, 0),
|
||||||
|
|||||||
@ -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())
|
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Unmarshal error: %s", err.Error())
|
||||||
}
|
}
|
||||||
// Initialize EPay client with merchant credentials
|
// 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
|
// Convert order amount to CNY using current exchange rate
|
||||||
amount, err := l.queryExchangeRate("CNY", info.Amount)
|
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())
|
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Unmarshal error: %s", err.Error())
|
||||||
}
|
}
|
||||||
// Initialize EPay client with merchant credentials
|
// 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
|
// Convert order amount to CNY using current exchange rate
|
||||||
amount, err := l.queryExchangeRate("CNY", info.Amount)
|
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
|
// Convert cents to decimal amount
|
||||||
amount = float64(src) / float64(100)
|
amount = float64(src) / float64(100)
|
||||||
|
|
||||||
|
if l.svcCtx.ExchangeRate != 0 && to == "CNY" {
|
||||||
|
amount = amount * l.svcCtx.ExchangeRate
|
||||||
|
return amount, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Retrieve system currency configuration
|
// Retrieve system currency configuration
|
||||||
currency, err := l.svcCtx.SystemModel.GetCurrencyConfig(l.ctx)
|
currency, err := l.svcCtx.SystemModel.GetCurrencyConfig(l.ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -83,6 +83,12 @@ func (l *PurchaseLogic) Purchase(req *types.PortalPurchaseRequest) (resp *types.
|
|||||||
if couponInfo.Count != 0 && couponInfo.Count <= couponInfo.UsedCount {
|
if couponInfo.Count != 0 && couponInfo.Count <= couponInfo.UsedCount {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponInsufficientUsage), "coupon used")
|
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)
|
couponSub := tool.StringToInt64Slice(couponInfo.Subscribe)
|
||||||
if len(couponSub) > 0 && !tool.Contains(couponSub, req.SubscribeId) {
|
if len(couponSub) > 0 && !tool.Contains(couponSub, req.SubscribeId) {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponNotApplicable), "coupon not match")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponNotApplicable), "coupon not match")
|
||||||
|
|||||||
@ -7,14 +7,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64 {
|
func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64) float64 {
|
||||||
var finalDiscount float64 = 1.0
|
var finalDiscount int64 = 100
|
||||||
|
|
||||||
for _, discount := range discounts {
|
for _, discount := range discounts {
|
||||||
if inputMonths >= discount.Quantity && discount.Discount < finalDiscount {
|
if inputMonths >= discount.Quantity && discount.Discount < finalDiscount {
|
||||||
finalDiscount = discount.Discount
|
finalDiscount = discount.Discount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return finalDiscount
|
return float64(finalDiscount) / float64(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
func calculateCoupon(amount int64, couponInfo *coupon.Coupon) int64 {
|
func calculateCoupon(amount int64, couponInfo *coupon.Coupon) int64 {
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package subscribe
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
@ -54,35 +53,8 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi
|
|||||||
var discount []types.SubscribeDiscount
|
var discount []types.SubscribeDiscount
|
||||||
_ = json.Unmarshal([]byte(item.Discount), &discount)
|
_ = json.Unmarshal([]byte(item.Discount), &discount)
|
||||||
sub.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
|
list[i] = sub
|
||||||
}
|
}
|
||||||
resp.List = list
|
resp.List = list
|
||||||
|
|||||||
@ -141,7 +141,6 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u
|
|||||||
Name: n.Name,
|
Name: n.Name,
|
||||||
Uuid: userSub.UUID,
|
Uuid: userSub.UUID,
|
||||||
Protocol: n.Protocol,
|
Protocol: n.Protocol,
|
||||||
Protocols: server.Protocols,
|
|
||||||
Port: n.Port,
|
Port: n.Port,
|
||||||
Address: n.Address,
|
Address: n.Address,
|
||||||
Tags: strings.Split(n.Tags, ","),
|
Tags: strings.Split(n.Tags, ","),
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
341
internal/logic/public/user/bindEmailWithVerificationLogic.go
Normal file
341
internal/logic/public/user/bindEmailWithVerificationLogic.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 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
|
var deleteDevice user.Device
|
||||||
err = tx.Model(&deleteDevice).Where("id = ?", req.Id).First(&deleteDevice).Error
|
err = tx.Model(&deleteDevice).Where("id = ?", req.Id).First(&deleteDevice).Error
|
||||||
if err != nil {
|
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
|
err = tx.Model(&userAuth).Where("auth_identifier = ? and auth_type = ?", deleteDevice.Identifier, "device").First(&userAuth).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
l.Infow("设备认证方法不存在,可能已被删除",
|
||||||
|
logger.Field("device_identifier", deleteDevice.Identifier),
|
||||||
|
logger.Field("user_id", userId))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find device online record err: %v", err)
|
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 {
|
if err != nil {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete device online record err: %v", err)
|
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.Infow("设备解绑成功",
|
||||||
l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey)
|
logger.Field("device_id", req.Id),
|
||||||
|
logger.Field("device_identifier", deviceIdentifier),
|
||||||
|
logger.Field("user_id", userId))
|
||||||
|
|
||||||
return nil
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,50 +30,37 @@ func NewUpdateBindEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateBindEmail 更新用户绑定的邮箱地址
|
|
||||||
// 该方法用于用户更新或绑定新的邮箱地址,支持首次绑定和修改已绑定邮箱
|
|
||||||
func (l *UpdateBindEmailLogic) UpdateBindEmail(req *types.UpdateBindEmailRequest) error {
|
func (l *UpdateBindEmailLogic) UpdateBindEmail(req *types.UpdateBindEmailRequest) error {
|
||||||
// 从上下文中获取当前用户信息
|
|
||||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Error("current user is not found in context")
|
logger.Error("current user is not found in context")
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询当前用户是否已有邮箱认证方式
|
|
||||||
method, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", u.Id)
|
method, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", u.Id)
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查要绑定的邮箱是否已被其他用户使用
|
|
||||||
m, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
|
m, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
|
||||||
}
|
}
|
||||||
|
// email already bind
|
||||||
// 如果邮箱已被绑定,返回错误
|
|
||||||
if m.Id > 0 {
|
if m.Id > 0 {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "email already bind")
|
return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "email already bind")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果用户还没有邮箱认证方式,创建新的认证记录
|
|
||||||
if method.Id == 0 {
|
if method.Id == 0 {
|
||||||
method = &user.AuthMethods{
|
method = &user.AuthMethods{
|
||||||
UserId: u.Id, // 用户ID
|
UserId: u.Id,
|
||||||
AuthType: "email", // 认证类型为邮箱
|
AuthType: "email",
|
||||||
AuthIdentifier: req.Email, // 邮箱地址
|
AuthIdentifier: req.Email,
|
||||||
Verified: false, // 初始状态为未验证
|
Verified: false,
|
||||||
}
|
}
|
||||||
// 插入新的认证方式记录
|
|
||||||
if err := l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, method); err != nil {
|
if err := l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, method); err != nil {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error")
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果用户已有邮箱认证方式,更新邮箱地址
|
method.Verified = false
|
||||||
method.Verified = false // 重置验证状态
|
method.AuthIdentifier = req.Email
|
||||||
method.AuthIdentifier = req.Email // 更新邮箱地址
|
|
||||||
// 更新认证方式记录
|
|
||||||
if err := l.svcCtx.UserModel.UpdateUserAuthMethods(l.ctx, method); err != nil {
|
if err := l.svcCtx.UserModel.UpdateUserAuthMethods(l.ctx, method); err != nil {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateUserAuthMethods error")
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateUserAuthMethods error")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,6 +88,7 @@ type EPayConfig struct {
|
|||||||
Pid string `json:"pid"`
|
Pid string `json:"pid"`
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *EPayConfig) Marshal() ([]byte, error) {
|
func (l *EPayConfig) Marshal() ([]byte, error) {
|
||||||
@ -109,6 +110,7 @@ type CryptoSaaSConfig struct {
|
|||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
AccountID string `json:"account_id"`
|
AccountID string `json:"account_id"`
|
||||||
SecretKey string `json:"secret_key"`
|
SecretKey string `json:"secret_key"`
|
||||||
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *CryptoSaaSConfig) Marshal() ([]byte, error) {
|
func (l *CryptoSaaSConfig) Marshal() ([]byte, error) {
|
||||||
|
|||||||
@ -72,7 +72,7 @@ func (s *Subscribe) BeforeUpdate(tx *gorm.DB) error {
|
|||||||
|
|
||||||
type Discount struct {
|
type Discount struct {
|
||||||
Months int64 `json:"months"`
|
Months int64 `json:"months"`
|
||||||
Discount float64 `json:"discount"`
|
Discount int64 `json:"discount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Group struct {
|
type Group struct {
|
||||||
|
|||||||
@ -20,7 +20,10 @@ func (m *defaultUserModel) FindUserAuthMethodByOpenID(ctx context.Context, metho
|
|||||||
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, v interface{}) error {
|
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 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) {
|
func (m *defaultUserModel) FindUserAuthMethodByPlatform(ctx context.Context, userId int64, platform string) (*AuthMethods, error) {
|
||||||
|
|||||||
@ -77,7 +77,6 @@ type customUserLogicModel interface {
|
|||||||
QueryUserSubscribe(ctx context.Context, userId int64, status ...int64) ([]*SubscribeDetails, error)
|
QueryUserSubscribe(ctx context.Context, userId int64, status ...int64) ([]*SubscribeDetails, error)
|
||||||
FindOneSubscribeDetailsById(ctx context.Context, id int64) (*SubscribeDetails, error)
|
FindOneSubscribeDetailsById(ctx context.Context, id int64) (*SubscribeDetails, error)
|
||||||
FindOneUserSubscribe(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)
|
FindUsersSubscribeBySubscribeId(ctx context.Context, subscribeId int64) ([]*Subscribe, error)
|
||||||
UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, tx ...*gorm.DB) error
|
UpdateUserSubscribeWithTraffic(ctx context.Context, id, download, upload int64, tx ...*gorm.DB) error
|
||||||
QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error)
|
QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error)
|
||||||
@ -87,6 +86,7 @@ type customUserLogicModel interface {
|
|||||||
UpdateUserCache(ctx context.Context, data *User) error
|
UpdateUserCache(ctx context.Context, data *User) error
|
||||||
UpdateUserSubscribeCache(ctx context.Context, data *Subscribe) error
|
UpdateUserSubscribeCache(ctx context.Context, data *Subscribe) error
|
||||||
QueryActiveSubscriptions(ctx context.Context, subscribeId ...int64) (map[int64]int64, 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)
|
FindUserAuthMethods(ctx context.Context, userId int64) ([]*AuthMethods, error)
|
||||||
InsertUserAuthMethods(ctx context.Context, data *AuthMethods, tx ...*gorm.DB) error
|
InsertUserAuthMethods(ctx context.Context, data *AuthMethods, tx ...*gorm.DB) error
|
||||||
UpdateUserAuthMethods(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
|
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
|
// QueryMonthlyUserStatisticsList Query monthly user statistics list for the past 6 months
|
||||||
func (m *customUserModel) QueryMonthlyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error) {
|
func (m *customUserModel) QueryMonthlyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error) {
|
||||||
var results []UserStatisticsWithDate
|
var results []UserStatisticsWithDate
|
||||||
|
|||||||
@ -198,24 +198,3 @@ func (m *defaultUserModel) DeleteSubscribeById(ctx context.Context, id int64, tx
|
|||||||
func (m *defaultUserModel) ClearSubscribeCache(ctx context.Context, data ...*Subscribe) error {
|
func (m *defaultUserModel) ClearSubscribeCache(ctx context.Context, data ...*Subscribe) error {
|
||||||
return m.ClearSubscribeCacheByModels(ctx, data...)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,27 +4,30 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// User 用户模型结构体
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int64 `gorm:"primaryKey"`
|
Id int64 `gorm:"primaryKey"` // 用户主键ID
|
||||||
Password string `gorm:"type:varchar(100);not null;comment:User Password"`
|
Password string `gorm:"type:varchar(100);not null;comment:User Password"` // 用户密码(加密存储)
|
||||||
Avatar string `gorm:"type:MEDIUMTEXT;comment:User Avatar"`
|
Algo string `gorm:"type:varchar(20);default:'default';comment:Encryption Algorithm"` // 密码加密算法
|
||||||
Balance int64 `gorm:"default:0;comment:User Balance"` // User Balance Amount
|
Salt string `gorm:"type:varchar(20);default:null;comment:Password Salt"` // 密码盐值
|
||||||
ReferCode string `gorm:"type:varchar(20);default:'';comment:Referral Code"`
|
Avatar string `gorm:"type:MEDIUMTEXT;comment:User Avatar"` // 用户头像
|
||||||
RefererId int64 `gorm:"index:idx_referer;comment:Referrer ID"`
|
Balance int64 `gorm:"default:0;comment:User Balance"` // 用户余额(以分为单位)
|
||||||
Commission int64 `gorm:"default:0;comment:Commission"` // Commission Amount
|
ReferCode string `gorm:"type:varchar(20);default:'';comment:Referral Code"` // 用户推荐码
|
||||||
ReferralPercentage uint8 `gorm:"default:0;comment:Referral"` // Referral Percentage
|
RefererId int64 `gorm:"index:idx_referer;comment:Referrer ID"` // 推荐人ID
|
||||||
OnlyFirstPurchase *bool `gorm:"default:true;not null;comment:Only First Purchase"` // Only First Purchase Referral
|
Commission int64 `gorm:"default:0;comment:Commission"` // 佣金金额
|
||||||
GiftAmount int64 `gorm:"default:0;comment:User Gift Amount"`
|
ReferralPercentage uint8 `gorm:"default:0;comment:Referral"` // 推荐奖励百分比
|
||||||
Enable *bool `gorm:"default:true;not null;comment:Is Account Enabled"`
|
OnlyFirstPurchase *bool `gorm:"default:true;not null;comment:Only First Purchase"` // 是否仅首次购买给推荐奖励
|
||||||
IsAdmin *bool `gorm:"default:false;not null;comment:Is Admin"`
|
GiftAmount int64 `gorm:"default:0;comment:User Gift Amount"` // 用户赠送金额
|
||||||
EnableBalanceNotify *bool `gorm:"default:false;not null;comment:Enable Balance Change Notifications"`
|
Enable *bool `gorm:"default:true;not null;comment:Is Account Enabled"` // 账户是否启用
|
||||||
EnableLoginNotify *bool `gorm:"default:false;not null;comment:Enable Login Notifications"`
|
IsAdmin *bool `gorm:"default:false;not null;comment:Is Admin"` // 是否为管理员
|
||||||
EnableSubscribeNotify *bool `gorm:"default:false;not null;comment:Enable Subscription Notifications"`
|
EnableBalanceNotify *bool `gorm:"default:false;not null;comment:Enable Balance Change Notifications"` // 是否启用余额变动通知
|
||||||
EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"`
|
EnableLoginNotify *bool `gorm:"default:false;not null;comment:Enable Login Notifications"` // 是否启用登录通知
|
||||||
AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"`
|
EnableSubscribeNotify *bool `gorm:"default:false;not null;comment:Enable Subscription Notifications"` // 是否启用订阅通知
|
||||||
UserDevices []Device `gorm:"foreignKey:UserId;references:Id"`
|
EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"` // 是否启用交易通知
|
||||||
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"` // 用户认证方式列表
|
||||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
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 {
|
func (*User) TableName() string {
|
||||||
|
|||||||
@ -36,6 +36,8 @@ type ServiceContext struct {
|
|||||||
Redis *redis.Client
|
Redis *redis.Client
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Queue *asynq.Client
|
Queue *asynq.Client
|
||||||
|
ExchangeRate float64
|
||||||
|
|
||||||
//NodeCache *cache.NodeCacheClient
|
//NodeCache *cache.NodeCacheClient
|
||||||
AuthModel auth.Model
|
AuthModel auth.Model
|
||||||
AdsModel ads.Model
|
AdsModel ads.Model
|
||||||
@ -86,6 +88,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
|||||||
Redis: rds,
|
Redis: rds,
|
||||||
Config: c,
|
Config: c,
|
||||||
Queue: NewAsynqClient(c),
|
Queue: NewAsynqClient(c),
|
||||||
|
ExchangeRate: 1.0,
|
||||||
//NodeCache: cache.NewNodeCacheClient(rds),
|
//NodeCache: cache.NewNodeCacheClient(rds),
|
||||||
AuthLimiter: authLimiter,
|
AuthLimiter: authLimiter,
|
||||||
AdsModel: ads.NewModel(db, rds),
|
AdsModel: ads.NewModel(db, rds),
|
||||||
|
|||||||
@ -178,15 +178,6 @@ type BatchSendEmailTask struct {
|
|||||||
UpdatedAt int64 `json:"updated_at"`
|
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 {
|
type BindOAuthCallbackRequest struct {
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Callback interface{} `json:"callback"`
|
Callback interface{} `json:"callback"`
|
||||||
@ -201,6 +192,10 @@ type BindOAuthResponse struct {
|
|||||||
Redirect string `json:"redirect"`
|
Redirect string `json:"redirect"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BindInviteCodeRequest struct {
|
||||||
|
InviteCode string `json:"invite_code" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type BindTelegramResponse struct {
|
type BindTelegramResponse struct {
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
ExpiredAt int64 `json:"expired_at"`
|
ExpiredAt int64 `json:"expired_at"`
|
||||||
@ -1179,7 +1174,6 @@ type InviteConfig struct {
|
|||||||
ForcedInvite bool `json:"forced_invite"`
|
ForcedInvite bool `json:"forced_invite"`
|
||||||
ReferralPercentage int64 `json:"referral_percentage"`
|
ReferralPercentage int64 `json:"referral_percentage"`
|
||||||
OnlyFirstPurchase bool `json:"only_first_purchase"`
|
OnlyFirstPurchase bool `json:"only_first_purchase"`
|
||||||
GiftDays int64 `json:"gift_days"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type KickOfflineRequest struct {
|
type KickOfflineRequest struct {
|
||||||
@ -2077,7 +2071,7 @@ type SubscribeConfig struct {
|
|||||||
|
|
||||||
type SubscribeDiscount struct {
|
type SubscribeDiscount struct {
|
||||||
Quantity int64 `json:"quantity"`
|
Quantity int64 `json:"quantity"`
|
||||||
Discount float64 `json:"discount"`
|
Discount int64 `json:"discount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubscribeGroup struct {
|
type SubscribeGroup struct {
|
||||||
@ -2645,7 +2639,6 @@ type UserSubscribeNodeInfo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Uuid string `json:"uuid"`
|
Uuid string `json:"uuid"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Protocols string `json:"protocols"`
|
|
||||||
Port uint16 `json:"port"`
|
Port uint16 `json:"port"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
@ -2702,6 +2695,18 @@ type VerifyEmailRequest struct {
|
|||||||
Code string `json:"code" validate:"required"`
|
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 {
|
type VersionResponse struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ type Client struct {
|
|||||||
Pid string
|
Pid string
|
||||||
Url string
|
Url string
|
||||||
Key string
|
Key string
|
||||||
|
Type string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Order struct {
|
type Order struct {
|
||||||
@ -37,11 +38,12 @@ type queryOrderStatusResponse struct {
|
|||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(pid, url, key string) *Client {
|
func NewClient(pid, url, key string, Type string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
Pid: pid,
|
Pid: pid,
|
||||||
Url: url,
|
Url: url,
|
||||||
Key: key,
|
Key: key,
|
||||||
|
Type: Type,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,6 +55,7 @@ func (c *Client) CreatePayUrl(order Order) string {
|
|||||||
params.Set("notify_url", order.NotifyUrl)
|
params.Set("notify_url", order.NotifyUrl)
|
||||||
params.Set("out_trade_no", order.OrderNo)
|
params.Set("out_trade_no", order.OrderNo)
|
||||||
params.Set("pid", c.Pid)
|
params.Set("pid", c.Pid)
|
||||||
|
params.Set("type", c.Type)
|
||||||
params.Set("return_url", order.ReturnUrl)
|
params.Set("return_url", order.ReturnUrl)
|
||||||
|
|
||||||
// Generate the sign using the CreateSign function
|
// 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["notify_url"] = order.NotifyUrl
|
||||||
result["out_trade_no"] = order.OrderNo
|
result["out_trade_no"] = order.OrderNo
|
||||||
result["pid"] = c.Pid
|
result["pid"] = c.Pid
|
||||||
|
result["type"] = c.Type
|
||||||
result["return_url"] = order.ReturnUrl
|
result["return_url"] = order.ReturnUrl
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package epay
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestEpay(t *testing.T) {
|
func TestEpay(t *testing.T) {
|
||||||
client := NewClient("", "http://127.0.0.1", "")
|
client := NewClient("", "http://127.0.0.1", "", "")
|
||||||
order := Order{
|
order := Order{
|
||||||
Name: "测试",
|
Name: "测试",
|
||||||
OrderNo: "123456789",
|
OrderNo: "123456789",
|
||||||
@ -19,7 +19,7 @@ func TestEpay(t *testing.T) {
|
|||||||
|
|
||||||
func TestQueryOrderStatus(t *testing.T) {
|
func TestQueryOrderStatus(t *testing.T) {
|
||||||
t.Skipf("Skip TestQueryOrderStatus test")
|
t.Skipf("Skip TestQueryOrderStatus test")
|
||||||
client := NewClient("Pid", "Url", "Key")
|
client := NewClient("Pid", "Url", "Key", "Type")
|
||||||
orderNo := "123456789"
|
orderNo := "123456789"
|
||||||
status := client.QueryOrderStatus(orderNo)
|
status := client.QueryOrderStatus(orderNo)
|
||||||
t.Logf("OrderNo: %s, Status: %v\n", orderNo, status)
|
t.Logf("OrderNo: %s, Status: %v\n", orderNo, status)
|
||||||
@ -40,7 +40,7 @@ func TestVerifySign(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := "LbTabbB580zWyhXhyyww7wwvy5u8k0wl"
|
key := "LbTabbB580zWyhXhyyww7wwvy5u8k0wl"
|
||||||
c := NewClient("Pid", "Url", key)
|
c := NewClient("Pid", "Url", key, "Type")
|
||||||
if c.VerifySign(params) {
|
if c.VerifySign(params) {
|
||||||
t.Logf("Sign verification success!")
|
t.Logf("Sign verification success!")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -68,6 +68,7 @@ func GetSupportedPlatforms() []types.PlatformInfo {
|
|||||||
"pid": "PID",
|
"pid": "PID",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
|
"type": "Type",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,12 +2,14 @@ package tool
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/sha512"
|
"crypto/sha512"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/anaskhan96/go-password-encoder"
|
"github.com/anaskhan96/go-password-encoder"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var options = &password.Options{SaltLen: 16, Iterations: 100, KeyLen: 32, HashFunction: sha512.New}
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
package tool
|
package tool
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestEncodePassWord(t *testing.T) {
|
func TestEncodePassWord(t *testing.T) {
|
||||||
t.Logf("EncodePassWord: %v", EncodePassWord("password"))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -57,6 +57,7 @@ const (
|
|||||||
CouponAlreadyUsed uint32 = 50002 // Coupon has already been used
|
CouponAlreadyUsed uint32 = 50002 // Coupon has already been used
|
||||||
CouponNotApplicable uint32 = 50003 // Coupon does not match the order or conditions
|
CouponNotApplicable uint32 = 50003 // Coupon does not match the order or conditions
|
||||||
CouponInsufficientUsage uint32 = 50004 // Coupon has insufficient remaining uses
|
CouponInsufficientUsage uint32 = 50004 // Coupon has insufficient remaining uses
|
||||||
|
CouponExpired uint32 = 50005 // Coupon is expired
|
||||||
)
|
)
|
||||||
|
|
||||||
// Subscribe
|
// Subscribe
|
||||||
|
|||||||
@ -46,6 +46,7 @@ func init() {
|
|||||||
CouponAlreadyUsed: "Coupon has already been used",
|
CouponAlreadyUsed: "Coupon has already been used",
|
||||||
CouponNotApplicable: "Coupon does not match the order or conditions",
|
CouponNotApplicable: "Coupon does not match the order or conditions",
|
||||||
CouponInsufficientUsage: "Coupon has insufficient remaining uses",
|
CouponInsufficientUsage: "Coupon has insufficient remaining uses",
|
||||||
|
CouponExpired: "Coupon is expired",
|
||||||
|
|
||||||
// Subscribe
|
// Subscribe
|
||||||
SubscribeExpired: "Subscribe is expired",
|
SubscribeExpired: "Subscribe is expired",
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"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"
|
orderLogic "github.com/perfect-panel/server/queue/logic/order"
|
||||||
smslogic "github.com/perfect-panel/server/queue/logic/sms"
|
smslogic "github.com/perfect-panel/server/queue/logic/sms"
|
||||||
"github.com/perfect-panel/server/queue/logic/subscription"
|
"github.com/perfect-panel/server/queue/logic/subscription"
|
||||||
@ -15,8 +14,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) {
|
func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) {
|
||||||
// get country task
|
|
||||||
mux.Handle(types.ForthwithGetCountry, countrylogic.NewGetNodeCountryLogic(serverCtx))
|
|
||||||
// Send email task
|
// Send email task
|
||||||
mux.Handle(types.ForthwithSendEmail, emailLogic.NewSendEmailLogic(serverCtx))
|
mux.Handle(types.ForthwithSendEmail, emailLogic.NewSendEmailLogic(serverCtx))
|
||||||
// Send sms task
|
// Send sms task
|
||||||
|
|||||||
@ -179,8 +179,8 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle referral reward in separate goroutine to avoid blocking
|
// Handle commission in separate goroutine to avoid blocking
|
||||||
go l.handleReferralReward(context.Background(), userInfo, orderInfo)
|
go l.handleCommission(context.Background(), userInfo, orderInfo)
|
||||||
|
|
||||||
// Clear cache
|
// Clear cache
|
||||||
l.clearServerCache(ctx, sub)
|
l.clearServerCache(ctx, sub)
|
||||||
@ -192,12 +192,12 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
|
|||||||
return nil
|
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) {
|
func (l *ActivateOrderLogic) getUserOrCreate(ctx context.Context, orderInfo *order.Order) (*user.User, error) {
|
||||||
if orderInfo.UserId != 0 {
|
if orderInfo.UserId != 0 {
|
||||||
return l.getExistingUser(ctx, orderInfo.UserId)
|
return l.getExistingUser(ctx, orderInfo.UserId)
|
||||||
}
|
}
|
||||||
return l.createUserFromTempOrder(ctx, orderInfo)
|
return l.createGuestUser(ctx, orderInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getExistingUser retrieves user information by user ID
|
// getExistingUser retrieves user information by user ID
|
||||||
@ -213,9 +213,9 @@ func (l *ActivateOrderLogic) getExistingUser(ctx context.Context, userId int64)
|
|||||||
return userInfo, nil
|
return userInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createUserFromTempOrder creates a new user account using temporary order information
|
// createGuestUser creates a new user account for guest orders using temporary order information
|
||||||
// stored in Redis cache. All users created this way are formal users, not guests.
|
// stored in Redis cache
|
||||||
func (l *ActivateOrderLogic) createUserFromTempOrder(ctx context.Context, orderInfo *order.Order) (*user.User, error) {
|
func (l *ActivateOrderLogic) createGuestUser(ctx context.Context, orderInfo *order.Order) (*user.User, error) {
|
||||||
tempOrder, err := l.getTempOrderInfo(ctx, orderInfo.OrderNo)
|
tempOrder, err := l.getTempOrderInfo(ctx, orderInfo.OrderNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -223,6 +223,7 @@ func (l *ActivateOrderLogic) createUserFromTempOrder(ctx context.Context, orderI
|
|||||||
|
|
||||||
userInfo := &user.User{
|
userInfo := &user.User{
|
||||||
Password: tool.EncodePassWord(tempOrder.Password),
|
Password: tool.EncodePassWord(tempOrder.Password),
|
||||||
|
Algo: "default",
|
||||||
AuthMethods: []user.AuthMethods{
|
AuthMethods: []user.AuthMethods{
|
||||||
{
|
{
|
||||||
AuthType: tempOrder.AuthType,
|
AuthType: tempOrder.AuthType,
|
||||||
@ -253,7 +254,7 @@ func (l *ActivateOrderLogic) createUserFromTempOrder(ctx context.Context, orderI
|
|||||||
// Handle referrer relationship
|
// Handle referrer relationship
|
||||||
l.handleReferrer(ctx, userInfo, tempOrder.InviteCode)
|
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("user_id", userInfo.Id),
|
||||||
logger.Field("identifier", tempOrder.Identifier),
|
logger.Field("identifier", tempOrder.Identifier),
|
||||||
logger.Field("auth_type", tempOrder.AuthType),
|
logger.Field("auth_type", tempOrder.AuthType),
|
||||||
@ -349,12 +350,10 @@ func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderIn
|
|||||||
return userSub, nil
|
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.
|
// This runs asynchronously to avoid blocking the main order processing flow.
|
||||||
// If referral percentage > 0: commission reward
|
func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *user.User, orderInfo *order.Order) {
|
||||||
// If referral percentage = 0: gift days to both parties
|
if !l.shouldProcessCommission(userInfo, orderInfo.IsNew) {
|
||||||
func (l *ActivateOrderLogic) handleReferralReward(ctx context.Context, userInfo *user.User, orderInfo *order.Order) {
|
|
||||||
if !l.shouldProcessReferralReward(userInfo, orderInfo.IsNew) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -374,25 +373,13 @@ func (l *ActivateOrderLogic) handleReferralReward(ctx context.Context, userInfo
|
|||||||
referralPercentage = uint8(l.svc.Config.Invite.ReferralPercentage)
|
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
|
// 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
|
// 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
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -434,73 +421,9 @@ func (l *ActivateOrderLogic) processCommissionReward(ctx context.Context, refere
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// processGiftDaysReward handles gift days rewards for both parties
|
// shouldProcessCommission determines if commission should be processed based on
|
||||||
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
|
|
||||||
// referrer existence, commission settings, and order type
|
// 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 {
|
if userInfo == nil || userInfo.RefererId == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -582,8 +505,8 @@ func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order
|
|||||||
// Clear cache
|
// Clear cache
|
||||||
l.clearServerCache(ctx, sub)
|
l.clearServerCache(ctx, sub)
|
||||||
|
|
||||||
// Handle referral reward
|
// Handle commission
|
||||||
go l.handleReferralReward(context.Background(), userInfo, orderInfo)
|
go l.handleCommission(context.Background(), userInfo, orderInfo)
|
||||||
|
|
||||||
// Send notifications
|
// Send notifications
|
||||||
l.sendNotifications(ctx, orderInfo, userInfo, sub, userSub, telegram.RenewalNotify)
|
l.sendNotifications(ctx, orderInfo, userInfo, sub, userSub, telegram.RenewalNotify)
|
||||||
|
|||||||
52
queue/logic/task/rateLogic.go
Normal file
52
queue/logic/task/rateLogic.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -167,7 +167,7 @@ func (l *StatLogic) ProcessTask(ctx context.Context, _ *asynq.Task) error {
|
|||||||
|
|
||||||
// Delete old traffic logs
|
// Delete old traffic logs
|
||||||
if l.svc.Config.Log.AutoClear {
|
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 {
|
if err != nil {
|
||||||
logger.Errorf("[Traffic Stat Queue] Delete server traffic log failed: %v", err.Error())
|
logger.Errorf("[Traffic Stat Queue] Delete server traffic log failed: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,4 +6,7 @@ const (
|
|||||||
|
|
||||||
// ForthwithQuotaTask create quota task immediately
|
// ForthwithQuotaTask create quota task immediately
|
||||||
ForthwithQuotaTask = "forthwith:quota:task"
|
ForthwithQuotaTask = "forthwith:quota:task"
|
||||||
|
|
||||||
|
// SchedulerExchangeRate fetch exchange rate task
|
||||||
|
SchedulerExchangeRate = "scheduler:exchange:rate"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -46,6 +46,12 @@ func (m *Service) Start() {
|
|||||||
logger.Errorf("register traffic stat task failed: %s", err.Error())
|
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 {
|
if err := m.server.Run(); err != nil {
|
||||||
logger.Errorf("run scheduler failed: %s", err.Error())
|
logger.Errorf("run scheduler failed: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user