Compare commits

..

7 Commits

Author SHA1 Message Date
62cf68b49b x
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m35s
2026-04-04 11:46:06 -07:00
e818ac8764 x
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m38s
2026-04-04 09:41:41 -07:00
98d8525fa9 refactor: 简化 trial 赠送配置,删除多余的白名单启用开关
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m42s
**简化方案**:
- 删除 `EnableTrialEmailWhitelist` 配置字段(多余)
- 保留 `TrialEmailDomainWhitelist`(逗号分隔的白名单域名)
- 赠送规则:白名单**非空** 且 **邮箱在列表** 才赠送

**新的赠送逻辑**:
```yaml
TrialEmailDomainWhitelist: "qq.com,163.com,gmail.com"
```
- 为空 →  不赠送(关闭)
- 非空 →  赠送给白名单域名的邮箱

**更新的地方**:
1. internal/config/config.go - 删除 EnableTrialEmailWhitelist 字段
2. userRegisterLogic.go - 简化赠送逻辑
3. emailLoginLogic.go - 简化赠送逻辑
4. bindEmailWithVerificationLogic.go - 简化赠送逻辑
5. oAuthLoginGetTokenLogic.go - 简化赠送逻辑

**配置升级说明**:
旧配置:
```yaml
EnableTrial: true
EnableTrialEmailWhitelist: true
TrialEmailDomainWhitelist: "qq.com,163.com"
```

新配置(只保留两项):
```yaml
EnableTrial: true
TrialEmailDomainWhitelist: "qq.com,163.com"
```

关闭赠送:
```yaml
EnableTrial: true
TrialEmailDomainWhitelist: ""
```

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-03 07:20:10 -07:00
19777df2ed fix: bindEmailWithVerificationLogic 邮箱绑定后赠送逻辑错误
**问题**: 邮箱绑定后应该赠送 trial 的逻辑判断错误,导致以下场景无法赠送:
- EnableTrial=true, EnableTrialEmailWhitelist=false → 应该赠送但未赠送

**根本原因**: 第215行条件判断使用 OR 逻辑,要求白名单必须启用才处理
```go
if !rc.EnableTrial || !rc.EnableTrialEmailWhitelist {
    return  //  错误:关闭白名单时也返回,无法赠送
}
```

**修复**: 改为正确的逻辑
```go
if !rc.EnableTrial {
    return  // 关闭赠送时不处理
}
if rc.EnableTrialEmailWhitelist && !IsEmailDomainWhitelisted(...) {
    return  // 白名单启用但域名不匹配时不赠送
}
// 否则赠送
```

**影响场景**:
- 设备登录 → 绑定邮箱 → 应该赠送 trial
  - 当 EnableTrialEmailWhitelist=false 时,应该赠送(修复前未赠送)
  - 当 EnableTrialEmailWhitelist=true 且域名在白名单 → 赠送(修复前未赠送)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-03 07:17:27 -07:00
d586bbeabb fix: OAuth registration missing email domain whitelist check for trial gifting
**Problem**: OAuth registration path (Google, Apple, Telegram) was missing the
email domain whitelist validation, causing trial subscriptions to be granted to
all users regardless of the whitelist configuration.

**Root Cause**: The previous commit (3417da2a) that implemented trial domain
whitelist only updated device/phone/email direct registration paths, but
missed the OAuth registration path in oAuthLoginGetTokenLogic.go.

**Solution**:
- Added email domain whitelist check to OAuth register() method
- Added isEmailDomainWhitelisted() helper function matching the pattern
  used in other auth logic files
- Only activate trial if EnableTrial=true AND
  (whitelist disabled OR email domain matches whitelist)
- Added email logging to trial subscription activation log

Affected flows:
- OAuth Google login with new user
- OAuth Apple login with new user
- OAuth Telegram login with new user

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-03 06:44:33 -07:00
92f278d38b x
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m50s
2026-04-02 22:17:31 -07:00
3417da2a9e feat: trial 赠送改为白名单邮箱域名控制
Some checks failed
Build docker and publish / build (20.15.1) (push) Failing after 7m14s
- 新增 EnableTrialEmailWhitelist + TrialEmailDomainWhitelist 配置
- 邮箱注册/登录:加白名单域名判断,域名匹配才赠送 trial
- 设备登录/手机注册:移除 activeTrial,不再自动赠送
- 绑定邮箱(bindEmailWithVerification):绑定成功后检查白名单+防重复赠送
- 新增 IsEmailDomainWhitelisted 导出函数供跨包调用
- 清理 deviceLoginLogic/telephoneUserRegisterLogic 中的 activeTrial 死代码

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-02 05:46:04 -07:00
17 changed files with 829 additions and 166 deletions

579
.codex-tmp/ua999_cleanup.go Normal file
View File

@ -0,0 +1,579 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"os"
"sort"
"strings"
"time"
"github.com/redis/go-redis/v9"
_ "github.com/go-sql-driver/mysql"
"gopkg.in/yaml.v3"
)
type appConfig struct {
MySQL struct {
Addr string `yaml:"Addr"`
Username string `yaml:"Username"`
Password string `yaml:"Password"`
Dbname string `yaml:"Dbname"`
Config string `yaml:"Config"`
} `yaml:"MySQL"`
Redis struct {
Host string `yaml:"Host"`
Pass string `yaml:"Pass"`
DB int `yaml:"DB"`
} `yaml:"Redis"`
}
type userRow struct {
ID int64 `json:"id"`
ReferCode string `json:"refer_code"`
Balance int64 `json:"balance"`
Commission int64 `json:"commission"`
GiftAmount int64 `json:"gift_amount"`
Enable bool `json:"enable"`
IsAdmin bool `json:"is_admin"`
ValidEmail bool `json:"valid_email"`
MemberStatus string `json:"member_status"`
CreatedAt time.Time `json:"created_at"`
DeletedAt sql.NullTime `json:"-"`
}
type authMethod struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
AuthType string `json:"auth_type"`
Identifier string `json:"identifier"`
Verified bool `json:"verified"`
CreatedAt time.Time `json:"created_at"`
}
type deviceInfo struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Identifier string `json:"identifier"`
ShortCode string `json:"short_code"`
Online bool `json:"online"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
type subscribeInfo struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
OrderID int64 `json:"order_id"`
SubscribeID int64 `json:"subscribe_id"`
Token string `json:"token"`
UUID string `json:"uuid"`
Status uint8 `json:"status"`
StartTime time.Time `json:"start_time"`
ExpireTime time.Time `json:"expire_time"`
}
type familyInfo struct {
FamilyID int64 `json:"family_id"`
OwnerUserID int64 `json:"owner_user_id"`
IsOwner bool `json:"is_owner"`
MemberCount int64 `json:"member_count"`
}
type userSummary struct {
User userRow `json:"user"`
AuthMethods []authMethod `json:"auth_methods"`
Devices []deviceInfo `json:"devices"`
Subscriptions []subscribeInfo `json:"subscriptions"`
Family *familyInfo `json:"family,omitempty"`
OrderCount int64 `json:"order_count"`
TicketCount int64 `json:"ticket_count"`
TrafficLogCount int64 `json:"traffic_log_count"`
SystemLogCount int64 `json:"system_log_count"`
WithdrawalCount int64 `json:"withdrawal_count"`
IAPTransactionCount int64 `json:"iap_transaction_count"`
LogMessageCount int64 `json:"log_message_count"`
OnlineRecordCount int64 `json:"online_record_count"`
}
type deleteResult struct {
UserID int64 `json:"user_id"`
DeletedDBRows []string `json:"deleted_db_rows"`
DeletedRedisKeys int `json:"deleted_redis_keys"`
}
func must(err error) {
if err != nil {
log.Fatal(err)
}
}
func main() {
ctx := context.Background()
cfg := loadConfig("/Users/Apple/code_vpn/vpn/ppanel-server/etc/ppanel.yaml")
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", cfg.MySQL.Username, cfg.MySQL.Password, cfg.MySQL.Addr, cfg.MySQL.Dbname, cfg.MySQL.Config)
db, err := sql.Open("mysql", dsn)
must(err)
defer db.Close()
must(db.PingContext(ctx))
rdb := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Host,
Password: cfg.Redis.Pass,
DB: cfg.Redis.DB,
})
defer rdb.Close()
must(rdb.Ping(ctx).Err())
targetUserIDs, err := findTargetUsers(ctx, db)
must(err)
if len(targetUserIDs) == 0 {
fmt.Println(`{"matched_users":[],"deleted":[]}`)
return
}
summaries := make([]userSummary, 0, len(targetUserIDs))
for _, userID := range targetUserIDs {
summary, sumErr := collectSummary(ctx, db, userID)
must(sumErr)
summaries = append(summaries, summary)
}
before, err := json.MarshalIndent(map[string]interface{}{
"matched_users": summaries,
}, "", " ")
must(err)
fmt.Println(string(before))
results := make([]deleteResult, 0, len(targetUserIDs))
for _, summary := range summaries {
result, delErr := deleteUser(ctx, db, rdb, summary)
must(delErr)
results = append(results, result)
}
after, err := json.MarshalIndent(map[string]interface{}{
"deleted": results,
}, "", " ")
must(err)
fmt.Println(string(after))
}
func loadConfig(path string) appConfig {
content, err := os.ReadFile(path)
must(err)
var cfg appConfig
must(yaml.Unmarshal(content, &cfg))
return cfg
}
func findTargetUsers(ctx context.Context, db *sql.DB) ([]int64, error) {
rows, err := db.QueryContext(ctx, `
SELECT DISTINCT user_id
FROM user_device
WHERE user_agent LIKE ?
ORDER BY user_id ASC
`, "%999%")
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func collectSummary(ctx context.Context, db *sql.DB, userID int64) (userSummary, error) {
var summary userSummary
summary.User.ID = userID
err := db.QueryRowContext(ctx, `
SELECT id, refer_code, balance, commission, gift_amount, enable, is_admin, valid_email, member_status, created_at, deleted_at
FROM user
WHERE id = ?
`, userID).Scan(
&summary.User.ID,
&summary.User.ReferCode,
&summary.User.Balance,
&summary.User.Commission,
&summary.User.GiftAmount,
&summary.User.Enable,
&summary.User.IsAdmin,
&summary.User.ValidEmail,
&summary.User.MemberStatus,
&summary.User.CreatedAt,
&summary.User.DeletedAt,
)
if err != nil {
return summary, err
}
summary.AuthMethods, err = queryAuthMethods(ctx, db, userID)
if err != nil {
return summary, err
}
summary.Devices, err = queryDevices(ctx, db, userID)
if err != nil {
return summary, err
}
summary.Subscriptions, err = querySubscriptions(ctx, db, userID)
if err != nil {
return summary, err
}
summary.Family, err = queryFamily(ctx, db, userID)
if err != nil {
return summary, err
}
if summary.OrderCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM `order` WHERE user_id = ?", userID); err != nil {
return summary, err
}
if summary.TicketCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM ticket WHERE user_id = ?", userID); err != nil {
return summary, err
}
if summary.TrafficLogCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM traffic_log WHERE user_id = ?", userID); err != nil {
return summary, err
}
if summary.SystemLogCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM system_logs WHERE object_id = ?", userID); err != nil {
return summary, err
}
if summary.WithdrawalCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM user_withdrawal WHERE user_id = ?", userID); err != nil {
return summary, err
}
if summary.IAPTransactionCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM apple_iap_transactions WHERE user_id = ?", userID); err != nil {
return summary, err
}
if summary.LogMessageCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM log_message WHERE user_id = ?", userID); err != nil {
return summary, err
}
if summary.OnlineRecordCount, err = queryCount(ctx, db, "SELECT COUNT(*) FROM user_device_online_record WHERE user_id = ?", userID); err != nil {
return summary, err
}
return summary, nil
}
func queryAuthMethods(ctx context.Context, db *sql.DB, userID int64) ([]authMethod, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, auth_type, auth_identifier, verified, created_at
FROM user_auth_methods
WHERE user_id = ?
ORDER BY id ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []authMethod
for rows.Next() {
var item authMethod
if err := rows.Scan(&item.ID, &item.UserID, &item.AuthType, &item.Identifier, &item.Verified, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func queryDevices(ctx context.Context, db *sql.DB, userID int64) ([]deviceInfo, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, ip, user_agent, identifier, short_code, online, enabled, created_at
FROM user_device
WHERE user_id = ?
ORDER BY id ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []deviceInfo
for rows.Next() {
var item deviceInfo
if err := rows.Scan(&item.ID, &item.UserID, &item.IP, &item.UserAgent, &item.Identifier, &item.ShortCode, &item.Online, &item.Enabled, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func querySubscriptions(ctx context.Context, db *sql.DB, userID int64) ([]subscribeInfo, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, order_id, subscribe_id, token, uuid, status, start_time, expire_time
FROM user_subscribe
WHERE user_id = ?
ORDER BY id ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []subscribeInfo
for rows.Next() {
var item subscribeInfo
if err := rows.Scan(&item.ID, &item.UserID, &item.OrderID, &item.SubscribeID, &item.Token, &item.UUID, &item.Status, &item.StartTime, &item.ExpireTime); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func queryFamily(ctx context.Context, db *sql.DB, userID int64) (*familyInfo, error) {
var info familyInfo
err := db.QueryRowContext(ctx, `
SELECT ufm.family_id, uf.owner_user_id
FROM user_family_member ufm
JOIN user_family uf ON uf.id = ufm.family_id AND uf.deleted_at IS NULL
WHERE ufm.user_id = ? AND ufm.deleted_at IS NULL
LIMIT 1
`, userID).Scan(&info.FamilyID, &info.OwnerUserID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
info.IsOwner = info.OwnerUserID == userID
memberCount, err := queryCount(ctx, db, `
SELECT COUNT(*)
FROM user_family_member
WHERE family_id = ? AND deleted_at IS NULL
`, info.FamilyID)
if err != nil {
return nil, err
}
info.MemberCount = memberCount
return &info, nil
}
func queryCount(ctx context.Context, db *sql.DB, q string, arg interface{}) (int64, error) {
var count int64
err := db.QueryRowContext(ctx, q, arg).Scan(&count)
return count, err
}
func deleteUser(ctx context.Context, db *sql.DB, rdb *redis.Client, summary userSummary) (deleteResult, error) {
result := deleteResult{UserID: summary.User.ID}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return result, err
}
defer tx.Rollback()
if summary.Family != nil {
if summary.Family.IsOwner {
if res, err := tx.ExecContext(ctx, `DELETE FROM user_family_member WHERE family_id = ?`, summary.Family.FamilyID); err != nil {
return result, err
} else {
result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_family_member=%d", rowsAffected(res)))
}
if res, err := tx.ExecContext(ctx, `DELETE FROM user_family WHERE id = ?`, summary.Family.FamilyID); err != nil {
return result, err
} else {
result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_family=%d", rowsAffected(res)))
}
} else {
if res, err := tx.ExecContext(ctx, `DELETE FROM user_family_member WHERE user_id = ? AND family_id = ?`, summary.User.ID, summary.Family.FamilyID); err != nil {
return result, err
} else {
result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_family_member=%d", rowsAffected(res)))
}
}
}
if res, err := tx.ExecContext(ctx, `DELETE FROM user_auth_methods WHERE user_id = ?`, summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_auth_methods=%d", rowsAffected(res))) }
if res, err := tx.ExecContext(ctx, `DELETE FROM user_subscribe WHERE user_id = ?`, summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_subscribe=%d", rowsAffected(res))) }
if res, err := tx.ExecContext(ctx, `DELETE FROM user_device WHERE user_id = ?`, summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_device=%d", rowsAffected(res))) }
if res, err := tx.ExecContext(ctx, `DELETE FROM user_device_online_record WHERE user_id = ?`, summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_device_online_record=%d", rowsAffected(res))) }
if res, err := tx.ExecContext(ctx, `DELETE FROM user_withdrawal WHERE user_id = ?`, summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user_withdrawal=%d", rowsAffected(res))) }
if res, err := tx.ExecContext(ctx, "DELETE FROM `order` WHERE user_id = ?", summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("order=%d", rowsAffected(res))) }
if res, err := tx.ExecContext(ctx, `DELETE FROM traffic_log WHERE user_id = ?`, summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("traffic_log=%d", rowsAffected(res))) }
if res, err := tx.ExecContext(ctx, `DELETE FROM system_logs WHERE object_id = ?`, summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("system_logs=%d", rowsAffected(res))) }
var ticketIDs []int64
ticketRows, err := tx.QueryContext(ctx, `SELECT id FROM ticket WHERE user_id = ?`, summary.User.ID)
if err != nil {
return result, err
}
for ticketRows.Next() {
var id int64
if err := ticketRows.Scan(&id); err != nil {
ticketRows.Close()
return result, err
}
ticketIDs = append(ticketIDs, id)
}
ticketRows.Close()
if len(ticketIDs) > 0 {
holders := strings.TrimSuffix(strings.Repeat("?,", len(ticketIDs)), ",")
args := make([]interface{}, 0, len(ticketIDs))
for _, id := range ticketIDs {
args = append(args, id)
}
if res, err := tx.ExecContext(ctx, "DELETE FROM ticket_follow WHERE ticket_id IN ("+holders+")", args...); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("ticket_follow=%d", rowsAffected(res))) }
}
if res, err := tx.ExecContext(ctx, `DELETE FROM ticket WHERE user_id = ?`, summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("ticket=%d", rowsAffected(res))) }
if res, err := tx.ExecContext(ctx, `DELETE FROM apple_iap_transactions WHERE user_id = ?`, summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("apple_iap_transactions=%d", rowsAffected(res))) }
if res, err := tx.ExecContext(ctx, `DELETE FROM log_message WHERE user_id = ?`, summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("log_message=%d", rowsAffected(res))) }
if res, err := tx.ExecContext(ctx, `DELETE FROM user WHERE id = ?`, summary.User.ID); err != nil {
return result, err
} else { result.DeletedDBRows = append(result.DeletedDBRows, fmt.Sprintf("user=%d", rowsAffected(res))) }
if err := tx.Commit(); err != nil {
return result, err
}
redisKeys, err := cleanupRedis(ctx, rdb, summary)
if err != nil {
return result, err
}
result.DeletedRedisKeys = len(redisKeys)
sort.Strings(result.DeletedDBRows)
return result, nil
}
func cleanupRedis(ctx context.Context, rdb *redis.Client, summary userSummary) ([]string, error) {
keySet := map[string]struct{}{
fmt.Sprintf("cache:user:id:%d", summary.User.ID): {},
fmt.Sprintf("cache:user:subscribe:user:%d", summary.User.ID): {},
fmt.Sprintf("cache:user:subscribe:user:%d:all", summary.User.ID): {},
fmt.Sprintf("auth:user_sessions:%d", summary.User.ID): {},
}
for _, am := range summary.AuthMethods {
if am.AuthType == "email" && am.Identifier != "" {
keySet[fmt.Sprintf("cache:user:email:%s", am.Identifier)] = struct{}{}
}
}
for _, sub := range summary.Subscriptions {
keySet[fmt.Sprintf("cache:user:subscribe:id:%d", sub.ID)] = struct{}{}
if sub.Token != "" {
keySet[fmt.Sprintf("cache:user:subscribe:token:%s", sub.Token)] = struct{}{}
}
}
for _, device := range summary.Devices {
keySet[fmt.Sprintf("cache:user:device:id:%d", device.ID)] = struct{}{}
if device.Identifier != "" {
keySet[fmt.Sprintf("cache:user:device:number:%s", device.Identifier)] = struct{}{}
keySet[fmt.Sprintf("auth:device_identifier:%s", device.Identifier)] = struct{}{}
}
}
sessionsKey := fmt.Sprintf("auth:user_sessions:%d", summary.User.ID)
sessionIDs, err := rdb.ZRange(ctx, sessionsKey, 0, -1).Result()
if err != nil && err != redis.Nil {
return nil, err
}
for _, sessionID := range sessionIDs {
if sessionID == "" {
continue
}
keySet[fmt.Sprintf("auth:session_id:%s", sessionID)] = struct{}{}
keySet[fmt.Sprintf("auth:session_id:detail:%s", sessionID)] = struct{}{}
}
var cursor uint64
for {
keys, nextCursor, scanErr := rdb.Scan(ctx, cursor, "auth:session_id:*", 200).Result()
if scanErr != nil {
return nil, scanErr
}
for _, key := range keys {
if strings.Contains(key, ":detail:") {
continue
}
value, getErr := rdb.Get(ctx, key).Result()
if getErr != nil {
continue
}
if value == fmt.Sprintf("%d", summary.User.ID) {
keySet[key] = struct{}{}
sessionID := strings.TrimPrefix(key, "auth:session_id:")
if sessionID != "" {
keySet[fmt.Sprintf("auth:session_id:detail:%s", sessionID)] = struct{}{}
}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
keys := make([]string, 0, len(keySet))
for key := range keySet {
keys = append(keys, key)
}
sort.Strings(keys)
if len(keys) == 0 {
return keys, nil
}
if err := rdb.Del(ctx, keys...).Err(); err != nil {
return nil, err
}
return keys, nil
}
func rowsAffected(res sql.Result) int64 {
if res == nil {
return 0
}
n, err := res.RowsAffected()
if err != nil {
return 0
}
return n
}

View File

@ -21,7 +21,7 @@ env:
SSH_PASSWORD: ${{ github.ref_name == 'main' && vars.SSH_PASSWORD || vars.DEV_SSH_PASSWORD }}
# TG通知
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
TG_CHAT_ID: "-49402438031"
TG_CHAT_ID: "-4940243803"
# Go构建变量
SERVICE: vpn
SERVICE_STYLE: vpn

View File

@ -68,3 +68,8 @@ device:
Administrator:
Email: admin@ppanel.dev # 后台登录邮箱,请修改
Password: CHANGE_ME_TO_STRONG_PASSWORD # 后台登录密码,请修改为强密码
Register:
EnableTrial: true
EnableTrialEmailWhitelist: true
TrialEmailDomainWhitelist: "facebook.com,yahoo.com,qq.com,live.com,outlook.com,msn.com,example.com,go.com,aol.com,free.fr,aliyun.com,163.com,yandex.ru,indiatimes.com,alibaba.com,geocities.com,about.com,naver.com,netscape.com,yahoo.co.jp,earthlink.net,zoho.com,sky.com,mail.ru,angelfire.com,uol.com.br,spb.ru,yandex.com,globo.com,gmail.com,medscape.com,space.com,discovery.com,t-online.de,mac.com,icloud.com,homestead.com,lycos.com,web.id,nus.edu.sg,altavista.com,berlin.de,rambler.ru,pp.ua,comcast.net,sapo.pt,msk.ru,ancestry.com,daum.net,proton.me,law.com,bt.com,techspot.com,icq.com,sina.cn,libero.it,test.com,yourdomain.com,docomo.ne.jp,wp.pl,hotmail.com,orange.fr,onet.pl,me.com,india.com,kansascity.com,wanadoo.fr,fortunecity.com,web.de,terra.com.br,att.net,canada.com,skynet.be,ya.ru,excite.com,detik.com,compuserve.com,ig.com.br,zp.ua,gmx.net,xoom.com,mindspring.com,freeserve.co.uk,interia.pl,excite.co.jp,test.de,shaw.ca,virgilio.it,chez.com,rr.com,freenet.de,ntlworld.com,seznam.cz,arcor.de,tiscali.it,sympatico.ca,sina.com,gazeta.pl,care2.com,yam.com,r7.com,telenet.be,rcn.com,geek.com,sfr.fr,hotbot.com,cox.net,blueyonder.co.uk,tom.com,virginmedia.com,btinternet.com,iinet.net.au,rogers.com,ireland.com,pochta.ru,ozemail.com.au,catholic.org,bluewin.ch,chat.ru,virgin.net,verizon.net,erols.com,lycos.de,lycos.co.uk,protonmail.com,doityourself.com,home.nl,nate.com,casino.com,o2.co.uk,terra.es,mail.com,albawaba.com,126.com,www.com,planet.nl,sanook.com,21cn.com,online.de,name.com,i.ua,centrum.cz,rin.ru,aol.co.uk,voila.fr,walla.co.il,poste.it,netcom.com,parrot.com,charter.net,mydomain.com,mail-tester.com,myway.com,chello.nl,club-internet.fr,sdf.org,tiscali.co.uk,freeuk.com,unican.es,sci.fi,anonymize.com,sify.com,metacrawler.com,go.ro,ivillage.com,telus.net,dailypioneer.com,iespana.es,lycos.es,hey.com,sweb.cz,optusnet.com.au,alice.it,tpg.com.au,hamptonroads.com,saudia.com,lycos.nl,blackplanet.com,frontier.com,looksmart.com,pobox.com,prodigy.net,i.am,freeyellow.com,gmx.com,bigpond.com,crosswinds.net,dejanews.com,wanadoo.es,foxmail.com,eircom.net,islamonline.net,webindia123.com,oath.com,frontiernet.net,hetnet.nl,onmilwaukee.com,ukr.net,bugmenot.com,neuf.fr,kiwibox.com,za.com,iol.it,zonnet.nl,newmail.ru,pacbell.net,cogeco.ca,depechemode.com,concentric.net,aim.com,f5.si,yahoo.jp,terra.com,hot.ee,netzero.net,netins.net,sprynet.com,mailbox.org,mail2web.com,o2.pl,idirect.com,bigfoot.com,netspace.net.au,masrawy.com,supereva.it,yahoo.de,lycos.it,yeah.net,montevideo.com.uy,gmx.de,yahoo.co.uk,yahoofs.com,scubadiving.com,hushmail.com,iprimus.com.au,gportal.hu,swissinfo.org,inbox.com,bolt.com,telstra.com,bellsouth.net,spray.se,c3.hu,attbi.com,talktalk.co.uk,dynu.net,juno.com,yahoo.fr,msn.co.uk,fr.nf,pe.hu,bigpond.net.au,incredimail.com,adelphia.net,elvis.com,interfree.it,starmedia.com,seanet.com,yahoo.com.tw,zip.net,tds.net,she.com,forthnet.gr,land.ru,wow.com,dnsmadeeasy.com,webjump.com,singnet.com.sg,spacewar.com,tin.it,4mg.com,sp.nl,wowway.com,dmv.com,bangkok.com,fastmail.fm,sbcglobal.net,bright.net,usa.com,37.com,aeiou.pt,terra.cl,thirdage.com,btconnect.com,optimum.net,cableone.net,talkcity.com,blogos.com,c2i.net,iwon.com,aver.com,barcelona.com,ddnsfree.com,oi.com.br,lex.bg,roadrunner.com,airmail.net,lawyer.com,yahoo.com.cn,cu.cc,ananzi.co.za,au.ru,pipeline.com,cs.com,3ammagazine.com,gmx.at,qwest.net,btopenworld.com,easypost.com,westnet.com.au,nyc.com,korea.com,front.ru,inbox.lv,yahoo.com.br,ny.com,hispavista.com,abv.bg,mchsi.com,apollo.lv,everyone.net,terra.com.ar,singpost.com,doctor.com,garbage.com,bizhosting.com,go2net.com,clerk.com,games.com,charm.net,onlinehome.de,laposte.net" # 填你的白名单域名,逗号分隔

View File

@ -5,7 +5,6 @@ import (
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/tool"
)
@ -17,7 +16,7 @@ func Register(ctx *svc.ServiceContext) {
logger.Errorf("[Init Register Config] Get Register Config Error: %s", err.Error())
return
}
var registerConfig config.RegisterConfig
registerConfig := ctx.Config.Register
tool.SystemConfigSliceReflectToStruct(configs, &registerConfig)
ctx.Config.Register = registerConfig
}

View File

@ -82,15 +82,17 @@ type SubscribeConfig struct {
}
type RegisterConfig struct {
StopRegister bool `yaml:"StopRegister" default:"false"`
EnableTrial bool `yaml:"EnableTrial" default:"false"`
TrialSubscribe int64 `yaml:"TrialSubscribe" default:"0"`
TrialTime int64 `yaml:"TrialTime" default:"0"`
TrialTimeUnit string `yaml:"TrialTimeUnit" default:""`
IpRegisterLimit int64 `yaml:"IpRegisterLimit" default:"0"`
IpRegisterLimitDuration int64 `yaml:"IpRegisterLimitDuration" default:"0"`
EnableIpRegisterLimit bool `yaml:"EnableIpRegisterLimit" default:"false"`
DeviceLimit int64 `yaml:"DeviceLimit" default:"2"`
StopRegister bool `yaml:"StopRegister" default:"false"`
EnableTrial bool `yaml:"EnableTrial" default:"false"`
EnableTrialEmailWhitelist bool `yaml:"EnableTrialEmailWhitelist" default:"true"`
TrialSubscribe int64 `yaml:"TrialSubscribe" default:"0"`
TrialTime int64 `yaml:"TrialTime" default:"0"`
TrialTimeUnit string `yaml:"TrialTimeUnit" default:""`
TrialEmailDomainWhitelist string `yaml:"TrialEmailDomainWhitelist" default:""`
IpRegisterLimit int64 `yaml:"IpRegisterLimit" default:"0"`
IpRegisterLimitDuration int64 `yaml:"IpRegisterLimitDuration" default:"0"`
EnableIpRegisterLimit bool `yaml:"EnableIpRegisterLimit" default:"false"`
DeviceLimit int64 `yaml:"DeviceLimit" default:"2"`
}
type EmailConfig struct {
@ -101,12 +103,12 @@ type EmailConfig struct {
EnableNotify bool `yaml:"enable_notify"`
EnableDomainSuffix bool `yaml:"enable_domain_suffix"`
DomainSuffixList string `yaml:"domain_suffix_list"`
VerifyEmailTemplate string `yaml:"verify_email_template"`
VerifyEmailTemplates map[string]string `yaml:"verify_email_templates"`
ExpirationEmailTemplate string `yaml:"expiration_email_template"`
MaintenanceEmailTemplate string `yaml:"maintenance_email_template"`
TrafficExceedEmailTemplate string `yaml:"traffic_exceed_email_template"`
DeleteAccountEmailTemplate string `yaml:"delete_account_email_template"`
VerifyEmailTemplate string `yaml:"verify_email_template"`
VerifyEmailTemplates map[string]string `yaml:"verify_email_templates"`
ExpirationEmailTemplate string `yaml:"expiration_email_template"`
MaintenanceEmailTemplate string `yaml:"maintenance_email_template"`
TrafficExceedEmailTemplate string `yaml:"traffic_exceed_email_template"`
DeleteAccountEmailTemplate string `yaml:"delete_account_email_template"`
}
type MobileConfig struct {

View File

@ -26,7 +26,19 @@ func NewGetRegisterConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
func (l *GetRegisterConfigLogic) GetRegisterConfig() (*types.RegisterConfig, error) {
resp := &types.RegisterConfig{}
resp := &types.RegisterConfig{
StopRegister: l.svcCtx.Config.Register.StopRegister,
EnableTrial: l.svcCtx.Config.Register.EnableTrial,
EnableTrialEmailWhitelist: l.svcCtx.Config.Register.EnableTrialEmailWhitelist,
TrialSubscribe: l.svcCtx.Config.Register.TrialSubscribe,
TrialTime: l.svcCtx.Config.Register.TrialTime,
TrialTimeUnit: l.svcCtx.Config.Register.TrialTimeUnit,
TrialEmailDomainWhitelist: l.svcCtx.Config.Register.TrialEmailDomainWhitelist,
EnableIpRegisterLimit: l.svcCtx.Config.Register.EnableIpRegisterLimit,
IpRegisterLimit: l.svcCtx.Config.Register.IpRegisterLimit,
IpRegisterLimitDuration: l.svcCtx.Config.Register.IpRegisterLimitDuration,
DeviceLimit: l.svcCtx.Config.Register.DeviceLimit,
}
// get register config from database
configs, err := l.svcCtx.SystemModel.GetRegisterConfig(l.ctx)

View File

@ -4,6 +4,7 @@ import (
"context"
"reflect"
"strings"
"github.com/perfect-panel/server/initialize"
"github.com/perfect-panel/server/internal/config"
@ -44,8 +45,28 @@ func (l *UpdateRegisterConfigLogic) UpdateRegisterConfig(req *types.RegisterConf
fieldName := t.Field(i).Name
// Get the field value to string
fieldValue := tool.ConvertValueToString(v.Field(i))
// Update the site config
err = db.Model(&system.System{}).Where("`category` = 'register' and `key` = ?", fieldName).Update("value", fieldValue).Error
var existing system.System
queryErr := db.Where("`category` = ? and `key` = ?", "register", fieldName).First(&existing).Error
if queryErr != nil && !errors.Is(queryErr, gorm.ErrRecordNotFound) {
return queryErr
}
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
fieldValue = l.defaultRegisterFieldValue(fieldName, fieldValue)
}
record := &system.System{
Category: "register",
Key: fieldName,
}
assignments := map[string]interface{}{
"value": fieldValue,
"type": inferRegisterSystemValueType(t.Field(i).Type.Kind()),
"desc": fieldName,
}
err = db.Where("`category` = ? and `key` = ?", "register", fieldName).
Assign(assignments).
FirstOrCreate(record).Error
if err != nil {
break
}
@ -63,3 +84,28 @@ func (l *UpdateRegisterConfigLogic) UpdateRegisterConfig(req *types.RegisterConf
initialize.Register(l.svcCtx)
return nil
}
func inferRegisterSystemValueType(kind reflect.Kind) string {
switch kind {
case reflect.Bool:
return "bool"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return "int"
default:
return "string"
}
}
func (l *UpdateRegisterConfigLogic) defaultRegisterFieldValue(fieldName, fieldValue string) string {
switch fieldName {
case "EnableTrialEmailWhitelist":
return "true"
case "TrialEmailDomainWhitelist":
if strings.TrimSpace(fieldValue) != "" {
return fieldValue
}
return l.svcCtx.Config.Register.TrialEmailDomainWhitelist
default:
return fieldValue
}
}

View File

@ -12,7 +12,6 @@ import (
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/jwt"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
@ -180,7 +179,6 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
)
var userInfo *user.User
var trialSubscribe *user.Subscribe
err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error {
// Create new user
userInfo = &user.User{
@ -239,15 +237,6 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert device failed: %v", err)
}
// Activate trial if enabled
if l.svcCtx.Config.Register.EnableTrial {
var trialErr error
trialSubscribe, trialErr = l.activeTrial(userInfo.Id, db)
if trialErr != nil {
return trialErr
}
}
return nil
})
@ -259,25 +248,6 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
return nil, err
}
// Clear cache after transaction success
if l.svcCtx.Config.Register.EnableTrial && trialSubscribe != nil {
// Clear user subscription cache
if err = l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, trialSubscribe); err != nil {
l.Errorw("ClearSubscribeCache failed", logger.Field("error", err.Error()), logger.Field("userSubscribeId", trialSubscribe.Id))
// Don't return error, just log it
}
// Clear subscription cache
if err = l.svcCtx.SubscribeModel.ClearCache(l.ctx, trialSubscribe.SubscribeId); err != nil {
l.Errorw("ClearSubscribeCache failed", logger.Field("error", err.Error()), logger.Field("subscribeId", trialSubscribe.SubscribeId))
// Don't return error, just log it
}
// Clear all server cache
if err = l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil {
l.Errorf("ClearServerAllCache error: %v", err.Error())
// Don't return error, just log it
}
}
l.Infow("device registration completed successfully",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier),
@ -309,51 +279,3 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
return userInfo, nil
}
func (l *DeviceLoginLogic) activeTrial(userId int64, db *gorm.DB) (*user.Subscribe, error) {
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, l.svcCtx.Config.Register.TrialSubscribe)
if err != nil {
l.Errorw("failed to find trial subscription template",
logger.Field("user_id", userId),
logger.Field("trial_subscribe_id", l.svcCtx.Config.Register.TrialSubscribe),
logger.Field("error", err.Error()),
)
return nil, err
}
startTime := time.Now()
expireTime := tool.AddTime(l.svcCtx.Config.Register.TrialTimeUnit, l.svcCtx.Config.Register.TrialTime, startTime)
subscribeToken := uuidx.NewUUID().String()
subscribeUUID := uuidx.NewUUID().String()
userSub := &user.Subscribe{
UserId: userId,
OrderId: 0,
SubscribeId: sub.Id,
StartTime: startTime,
ExpireTime: expireTime,
Traffic: sub.Traffic,
Download: 0,
Upload: 0,
Token: subscribeToken,
UUID: subscribeUUID,
Status: 1,
}
if err := db.Create(userSub).Error; err != nil {
l.Errorw("failed to insert trial subscription",
logger.Field("user_id", userId),
logger.Field("error", err.Error()),
)
return nil, err
}
l.Infow("trial subscription activated successfully",
logger.Field("user_id", userId),
logger.Field("subscribe_id", sub.Id),
logger.Field("expire_time", expireTime),
logger.Field("traffic", sub.Traffic),
)
return userSub, nil
}

View File

@ -125,7 +125,8 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
if err = db.Create(authInfo).Error; err != nil {
return err
}
if l.svcCtx.Config.Register.EnableTrial {
rc := l.svcCtx.Config.Register
if ShouldGrantTrialForEmail(rc, req.Email) {
if err = l.activeTrial(userInfo.Id); err != nil {
return err
}

View File

@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/perfect-panel/server/internal/config"
authlogic "github.com/perfect-panel/server/internal/logic/auth"
"github.com/perfect-panel/server/internal/model/auth"
"github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/user"
@ -393,10 +395,14 @@ func (l *OAuthLoginGetTokenLogic) register(email, avatar, method, openid, reques
}
}
if l.svcCtx.Config.Register.EnableTrial {
rc := l.svcCtx.Config.Register
shouldActivateTrial := email != "" && authlogic.ShouldGrantTrialForEmail(rc, email)
if shouldActivateTrial {
l.Debugw("activating trial subscription",
logger.Field("request_id", requestID),
logger.Field("user_id", userInfo.Id),
logger.Field("email", email),
)
var trialErr error
trialSubscribe, trialErr = l.activeTrial(userInfo.Id, requestID)
@ -882,3 +888,22 @@ func (l *OAuthLoginGetTokenLogic) activeTrial(uid int64, requestID string) (*use
)
return userSub, nil
}
// isEmailDomainWhitelisted checks if the email's domain is in the comma-separated whitelist.
// Returns false if the email format is invalid.
func (l *OAuthLoginGetTokenLogic) isEmailDomainWhitelisted(email, whitelistCSV string) bool {
if whitelistCSV == "" {
return false
}
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 {
return false
}
domain := strings.ToLower(strings.TrimSpace(parts[1]))
for _, d := range strings.Split(whitelistCSV, ",") {
if strings.ToLower(strings.TrimSpace(d)) == domain {
return true
}
}
return false
}

View File

@ -46,7 +46,6 @@ func NewTelephoneUserRegisterLogic(ctx context.Context, svcCtx *svc.ServiceConte
func (l *TelephoneUserRegisterLogic) TelephoneUserRegister(req *types.TelephoneRegisterRequest) (resp *types.LoginResponse, err error) {
c := l.svcCtx.Config.Register
var trialSubscribe *user.Subscribe
// Check if the registration is stopped
if c.StopRegister {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.StopRegister), "stop register")
@ -141,39 +140,12 @@ func (l *TelephoneUserRegisterLogic) TelephoneUserRegister(req *types.TelephoneR
if err := db.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil {
return err
}
if l.svcCtx.Config.Register.EnableTrial {
// Active trial
var trialErr error
trialSubscribe, trialErr = l.activeTrial(userInfo.Id)
if trialErr != nil {
return trialErr
}
}
return nil
})
if err != nil {
return nil, err
}
// Clear cache after transaction success
if l.svcCtx.Config.Register.EnableTrial && trialSubscribe != nil {
// Clear user subscription cache
if err = l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, trialSubscribe); err != nil {
l.Errorw("ClearSubscribeCache failed", logger.Field("error", err.Error()), logger.Field("userSubscribeId", trialSubscribe.Id))
// Don't return error, just log it
}
// Clear subscription cache
if err = l.svcCtx.SubscribeModel.ClearCache(l.ctx, trialSubscribe.SubscribeId); err != nil {
l.Errorw("ClearSubscribeCache failed", logger.Field("error", err.Error()), logger.Field("subscribeId", trialSubscribe.SubscribeId))
// Don't return error, just log it
}
// Clear all server cache
if err = l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil {
l.Errorf("ClearServerAllCache error: %v", err.Error())
// Don't return error, just log it
}
}
// Bind device to user if identifier is provided
if req.Identifier != "" {
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
@ -261,32 +233,6 @@ func (l *TelephoneUserRegisterLogic) TelephoneUserRegister(req *types.TelephoneR
}, nil
}
func (l *TelephoneUserRegisterLogic) activeTrial(uid int64) (*user.Subscribe, error) {
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, l.svcCtx.Config.Register.TrialSubscribe)
if err != nil {
return nil, err
}
userSub := &user.Subscribe{
Id: 0,
UserId: uid,
OrderId: 0,
SubscribeId: sub.Id,
StartTime: time.Now(),
ExpireTime: tool.AddTime(l.svcCtx.Config.Register.TrialTimeUnit, l.svcCtx.Config.Register.TrialTime, time.Now()),
Traffic: sub.Traffic,
Download: 0,
Upload: 0,
Token: uuidx.NewUUID().String(),
UUID: uuidx.NewUUID().String(),
Status: 1,
}
err = l.svcCtx.UserModel.InsertSubscribe(l.ctx, userSub)
if err != nil {
return nil, err
}
return userSub, nil
}
func (l *TelephoneUserRegisterLogic) verifyCaptcha(req *types.TelephoneRegisterRequest) error {
verifyCfg, err := l.svcCtx.SystemModel.GetVerifyConfig(l.ctx)
if err != nil {

View File

@ -0,0 +1,39 @@
package auth
import (
"strings"
"github.com/perfect-panel/server/internal/config"
)
// IsEmailDomainWhitelisted checks if the email's domain is in the comma-separated whitelist.
// Returns false if the email format is invalid.
func IsEmailDomainWhitelisted(email, whitelistCSV string) bool {
if whitelistCSV == "" {
return false
}
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 {
return false
}
domain := strings.ToLower(strings.TrimSpace(parts[1]))
for _, d := range strings.Split(whitelistCSV, ",") {
if strings.ToLower(strings.TrimSpace(d)) == domain {
return true
}
}
return false
}
func ShouldGrantTrialForEmail(register config.RegisterConfig, email string) bool {
if !register.EnableTrial {
return false
}
if !register.EnableTrialEmailWhitelist {
return true
}
if register.TrialEmailDomainWhitelist == "" {
return false
}
return IsEmailDomainWhitelisted(email, register.TrialEmailDomainWhitelist)
}

View File

@ -147,7 +147,8 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
}
// Activate trial subscription after transaction success (moved outside transaction to reduce lock time)
if l.svcCtx.Config.Register.EnableTrial {
rc := l.svcCtx.Config.Register
if ShouldGrantTrialForEmail(rc, req.Email) {
trialSubscribe, err = l.activeTrial(userInfo.Id)
if err != nil {
l.Errorw("Failed to activate trial subscription", logger.Field("error", err.Error()))
@ -156,7 +157,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
}
// Clear cache after transaction success
if l.svcCtx.Config.Register.EnableTrial && trialSubscribe != nil {
if trialSubscribe != nil {
// Trigger user group recalculation (runs in background)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

View File

@ -127,6 +127,7 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
if err != nil {
return nil, err
}
tryGrantTrialOnEmailBind(l.ctx, l.svcCtx, l.Logger, emailUser.Id, req.Email)
return &types.BindEmailWithVerificationResponse{
Success: true,
Message: "email user created and joined family",
@ -154,6 +155,8 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
return nil, err
}
tryGrantTrialOnEmailBind(l.ctx, l.svcCtx, l.Logger, existingMethod.UserId, req.Email)
return &types.BindEmailWithVerificationResponse{
Success: true,
Message: "joined family successfully",

View File

@ -0,0 +1,80 @@
package user
import (
"context"
"time"
"github.com/perfect-panel/server/internal/logic/auth"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/uuidx"
)
func tryGrantTrialOnEmailBind(ctx context.Context, svcCtx *svc.ServiceContext, log logger.Logger, ownerUserId int64, email string) {
rc := svcCtx.Config.Register
if !auth.ShouldGrantTrialForEmail(rc, email) {
if rc.EnableTrial && rc.EnableTrialEmailWhitelist {
log.Infow("email domain not in trial whitelist, skip",
logger.Field("email", email),
logger.Field("owner_user_id", ownerUserId),
)
}
return
}
var count int64
if err := svcCtx.DB.WithContext(ctx).
Model(&user.Subscribe{}).
Where("user_id = ? AND subscribe_id = ?", ownerUserId, rc.TrialSubscribe).
Count(&count).Error; err != nil {
log.Errorw("failed to check existing trial", logger.Field("error", err.Error()))
return
}
if count > 0 {
log.Infow("trial already granted, skip",
logger.Field("owner_user_id", ownerUserId),
)
return
}
sub, err := svcCtx.SubscribeModel.FindOne(ctx, rc.TrialSubscribe)
if err != nil {
log.Errorw("failed to find trial subscribe template", logger.Field("error", err.Error()))
return
}
userSub := &user.Subscribe{
UserId: ownerUserId,
OrderId: 0,
SubscribeId: sub.Id,
StartTime: time.Now(),
ExpireTime: tool.AddTime(rc.TrialTimeUnit, rc.TrialTime, time.Now()),
Traffic: sub.Traffic,
Download: 0,
Upload: 0,
Token: uuidx.NewUUID().String(),
UUID: uuidx.NewUUID().String(),
Status: 1,
}
if err = svcCtx.UserModel.InsertSubscribe(ctx, userSub); err != nil {
log.Errorw("failed to insert trial subscribe",
logger.Field("error", err.Error()),
logger.Field("owner_user_id", ownerUserId),
)
return
}
if svcCtx.NodeModel != nil {
if err = svcCtx.NodeModel.ClearServerAllCache(ctx); err != nil {
log.Errorw("ClearServerAllCache error", logger.Field("error", err.Error()))
}
}
log.Infow("trial granted on email bind",
logger.Field("owner_user_id", ownerUserId),
logger.Field("email", email),
logger.Field("subscribe_id", sub.Id),
)
}

View File

@ -77,5 +77,6 @@ func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateUserAuthMethods error")
}
tryGrantTrialOnEmailBind(l.ctx, l.svcCtx, l.Logger, method.UserId, req.Email)
return nil
}

View File

@ -2267,15 +2267,17 @@ type RedemptionRecord struct {
}
type RegisterConfig struct {
StopRegister bool `json:"stop_register"`
EnableTrial bool `json:"enable_trial"`
TrialSubscribe int64 `json:"trial_subscribe"`
TrialTime int64 `json:"trial_time"`
TrialTimeUnit string `json:"trial_time_unit"`
EnableIpRegisterLimit bool `json:"enable_ip_register_limit"`
IpRegisterLimit int64 `json:"ip_register_limit"`
IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"`
DeviceLimit int64 `json:"device_limit"`
StopRegister bool `json:"stop_register"`
EnableTrial bool `json:"enable_trial"`
EnableTrialEmailWhitelist bool `json:"enable_trial_email_whitelist"`
TrialSubscribe int64 `json:"trial_subscribe"`
TrialTime int64 `json:"trial_time"`
TrialTimeUnit string `json:"trial_time_unit"`
TrialEmailDomainWhitelist string `json:"trial_email_domain_whitelist"`
EnableIpRegisterLimit bool `json:"enable_ip_register_limit"`
IpRegisterLimit int64 `json:"ip_register_limit"`
IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"`
DeviceLimit int64 `json:"device_limit"`
}
type RegisterLog struct {