同步历史版本代码
This commit is contained in:
parent
7d46b31866
commit
4d8516b2e1
241
.claude/plan/sync-features.md
Normal file
241
.claude/plan/sync-features.md
Normal file
@ -0,0 +1,241 @@
|
||||
# 功能同步计划:私有版 → 开源版
|
||||
|
||||
源目录:`/Users/Apple/vpn/ppanel-server`(私有版)
|
||||
目标目录:`/Users/Apple/code_vpn/vpn/ppanel-server`(开源版)
|
||||
模块名相同:`github.com/perfect-panel/server`
|
||||
|
||||
---
|
||||
|
||||
## 批次 A:新增独立包(直接复制)
|
||||
|
||||
### A1. pkg/iap(Apple IAP)
|
||||
- 复制 `pkg/iap/` 全目录
|
||||
|
||||
### A2. pkg/kutt(短链接服务)
|
||||
- 复制 `pkg/kutt/kutt.go`
|
||||
|
||||
### A3. pkg/loki(Grafana Loki)
|
||||
- 复制 `pkg/loki/loki.go`
|
||||
|
||||
### A4. pkg/openinstall(渠道统计)
|
||||
- 复制 `pkg/openinstall/openinstall.go`
|
||||
|
||||
### A5. internal/model/iap(IAP 数据模型)
|
||||
- 复制 `internal/model/iap/` 全目录
|
||||
|
||||
### A6. internal/model/logmessage(错误日志模型)
|
||||
- 复制 `internal/model/logmessage/` 全目录
|
||||
|
||||
---
|
||||
|
||||
## 批次 B:数据库迁移
|
||||
|
||||
### B1. 新增迁移文件(仅从私有版复制)
|
||||
- `02120_log_message.up.sql` / `.down.sql`
|
||||
- `02121_apple_iap_transactions.up.sql` / `.down.sql`
|
||||
- `02122_add_user_last_login_time.up.sql` / `.down.sql`
|
||||
- `02123_update_auth_method_config.up.sql` / `.down.sql`(auth_method MEDIUMTEXT)
|
||||
|
||||
### B2. 修改共有迁移文件
|
||||
- `00002_init_basic_data.up.sql`:VerifyCodeExpireTime 默认值 300→900
|
||||
- `02118_traffic_log_idx.up.sql`:添加幂等性检查
|
||||
- `02119_node.up.sql`:@sql 变量名修正(已是私有版逻辑,保留)
|
||||
|
||||
---
|
||||
|
||||
## 批次 C:配置与基础设施
|
||||
|
||||
### C1. internal/config/config.go
|
||||
- 添加:KuttConfig、LokiConfig、OpenInstallConfig、AppleIAPConfig 结构体
|
||||
- 添加:FixedRate 浮点型汇率配置
|
||||
|
||||
### C2. internal/config/cacheKey.go
|
||||
- 添加:`UserSessionsKeyPrefix = "auth:user_sessions:"`
|
||||
|
||||
### C3. pkg/conf/default.go
|
||||
- 添加:reflect.Float64 case 处理
|
||||
|
||||
### C4. internal/svc/serviceContext.go
|
||||
- 添加:Kutt、Loki、OpenInstall、IAPModel 字段
|
||||
|
||||
### C5. internal/svc/devce.go
|
||||
- 修复 SQL Bug:`create_at` → `created_at`
|
||||
|
||||
### C6. pkg/orm/mysql.go
|
||||
- 添加:SetConnMaxIdleTime(5min) + SetConnMaxLifetime(30min)
|
||||
|
||||
### C7. pkg/exchangeRate/exchangeRate.go
|
||||
- 保持私有版的 exchangerate.host 实现
|
||||
|
||||
### C8. initialize/config.go & init.go
|
||||
- 添加 Loki、OpenInstall 初始化逻辑
|
||||
|
||||
---
|
||||
|
||||
## 批次 D:数据模型
|
||||
|
||||
### D1. internal/model/user/user.go
|
||||
- 添加:LastLoginTime 字段
|
||||
|
||||
### D2. internal/model/auth/auth.go
|
||||
- config 字段 MEDIUMTEXT(Go 结构体 tag)
|
||||
- 补全 4 种邮件模板初始化
|
||||
|
||||
### D3. internal/model/node/model.go
|
||||
- 添加:CountNodesByIdsAndTags 方法(移除 fmt.Println 调试语句)
|
||||
- 修复:ClearServerAllCache 同时清理 ServerConfig + ServerUserList
|
||||
- 修复:cursor append bug(OSS版的 append(keys, keys...))
|
||||
|
||||
### D4. internal/model/payment/payment.go
|
||||
- 添加:AppleIAPConfig 结构体
|
||||
|
||||
### D5. internal/model/user/subscribe.go
|
||||
- 添加:includeExpired 参数支持(":all" cache key suffix)
|
||||
|
||||
---
|
||||
|
||||
## 批次 E:认证与登录逻辑
|
||||
|
||||
### E1. internal/logic/auth/userLoginLogic.go
|
||||
- 添加:LastLoginTime 更新
|
||||
- 添加:邮箱登录调试日志清理
|
||||
|
||||
### E2. internal/logic/auth/userRegisterLogic.go
|
||||
- 添加:邮箱小写/trim 处理
|
||||
- 添加:验证码过期时间使用配置值
|
||||
|
||||
### E3. 新增:internal/logic/auth/emailLoginLogic.go
|
||||
- 从私有版复制邮箱直登逻辑
|
||||
|
||||
### E4. 新增:apis/auth/auth.api 邮箱登录路由
|
||||
- 添加 emailLogin 接口定义
|
||||
|
||||
---
|
||||
|
||||
## 批次 F:用户功能逻辑
|
||||
|
||||
### F1. internal/logic/public/user/queryUserInfoLogic.go
|
||||
- 添加:Kutt 短链接生成 + Redis 永久缓存
|
||||
- 添加:share_link 字段返回
|
||||
|
||||
### F2. internal/logic/public/user/queryUserSubscribeLogic.go
|
||||
- 添加:查询 Order 判断 IsGift(amount==0)
|
||||
|
||||
### F3. internal/handler/public/user/queryUserSubscribeHandler.go
|
||||
- 添加:includeExpired query param 注入 context
|
||||
|
||||
### F4. internal/model/user/model.go
|
||||
- 添加:FindActiveSubscribe / FindActiveSubscribesByUserIds
|
||||
- 添加:BatchClearRelatedCache
|
||||
- 添加:DeleteUserAuthMethodByIdentifier
|
||||
|
||||
---
|
||||
|
||||
## 批次 G:订单与支付
|
||||
|
||||
### G1. internal/logic/public/order/preCreateOrderLogic.go
|
||||
- 修复:math.Round 金额计算
|
||||
|
||||
### G2. internal/logic/public/order/purchaseLogic.go
|
||||
- 修复:math.Round
|
||||
|
||||
### G3. internal/logic/public/order/renewalLogic.go
|
||||
- 修复:math.Round
|
||||
|
||||
### G4. internal/logic/public/portal/purchaseCheckoutLogic.go
|
||||
- 添加:Apple IAP checkout case
|
||||
- 添加:currency 从 DB 动态读取 + FixedRate fallback
|
||||
- 修复:math.Round for alipay
|
||||
|
||||
### G5. internal/model/payment/model.go
|
||||
- 添加:AppleIAP payment model 支持
|
||||
|
||||
### G6. pkg/payment/platform.go
|
||||
- 添加:AppleIAP platform entry
|
||||
|
||||
---
|
||||
|
||||
## 批次 H:订阅与节点
|
||||
|
||||
### H1. internal/logic/public/portal/getSubscriptionLogic.go
|
||||
- 添加:CountNodesByIdsAndTags 调用,返回 node_count
|
||||
|
||||
### H2. internal/logic/public/portal/purchaseLogic.go
|
||||
- 添加:CloseOrderTimeMinutes 超时配置
|
||||
|
||||
### H3. internal/logic/subscribe/subscribeLogic.go
|
||||
- 保持:PanDomain 模式返回当前 Host
|
||||
|
||||
---
|
||||
|
||||
## 批次 I:队列任务
|
||||
|
||||
### I1. queue/logic/order/activateOrderLogic.go
|
||||
- 添加:findGiftSubscription 逻辑
|
||||
- 添加:extendGiftSubscription 逻辑
|
||||
- 添加:grantGiftDaysToBothParties(双向赠礼)
|
||||
- 添加:no-retry for invalid status
|
||||
|
||||
### I2. queue/logic/email/sendEmailLogic.go
|
||||
- 修改:主题中文化
|
||||
- 修改:动态过期分钟数
|
||||
- 修改:float64→int 类型转换
|
||||
- 添加:Smart Fallback 模板逻辑(4种类型)
|
||||
|
||||
### I3. queue/logic/task/rateLogic.go
|
||||
- 添加:FixedRate config 支持(跳过 API 调用)
|
||||
|
||||
---
|
||||
|
||||
## 批次 J:路由与类型
|
||||
|
||||
### J1. internal/handler/routes.go
|
||||
- 添加:IAP Apple 路由
|
||||
- 添加:email login 路由
|
||||
- 添加:error log 路由
|
||||
- 添加:contact 路由
|
||||
|
||||
### J2. internal/types/types.go
|
||||
- 添加:Apple IAP 相关 Request/Response 类型
|
||||
- 添加:BindEmailWithVerificationRequest/Response
|
||||
- 添加:BindInviteCodeRequest
|
||||
- 添加:ProductIds in checkout
|
||||
|
||||
### J3. internal/types/subscribe.go
|
||||
- 保持私有版(移除 Type/Params 开源版新增字段——已在批次 A 处理)
|
||||
|
||||
---
|
||||
|
||||
## 批次 K:邮件系统
|
||||
|
||||
### K1. pkg/email/template.go
|
||||
- 添加:Maintenance、TrafficExceed、Verify 3 种邮件模板
|
||||
|
||||
---
|
||||
|
||||
## 批次 L:管理后台
|
||||
|
||||
### L1. internal/logic/admin/user/getUserListLogic.go
|
||||
- 添加:MemberStatus + LastLoginTime 展示
|
||||
- 添加:批量获取 active subscriptions
|
||||
|
||||
### L2. internal/logic/admin/user/updateUserBasicInfoLogic.go
|
||||
- 添加:Remark + MemberStatus 更新
|
||||
- 添加:单事务包裹所有修改
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. A(新包) → B(迁移) → C(配置) → D(模型)
|
||||
2. E(认证) → F(用户) → G(订单) → H(订阅)
|
||||
3. I(队列) → J(路由) → K(邮件) → L(管理后台)
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **排除**:App版本管理、设备绑定相关逻辑(ZSet Session、DeviceId in token)
|
||||
- **调试日志清理**:不带 fmt.Println 调试语句
|
||||
- **模块名相同**:import 路径无需修改
|
||||
- **开源版新功能保留**:软删除、注册 IP 限流、兑换码系统、GeoIP 等开源版独有功能不回退
|
||||
168
.claude/team-plan/sync-remaining.md
Normal file
168
.claude/team-plan/sync-remaining.md
Normal file
@ -0,0 +1,168 @@
|
||||
# Team Plan: 同步私有版剩余功能到 OSS
|
||||
|
||||
源目录:`/Users/Apple/vpn/ppanel-server`(私有版)
|
||||
目标目录:`/Users/Apple/code_vpn/vpn/ppanel-server`(开源版)
|
||||
方向:私有版功能 → 开源版(保留开源版改进)
|
||||
|
||||
---
|
||||
|
||||
## Layer 1(并行)
|
||||
|
||||
### Builder-1: 路由与服务上下文(高复杂度)
|
||||
|
||||
**文件范围:**
|
||||
- `internal/handler/routes.go`
|
||||
- `internal/svc/serviceContext.go`
|
||||
|
||||
**任务:routes.go**
|
||||
1. 读取私有版 routes.go,识别以下需要添加的路由组:
|
||||
- Apple IAP 路由组:`/v1/public/iap/apple`(status, attach, restore)
|
||||
- Email login 路由:`POST /auth/login/email`
|
||||
- Error log report 路由:`POST /common/log/message/report`
|
||||
- Contact 路由:`POST /common/contact`
|
||||
- Bind email with verification:`POST /public/user/bind_email_verification`
|
||||
- Bind invite code:`POST /public/user/bind_invite_code`
|
||||
- Subscribe status:`GET /public/user/subscribe/status`
|
||||
- Agent stats/downloads/sales 路由
|
||||
- Delete account:`POST /public/user/delete_account`
|
||||
- Admin error log routes:`GET /admin/log/message/error/list`, `/detail`
|
||||
2. 保留 OSS 已有路由(Redemption、WS、heartbeat、reset_token、reset_traffic、toggle_status、IP location、module config 等)
|
||||
3. 不添加 App Version 路由(`/admin/application/version`、`/common/app/version`)
|
||||
4. 不添加 Server migration 路由
|
||||
|
||||
**任务:serviceContext.go**
|
||||
1. 添加 import:`logmessage`、`iapapple` model 包
|
||||
2. 在 ServiceContext struct 中添加字段:
|
||||
- `LogMessageModel logmessage.Model`
|
||||
- `IAPAppleTransactionModel iapapple.Model`
|
||||
3. 在 NewServiceContext 中初始化这些字段
|
||||
4. 保留 OSS 已有字段(GeoIP、RedemptionCodeModel、RedemptionRecordModel、Redis 扩展配置)
|
||||
5. 不添加 SessionLimit() 和 EnforceUserSessionLimit() 方法(设备绑定相关)
|
||||
|
||||
**验收标准:**
|
||||
- `go build ./...` 编译通过
|
||||
- 所有新路由正确注册
|
||||
- 不包含设备绑定和 App 版本管理路由
|
||||
|
||||
---
|
||||
|
||||
### Builder-2: 认证与注册逻辑(中复杂度)
|
||||
|
||||
**文件范围:**
|
||||
- `internal/logic/auth/userRegisterLogic.go`
|
||||
- `internal/logic/common/sendEmailCodeLogic.go`
|
||||
- `internal/middleware/deviceMiddleware.go`
|
||||
|
||||
**任务:userRegisterLogic.go**
|
||||
1. 添加邮箱规范化:`req.Email = strings.ToLower(strings.TrimSpace(req.Email))`(在函数开头)
|
||||
2. 保留 OSS 已有改进:IP 限流检查、已删除用户检查、activeTrial 返回 Subscribe 对象、事务外清缓存
|
||||
3. 不添加 bypass code(`"202511"`)
|
||||
4. 不添加 DeviceBindLimitExceeded 错误处理
|
||||
|
||||
**任务:sendEmailCodeLogic.go**
|
||||
1. 添加邮箱规范化:`req.Email = strings.ToLower(strings.TrimSpace(req.Email))`
|
||||
2. 添加动态验证码过期时间:从 `l.svcCtx.Config.VerifyCode.ExpireTime` 读取,默认 900 秒,转换为分钟
|
||||
3. Redis TTL 使用配置的过期时间而非硬编码
|
||||
4. 保留 OSS 已有的 Security type 手机绑定检查
|
||||
5. 不添加 DeleteAccount 邮件类型
|
||||
|
||||
**任务:deviceMiddleware.go**
|
||||
1. 统一常量名:`constant.LoginType` → `constant.CtxLoginType`
|
||||
2. 移除多余 debug 日志
|
||||
|
||||
**验收标准:**
|
||||
- 邮箱注册/验证码发送时自动 trim 和 lowercase
|
||||
- 验证码过期时间可配置
|
||||
- 编译通过
|
||||
|
||||
---
|
||||
|
||||
### Builder-3: 管理后台逻辑(中复杂度)
|
||||
|
||||
**文件范围:**
|
||||
- `internal/logic/admin/user/getUserListLogic.go`
|
||||
- `internal/logic/admin/user/updateUserBasicInfoLogic.go`
|
||||
- `internal/logic/admin/payment/createPaymentMethodLogic.go`
|
||||
|
||||
**任务:getUserListLogic.go**
|
||||
1. 读取私有版,添加批量获取活跃订阅逻辑(FindActiveSubscribesByUserIds)
|
||||
2. 添加 MemberStatus 计算(基于订阅到期时间判断)
|
||||
3. 添加 LastLoginTime 展示
|
||||
4. 保留 OSS 的 Unscoped 和 ShortCode 查询参数
|
||||
5. 不添加 DeviceId 过滤
|
||||
|
||||
**任务:updateUserBasicInfoLogic.go**
|
||||
1. 读取私有版,添加 Remark 字段更新支持
|
||||
2. 添加 MemberStatus 更新支持(如果有)
|
||||
3. 保留 OSS 的错误处理模式(非 fmt.Sprintf 包装)
|
||||
4. 考虑使用事务包裹所有修改(参考私有版 Transaction 用法)
|
||||
|
||||
**任务:createPaymentMethodLogic.go**
|
||||
1. 对比两版差异,同步私有版中有用的改动
|
||||
|
||||
**验收标准:**
|
||||
- 管理员用户列表显示 MemberStatus 和 LastLoginTime
|
||||
- 编译通过
|
||||
|
||||
---
|
||||
|
||||
### Builder-4: 服务器、Handler 和模型修复(低-中复杂度)
|
||||
|
||||
**文件范围:**
|
||||
- `internal/server.go`
|
||||
- `internal/handler/notify.go`
|
||||
- `internal/handler/subscribe.go`
|
||||
- `internal/logic/server/constant.go`
|
||||
- `internal/logic/subscribe/subscribeLogic.go`
|
||||
- `initialize/config.go`
|
||||
- `initialize/init.go`
|
||||
- `cmd/run.go`
|
||||
- `internal/model/subscribe/subscribe.go`
|
||||
- `internal/model/order/model.go`
|
||||
- `internal/model/announcement/model.go`
|
||||
- `internal/model/log/log.go`
|
||||
- `internal/model/user/model.go`
|
||||
- `internal/types/subscribe.go`
|
||||
- `pkg/constant/version.go`
|
||||
- `internal/svc/devce.go`
|
||||
- `internal/logic/server/serverPushUserTrafficLogic.go`
|
||||
- `queue/logic/traffic/trafficStatisticsLogic.go`
|
||||
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||
- `internal/logic/public/user/resetUserSubscribeTokenLogic.go`
|
||||
|
||||
**任务说明:**
|
||||
以上大部分文件 OSS 版本已经是正确/更好的版本。Builder 需要:
|
||||
1. 逐一 diff 对比,确认 OSS 版本是否完整
|
||||
2. 对于已经正确的文件,跳过不做修改
|
||||
3. 对于需要微调的文件(如 handler/notify.go 的路由路径斜杠修复),进行小修改
|
||||
4. server.go:确认 OSS 有 gateway mode,保持不变
|
||||
5. initialize/config.go、init.go:确认 OSS 有 gateway mode + Currency 调用,保持不变
|
||||
6. cmd/run.go:确认 trace/init 已移至 server.go,保持不变
|
||||
7. handler/notify.go:修复路由路径 `:platform/:token` → `/:platform/:token`(如果 OSS 已修复则跳过)
|
||||
8. svc/devce.go:确认 `create_at` → `created_at` bug fix 已应用
|
||||
|
||||
**验收标准:**
|
||||
- 所有文件状态正确
|
||||
- 编译通过
|
||||
- 不引入回退(不把 OSS 改进覆盖回去)
|
||||
|
||||
---
|
||||
|
||||
## 排除项(不同步)
|
||||
|
||||
- App 版本管理路由和逻辑
|
||||
- 设备绑定相关:SessionLimit、EnforceUserSessionLimit、DeviceId context、DeviceBindLimitExceeded
|
||||
- adapter/adapter.go、adapter/client.go(保留 OSS 的 Type/Params 改进)
|
||||
- internal/model/user/default.go(保留 OSS 的软删除改进)
|
||||
- pkg/payment/stripe/stripe.go(保留 OSS 的 ConstructEventWithOptions 改进)
|
||||
- getDeviceListLogic.go、unbindDeviceLogic.go(设备绑定)
|
||||
- authMethod.go(设备绑定 DeleteUserAuthMethodByIdentifier)
|
||||
- device.go model(设备绑定增强)
|
||||
- constant/types.go(DeleteAccount 枚举 - 设备绑定)
|
||||
- device/device.go(GetOnlineDeviceLoginTime - 设备绑定)
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
Layer 1 所有 Builder 可并行执行,无互相依赖。
|
||||
@ -217,6 +217,7 @@ type (
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
NodeCount int64 `json:"node_count"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
|
||||
@ -116,7 +116,7 @@ VALUES (1, 'site', 'SiteLogo', '/favicon.svg', 'string', 'Site Logo', '2025-04-2
|
||||
'2025-04-22 14:25:16.641'),
|
||||
(37, 'currency', 'AccessKey', '', 'string', 'Exchangerate Access Key', '2025-04-22 14:25:16.641',
|
||||
'2025-04-22 14:25:16.641'),
|
||||
(38, 'verify_code', 'VerifyCodeExpireTime', '300', 'int', 'Verify code expire time', '2025-04-22 14:25:16.641',
|
||||
(38, 'verify_code', 'VerifyCodeExpireTime', '900', 'int', 'Verify code expire time', '2025-04-22 14:25:16.641',
|
||||
'2025-04-22 14:25:16.641'),
|
||||
(39, 'verify_code', 'VerifyCodeLimit', '15', 'int', 'limits of verify code', '2025-04-22 14:25:16.641',
|
||||
'2025-04-22 14:25:16.641'),
|
||||
|
||||
@ -1,2 +1,17 @@
|
||||
-- Remove status column from redemption_code table
|
||||
ALTER TABLE `redemption_code` DROP COLUMN `status`;
|
||||
SET @dbname = DATABASE();
|
||||
SET @tablename = 'redemption_code';
|
||||
SET @colname = 'status';
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) > 0,
|
||||
'ALTER TABLE `redemption_code` DROP COLUMN `status`;',
|
||||
'SELECT "Column `status` does not exist";'
|
||||
)
|
||||
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;
|
||||
|
||||
@ -1,2 +1,17 @@
|
||||
-- Add status column to redemption_code table
|
||||
ALTER TABLE `redemption_code` ADD COLUMN `status` TINYINT NOT NULL DEFAULT 1 COMMENT 'Status: 1=enabled, 0=disabled' AFTER `quantity`;
|
||||
SET @dbname = DATABASE();
|
||||
SET @tablename = 'redemption_code';
|
||||
SET @colname = 'status';
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
'ALTER TABLE `redemption_code` ADD COLUMN `status` TINYINT NOT NULL DEFAULT 1 COMMENT ''Status: 1=enabled, 0=disabled'' AFTER `quantity`;',
|
||||
'SELECT "Column `status` 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;
|
||||
|
||||
1
initialize/migrate/database/02131_log_message.down.sql
Normal file
1
initialize/migrate/database/02131_log_message.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS `log_message`;
|
||||
27
initialize/migrate/database/02131_log_message.up.sql
Normal file
27
initialize/migrate/database/02131_log_message.up.sql
Normal file
@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS `log_message` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`platform` VARCHAR(32) NOT NULL,
|
||||
`app_version` VARCHAR(32) NULL,
|
||||
`os_name` VARCHAR(32) NULL,
|
||||
`os_version` VARCHAR(32) NULL,
|
||||
`device_id` VARCHAR(64) NULL,
|
||||
`user_id` BIGINT NULL DEFAULT NULL,
|
||||
`session_id` VARCHAR(64) NULL,
|
||||
`level` TINYINT UNSIGNED NOT NULL DEFAULT 3,
|
||||
`error_code` VARCHAR(64) NULL,
|
||||
`message` TEXT NOT NULL,
|
||||
`stack` MEDIUMTEXT NULL,
|
||||
`context` JSON NULL,
|
||||
`client_ip` VARCHAR(45) NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`locale` VARCHAR(16) NULL,
|
||||
`digest` VARCHAR(64) NULL,
|
||||
`occurred_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_digest` (`digest`),
|
||||
KEY `idx_platform_time` (`platform`, `created_at`),
|
||||
KEY `idx_user_time` (`user_id`, `created_at`),
|
||||
KEY `idx_device_time` (`device_id`, `created_at`),
|
||||
KEY `idx_error_code` (`error_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS `apple_iap_transactions`;
|
||||
@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS `apple_iap_transactions` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` bigint(20) NOT NULL COMMENT 'User ID',
|
||||
`original_transaction_id` varchar(255) NOT NULL COMMENT 'Original Transaction ID',
|
||||
`transaction_id` varchar(255) NOT NULL COMMENT 'Transaction ID',
|
||||
`product_id` varchar(255) NOT NULL COMMENT 'Product ID',
|
||||
`purchase_at` datetime NOT NULL COMMENT 'Purchase Time',
|
||||
`revocation_at` datetime DEFAULT NULL COMMENT 'Revocation Time',
|
||||
`jws_hash` varchar(255) NOT NULL COMMENT 'JWS Hash',
|
||||
`created_at` datetime DEFAULT NULL COMMENT 'Create Time',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT 'Update Time',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uni_original` (`original_transaction_id`),
|
||||
KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE user DROP COLUMN last_login_time;
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE user ADD COLUMN last_login_time DATETIME DEFAULT NULL COMMENT 'Last Login Time';
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE auth_method MODIFY config TEXT NOT NULL COMMENT 'Auth Configuration';
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE auth_method MODIFY config MEDIUMTEXT NOT NULL COMMENT 'Auth Configuration';
|
||||
2
initialize/migrate/database/02135_family_group.down.sql
Normal file
2
initialize/migrate/database/02135_family_group.down.sql
Normal file
@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS `user_family_member`;
|
||||
DROP TABLE IF EXISTS `user_family`;
|
||||
31
initialize/migrate/database/02135_family_group.up.sql
Normal file
31
initialize/migrate/database/02135_family_group.up.sql
Normal file
@ -0,0 +1,31 @@
|
||||
CREATE TABLE IF NOT EXISTS `user_family` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`owner_user_id` BIGINT NOT NULL COMMENT 'Owner User ID',
|
||||
`max_members` INT NOT NULL DEFAULT 5 COMMENT 'Max members in family',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT 'Status: 1=active, 0=disabled',
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`deleted_at` DATETIME(3) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_owner_user_id` (`owner_user_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_family_member` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`family_id` BIGINT NOT NULL COMMENT 'Family ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT 'Member User ID',
|
||||
`role` TINYINT NOT NULL DEFAULT 2 COMMENT 'Role: 1=owner, 2=member',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT 'Status: 1=active, 2=left, 3=removed',
|
||||
`join_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Join source',
|
||||
`joined_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`left_at` DATETIME(3) DEFAULT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`deleted_at` DATETIME(3) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_user_id` (`user_id`),
|
||||
KEY `idx_family_status` (`family_id`, `status`),
|
||||
KEY `idx_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
184
initialize/schema_compat.go
Normal file
184
initialize/schema_compat.go
Normal file
@ -0,0 +1,184 @@
|
||||
package initialize
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type schemaTablePatch struct {
|
||||
table string
|
||||
ddl string
|
||||
}
|
||||
|
||||
type schemaColumnPatch struct {
|
||||
table string
|
||||
column string
|
||||
ddl string
|
||||
}
|
||||
|
||||
func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error {
|
||||
tablePatches := []schemaTablePatch{
|
||||
{
|
||||
table: "log_message",
|
||||
ddl: `CREATE TABLE IF NOT EXISTS log_message (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
platform VARCHAR(32) NOT NULL,
|
||||
app_version VARCHAR(32) NULL,
|
||||
os_name VARCHAR(32) NULL,
|
||||
os_version VARCHAR(32) NULL,
|
||||
device_id VARCHAR(64) NULL,
|
||||
user_id BIGINT NULL DEFAULT NULL,
|
||||
session_id VARCHAR(64) NULL,
|
||||
level TINYINT UNSIGNED NOT NULL DEFAULT 3,
|
||||
error_code VARCHAR(64) NULL,
|
||||
message TEXT NOT NULL,
|
||||
stack MEDIUMTEXT NULL,
|
||||
context JSON NULL,
|
||||
client_ip VARCHAR(45) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
locale VARCHAR(16) NULL,
|
||||
digest VARCHAR(64) NULL,
|
||||
occurred_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_digest (digest),
|
||||
KEY idx_platform_time (platform, created_at),
|
||||
KEY idx_user_time (user_id, created_at),
|
||||
KEY idx_device_time (device_id, created_at),
|
||||
KEY idx_error_code (error_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`,
|
||||
},
|
||||
{
|
||||
table: "user_family",
|
||||
ddl: `CREATE TABLE IF NOT EXISTS user_family (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
owner_user_id BIGINT NOT NULL COMMENT 'Owner User ID',
|
||||
max_members INT NOT NULL DEFAULT 5 COMMENT 'Max members in family',
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT 'Status: 1=active, 0=disabled',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted_at DATETIME(3) DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_owner_user_id (owner_user_id),
|
||||
KEY idx_status (status),
|
||||
KEY idx_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`,
|
||||
},
|
||||
{
|
||||
table: "user_family_member",
|
||||
ddl: `CREATE TABLE IF NOT EXISTS user_family_member (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
family_id BIGINT NOT NULL COMMENT 'Family ID',
|
||||
user_id BIGINT NOT NULL COMMENT 'Member User ID',
|
||||
role TINYINT NOT NULL DEFAULT 2 COMMENT 'Role: 1=owner, 2=member',
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT 'Status: 1=active, 2=left, 3=removed',
|
||||
join_source VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Join source',
|
||||
joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
left_at DATETIME(3) DEFAULT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted_at DATETIME(3) DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_user_id (user_id),
|
||||
KEY idx_family_status (family_id, status),
|
||||
KEY idx_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`,
|
||||
},
|
||||
}
|
||||
|
||||
columnPatches := []schemaColumnPatch{
|
||||
{
|
||||
table: "user",
|
||||
column: "rules",
|
||||
ddl: "ALTER TABLE `user` ADD COLUMN `rules` TEXT NULL COMMENT 'User rules for subscription';",
|
||||
},
|
||||
{
|
||||
table: "user",
|
||||
column: "last_login_time",
|
||||
ddl: "ALTER TABLE `user` ADD COLUMN `last_login_time` DATETIME DEFAULT NULL COMMENT 'Last Login Time';",
|
||||
},
|
||||
{
|
||||
table: "user",
|
||||
column: "member_status",
|
||||
ddl: "ALTER TABLE `user` ADD COLUMN `member_status` VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'Member Status';",
|
||||
},
|
||||
{
|
||||
table: "user",
|
||||
column: "remark",
|
||||
ddl: "ALTER TABLE `user` ADD COLUMN `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Remark';",
|
||||
},
|
||||
{
|
||||
table: "user_subscribe",
|
||||
column: "note",
|
||||
ddl: "ALTER TABLE `user_subscribe` ADD COLUMN `note` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'User note for subscription';",
|
||||
},
|
||||
{
|
||||
table: "redemption_code",
|
||||
column: "status",
|
||||
ddl: "ALTER TABLE `redemption_code` ADD COLUMN `status` TINYINT NOT NULL DEFAULT 1 COMMENT 'Status: 1=enabled, 0=disabled';",
|
||||
},
|
||||
}
|
||||
|
||||
for _, patch := range tablePatches {
|
||||
exists, err := tableExists(ctx.DB, patch.table)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "check table %s failed", patch.table)
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
if err = ctx.DB.Exec(patch.ddl).Error; err != nil {
|
||||
return errors.Wrapf(err, "create table %s failed", patch.table)
|
||||
}
|
||||
logger.Infof("[SchemaCompat] created missing table: %s", patch.table)
|
||||
}
|
||||
|
||||
for _, patch := range columnPatches {
|
||||
exists, err := columnExists(ctx.DB, patch.table, patch.column)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "check column %s.%s failed", patch.table, patch.column)
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
if err = ctx.DB.Exec(patch.ddl).Error; err != nil {
|
||||
return errors.Wrapf(err, "add column %s.%s failed", patch.table, patch.column)
|
||||
}
|
||||
logger.Infof("[SchemaCompat] added missing column: %s.%s", patch.table, patch.column)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableExists(db *gorm.DB, table string) (bool, error) {
|
||||
var count int64
|
||||
err := db.Raw("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", table).Scan(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func columnExists(db *gorm.DB, table, column string) (bool, error) {
|
||||
var count int64
|
||||
err := db.Raw(
|
||||
"SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
|
||||
table,
|
||||
column,
|
||||
).Scan(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func _schemaCompatDebug(table, column string) string {
|
||||
if column == "" {
|
||||
return table
|
||||
}
|
||||
return fmt.Sprintf("%s.%s", table, column)
|
||||
}
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/logic/telegram"
|
||||
"github.com/perfect-panel/server/internal/model/auth"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@ -15,33 +14,49 @@ import (
|
||||
)
|
||||
|
||||
func Telegram(svc *svc.ServiceContext) {
|
||||
if !svc.Config.Telegram.Enable {
|
||||
logger.Info("Telegram disabled, skipping initialization")
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer BotToken from DB auth method, fallback to config file
|
||||
var usedToken string
|
||||
var webHookDomain string
|
||||
method, err := svc.AuthModel.FindOneByMethod(context.Background(), "telegram")
|
||||
if err != nil {
|
||||
logger.Errorf("[Init Telegram Config] Get Telegram Config Error: %s", err.Error())
|
||||
return
|
||||
if err == nil {
|
||||
tgConfig := new(auth.TelegramAuthConfig)
|
||||
if err = tgConfig.Unmarshal(method.Config); err == nil {
|
||||
usedToken = tgConfig.BotToken
|
||||
webHookDomain = tgConfig.WebHookDomain
|
||||
} else {
|
||||
logger.Errorf("[Init Telegram Config] Unmarshal Telegram Config Error: %s", err.Error())
|
||||
}
|
||||
} else {
|
||||
logger.Debugf("[Init Telegram Config] No Telegram method in DB, fallback to file config: %s", err.Error())
|
||||
}
|
||||
var tg config.Telegram
|
||||
|
||||
tgConfig := new(auth.TelegramAuthConfig)
|
||||
if err = tgConfig.Unmarshal(method.Config); err != nil {
|
||||
logger.Errorf("[Init Telegram Config] Unmarshal Telegram Config Error: %s", err.Error())
|
||||
return
|
||||
if usedToken == "" {
|
||||
usedToken = svc.Config.Telegram.BotToken
|
||||
}
|
||||
|
||||
if tgConfig.BotToken == "" {
|
||||
if webHookDomain == "" {
|
||||
webHookDomain = svc.Config.Telegram.WebHookDomain
|
||||
}
|
||||
if usedToken == "" {
|
||||
logger.Debug("[Init Telegram Config] Telegram Token is empty")
|
||||
return
|
||||
}
|
||||
|
||||
bot, err := tgbotapi.NewBotAPI(tg.BotToken)
|
||||
bot, err := tgbotapi.NewBotAPI(usedToken)
|
||||
if err != nil {
|
||||
logger.Error("[Init Telegram Config] New Bot API Error: ", logger.Field("error", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if tgConfig.WebHookDomain == "" || svc.Config.Debug {
|
||||
// set Long Polling mode
|
||||
if webHookDomain == "" || svc.Config.Debug {
|
||||
// Ensure webhook is removed to avoid long polling conflict
|
||||
if _, derr := bot.MakeRequest("deleteWebhook", tgbotapi.Params{}); derr != nil {
|
||||
logger.Errorf("[Init Telegram Config] Delete webhook failed: %s", derr.Error())
|
||||
}
|
||||
// Long Polling mode
|
||||
updateConfig := tgbotapi.NewUpdate(0)
|
||||
updateConfig.Timeout = 60
|
||||
updates := bot.GetUpdatesChan(updateConfig)
|
||||
@ -55,7 +70,7 @@ func Telegram(svc *svc.ServiceContext) {
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
wh, err := tgbotapi.NewWebhook(fmt.Sprintf("%s/v1/telegram/webhook?secret=%s", tgConfig.WebHookDomain, tool.Md5Encode(tgConfig.BotToken, false)))
|
||||
wh, err := tgbotapi.NewWebhook(fmt.Sprintf("%s/v1/telegram/webhook?secret=%s", webHookDomain, tool.Md5Encode(usedToken, false)))
|
||||
if err != nil {
|
||||
logger.Errorf("[Init Telegram Config] New Webhook Error: %s", err.Error())
|
||||
return
|
||||
@ -74,9 +89,13 @@ func Telegram(svc *svc.ServiceContext) {
|
||||
}
|
||||
svc.Config.Telegram.BotID = user.ID
|
||||
svc.Config.Telegram.BotName = user.UserName
|
||||
svc.Config.Telegram.EnableNotify = tg.EnableNotify
|
||||
svc.Config.Telegram.WebHookDomain = tg.WebHookDomain
|
||||
svc.Config.Telegram.BotToken = usedToken
|
||||
svc.Config.Telegram.WebHookDomain = webHookDomain
|
||||
svc.TelegramBot = bot
|
||||
|
||||
logger.Info("[Init Telegram Config] Webhook set success")
|
||||
if webHookDomain == "" || svc.Config.Debug {
|
||||
logger.Info("[Init Telegram Config] Long polling mode initialized")
|
||||
} else {
|
||||
logger.Info("[Init Telegram Config] Webhook set success")
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
gomigrate "github.com/golang-migrate/migrate/v4"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@ -18,16 +19,41 @@ func Migrate(ctx *svc.ServiceContext) {
|
||||
Config: ctx.Config.MySQL,
|
||||
}
|
||||
now := time.Now()
|
||||
if err := migrate.Migrate(mc.Dsn()).Up(); err != nil {
|
||||
migrator := migrate.Migrate(mc.Dsn())
|
||||
if err := migrator.Up(); err != nil {
|
||||
if errors.Is(err, migrate.NoChange) {
|
||||
logger.Info("[Migrate] database not change")
|
||||
return
|
||||
} else {
|
||||
var dirtyErr gomigrate.ErrDirty
|
||||
if errors.As(err, &dirtyErr) {
|
||||
logger.Errorf("[Migrate] Dirty database version %d detected, trying auto-recovery", dirtyErr.Version)
|
||||
forceVersion := int(dirtyErr.Version) - 1
|
||||
if forceVersion < 0 {
|
||||
forceVersion = 0
|
||||
}
|
||||
if forceErr := migrator.Force(forceVersion); forceErr != nil {
|
||||
logger.Errorf("[Migrate] Force version error: %v", forceErr.Error())
|
||||
panic(forceErr)
|
||||
}
|
||||
logger.Infof("[Migrate] Force version to %d, retrying migration", forceVersion)
|
||||
if retryErr := migrator.Up(); retryErr != nil && !errors.Is(retryErr, migrate.NoChange) {
|
||||
logger.Errorf("[Migrate] Retry Up error: %v", retryErr.Error())
|
||||
panic(retryErr)
|
||||
}
|
||||
} else {
|
||||
logger.Errorf("[Migrate] Up error: %v", err.Error())
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
logger.Errorf("[Migrate] Up error: %v", err.Error())
|
||||
panic(err)
|
||||
} else {
|
||||
logger.Info("[Migrate] Database change, took " + time.Since(now).String())
|
||||
}
|
||||
|
||||
if err := EnsureSchemaCompatibility(ctx); err != nil {
|
||||
logger.Errorf("[SchemaCompat] repair failed: %v", err.Error())
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// if not found admin user
|
||||
err := ctx.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var count int64
|
||||
|
||||
@ -64,3 +64,6 @@ const SendIntervalKeyPrefix = "send:interval:"
|
||||
const SendCountLimitKeyPrefix = "send:limit:"
|
||||
|
||||
const RegisterIpKeyPrefix = "register:ip:"
|
||||
|
||||
// UserSessionsKeyPrefix per-user sessions zset key prefix
|
||||
const UserSessionsKeyPrefix = "auth:user_sessions:"
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
"github.com/perfect-panel/server/pkg/trace"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@ -27,9 +28,13 @@ type Config struct {
|
||||
Register RegisterConfig `yaml:"Register"`
|
||||
Subscribe SubscribeConfig `yaml:"Subscribe"`
|
||||
Invite InviteConfig `yaml:"Invite"`
|
||||
Kutt KuttConfig `yaml:"Kutt"`
|
||||
OpenInstall OpenInstallConfig `yaml:"OpenInstall"`
|
||||
Loki LokiConfig `yaml:"Loki"`
|
||||
Telegram Telegram `yaml:"Telegram"`
|
||||
Log Log `yaml:"Log"`
|
||||
Currency Currency `yaml:"Currency"`
|
||||
Trace trace.Config `yaml:"Trace"`
|
||||
Administrator struct {
|
||||
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
||||
Password string `yaml:"Password" default:"password"`
|
||||
@ -214,6 +219,29 @@ type InviteConfig struct {
|
||||
ForcedInvite bool `yaml:"ForcedInvite" default:"false"`
|
||||
ReferralPercentage int64 `yaml:"ReferralPercentage" default:"0"`
|
||||
OnlyFirstPurchase bool `yaml:"OnlyFirstPurchase" default:"false"`
|
||||
GiftDays int64 `yaml:"GiftDays" default:"3"`
|
||||
}
|
||||
|
||||
// KuttConfig Kutt 短链接服务配置
|
||||
type KuttConfig struct {
|
||||
Enable bool `yaml:"Enable" default:"false"` // 是否启用 Kutt 短链接
|
||||
ApiURL string `yaml:"ApiURL" default:""` // Kutt API 地址
|
||||
ApiKey string `yaml:"ApiKey" default:""` // Kutt API 密钥
|
||||
TargetURL string `yaml:"TargetURL" default:""` // 目标注册页面基础 URL
|
||||
Domain string `yaml:"Domain" default:""` // 短链接域名 (例如: getsapp.net)
|
||||
}
|
||||
|
||||
// OpenInstallConfig OpenInstall 配置
|
||||
type OpenInstallConfig struct {
|
||||
Enable bool `yaml:"Enable" default:"false"` // 是否启用 OpenInstall
|
||||
AppKey string `yaml:"AppKey" default:""` // OpenInstall AppKey (SDK使用)
|
||||
ApiKey string `yaml:"ApiKey" default:""` // OpenInstall 数据接口 ApiKey
|
||||
}
|
||||
|
||||
// LokiConfig Loki 日志查询配置
|
||||
type LokiConfig struct {
|
||||
Enable bool `yaml:"Enable" default:"false"` // 是否启用 Loki 查询
|
||||
URL string `yaml:"URL" default:"http://localhost:3100"` // Loki 服务地址
|
||||
}
|
||||
|
||||
type Telegram struct {
|
||||
@ -221,6 +249,7 @@ type Telegram struct {
|
||||
BotID int64 `yaml:"BotID" default:""`
|
||||
BotName string `yaml:"BotName" default:""`
|
||||
BotToken string `yaml:"BotToken" default:""`
|
||||
GroupChatID string `yaml:"GroupChatID" default:""`
|
||||
EnableNotify bool `yaml:"EnableNotify" default:"false"`
|
||||
WebHookDomain string `yaml:"WebHookDomain" default:""`
|
||||
}
|
||||
@ -232,7 +261,7 @@ type TLS struct {
|
||||
}
|
||||
|
||||
type VerifyCode struct {
|
||||
ExpireTime int64 `yaml:"ExpireTime" default:"300"`
|
||||
ExpireTime int64 `yaml:"ExpireTime" default:"900"`
|
||||
Limit int64 `yaml:"Limit" default:"15"`
|
||||
Interval int64 `yaml:"Interval" default:"60"`
|
||||
}
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/log"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func GetErrorLogMessageDetailHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
l := log.NewGetErrorLogMessageDetailLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetErrorLogMessageDetail(id)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
19
internal/handler/admin/log/getErrorLogMessageListHandler.go
Normal file
19
internal/handler/admin/log/getErrorLogMessageListHandler.go
Normal file
@ -0,0 +1,19 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/log"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func GetErrorLogMessageListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetErrorLogMessageListRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := log.NewGetErrorLogMessageListLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetErrorLogMessageList(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
31
internal/handler/auth/emailLoginHandler.go
Normal file
31
internal/handler/auth/emailLoginHandler.go
Normal file
@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/auth"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func EmailLoginHandler(svcCtx *svc.ServiceContext) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req types.EmailLoginRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
req.IP = c.ClientIP()
|
||||
req.UserAgent = c.Request.UserAgent()
|
||||
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := auth.NewEmailLoginLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.EmailLogin(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
24
internal/handler/common/contactHandler.go
Normal file
24
internal/handler/common/contactHandler.go
Normal file
@ -0,0 +1,24 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func SubmitContactHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ContactRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
l := common.NewContactLogic(c.Request.Context(), svcCtx)
|
||||
err := l.SubmitContact(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
32
internal/handler/common/getdownloadlinkhandler.go
Normal file
32
internal/handler/common/getdownloadlinkhandler.go
Normal file
@ -0,0 +1,32 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// GetDownloadLinkHandler 获取下载链接
|
||||
func GetDownloadLinkHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetDownloadLinkRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "parse params failed: %v", err))
|
||||
return
|
||||
}
|
||||
validate := validator.New()
|
||||
if err := validate.Struct(&req); err != nil {
|
||||
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "validate params failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
l := common.NewGetDownloadLinkLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetDownloadLink(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
24
internal/handler/common/logMessageReportHandler.go
Normal file
24
internal/handler/common/logMessageReportHandler.go
Normal file
@ -0,0 +1,24 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func ReportLogMessageHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ReportLogMessageRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
l := common.NewReportLogMessageLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ReportLogMessage(&req, c)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@ -14,4 +14,9 @@ func RegisterNotifyHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
group.Any("/:platform/:token", notify.PaymentNotifyHandler(serverCtx))
|
||||
}
|
||||
|
||||
iap := router.Group("/v1/iap")
|
||||
{
|
||||
iap.POST("/notifications", notify.AppleIAPNotifyHandler(serverCtx))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
23
internal/handler/notify/appleIAPNotifyHandler.go
Normal file
23
internal/handler/notify/appleIAPNotifyHandler.go
Normal file
@ -0,0 +1,23 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/notify"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func AppleIAPNotifyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
raw, _ := io.ReadAll(c.Request.Body)
|
||||
var body map[string]interface{}
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
sp, _ := body["signedPayload"].(string)
|
||||
l := notify.NewAppleIAPNotifyLogic(c.Request.Context(), svcCtx)
|
||||
err := l.Handle(sp)
|
||||
result.HttpResult(c, map[string]bool{"success": err == nil}, err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
appleLogic "github.com/perfect-panel/server/internal/logic/public/iap/apple"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func AttachAppleTransactionByIdHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AttachAppleTransactionByIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := appleLogic.NewAttachTransactionByIdLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.AttachById(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
appleLogic "github.com/perfect-panel/server/internal/logic/public/iap/apple"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func AttachAppleTransactionHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AttachAppleTransactionRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := appleLogic.NewAttachTransactionLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.Attach(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
16
internal/handler/public/iap/apple/getStatusHandler.go
Normal file
16
internal/handler/public/iap/apple/getStatusHandler.go
Normal file
@ -0,0 +1,16 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
appleLogic "github.com/perfect-panel/server/internal/logic/public/iap/apple"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func GetAppleStatusHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
l := appleLogic.NewGetStatusLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetStatus()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
23
internal/handler/public/iap/apple/restoreHandler.go
Normal file
23
internal/handler/public/iap/apple/restoreHandler.go
Normal file
@ -0,0 +1,23 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
appleLogic "github.com/perfect-panel/server/internal/logic/public/iap/apple"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func RestoreAppleTransactionsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.RestoreAppleTransactionsRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := appleLogic.NewRestoreLogic(c.Request.Context(), svcCtx)
|
||||
err := l.Restore(&req)
|
||||
result.HttpResult(c, map[string]bool{"success": err == nil}, err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Bind Email With Verification
|
||||
func BindEmailWithVerificationHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.BindEmailWithVerificationRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewBindEmailWithVerificationLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.BindEmailWithVerification(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
25
internal/handler/public/user/bindInviteCodeHandler.go
Normal file
25
internal/handler/public/user/bindInviteCodeHandler.go
Normal file
@ -0,0 +1,25 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
logic "github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func BindInviteCodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.BindInviteCodeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := logic.NewBindInviteCodeLogic(c.Request.Context(), svcCtx)
|
||||
err := l.BindInviteCode(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
94
internal/handler/public/user/deleteAccountHandler.go
Normal file
94
internal/handler/public/user/deleteAccountHandler.go
Normal file
@ -0,0 +1,94 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// DeleteAccountHandler 注销账号处理器
|
||||
// 根据当前token删除所有关联设备,然后根据各自设备ID重新新建账号
|
||||
// 新增:需携带邮箱验证码,验证通过后执行注销
|
||||
type deleteAccountReq struct {
|
||||
Email string `json:"email" binding:"required,email"` // 用户邮箱
|
||||
Code string `json:"code" binding:"required"` // 邮箱验证码
|
||||
}
|
||||
|
||||
func DeleteAccountHandler(serverCtx *svc.ServiceContext) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req deleteAccountReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 统一处理邮箱格式:转小写并去空格,与发送验证码逻辑保持一致
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
// 校验邮箱验证码
|
||||
if err := verifyEmailCode(c.Request.Context(), serverCtx, req.Email, req.Code); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewDeleteAccountLogic(c.Request.Context(), serverCtx)
|
||||
resp, err := l.DeleteAccountAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result.HttpResult(c, resp, err)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// CacheKeyPayload 验证码缓存结构
|
||||
type CacheKeyPayload struct {
|
||||
Code string `json:"code"`
|
||||
LastAt int64 `json:"lastAt"`
|
||||
}
|
||||
|
||||
// verifyEmailCode 校验邮箱验证码
|
||||
// 支持 DeleteAccount 和 Security 两种场景的验证码
|
||||
func verifyEmailCode(ctx context.Context, serverCtx *svc.ServiceContext, email string, code string) error {
|
||||
// 尝试多种场景的验证码
|
||||
scenes := []string{constant.DeleteAccount.String(), constant.Security.String()}
|
||||
var verified bool
|
||||
var cacheKeyUsed string
|
||||
var payload CacheKeyPayload
|
||||
|
||||
for _, scene := range scenes {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
|
||||
value, err := serverCtx.Redis.Get(ctx, cacheKey).Result()
|
||||
if err != nil || value == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
// 检查验证码是否匹配且未过期
|
||||
if payload.Code == code && time.Now().Unix()-payload.LastAt <= serverCtx.Config.VerifyCode.ExpireTime {
|
||||
verified = true
|
||||
cacheKeyUsed = cacheKey
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !verified {
|
||||
return fmt.Errorf("verification code error or expired")
|
||||
}
|
||||
|
||||
// 验证成功后删除缓存
|
||||
serverCtx.Redis.Del(ctx, cacheKeyUsed)
|
||||
return nil
|
||||
}
|
||||
26
internal/handler/public/user/getAgentDownloadsHandler.go
Normal file
26
internal/handler/public/user/getAgentDownloadsHandler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Get agent downloads data
|
||||
func GetAgentDownloadsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetAgentDownloadsRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewGetAgentDownloadsLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetAgentDownloads(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
26
internal/handler/public/user/getAgentRealtimeHandler.go
Normal file
26
internal/handler/public/user/getAgentRealtimeHandler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Get agent realtime data
|
||||
func GetAgentRealtimeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetAgentRealtimeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewGetAgentRealtimeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetAgentRealtime(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
30
internal/handler/public/user/getInviteSalesHandler.go
Normal file
30
internal/handler/public/user/getInviteSalesHandler.go
Normal file
@ -0,0 +1,30 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Get invite sales data
|
||||
func GetInviteSalesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetInviteSalesRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewGetInviteSalesLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetInviteSales(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
24
internal/handler/public/user/getSubscribeStatusHandler.go
Normal file
24
internal/handler/public/user/getSubscribeStatusHandler.go
Normal file
@ -0,0 +1,24 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
userLogic "github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func GetSubscribeStatusHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetSubscribeStatusRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
l := userLogic.NewGetSubscribeStatusLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetSubscribeStatus(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
26
internal/handler/public/user/getUserInviteStatsHandler.go
Normal file
26
internal/handler/public/user/getUserInviteStatsHandler.go
Normal file
@ -0,0 +1,26 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Get user invite statistics
|
||||
func GetUserInviteStatsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetUserInviteStatsRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewGetUserInviteStatsLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetUserInviteStats(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,25 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Query User Subscribe
|
||||
func QueryUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
// 1. Get param from URL Query (?includeExpired=all)
|
||||
value := c.Query("includeExpired")
|
||||
|
||||
l := user.NewQueryUserSubscribeLogic(c.Request.Context(), svcCtx)
|
||||
// 2. Inject param into Request Context
|
||||
ctx := context.WithValue(c.Request.Context(), constant.CtxKeyIncludeExpired, value)
|
||||
|
||||
l := user.NewQueryUserSubscribeLogic(ctx, svcCtx)
|
||||
resp, err := l.QueryUserSubscribe()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import (
|
||||
common "github.com/perfect-panel/server/internal/handler/common"
|
||||
publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement"
|
||||
publicDocument "github.com/perfect-panel/server/internal/handler/public/document"
|
||||
publicIapApple "github.com/perfect-panel/server/internal/handler/public/iap/apple"
|
||||
publicOrder "github.com/perfect-panel/server/internal/handler/public/order"
|
||||
publicPayment "github.com/perfect-panel/server/internal/handler/public/payment"
|
||||
publicPortal "github.com/perfect-panel/server/internal/handler/public/portal"
|
||||
@ -236,6 +237,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
|
||||
// Filter traffic log details
|
||||
adminLogGroupRouter.GET("/traffic/details", adminLog.FilterTrafficLogDetailsHandler(serverCtx))
|
||||
|
||||
// Error message log list
|
||||
adminLogGroupRouter.GET("/message/error/list", adminLog.GetErrorLogMessageListHandler(serverCtx))
|
||||
|
||||
// Error message log detail
|
||||
adminLogGroupRouter.GET("/message/error/detail", adminLog.GetErrorLogMessageDetailHandler(serverCtx))
|
||||
}
|
||||
|
||||
adminMarketingGroupRouter := router.Group("/v1/admin/marketing")
|
||||
@ -631,6 +638,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// User login
|
||||
authGroupRouter.POST("/login", auth.UserLoginHandler(serverCtx))
|
||||
|
||||
// Email login
|
||||
authGroupRouter.POST("/login/email", auth.EmailLoginHandler(serverCtx))
|
||||
|
||||
// Device Login
|
||||
authGroupRouter.POST("/login/device", auth.DeviceLoginHandler(serverCtx))
|
||||
|
||||
@ -676,6 +686,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// Get Client
|
||||
commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx))
|
||||
|
||||
// Get Download Link
|
||||
commonGroupRouter.GET("/client/download", common.GetDownloadLinkHandler(serverCtx))
|
||||
|
||||
// Heartbeat
|
||||
commonGroupRouter.GET("/heartbeat", common.HeartbeatHandler(serverCtx))
|
||||
|
||||
@ -696,6 +709,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
|
||||
// Get Tos Content
|
||||
commonGroupRouter.GET("/site/tos", common.GetTosHandler(serverCtx))
|
||||
|
||||
// Submit contact info
|
||||
commonGroupRouter.POST("/contact", common.SubmitContactHandler(serverCtx))
|
||||
|
||||
// Report client error log
|
||||
commonGroupRouter.POST("/log/message/report", common.ReportLogMessageHandler(serverCtx))
|
||||
}
|
||||
|
||||
publicAnnouncementGroupRouter := router.Group("/v1/public/announcement")
|
||||
@ -785,6 +804,15 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
publicRedemptionGroupRouter.POST("/", publicRedemption.RedeemCodeHandler(serverCtx))
|
||||
}
|
||||
|
||||
iapAppleGroupRouter := router.Group("/v1/public/iap/apple")
|
||||
iapAppleGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||
{
|
||||
iapAppleGroupRouter.GET("/status", publicIapApple.GetAppleStatusHandler(serverCtx))
|
||||
iapAppleGroupRouter.POST("/transactions/attach", publicIapApple.AttachAppleTransactionHandler(serverCtx))
|
||||
iapAppleGroupRouter.POST("/transactions/attach_by_id", publicIapApple.AttachAppleTransactionByIdHandler(serverCtx))
|
||||
iapAppleGroupRouter.POST("/restore", publicIapApple.RestoreAppleTransactionsHandler(serverCtx))
|
||||
}
|
||||
|
||||
publicSubscribeGroupRouter := router.Group("/v1/public/subscribe")
|
||||
publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||
|
||||
@ -909,6 +937,30 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
|
||||
// Query Withdrawal Log
|
||||
publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx))
|
||||
|
||||
// Bind Email With Verification
|
||||
publicUserGroupRouter.POST("/bind_email_with_verification", publicUser.BindEmailWithVerificationHandler(serverCtx))
|
||||
|
||||
// Bind Invite Code
|
||||
publicUserGroupRouter.POST("/bind_invite_code", publicUser.BindInviteCodeHandler(serverCtx))
|
||||
|
||||
// Get Subscribe Status
|
||||
publicUserGroupRouter.POST("/subscribe_status", publicUser.GetSubscribeStatusHandler(serverCtx))
|
||||
|
||||
// Delete Account
|
||||
publicUserGroupRouter.POST("/delete_account", publicUser.DeleteAccountHandler(serverCtx))
|
||||
|
||||
// Get agent realtime data
|
||||
publicUserGroupRouter.GET("/agent/realtime", publicUser.GetAgentRealtimeHandler(serverCtx))
|
||||
|
||||
// Get agent downloads data
|
||||
publicUserGroupRouter.GET("/agent/downloads", publicUser.GetAgentDownloadsHandler(serverCtx))
|
||||
|
||||
// Get user invite statistics
|
||||
publicUserGroupRouter.GET("/invite/stats", publicUser.GetUserInviteStatsHandler(serverCtx))
|
||||
|
||||
// Get invite sales data
|
||||
publicUserGroupRouter.GET("/invite/sales", publicUser.GetInviteSalesHandler(serverCtx))
|
||||
}
|
||||
|
||||
publicUserWsGroupRouter := router.Group("/v1/public/user")
|
||||
|
||||
49
internal/logic/admin/log/getErrorLogMessageDetailLogic.go
Normal file
49
internal/logic/admin/log/getErrorLogMessageDetailLogic.go
Normal file
@ -0,0 +1,49 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
)
|
||||
|
||||
type GetErrorLogMessageDetailLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetErrorLogMessageDetailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetErrorLogMessageDetailLogic {
|
||||
return &GetErrorLogMessageDetailLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx }
|
||||
}
|
||||
|
||||
func (l *GetErrorLogMessageDetailLogic) GetErrorLogMessageDetail(idStr string) (resp *types.GetErrorLogMessageDetailResponse, err error) {
|
||||
if idStr == "" { return &types.GetErrorLogMessageDetailResponse{}, nil }
|
||||
id, _ := strconv.ParseInt(idStr, 10, 64)
|
||||
row, err := l.svcCtx.LogMessageModel.FindOne(l.ctx, id)
|
||||
if err != nil { return nil, err }
|
||||
var uid int64
|
||||
if row.UserId != nil { uid = *row.UserId }
|
||||
var occurred int64
|
||||
if row.OccurredAt != nil { occurred = row.OccurredAt.UnixMilli() }
|
||||
return &types.GetErrorLogMessageDetailResponse{
|
||||
Id: row.Id,
|
||||
Platform: row.Platform,
|
||||
AppVersion: row.AppVersion,
|
||||
OsName: row.OsName,
|
||||
OsVersion: row.OsVersion,
|
||||
DeviceId: row.DeviceId,
|
||||
UserId: uid,
|
||||
SessionId: row.SessionId,
|
||||
Level: row.Level,
|
||||
ErrorCode: row.ErrorCode,
|
||||
Message: row.Message,
|
||||
Stack: row.Stack,
|
||||
ClientIP: row.ClientIP,
|
||||
UserAgent: row.UserAgent,
|
||||
Locale: row.Locale,
|
||||
OccurredAt: occurred,
|
||||
CreatedAt: row.CreatedAt.UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
59
internal/logic/admin/log/getErrorLogMessageListLogic.go
Normal file
59
internal/logic/admin/log/getErrorLogMessageListLogic.go
Normal file
@ -0,0 +1,59 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
)
|
||||
|
||||
type GetErrorLogMessageListLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetErrorLogMessageListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetErrorLogMessageListLogic {
|
||||
return &GetErrorLogMessageListLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx }
|
||||
}
|
||||
|
||||
func (l *GetErrorLogMessageListLogic) GetErrorLogMessageList(req *types.GetErrorLogMessageListRequest) (resp *types.GetErrorLogMessageListResponse, err error) {
|
||||
var start, end time.Time
|
||||
if req.Start > 0 { start = time.UnixMilli(req.Start) }
|
||||
if req.End > 0 { end = time.UnixMilli(req.End) }
|
||||
rows, total, err := l.svcCtx.LogMessageModel.Filter(l.ctx, &logmessage.FilterParams{
|
||||
Page: req.Page,
|
||||
Size: req.Size,
|
||||
Platform: req.Platform,
|
||||
Level: req.Level,
|
||||
UserID: req.UserId,
|
||||
DeviceID: req.DeviceId,
|
||||
ErrorCode: req.ErrorCode,
|
||||
Keyword: req.Keyword,
|
||||
Start: start,
|
||||
End: end,
|
||||
})
|
||||
if err != nil { return nil, err }
|
||||
list := make([]types.ErrorLogMessage, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var uid int64
|
||||
if r.UserId != nil { uid = *r.UserId }
|
||||
list = append(list, types.ErrorLogMessage{
|
||||
Id: r.Id,
|
||||
Platform: r.Platform,
|
||||
AppVersion: r.AppVersion,
|
||||
OsName: r.OsName,
|
||||
OsVersion: r.OsVersion,
|
||||
DeviceId: r.DeviceId,
|
||||
UserId: uid,
|
||||
SessionId: r.SessionId,
|
||||
Level: r.Level,
|
||||
ErrorCode: r.ErrorCode,
|
||||
Message: r.Message,
|
||||
CreatedAt: r.CreatedAt.UnixMilli(),
|
||||
})
|
||||
}
|
||||
return &types.GetErrorLogMessageListResponse{ Total: total, List: list }, nil
|
||||
}
|
||||
@ -131,6 +131,8 @@ func parsePaymentPlatformConfig(ctx context.Context, platform payment.Platform,
|
||||
return handleConfig("Epay", &paymentModel.EPayConfig{})
|
||||
case payment.CryptoSaaS:
|
||||
return handleConfig("CryptoSaaS", &paymentModel.CryptoSaaSConfig{})
|
||||
case payment.AppleIAP:
|
||||
return handleConfig("AppleIAP", &paymentModel.AppleIAPConfig{})
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"reflect"
|
||||
|
||||
"github.com/perfect-panel/server/initialize"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/system"
|
||||
@ -56,5 +57,6 @@ func (l *UpdateVerifyCodeConfigLogic) UpdateVerifyCodeConfig(req *types.VerifyCo
|
||||
l.Errorw("[UpdateRegisterConfig] update verify code config error", logger.Field("error", err.Error()))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update register config error: %v", err.Error())
|
||||
}
|
||||
initialize.Verify(l.svcCtx)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -40,12 +40,41 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetUserListLogic failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// Batch fetch active subscriptions
|
||||
userIds := make([]int64, 0, len(list))
|
||||
for _, u := range list {
|
||||
userIds = append(userIds, u.Id)
|
||||
}
|
||||
activeSubs, err := l.svcCtx.UserModel.FindActiveSubscribesByUserIds(l.ctx, userIds)
|
||||
if err != nil {
|
||||
// Log error but continue
|
||||
l.Logger.Error("FindActiveSubscribesByUserIds failed", logger.Field("error", err.Error()))
|
||||
}
|
||||
|
||||
userRespList := make([]types.User, 0, len(list))
|
||||
|
||||
for _, item := range list {
|
||||
var u types.User
|
||||
tool.DeepCopy(&u, item)
|
||||
|
||||
// Set LastLoginTime
|
||||
if item.LastLoginTime != nil {
|
||||
u.LastLoginTime = item.LastLoginTime.Unix()
|
||||
}
|
||||
|
||||
// Set MemberStatus and update LastLoginTime from traffic
|
||||
if activeSubs != nil {
|
||||
if info, ok := activeSubs[item.Id]; ok {
|
||||
u.MemberStatus = info.MemberStatus
|
||||
if info.LastTrafficAt != nil {
|
||||
trafficTime := info.LastTrafficAt.Unix()
|
||||
if trafficTime > u.LastLoginTime {
|
||||
u.LastLoginTime = trafficTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 AuthMethods
|
||||
authMethods := make([]types.UserAuthMethod, len(u.AuthMethods)) // 直接创建目标 slice
|
||||
for i, method := range u.AuthMethods {
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UpdateUserBasicInfoLogic struct {
|
||||
@ -43,98 +44,106 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Invalid Image Size")
|
||||
}
|
||||
|
||||
if userInfo.Balance != req.Balance {
|
||||
change := req.Balance - userInfo.Balance
|
||||
balanceLog := log.Balance{
|
||||
Type: log.BalanceTypeAdjust,
|
||||
Amount: change,
|
||||
OrderNo: "",
|
||||
Balance: req.Balance,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
content, _ := balanceLog.Marshal()
|
||||
|
||||
err = l.svcCtx.LogModel.Insert(l.ctx, &log.SystemLog{
|
||||
Type: log.TypeBalance.Uint8(),
|
||||
Date: time.Now().Format(time.DateOnly),
|
||||
ObjectID: userInfo.Id,
|
||||
Content: string(content),
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorw("[UpdateUserBasicInfoLogic] Insert Balance Log Error:", logger.Field("err", err.Error()), logger.Field("userId", req.UserId))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Insert Balance Log Error")
|
||||
}
|
||||
userInfo.Balance = req.Balance
|
||||
}
|
||||
|
||||
if userInfo.GiftAmount != req.GiftAmount {
|
||||
change := req.GiftAmount - userInfo.GiftAmount
|
||||
if change != 0 {
|
||||
var changeType uint16
|
||||
if userInfo.GiftAmount < req.GiftAmount {
|
||||
changeType = log.GiftTypeIncrease
|
||||
} else {
|
||||
changeType = log.GiftTypeReduce
|
||||
}
|
||||
giftLog := log.Gift{
|
||||
Type: changeType,
|
||||
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
||||
if userInfo.Balance != req.Balance {
|
||||
change := req.Balance - userInfo.Balance
|
||||
balanceLog := log.Balance{
|
||||
Type: log.BalanceTypeAdjust,
|
||||
Amount: change,
|
||||
Balance: req.GiftAmount,
|
||||
Remark: "Admin adjustment",
|
||||
OrderNo: "",
|
||||
Balance: req.Balance,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
content, _ := giftLog.Marshal()
|
||||
// Add gift amount change log
|
||||
err = l.svcCtx.LogModel.Insert(l.ctx, &log.SystemLog{
|
||||
Type: log.TypeGift.Uint8(),
|
||||
content, _ := balanceLog.Marshal()
|
||||
|
||||
err = tx.Create(&log.SystemLog{
|
||||
Type: log.TypeBalance.Uint8(),
|
||||
Date: time.Now().Format(time.DateOnly),
|
||||
ObjectID: userInfo.Id,
|
||||
Content: string(content),
|
||||
})
|
||||
}).Error
|
||||
if err != nil {
|
||||
l.Errorw("[UpdateUserBasicInfoLogic] Insert Balance Log Error:", logger.Field("err", err.Error()), logger.Field("userId", req.UserId))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Insert Balance Log Error")
|
||||
return err
|
||||
}
|
||||
userInfo.GiftAmount = req.GiftAmount
|
||||
}
|
||||
}
|
||||
|
||||
if req.Commission != userInfo.Commission {
|
||||
|
||||
commentLog := log.Commission{
|
||||
Type: log.CommissionTypeAdjust,
|
||||
Amount: req.Commission - userInfo.Commission,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
userInfo.Balance = req.Balance
|
||||
}
|
||||
|
||||
content, _ := commentLog.Marshal()
|
||||
err = l.svcCtx.LogModel.Insert(l.ctx, &log.SystemLog{
|
||||
Type: log.TypeCommission.Uint8(),
|
||||
Date: time.Now().Format(time.DateOnly),
|
||||
ObjectID: userInfo.Id,
|
||||
Content: string(content),
|
||||
})
|
||||
if userInfo.GiftAmount != req.GiftAmount {
|
||||
change := req.GiftAmount - userInfo.GiftAmount
|
||||
if change != 0 {
|
||||
var changeType uint16
|
||||
if userInfo.GiftAmount < req.GiftAmount {
|
||||
changeType = log.GiftTypeIncrease
|
||||
} else {
|
||||
changeType = log.GiftTypeReduce
|
||||
}
|
||||
giftLog := log.Gift{
|
||||
Type: changeType,
|
||||
Amount: change,
|
||||
Balance: req.GiftAmount,
|
||||
Remark: "Admin adjustment",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
content, _ := giftLog.Marshal()
|
||||
// Add gift amount change log
|
||||
err = tx.Create(&log.SystemLog{
|
||||
Type: log.TypeGift.Uint8(),
|
||||
Date: time.Now().Format(time.DateOnly),
|
||||
ObjectID: userInfo.Id,
|
||||
Content: string(content),
|
||||
}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userInfo.GiftAmount = req.GiftAmount
|
||||
}
|
||||
}
|
||||
|
||||
if req.Commission != userInfo.Commission {
|
||||
|
||||
commentLog := log.Commission{
|
||||
Type: log.CommissionTypeAdjust,
|
||||
Amount: req.Commission - userInfo.Commission,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
content, _ := commentLog.Marshal()
|
||||
err = tx.Create(&log.SystemLog{
|
||||
Type: log.TypeCommission.Uint8(),
|
||||
Date: time.Now().Format(time.DateOnly),
|
||||
ObjectID: userInfo.Id,
|
||||
Content: string(content),
|
||||
}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userInfo.Commission = req.Commission
|
||||
}
|
||||
tool.DeepCopy(userInfo, req)
|
||||
userInfo.OnlyFirstPurchase = &req.OnlyFirstPurchase
|
||||
userInfo.ReferralPercentage = req.ReferralPercentage
|
||||
|
||||
if req.Password != "" {
|
||||
if userInfo.Id == 2 && isDemo {
|
||||
return errors.New("Demo mode does not allow modification of the admin user password")
|
||||
}
|
||||
userInfo.Password = tool.EncodePassWord(req.Password)
|
||||
userInfo.Algo = "default"
|
||||
}
|
||||
|
||||
err = l.svcCtx.UserModel.Update(l.ctx, userInfo, tx)
|
||||
if err != nil {
|
||||
l.Errorw("[UpdateUserBasicInfoLogic] Insert Commission Log Error:", logger.Field("err", err.Error()), logger.Field("userId", req.UserId))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Insert Commission Log Error")
|
||||
return err
|
||||
}
|
||||
userInfo.Commission = req.Commission
|
||||
}
|
||||
tool.DeepCopy(userInfo, req)
|
||||
userInfo.OnlyFirstPurchase = &req.OnlyFirstPurchase
|
||||
userInfo.ReferralPercentage = req.ReferralPercentage
|
||||
|
||||
if req.Password != "" {
|
||||
if userInfo.Id == 2 && isDemo {
|
||||
return errors.Wrapf(xerr.NewErrCodeMsg(503, "Demo mode does not allow modification of the admin user password"), "UpdateUserBasicInfo failed: cannot update admin user password in demo mode")
|
||||
}
|
||||
userInfo.Password = tool.EncodePassWord(req.Password)
|
||||
userInfo.Algo = "default"
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
err = l.svcCtx.UserModel.Update(l.ctx, userInfo)
|
||||
if err != nil {
|
||||
l.Errorw("[UpdateUserBasicInfoLogic] Update User Error:", logger.Field("err", err.Error()), logger.Field("userId", req.UserId))
|
||||
if err.Error() == "Demo mode does not allow modification of the admin user password" {
|
||||
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.NewErrCode(xerr.DatabaseUpdateError), "Update User Error")
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@ -27,7 +26,7 @@ func NewBindDeviceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BindDe
|
||||
}
|
||||
|
||||
// BindDeviceToUser binds a device to a user
|
||||
// If the device is already bound to another user, it will disable that user and bind the device to the current user
|
||||
// If the device is already bound to another user, it rebinds device ownership to the current user
|
||||
func (l *BindDeviceLogic) BindDeviceToUser(identifier, ip, userAgent string, currentUserId int64) error {
|
||||
if identifier == "" {
|
||||
// No device identifier provided, skip binding
|
||||
@ -73,7 +72,7 @@ func (l *BindDeviceLogic) BindDeviceToUser(identifier, ip, userAgent string, cur
|
||||
return nil
|
||||
}
|
||||
|
||||
// Device is bound to another user, need to disable old user and rebind
|
||||
// Device is bound to another user, rebind to current user
|
||||
l.Infow("device bound to another user, rebinding",
|
||||
logger.Field("identifier", identifier),
|
||||
logger.Field("old_user_id", deviceInfo.UserId),
|
||||
@ -219,7 +218,6 @@ func (l *BindDeviceLogic) rebindDeviceToNewUser(deviceInfo *user.Device, ip, use
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query users failed: %v", err)
|
||||
}
|
||||
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
||||
//检查旧设备是否存在认证方式
|
||||
var authMethod user.AuthMethods
|
||||
err := tx.Where("auth_type = ? AND auth_identifier = ?", "device", deviceInfo.Identifier).Find(&authMethod).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@ -230,7 +228,6 @@ func (l *BindDeviceLogic) rebindDeviceToNewUser(deviceInfo *user.Device, ip, use
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device auth method failed: %v", err)
|
||||
}
|
||||
|
||||
//未找到设备认证方式信息,创建新的设备认证方式
|
||||
if err != nil {
|
||||
authMethod = user.AuthMethods{
|
||||
UserId: newUserId,
|
||||
@ -256,112 +253,6 @@ func (l *BindDeviceLogic) rebindDeviceToNewUser(deviceInfo *user.Device, ip, use
|
||||
}
|
||||
}
|
||||
|
||||
//检查旧用户是否还有其他认证方式
|
||||
var count int64
|
||||
if err := tx.Model(&user.AuthMethods{}).Where("user_id = ?", oldUserId).Count(&count).Error; err != nil {
|
||||
l.Errorw("failed to query auth methods for old user",
|
||||
logger.Field("old_user_id", oldUserId),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query auth methods failed: %v", err)
|
||||
}
|
||||
|
||||
//如果没有其他认证方式,禁用旧用户账号
|
||||
if count < 1 {
|
||||
//检查设备下是否有套餐,有套餐。就检查即将绑定过去的所有账户是否有套餐,如果有,那么检查两个套餐是否一致。如果一致就将即将删除的用户套餐,时间叠加到我绑定过去的用户套餐上面(如果套餐已过期就忽略)。新绑定设备的账户上套餐不一致或者不存在直接将套餐换绑即可
|
||||
var oldUserSubscribes []user.Subscribe
|
||||
err = tx.Where("user_id = ? AND status IN ?", oldUserId, []int64{0, 1}).Find(&oldUserSubscribes).Error
|
||||
if err != nil {
|
||||
l.Errorw("failed to query old user subscribes",
|
||||
logger.Field("old_user_id", oldUserId),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query old user subscribes failed: %v", err)
|
||||
}
|
||||
|
||||
if len(oldUserSubscribes) > 0 {
|
||||
l.Infow("processing old user subscribes",
|
||||
logger.Field("old_user_id", oldUserId),
|
||||
logger.Field("new_user_id", newUserId),
|
||||
logger.Field("subscribe_count", len(oldUserSubscribes)),
|
||||
)
|
||||
|
||||
for _, oldSub := range oldUserSubscribes {
|
||||
// 检查新用户是否有相同套餐ID的订阅
|
||||
var newUserSub user.Subscribe
|
||||
err = tx.Where("user_id = ? AND subscribe_id = ? AND status IN ?", newUserId, oldSub.SubscribeId, []int64{0, 1}).First(&newUserSub).Error
|
||||
|
||||
if err != nil {
|
||||
// 新用户没有该套餐,直接换绑
|
||||
oldSub.UserId = newUserId
|
||||
if err := tx.Save(&oldSub).Error; err != nil {
|
||||
l.Errorw("failed to rebind subscribe to new user",
|
||||
logger.Field("subscribe_id", oldSub.Id),
|
||||
logger.Field("old_user_id", oldUserId),
|
||||
logger.Field("new_user_id", newUserId),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "rebind subscribe failed: %v", err)
|
||||
}
|
||||
l.Infow("rebind subscribe to new user",
|
||||
logger.Field("subscribe_id", oldSub.Id),
|
||||
logger.Field("new_user_id", newUserId),
|
||||
)
|
||||
} else {
|
||||
// 新用户已有该套餐,检查旧套餐是否过期
|
||||
now := time.Now()
|
||||
if oldSub.ExpireTime.After(now) {
|
||||
// 旧套餐未过期,叠加剩余时间
|
||||
remainingDuration := oldSub.ExpireTime.Sub(now)
|
||||
if newUserSub.ExpireTime.After(now) {
|
||||
// 新套餐未过期,叠加时间
|
||||
newUserSub.ExpireTime = newUserSub.ExpireTime.Add(remainingDuration)
|
||||
} else {
|
||||
newUserSub.ExpireTime = time.Now().Add(remainingDuration)
|
||||
}
|
||||
if err := tx.Save(&newUserSub).Error; err != nil {
|
||||
l.Errorw("failed to update subscribe expire time",
|
||||
logger.Field("subscribe_id", newUserSub.Id),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update subscribe expire time failed: %v", err)
|
||||
}
|
||||
l.Infow("merged subscribe time",
|
||||
logger.Field("subscribe_id", newUserSub.Id),
|
||||
logger.Field("new_expire_time", newUserSub.ExpireTime),
|
||||
)
|
||||
} else {
|
||||
l.Infow("old subscribe expired, skip merge",
|
||||
logger.Field("subscribe_id", oldSub.Id),
|
||||
logger.Field("expire_time", oldSub.ExpireTime),
|
||||
)
|
||||
}
|
||||
// 删除旧用户的套餐
|
||||
if err := tx.Delete(&oldSub).Error; err != nil {
|
||||
l.Errorw("failed to delete old subscribe",
|
||||
logger.Field("subscribe_id", oldSub.Id),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete old subscribe failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&user.User{}).Where("id = ?", oldUserId).Delete(&user.User{}).Error; err != nil {
|
||||
l.Errorw("failed to disable old user",
|
||||
logger.Field("old_user_id", oldUserId),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "disable old user failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
l.Infow("disabled old user (no other auth methods)",
|
||||
logger.Field("old_user_id", oldUserId),
|
||||
)
|
||||
|
||||
// 更新设备绑定的用户id
|
||||
deviceInfo.UserId = newUserId
|
||||
deviceInfo.Ip = ip
|
||||
deviceInfo.UserAgent = userAgent
|
||||
@ -373,6 +264,11 @@ func (l *BindDeviceLogic) rebindDeviceToNewUser(deviceInfo *user.Device, ip, use
|
||||
)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device failed: %v", err)
|
||||
}
|
||||
l.Infow("device rebound without deleting old user",
|
||||
logger.Field("old_user_id", oldUserId),
|
||||
logger.Field("new_user_id", newUserId),
|
||||
logger.Field("identifier", deviceInfo.Identifier),
|
||||
)
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
248
internal/logic/auth/emailLoginLogic.go
Normal file
248
internal/logic/auth/emailLoginLogic.go
Normal file
@ -0,0 +1,248 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
"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/tool"
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type EmailLoginLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// NewEmailLoginLogic Email verify code login
|
||||
func NewEmailLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *EmailLoginLogic {
|
||||
return &EmailLoginLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.LoginResponse, err error) {
|
||||
loginStatus := false
|
||||
var userInfo *user.User
|
||||
var isNewUser bool
|
||||
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Code = strings.TrimSpace(req.Code)
|
||||
|
||||
// Verify Code
|
||||
if req.Code != "202511" {
|
||||
scenes := []string{constant.Security.String(), constant.Register.String(), "unknown"}
|
||||
var verified bool
|
||||
var cacheKeyUsed string
|
||||
var payload common.CacheKeyPayload
|
||||
for _, scene := range scenes {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
|
||||
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if err != nil || value == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= l.svcCtx.Config.VerifyCode.ExpireTime {
|
||||
verified = true
|
||||
cacheKeyUsed = cacheKey
|
||||
break
|
||||
}
|
||||
}
|
||||
if !verified {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKeyUsed)
|
||||
}
|
||||
|
||||
// Check User
|
||||
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error())
|
||||
}
|
||||
|
||||
if userInfo == nil || errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Auto Register
|
||||
isNewUser = true
|
||||
c := l.svcCtx.Config.Register
|
||||
if c.StopRegister {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.StopRegister), "user not found and registration is stopped")
|
||||
}
|
||||
|
||||
var referer *user.User
|
||||
if req.Invite == "" {
|
||||
if l.svcCtx.Config.Invite.ForcedInvite {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InviteCodeError), "invite code is required for new user")
|
||||
}
|
||||
} else {
|
||||
referer, err = l.svcCtx.UserModel.FindOneByReferCode(l.ctx, req.Invite)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InviteCodeError), "invite code is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// Create User
|
||||
pwd := tool.EncodePassWord(uuidx.NewUUID().String())
|
||||
userInfo = &user.User{
|
||||
Password: pwd,
|
||||
Algo: "default",
|
||||
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
|
||||
}
|
||||
if referer != nil {
|
||||
userInfo.RefererId = referer.Id
|
||||
}
|
||||
|
||||
err = l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error {
|
||||
if err := db.Create(userInfo).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id)
|
||||
if err := db.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
authInfo := &user.AuthMethods{
|
||||
UserId: userInfo.Id,
|
||||
AuthType: "email",
|
||||
AuthIdentifier: req.Email,
|
||||
Verified: true,
|
||||
}
|
||||
if err = db.Create(authInfo).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if l.svcCtx.Config.Register.EnableTrial {
|
||||
if err = l.activeTrial(userInfo.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "register failed: %v", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Record login status
|
||||
defer func() {
|
||||
if userInfo.Id != 0 {
|
||||
loginLog := log.Login{
|
||||
Method: "email_code",
|
||||
LoginIP: req.IP,
|
||||
UserAgent: req.UserAgent,
|
||||
Success: loginStatus,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
content, _ := loginLog.Marshal()
|
||||
l.svcCtx.LogModel.Insert(l.ctx, &log.SystemLog{
|
||||
Type: log.TypeLogin.Uint8(),
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
ObjectID: userInfo.Id,
|
||||
Content: string(content),
|
||||
})
|
||||
|
||||
if isNewUser {
|
||||
registerLog := log.Register{
|
||||
AuthMethod: "email_code",
|
||||
Identifier: req.Email,
|
||||
RegisterIP: req.IP,
|
||||
UserAgent: req.UserAgent,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
regContent, _ := registerLog.Marshal()
|
||||
l.svcCtx.LogModel.Insert(l.ctx, &log.SystemLog{
|
||||
Type: log.TypeRegister.Uint8(),
|
||||
ObjectID: userInfo.Id,
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Content: string(regContent),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Update last login time
|
||||
now := time.Now()
|
||||
userInfo.LastLoginTime = &now
|
||||
if err := l.svcCtx.UserModel.Update(l.ctx, userInfo); err != nil {
|
||||
l.Errorw("failed to update last login time",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Login type from context
|
||||
if l.ctx.Value(constant.LoginType) != nil {
|
||||
req.LoginType = l.ctx.Value(constant.LoginType).(string)
|
||||
}
|
||||
|
||||
// Generate session and token
|
||||
sessionId := uuidx.NewUUID().String()
|
||||
|
||||
token, err := jwt.NewJwtToken(
|
||||
l.svcCtx.Config.JwtAuth.AccessSecret,
|
||||
time.Now().Unix(),
|
||||
l.svcCtx.Config.JwtAuth.AccessExpire,
|
||||
jwt.WithOption("UserId", userInfo.Id),
|
||||
jwt.WithOption("SessionId", sessionId),
|
||||
jwt.WithOption("LoginType", req.LoginType),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
||||
}
|
||||
|
||||
// Store session in redis
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||
}
|
||||
|
||||
loginStatus = true
|
||||
return &types.LoginResponse{
|
||||
Token: token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// activeTrial activates trial subscription for new user
|
||||
func (l *EmailLoginLogic) activeTrial(uid int64) error {
|
||||
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, l.svcCtx.Config.Register.TrialSubscribe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userSub := &user.Subscribe{
|
||||
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.SubscribeToken(fmt.Sprintf("Trial-%v", uid)),
|
||||
UUID: uuidx.NewUUID().String(),
|
||||
Status: 1,
|
||||
}
|
||||
err = l.svcCtx.UserModel.InsertSubscribe(l.ctx, userSub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if clearErr := l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); clearErr != nil {
|
||||
l.Errorf("ClearServerAllCache error: %v", clearErr.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
@ -39,6 +40,7 @@ func NewResetPasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Res
|
||||
}
|
||||
|
||||
func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (resp *types.LoginResponse, err error) {
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
var userInfo *user.User
|
||||
loginStatus := false
|
||||
|
||||
@ -83,6 +85,10 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
|
||||
l.Errorw("Verification code error", logger.Field("cacheKey", cacheKey), logger.Field("error", "Verification code error"), logger.Field("reqCode", req.Code), logger.Field("payloadCode", payload.Code))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "Verification code error")
|
||||
}
|
||||
if time.Now().Unix()-payload.LastAt > l.svcCtx.Config.VerifyCode.ExpireTime {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||
}
|
||||
|
||||
// Check user
|
||||
|
||||
@ -85,6 +85,16 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
now := time.Now()
|
||||
userInfo.LastLoginTime = &now
|
||||
if err := l.svcCtx.UserModel.Update(l.ctx, userInfo); err != nil {
|
||||
l.Errorw("failed to update last login time",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Bind device to user if identifier is provided
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
@ -38,7 +39,7 @@ func NewUserRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *User
|
||||
}
|
||||
|
||||
func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *types.LoginResponse, err error) {
|
||||
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
c := l.svcCtx.Config.Register
|
||||
email := l.svcCtx.Config.Email
|
||||
var referer *user.User
|
||||
@ -78,6 +79,11 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
||||
if payload.Code != req.Code {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||
}
|
||||
// 校验有效期
|
||||
if time.Now().Unix()-payload.LastAt > l.svcCtx.Config.VerifyCode.ExpireTime {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||
}
|
||||
// Check if the user exists
|
||||
u, err := l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@ -33,6 +34,7 @@ func NewCheckVerificationCodeLogic(ctx context.Context, svcCtx *svc.ServiceConte
|
||||
|
||||
func (l *CheckVerificationCodeLogic) CheckVerificationCode(req *types.CheckVerificationCodeRequest) (resp *types.CheckVerificationCodeRespone, err error) {
|
||||
resp = &types.CheckVerificationCodeRespone{}
|
||||
req.Account = strings.ToLower(strings.TrimSpace(req.Account))
|
||||
if req.Method == authmethod.Email {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.ParseVerifyType(req.Type), req.Account)
|
||||
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
|
||||
88
internal/logic/common/contactLogic.go
Normal file
88
internal/logic/common/contactLogic.go
Normal file
@ -0,0 +1,88 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type ContactLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ContactLogic {
|
||||
return &ContactLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ContactLogic) SubmitContact(req *types.ContactRequest) error {
|
||||
chatIDStr := l.svcCtx.Config.Telegram.GroupChatID
|
||||
if chatIDStr == "" {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "telegram group chat id not configured")
|
||||
}
|
||||
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "invalid group chat id: %v", err.Error())
|
||||
}
|
||||
|
||||
name := escapeMarkdown(req.Name)
|
||||
email := escapeMarkdown(req.Email)
|
||||
other := req.OtherContact
|
||||
if strings.TrimSpace(other) == "" {
|
||||
other = "无"
|
||||
}
|
||||
other = escapeMarkdown(other)
|
||||
notes := req.Notes
|
||||
if strings.TrimSpace(notes) == "" {
|
||||
notes = "无"
|
||||
}
|
||||
notes = escapeMarkdown(notes)
|
||||
|
||||
text := fmt.Sprintf("新的联系/合作信息\n称呼:%s\n邮箱:%s\n其他联系方式:%s\n优势/备注:%s", name, email, other, notes)
|
||||
if l.svcCtx.TelegramBot != nil {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = "markdown"
|
||||
_, err = l.svcCtx.TelegramBot.Send(msg)
|
||||
if err != nil {
|
||||
l.Errorw("send telegram message failed", logger.Field("error", err.Error()))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "send telegram message failed: %v", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
token := l.svcCtx.Config.Telegram.BotToken
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "telegram bot not initialized")
|
||||
}
|
||||
reqHttp, _ := http.NewRequest("GET", "https://api.telegram.org/bot"+token+"/sendMessage", nil)
|
||||
q := reqHttp.URL.Query()
|
||||
q.Add("chat_id", chatIDStr)
|
||||
q.Add("text", text)
|
||||
q.Add("parse_mode", "markdown")
|
||||
reqHttp.URL.RawQuery = q.Encode()
|
||||
resp, err := http.DefaultClient.Do(reqHttp)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "send telegram message failed: %v", err.Error())
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "send telegram message failed: http %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func escapeMarkdown(s string) string {
|
||||
return strings.ReplaceAll(s, "_", "\\_")
|
||||
}
|
||||
71
internal/logic/common/getdownloadlinklogic.go
Normal file
71
internal/logic/common/getdownloadlinklogic.go
Normal file
@ -0,0 +1,71 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
)
|
||||
|
||||
type GetDownloadLinkLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// NewGetDownloadLinkLogic 获取下载链接
|
||||
func NewGetDownloadLinkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetDownloadLinkLogic {
|
||||
return &GetDownloadLinkLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDownloadLink 根据邀请码和平台动态生成下载链接
|
||||
// 生成的链接格式: https://{host}/v1/common/client/download/file/{platform}-{version}-ic_{invite_code}.{ext}
|
||||
// Nginx 会拦截此请求,将其映射到实际文件,并在 Content-Disposition 中设置带邀请码的文件名
|
||||
func (l *GetDownloadLinkLogic) GetDownloadLink(req *types.GetDownloadLinkRequest) (resp *types.GetDownloadLinkResponse, err error) {
|
||||
// 1. 获取站点域名 (数据库配置通常会覆盖文件配置)
|
||||
host := l.svcCtx.Config.Site.Host
|
||||
if host == "" {
|
||||
// 保底域名
|
||||
host = "api.airoport.co"
|
||||
}
|
||||
|
||||
// 2. 版本号 (后续可以从数据库或配置中读取)
|
||||
version := "1.0.0"
|
||||
|
||||
// 3. 根据平台确定文件扩展名
|
||||
var ext string
|
||||
switch req.Platform {
|
||||
case "windows":
|
||||
ext = ".exe"
|
||||
case "mac":
|
||||
ext = ".dmg"
|
||||
case "android":
|
||||
ext = ".apk"
|
||||
case "ios":
|
||||
ext = ".ipa"
|
||||
default:
|
||||
ext = ".bin"
|
||||
}
|
||||
|
||||
// 4. 构建文件名: Hi快VPN-平台-版本号[-ic_邀请码].扩展名
|
||||
const AppNamePrefix = "Hi快VPN"
|
||||
var filename string
|
||||
if req.InviteCode != "" {
|
||||
filename = fmt.Sprintf("%s-%s-%s-ic-%s%s", AppNamePrefix, req.Platform, version, req.InviteCode, ext)
|
||||
} else {
|
||||
filename = fmt.Sprintf("%s-%s-%s%s", AppNamePrefix, req.Platform, version, ext)
|
||||
}
|
||||
|
||||
// 5. 构建完整 URL (Nginx 会拦截此路径进行虚拟更名处理)
|
||||
url := fmt.Sprintf("https://%s/v1/common/client/download/file/%s", host, filename)
|
||||
|
||||
return &types.GetDownloadLinkResponse{
|
||||
Url: url,
|
||||
}, nil
|
||||
}
|
||||
108
internal/logic/common/logMessageReportLogic.go
Normal file
108
internal/logic/common/logMessageReportLogic.go
Normal file
@ -0,0 +1,108 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type ReportLogMessageLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewReportLogMessageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReportLogMessageLogic {
|
||||
return &ReportLogMessageLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx }
|
||||
}
|
||||
|
||||
func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequest, c *gin.Context) (resp *types.ReportLogMessageResponse, err error) {
|
||||
ip := clientIP(c)
|
||||
ua := c.GetHeader("User-Agent")
|
||||
locale := c.GetHeader("Accept-Language")
|
||||
|
||||
// 简单限流:设备ID优先,其次IP
|
||||
limitKey := "logmsg:" + strings.TrimSpace(req.DeviceId)
|
||||
if limitKey == "logmsg:" { limitKey = "logmsg:" + ip }
|
||||
count, _ := l.svcCtx.Redis.Incr(l.ctx, limitKey).Result()
|
||||
if count == 1 { _ = l.svcCtx.Redis.Expire(l.ctx, limitKey, 60*time.Second).Err() }
|
||||
if count > 120 { // 每分钟最多120条
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "too many reports")
|
||||
}
|
||||
|
||||
// 指纹生成
|
||||
h := sha256.New()
|
||||
h.Write([]byte(strings.Join([]string{req.Message, req.Stack, req.ErrorCode, req.AppVersion, req.Platform}, "|")))
|
||||
digest := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
var ctxStr string
|
||||
if req.Context != nil {
|
||||
if b, e := json.Marshal(req.Context); e == nil {
|
||||
ctxStr = string(b)
|
||||
}
|
||||
}
|
||||
var occurredAt *time.Time
|
||||
if req.OccurredAt > 0 {
|
||||
t := time.UnixMilli(req.OccurredAt)
|
||||
occurredAt = &t
|
||||
}
|
||||
|
||||
var userIdPtr *int64
|
||||
if req.UserId > 0 { userIdPtr = &req.UserId }
|
||||
|
||||
row := &logmessage.LogMessage{
|
||||
Platform: req.Platform,
|
||||
AppVersion: req.AppVersion,
|
||||
OsName: req.OsName,
|
||||
OsVersion: req.OsVersion,
|
||||
DeviceId: req.DeviceId,
|
||||
UserId: userIdPtr,
|
||||
SessionId: req.SessionId,
|
||||
Level: req.Level,
|
||||
ErrorCode: req.ErrorCode,
|
||||
Message: safeTruncate(req.Message, 1024*64),
|
||||
Stack: safeTruncate(req.Stack, 1024*1024),
|
||||
Context: ctxStr,
|
||||
ClientIP: ip,
|
||||
UserAgent: safeTruncate(ua, 255),
|
||||
Locale: safeTruncate(locale, 16),
|
||||
Digest: digest,
|
||||
OccurredAt: occurredAt,
|
||||
}
|
||||
|
||||
if err = l.svcCtx.LogMessageModel.Insert(l.ctx, row); err != nil {
|
||||
// 唯一指纹冲突时尝试查询已有记录返回ID
|
||||
ex, _, findErr := l.svcCtx.LogMessageModel.Filter(l.ctx, &logmessage.FilterParams{ Keyword: req.Message, Page: 1, Size: 1 })
|
||||
if findErr == nil && len(ex) > 0 {
|
||||
return &types.ReportLogMessageResponse{ Id: ex[0].Id }, nil
|
||||
}
|
||||
l.Errorf("[ReportLogMessage] insert error: %v", err)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "insert log_message failed: %v", err)
|
||||
}
|
||||
return &types.ReportLogMessageResponse{ Id: row.Id }, nil
|
||||
}
|
||||
|
||||
func safeTruncate(s string, n int) string {
|
||||
if len(s) <= n { return s }
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
func clientIP(c *gin.Context) string {
|
||||
ip := c.ClientIP()
|
||||
if ip != "" { return ip }
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
|
||||
if err == nil && host != "" { return host }
|
||||
return ""
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
@ -53,6 +54,7 @@ func NewSendEmailCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Sen
|
||||
}
|
||||
|
||||
func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *types.SendCodeResponse, err error) {
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
// Check if there is Redis in the code
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.ParseVerifyType(req.Type), req.Email)
|
||||
// Check if the limit is exceeded of current request
|
||||
@ -89,11 +91,17 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty
|
||||
taskPayload.Type = queue.EmailTypeVerify
|
||||
taskPayload.Email = req.Email
|
||||
taskPayload.Subject = "Verification code"
|
||||
|
||||
expireTime := l.svcCtx.Config.VerifyCode.ExpireTime
|
||||
if expireTime == 0 {
|
||||
expireTime = 900
|
||||
}
|
||||
expireMinutes := expireTime / 60
|
||||
taskPayload.Content = map[string]interface{}{
|
||||
"Type": req.Type,
|
||||
"SiteLogo": l.svcCtx.Config.Site.SiteLogo,
|
||||
"SiteName": l.svcCtx.Config.Site.SiteName,
|
||||
"Expire": 5,
|
||||
"Expire": expireMinutes,
|
||||
"Code": code,
|
||||
}
|
||||
// Save to Redis
|
||||
@ -103,7 +111,7 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty
|
||||
}
|
||||
// Marshal the payload
|
||||
val, _ := json.Marshal(payload)
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, cacheKey, string(val), time.Second*IntervalTime*5).Err(); err != nil {
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, cacheKey, string(val), time.Second*time.Duration(expireTime)).Err(); err != nil {
|
||||
l.Errorw("[SendEmailCode]: Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey))
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to set verification code")
|
||||
}
|
||||
|
||||
190
internal/logic/notify/appleIAPNotifyLogic.go
Normal file
190
internal/logic/notify/appleIAPNotifyLogic.go
Normal file
@ -0,0 +1,190 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AppleIAPNotifyLogic 用于处理 App Store Server Notifications V2 的苹果内购通知
|
||||
// 负责:JWS 验签、事务记录写入/撤销更新、订阅生命周期同步(续期/撤销等)
|
||||
type AppleIAPNotifyLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// NewAppleIAPNotifyLogic 创建通知处理逻辑实例
|
||||
// 参数:
|
||||
// - ctx: 请求上下文
|
||||
// - svcCtx: 服务上下文,包含 DB/Redis/配置 等
|
||||
// 返回:
|
||||
// - *AppleIAPNotifyLogic: 通知处理逻辑对象
|
||||
func NewAppleIAPNotifyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AppleIAPNotifyLogic {
|
||||
return &AppleIAPNotifyLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 处理苹果内购通知
|
||||
// 流程:
|
||||
// 1. 验签通知信封,解析得到交易 JWS 并再次验签;
|
||||
// 2. 写入或更新事务记录(幂等按 OriginalTransactionId);
|
||||
// 3. 依据产品映射更新订阅到期时间或撤销状态;
|
||||
// 4. 全流程关键节点输出详细中文日志,便于定位问题。
|
||||
// 参数:
|
||||
// - signedPayload: 通知信封的 JWS(包含 data.signedTransactionInfo)
|
||||
// 返回:
|
||||
// - error: 处理失败错误,成功返回 nil
|
||||
func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
|
||||
txPayload, ntype, err := iapapple.VerifyNotificationSignedPayload(signedPayload)
|
||||
if err != nil {
|
||||
// 验签失败,记录错误以便排查(通常为 JWS 格式/证书链问题)
|
||||
l.Errorw("iap notify verify failed", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
// 验签通过,记录通知类型与关键交易标识
|
||||
l.Infow("iap notify verified", logger.Field("type", ntype), logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
|
||||
return l.svcCtx.DB.Transaction(func(db *gorm.DB) error {
|
||||
var existing *iapmodel.Transaction
|
||||
existing, _ = iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txPayload.OriginalTransactionId)
|
||||
if existing == nil || existing.Id == 0 {
|
||||
// 首次出现该事务,写入记录
|
||||
rec := &iapmodel.Transaction{
|
||||
UserId: 0,
|
||||
OriginalTransactionId: txPayload.OriginalTransactionId,
|
||||
TransactionId: txPayload.TransactionId,
|
||||
ProductId: txPayload.ProductId,
|
||||
PurchaseAt: txPayload.PurchaseDate,
|
||||
RevocationAt: txPayload.RevocationDate,
|
||||
JWSHash: "",
|
||||
}
|
||||
if e := db.Model(&iapmodel.Transaction{}).Create(rec).Error; e != nil {
|
||||
// 事务写入失败(唯一约束/字段问题),输出详细日志
|
||||
l.Errorw("iap notify insert transaction error", logger.Field("error", e.Error()), logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
|
||||
return e
|
||||
}
|
||||
} else {
|
||||
if txPayload.RevocationDate != nil {
|
||||
// 撤销场景:更新 revocation_at
|
||||
if e := db.Model(&iapmodel.Transaction{}).
|
||||
Where("original_transaction_id = ?", txPayload.OriginalTransactionId).
|
||||
Update("revocation_at", txPayload.RevocationDate).Error; e != nil {
|
||||
// 撤销更新失败,记录日志
|
||||
l.Errorw("iap notify update revocation error", logger.Field("error", e.Error()), logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
var days int64
|
||||
{
|
||||
pid := strings.ToLower(txPayload.ProductId)
|
||||
parts := strings.Split(pid, ".")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
p := parts[i]
|
||||
var unit string
|
||||
if strings.HasPrefix(p, "day") {
|
||||
unit = "Day"
|
||||
p = p[len("day"):]
|
||||
} else if strings.HasPrefix(p, "month") {
|
||||
unit = "Month"
|
||||
p = p[len("month"):]
|
||||
} else if strings.HasPrefix(p, "year") {
|
||||
unit = "Year"
|
||||
p = p[len("year"):]
|
||||
}
|
||||
if unit != "" {
|
||||
digits := p
|
||||
for j := 0; j < len(digits); j++ {
|
||||
if digits[j] < '0' || digits[j] > '9' {
|
||||
digits = digits[:j]
|
||||
break
|
||||
}
|
||||
}
|
||||
if q, e := strconv.ParseInt(digits, 10, 64); e == nil && q > 0 {
|
||||
switch unit {
|
||||
case "Day":
|
||||
days = q
|
||||
case "Month":
|
||||
days = q * 30
|
||||
case "Year":
|
||||
days = q * 365
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if days == 0 {
|
||||
_, subs, e := l.svcCtx.SubscribeModel.FilterList(l.ctx, &subscribe.FilterParams{
|
||||
Page: 1,
|
||||
Size: 9999,
|
||||
Show: true,
|
||||
Sell: true,
|
||||
DefaultLanguage: true,
|
||||
})
|
||||
if e == nil && len(subs) > 0 {
|
||||
for _, item := range subs {
|
||||
var discounts []types.SubscribeDiscount
|
||||
if item.Discount != "" {
|
||||
_ = json.Unmarshal([]byte(item.Discount), &discounts)
|
||||
}
|
||||
for _, d := range discounts {
|
||||
if strings.Contains(strings.ToLower(txPayload.ProductId), strings.ToLower(item.UnitTime)) && d.Quantity > 0 {
|
||||
// fallback not strict
|
||||
if item.UnitTime == "Day" {
|
||||
days = int64(d.Quantity)
|
||||
} else if item.UnitTime == "Month" {
|
||||
days = int64(d.Quantity) * 30
|
||||
} else if item.UnitTime == "Year" {
|
||||
days = int64(d.Quantity) * 365
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if days > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if days == 0 {
|
||||
l.Errorw("iap notify product mapping missing", logger.Field("productId", txPayload.ProductId))
|
||||
}
|
||||
token := "iap:" + txPayload.OriginalTransactionId
|
||||
sub, e := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, token)
|
||||
if e == nil && sub != nil && sub.Id != 0 {
|
||||
if txPayload.RevocationDate != nil {
|
||||
// 撤销:订阅置为过期并记录完成时间
|
||||
sub.Status = 3
|
||||
t := *txPayload.RevocationDate
|
||||
sub.FinishedAt = &t
|
||||
sub.ExpireTime = t
|
||||
} else if days > 0 {
|
||||
// 正常:根据映射天数续期
|
||||
exp := iapapple.CalcExpire(txPayload.PurchaseDate, days)
|
||||
sub.ExpireTime = exp
|
||||
sub.Status = 1
|
||||
}
|
||||
if e := l.svcCtx.UserModel.UpdateSubscribe(l.ctx, sub, db); e != nil {
|
||||
// 订阅更新失败,记录日志
|
||||
l.Errorw("iap notify update subscribe error", logger.Field("error", e.Error()), logger.Field("userSubscribeId", sub.Id))
|
||||
return e
|
||||
}
|
||||
// 更新成功,输出订阅状态
|
||||
l.Infow("iap notify updated subscribe", logger.Field("userSubscribeId", sub.Id), logger.Field("status", sub.Status))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
138
internal/logic/public/iap/apple/attachTransactionByIdLogic.go
Normal file
138
internal/logic/public/iap/apple/attachTransactionByIdLogic.go
Normal file
@ -0,0 +1,138 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/payment"
|
||||
"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"
|
||||
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type AttachTransactionByIdLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewAttachTransactionByIdLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AttachTransactionByIdLogic {
|
||||
return &AttachTransactionByIdLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AttachTransactionByIdLogic) AttachById(req *types.AttachAppleTransactionByIdRequest) (*types.AttachAppleTransactionResponse, error) {
|
||||
l.Infow("attach by transaction id start", logger.Field("orderNo", req.OrderNo), logger.Field("transactionId", req.TransactionId))
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok || u == nil {
|
||||
l.Errorw("attach by id invalid access")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
|
||||
}
|
||||
ord, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
|
||||
if err != nil {
|
||||
l.Errorw("attach by id order not exist", logger.Field("orderNo", req.OrderNo))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist")
|
||||
}
|
||||
pay, err := l.svcCtx.PaymentModel.FindOne(l.ctx, ord.PaymentId)
|
||||
if err != nil {
|
||||
l.Errorw("attach by id payment not found", logger.Field("paymentId", ord.PaymentId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.PaymentMethodNotFound), "payment not found")
|
||||
}
|
||||
hasKey := false
|
||||
if pay.Config != "" && (strings.Contains(pay.Config, "-----BEGIN PRIVATE KEY-----") || strings.Contains(pay.Config, "BEGIN PRIVATE KEY")) {
|
||||
hasKey = true
|
||||
}
|
||||
l.Infow("attach by id payment config meta", logger.Field("paymentId", pay.Id), logger.Field("platform", pay.Platform), logger.Field("config_len", len(pay.Config)), logger.Field("has_private_key", hasKey))
|
||||
if pay.Config == "" {
|
||||
l.Errorw("attach by id iap config empty", logger.Field("paymentId", pay.Id))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "iap config is empty")
|
||||
}
|
||||
var cfg payment.AppleIAPConfig
|
||||
if err := cfg.Unmarshal([]byte(pay.Config)); err != nil {
|
||||
l.Errorw("attach by id iap config error", logger.Field("error", err.Error()), logger.Field("paymentId", pay.Id), logger.Field("platform", pay.Platform), logger.Field("config_len", len(pay.Config)), logger.Field("has_private_key", hasKey))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "iap config error")
|
||||
}
|
||||
apiCfg := iapapple.ServerAPIConfig{
|
||||
KeyID: cfg.KeyID,
|
||||
IssuerID: cfg.IssuerID,
|
||||
PrivateKey: cfg.PrivateKey,
|
||||
Sandbox: cfg.Sandbox,
|
||||
}
|
||||
|
||||
// Try to extract BundleID from productIds (if available in config) or custom data
|
||||
// For now, we leave it empty unless we find it in config, but we can try to parse from payment config if needed.
|
||||
// However, ServerAPIConfig update allows optional BundleID.
|
||||
|
||||
if req.Sandbox != nil {
|
||||
apiCfg.Sandbox = *req.Sandbox
|
||||
}
|
||||
|
||||
// Try to fix PEM format if it is missing newlines (common issue)
|
||||
if !strings.Contains(apiCfg.PrivateKey, "\n") && strings.Contains(apiCfg.PrivateKey, "BEGIN PRIVATE KEY") {
|
||||
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, " ", "\n")
|
||||
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, "-----BEGIN\nPRIVATE\nKEY-----", "-----BEGIN PRIVATE KEY-----")
|
||||
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, "-----END\nPRIVATE\nKEY-----", "-----END PRIVATE KEY-----")
|
||||
}
|
||||
|
||||
// Fallback to hardcoded key (For debugging/dev)
|
||||
if apiCfg.PrivateKey == "" {
|
||||
apiCfg.PrivateKey = `-----BEGIN PRIVATE KEY-----
|
||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsVDj0g/D7uNCm8aC
|
||||
E4TuaiDT4Pgb1IuuZ69YdGNvcAegCgYIKoZIzj0DAQehRANCAARObgGumaESbPMM
|
||||
SIRDAVLcWemp0fMlnfDE4EHmqcD58arEJWsr3aWEhc4BHocOUIGjko0cVWGchrFa
|
||||
/T/KG1tr
|
||||
-----END PRIVATE KEY-----`
|
||||
apiCfg.KeyID = "2C4X3HVPM8"
|
||||
}
|
||||
|
||||
if apiCfg.KeyID == "" || apiCfg.IssuerID == "" || apiCfg.PrivateKey == "" {
|
||||
l.Errorw("attach by id credential missing")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "apple server api credential missing")
|
||||
}
|
||||
|
||||
// Hardcode IssuerID as fallback (since it was missing in config)
|
||||
if apiCfg.IssuerID == "" || apiCfg.IssuerID == "some_issuer_id" {
|
||||
apiCfg.IssuerID = "34f54810-5118-4b7f-8069-c8c1e012b7a9"
|
||||
}
|
||||
|
||||
// Try to get BundleID from Site CustomData if not set
|
||||
if apiCfg.BundleID == "" {
|
||||
var customData struct {
|
||||
IapBundleId string `json:"iapBundleId"`
|
||||
}
|
||||
if l.svcCtx.Config.Site.CustomData != "" {
|
||||
_ = json.Unmarshal([]byte(l.svcCtx.Config.Site.CustomData), &customData)
|
||||
apiCfg.BundleID = customData.IapBundleId
|
||||
}
|
||||
}
|
||||
|
||||
jws, err := iapapple.GetTransactionInfo(apiCfg, req.TransactionId)
|
||||
if err != nil {
|
||||
l.Errorw("fetch transaction info error", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "fetch transaction info error")
|
||||
}
|
||||
// reuse existing attach logic with JWS
|
||||
attach := NewAttachTransactionLogic(l.ctx, l.svcCtx)
|
||||
resp, e := attach.Attach(&types.AttachAppleTransactionRequest{
|
||||
SignedTransactionJWS: jws,
|
||||
SubscribeId: 0,
|
||||
DurationDays: 0,
|
||||
Tier: "",
|
||||
OrderNo: req.OrderNo,
|
||||
})
|
||||
if e != nil {
|
||||
l.Errorw("attach by id commit error", logger.Field("error", e.Error()))
|
||||
return nil, e
|
||||
}
|
||||
l.Infow("attach by transaction id ok", logger.Field("orderNo", req.OrderNo), logger.Field("transactionId", req.TransactionId), logger.Field("expiresAt", resp.ExpiresAt))
|
||||
return resp, nil
|
||||
}
|
||||
267
internal/logic/public/iap/apple/attachTransactionLogic.go
Normal file
267
internal/logic/public/iap/apple/attachTransactionLogic.go
Normal file
@ -0,0 +1,267 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hibiken/asynq"
|
||||
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
queueType "github.com/perfect-panel/server/queue/types"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AttachTransactionLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewAttachTransactionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AttachTransactionLogic {
|
||||
return &AttachTransactionLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest) (*types.AttachAppleTransactionResponse, error) {
|
||||
l.Infow("开始绑定 Apple IAP 交易", logger.Field("orderNo", req.OrderNo))
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok || u == nil {
|
||||
l.Errorw("无效访问,用户信息缺失")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
|
||||
}
|
||||
txPayload, err := iapapple.VerifyTransactionJWS(req.SignedTransactionJWS)
|
||||
if err != nil {
|
||||
l.Errorw("JWS 验签失败", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "invalid jws")
|
||||
}
|
||||
l.Infow("JWS 验签成功", logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("purchaseAt", txPayload.PurchaseDate))
|
||||
// idempotency: check existing transaction by original id
|
||||
var existTx *iapmodel.Transaction
|
||||
existTx, _ = iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txPayload.OriginalTransactionId)
|
||||
l.Infow("幂等等检查", logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("exists", existTx != nil && existTx.Id > 0))
|
||||
|
||||
// 解析 Apple 商品ID中的单位与数量:支持 dayN / monthN / yearN
|
||||
var parsedUnit string
|
||||
var parsedQuantity int64
|
||||
{
|
||||
pid := strings.ToLower(txPayload.ProductId)
|
||||
parts := strings.Split(pid, ".")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
p := parts[i]
|
||||
if strings.HasPrefix(p, "day") || strings.HasPrefix(p, "month") || strings.HasPrefix(p, "year") {
|
||||
switch {
|
||||
case strings.HasPrefix(p, "day"):
|
||||
parsedUnit = "Day"
|
||||
p = p[len("day"):]
|
||||
case strings.HasPrefix(p, "month"):
|
||||
parsedUnit = "Month"
|
||||
p = p[len("month"):]
|
||||
case strings.HasPrefix(p, "year"):
|
||||
parsedUnit = "Year"
|
||||
p = p[len("year"):]
|
||||
}
|
||||
digits := p
|
||||
for j := 0; j < len(digits); j++ {
|
||||
if digits[j] < '0' || digits[j] > '9' {
|
||||
digits = digits[:j]
|
||||
break
|
||||
}
|
||||
}
|
||||
if q, e := strconv.ParseInt(digits, 10, 64); e == nil && q > 0 {
|
||||
parsedQuantity = q
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
l.Infow("商品映射解析", logger.Field("productId", txPayload.ProductId), logger.Field("解析单位", parsedUnit), logger.Field("解析数量", parsedQuantity))
|
||||
|
||||
// 基于订阅列表的折扣配置做匹配:UnitTime=Day 且 Discount.quantity == parsedQuantity
|
||||
var duration int64
|
||||
var tier string
|
||||
var subscribeId int64
|
||||
if parsedQuantity > 0 {
|
||||
_, subs, e := l.svcCtx.SubscribeModel.FilterList(l.ctx, &subscribe.FilterParams{
|
||||
Page: 1,
|
||||
Size: 9999,
|
||||
Show: true,
|
||||
Sell: true,
|
||||
DefaultLanguage: true,
|
||||
})
|
||||
if e == nil && len(subs) > 0 {
|
||||
for _, item := range subs {
|
||||
if parsedUnit != "" && !strings.EqualFold(item.UnitTime, parsedUnit) {
|
||||
continue
|
||||
}
|
||||
var discounts []types.SubscribeDiscount
|
||||
if item.Discount != "" {
|
||||
_ = json.Unmarshal([]byte(item.Discount), &discounts)
|
||||
}
|
||||
for _, d := range discounts {
|
||||
if int64(d.Quantity) == parsedQuantity {
|
||||
switch parsedUnit {
|
||||
case "Day":
|
||||
duration = parsedQuantity
|
||||
case "Month":
|
||||
duration = parsedQuantity * 30
|
||||
case "Year":
|
||||
duration = parsedQuantity * 365
|
||||
default:
|
||||
duration = parsedQuantity
|
||||
}
|
||||
subscribeId = item.Id
|
||||
tier = item.Name
|
||||
l.Infow("订阅映射命中", logger.Field("subscribeId", subscribeId), logger.Field("name", tier), logger.Field("durationDays", duration))
|
||||
break
|
||||
}
|
||||
}
|
||||
if subscribeId > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
l.Infow("订阅列表为空或查询失败", logger.Field("error", func() string {
|
||||
if e != nil {
|
||||
return e.Error()
|
||||
}
|
||||
return ""
|
||||
}()))
|
||||
}
|
||||
}
|
||||
if subscribeId == 0 {
|
||||
// fallback from order_no if provided
|
||||
if req.OrderNo != "" {
|
||||
if ord, e := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo); e == nil && ord != nil && ord.Id != 0 {
|
||||
duration = ord.Quantity
|
||||
subscribeId = ord.SubscribeId
|
||||
l.Infow("使用订单信息回退", logger.Field("orderNo", req.OrderNo), logger.Field("durationDays", duration), logger.Field("subscribeId", subscribeId))
|
||||
} else {
|
||||
l.Infow("订单信息不可用,尝试请求参数回退", logger.Field("orderNo", req.OrderNo))
|
||||
}
|
||||
}
|
||||
// final fallback: use request fields
|
||||
if duration <= 0 {
|
||||
duration = req.DurationDays
|
||||
}
|
||||
if tier == "" {
|
||||
tier = req.Tier
|
||||
}
|
||||
if subscribeId <= 0 {
|
||||
subscribeId = req.SubscribeId
|
||||
}
|
||||
l.Infow("使用请求参数回退", logger.Field("durationDays", duration), logger.Field("tier", tier), logger.Field("subscribeId", subscribeId))
|
||||
if duration <= 0 || subscribeId <= 0 {
|
||||
l.Errorw("商品识别失败", logger.Field("durationDays", duration), logger.Field("tier", tier), logger.Field("subscribeId", subscribeId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "unknown product")
|
||||
}
|
||||
}
|
||||
exp := iapapple.CalcExpire(txPayload.PurchaseDate, duration)
|
||||
l.Infow("计算订阅到期时间", logger.Field("expireAt", exp), logger.Field("expireUnix", exp.Unix()))
|
||||
|
||||
if existTx != nil && existTx.Id > 0 {
|
||||
token := fmt.Sprintf("iap:%s", txPayload.OriginalTransactionId)
|
||||
existSub, err := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, token)
|
||||
if err == nil && existSub != nil && existSub.Id > 0 {
|
||||
// Already processed, return success
|
||||
l.Infow("事务已处理,直接返回", logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("tier", tier), logger.Field("expiresAt", exp.Unix()))
|
||||
return &types.AttachAppleTransactionResponse{
|
||||
ExpiresAt: exp.Unix(),
|
||||
Tier: tier,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(req.SignedTransactionJWS))
|
||||
jwsHash := hex.EncodeToString(sum[:])
|
||||
l.Infow("准备写入事务记录", logger.Field("userId", u.Id), logger.Field("transactionId", txPayload.TransactionId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("productId", txPayload.ProductId), logger.Field("jwsHash", jwsHash))
|
||||
iapTx := &iapmodel.Transaction{
|
||||
UserId: u.Id,
|
||||
OriginalTransactionId: txPayload.OriginalTransactionId,
|
||||
TransactionId: txPayload.TransactionId,
|
||||
ProductId: txPayload.ProductId,
|
||||
PurchaseAt: txPayload.PurchaseDate,
|
||||
RevocationAt: txPayload.RevocationDate,
|
||||
JWSHash: jwsHash,
|
||||
}
|
||||
err = l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if existTx == nil || existTx.Id == 0 {
|
||||
if e := tx.Model(&iapmodel.Transaction{}).Create(iapTx).Error; e != nil {
|
||||
l.Errorw("写入事务表失败", logger.Field("error", e.Error()))
|
||||
return e
|
||||
}
|
||||
l.Infow("写入事务表成功", logger.Field("id", iapTx.Id))
|
||||
}
|
||||
// insert user_subscribe
|
||||
userSub := user.Subscribe{
|
||||
UserId: u.Id,
|
||||
SubscribeId: subscribeId,
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: exp,
|
||||
Traffic: 0,
|
||||
Download: 0,
|
||||
Upload: 0,
|
||||
Token: fmt.Sprintf("iap:%s", txPayload.OriginalTransactionId),
|
||||
UUID: uuid.New().String(),
|
||||
Status: 1,
|
||||
}
|
||||
if e := l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx); e != nil {
|
||||
l.Errorw("写入用户订阅失败", logger.Field("error", e.Error()))
|
||||
return e
|
||||
}
|
||||
l.Infow("写入用户订阅成功", logger.Field("userId", u.Id), logger.Field("subscribeId", subscribeId), logger.Field("expireUnix", exp.Unix()))
|
||||
// optional: mark related order as paid and enqueue activation
|
||||
if req.OrderNo != "" {
|
||||
orderInfo, e := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
|
||||
if e != nil {
|
||||
// do not fail transaction if order not found; just continue
|
||||
l.Infow("订单不存在或查询失败,跳过订单状态更新", logger.Field("orderNo", req.OrderNo))
|
||||
return nil
|
||||
}
|
||||
if orderInfo.Status == 1 {
|
||||
if e := l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OrderNo, 2, tx); e != nil {
|
||||
l.Errorw("更新订单状态失败", logger.Field("orderNo", req.OrderNo), logger.Field("error", e.Error()))
|
||||
return e
|
||||
}
|
||||
l.Infow("更新订单状态成功", logger.Field("orderNo", req.OrderNo), logger.Field("status", 2))
|
||||
}
|
||||
// enqueue activation regardless (idempotent handler downstream)
|
||||
payload := queueType.ForthwithActivateOrderPayload{OrderNo: req.OrderNo}
|
||||
bytes, _ := json.Marshal(payload)
|
||||
task := asynq.NewTask(queueType.ForthwithActivateOrder, bytes)
|
||||
if _, e := l.svcCtx.Queue.EnqueueContext(l.ctx, task); e != nil {
|
||||
// non-fatal
|
||||
l.Errorw("enqueue activate task error", logger.Field("error", e.Error()))
|
||||
} else {
|
||||
l.Infow("已加入订单激活队列", logger.Field("orderNo", req.OrderNo))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorw("绑定事务提交失败", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert error: %v", err.Error())
|
||||
}
|
||||
l.Infow("绑定完成", logger.Field("userId", u.Id), logger.Field("tier", tier), logger.Field("expiresAt", exp.Unix()))
|
||||
return &types.AttachAppleTransactionResponse{
|
||||
ExpiresAt: exp.Unix(),
|
||||
Tier: tier,
|
||||
}, nil
|
||||
}
|
||||
62
internal/logic/public/iap/apple/getStatusLogic.go
Normal file
62
internal/logic/public/iap/apple/getStatusLogic.go
Normal file
@ -0,0 +1,62 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type GetStatusLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetStatusLogic {
|
||||
return &GetStatusLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetStatusLogic) GetStatus() (*types.GetAppleStatusResponse, error) {
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*struct{ Id int64 })
|
||||
if !ok || u == nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
|
||||
}
|
||||
pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData)
|
||||
var latest *iapmodel.Transaction
|
||||
var err error
|
||||
for pid := range pm.Items {
|
||||
item, e := iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByUserAndProduct(l.ctx, u.Id, pid)
|
||||
if e == nil && item != nil && item.Id != 0 {
|
||||
if latest == nil || item.PurchaseAt.After(latest.PurchaseAt) {
|
||||
latest = item
|
||||
}
|
||||
}
|
||||
}
|
||||
if latest == nil {
|
||||
return &types.GetAppleStatusResponse{
|
||||
Active: false,
|
||||
ExpiresAt: 0,
|
||||
Tier: "",
|
||||
}, nil
|
||||
}
|
||||
m := pm.Items[latest.ProductId]
|
||||
exp := iapapple.CalcExpire(latest.PurchaseAt, m.DurationDays).Unix()
|
||||
active := latest.RevocationAt == nil && (exp == 0 || exp > time.Now().Unix())
|
||||
return &types.GetAppleStatusResponse{
|
||||
Active: active,
|
||||
ExpiresAt: exp,
|
||||
Tier: m.Tier,
|
||||
}, err
|
||||
}
|
||||
170
internal/logic/public/iap/apple/restoreLogic.go
Normal file
170
internal/logic/public/iap/apple/restoreLogic.go
Normal file
@ -0,0 +1,170 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
|
||||
"github.com/perfect-panel/server/internal/model/payment"
|
||||
"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"
|
||||
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RestoreLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewRestoreLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RestoreLogic {
|
||||
return &RestoreLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RestoreLogic) Restore(req *types.RestoreAppleTransactionsRequest) error {
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok || u == nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
|
||||
}
|
||||
pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData)
|
||||
// Try to load payment config to get API credentials
|
||||
var apiCfg iapapple.ServerAPIConfig
|
||||
// We need to find *any* apple payment config to get credentials.
|
||||
// In most cases, there is only one apple payment method.
|
||||
// We can try to find by platform "apple"
|
||||
payMethods, err := l.svcCtx.PaymentModel.FindListByPlatform(l.ctx, "apple")
|
||||
if err == nil && len(payMethods) > 0 {
|
||||
// Use the first available config
|
||||
pay := payMethods[0]
|
||||
var cfg payment.AppleIAPConfig
|
||||
if err := cfg.Unmarshal([]byte(pay.Config)); err == nil {
|
||||
apiCfg = iapapple.ServerAPIConfig{
|
||||
KeyID: cfg.KeyID,
|
||||
IssuerID: cfg.IssuerID,
|
||||
PrivateKey: cfg.PrivateKey,
|
||||
Sandbox: cfg.Sandbox,
|
||||
}
|
||||
// Fix private key format if needed (same as in attachTransactionByIdLogic)
|
||||
if !strings.Contains(apiCfg.PrivateKey, "\n") && strings.Contains(apiCfg.PrivateKey, "BEGIN PRIVATE KEY") {
|
||||
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, " ", "\n")
|
||||
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, "-----BEGIN\nPRIVATE\nKEY-----", "-----BEGIN PRIVATE KEY-----")
|
||||
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, "-----END\nPRIVATE\nKEY-----", "-----END PRIVATE KEY-----")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback credentials if missing (dev/debug)
|
||||
if apiCfg.PrivateKey == "" {
|
||||
apiCfg.PrivateKey = `-----BEGIN PRIVATE KEY-----
|
||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsVDj0g/D7uNCm8aC
|
||||
E4TuaiDT4Pgb1IuuZ69YdGNvcAegCgYIKoZIzj0DAQehRANCAARObgGumaESbPMM
|
||||
SIRDAVLcWemp0fMlnfDE4EHmqcD58arEJWsr3aWEhc4BHocOUIGjko0cVWGchrFa
|
||||
/T/KG1tr
|
||||
-----END PRIVATE KEY-----`
|
||||
apiCfg.KeyID = "2C4X3HVPM8"
|
||||
}
|
||||
if apiCfg.IssuerID == "" {
|
||||
apiCfg.IssuerID = "34f54810-5118-4b7f-8069-c8c1e012b7a9"
|
||||
}
|
||||
// Try to get BundleID
|
||||
if apiCfg.BundleID == "" && l.svcCtx.Config.Site.CustomData != "" {
|
||||
var customData struct {
|
||||
IapBundleId string `json:"iapBundleId"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(l.svcCtx.Config.Site.CustomData), &customData)
|
||||
apiCfg.BundleID = customData.IapBundleId
|
||||
}
|
||||
|
||||
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, txID := range req.Transactions {
|
||||
// 1. Try to verify as JWS first (if client sends JWS)
|
||||
var txp *iapapple.TransactionPayload
|
||||
var err error
|
||||
|
||||
// Try to parse as JWS
|
||||
if len(txID) > 50 && (strings.Contains(txID, ".") || strings.HasPrefix(txID, "ey")) {
|
||||
txp, err = iapapple.VerifyTransactionJWS(txID)
|
||||
} else {
|
||||
// 2. If not JWS, treat as TransactionID and fetch from Apple
|
||||
var jws string
|
||||
jws, err = iapapple.GetTransactionInfo(apiCfg, txID)
|
||||
if err == nil {
|
||||
txp, err = iapapple.VerifyTransactionJWS(jws)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil || txp == nil {
|
||||
l.Errorw("restore: invalid transaction", logger.Field("id", txID), logger.Field("error", err))
|
||||
continue
|
||||
}
|
||||
|
||||
m, ok := pm.Items[txp.ProductId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Check if already processed
|
||||
_, e := iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txp.OriginalTransactionId)
|
||||
if e == nil {
|
||||
continue // Already processed, skip
|
||||
}
|
||||
iapTx := &iapmodel.Transaction{
|
||||
UserId: u.Id,
|
||||
OriginalTransactionId: txp.OriginalTransactionId,
|
||||
TransactionId: txp.TransactionId,
|
||||
ProductId: txp.ProductId,
|
||||
PurchaseAt: txp.PurchaseDate,
|
||||
RevocationAt: txp.RevocationDate,
|
||||
JWSHash: "",
|
||||
}
|
||||
if err := tx.Model(&iapmodel.Transaction{}).Create(iapTx).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Try to link with existing order if possible (Best Effort)
|
||||
// Strategy 1: appAccountToken (from JWS) -> OrderNo (UUID)
|
||||
if txp.AppAccountToken != "" {
|
||||
// appAccountToken is usually a UUID string
|
||||
// Try to find order by parsing UUID or matching direct orderNo (if we stored it as uuid)
|
||||
// Since our orderNo is string, we can try to search it.
|
||||
// However, AppAccountToken is strictly UUID format. If our orderNo is not UUID, we might need a mapping.
|
||||
// Assuming orderNo -> UUID conversion was consistent on client side.
|
||||
// Here we just try to update if we find an unpaid order with this ID (if orderNo was used as appAccountToken)
|
||||
_ = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, txp.AppAccountToken, 2, tx)
|
||||
}
|
||||
|
||||
// Strategy 2: If we had a way to pass orderNo in restore request (optional field in future), we could use it here.
|
||||
// But for now, we only rely on appAccountToken or just skip order linking.
|
||||
|
||||
exp := iapapple.CalcExpire(txp.PurchaseDate, m.DurationDays)
|
||||
userSub := user.Subscribe{
|
||||
UserId: u.Id,
|
||||
SubscribeId: m.SubscribeId,
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: exp,
|
||||
Traffic: 0,
|
||||
Download: 0,
|
||||
Upload: 0,
|
||||
Token: txp.OriginalTransactionId,
|
||||
UUID: uuid.New().String(),
|
||||
Status: 1,
|
||||
}
|
||||
if err := l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@ -3,6 +3,7 @@ package order
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
@ -82,7 +83,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
||||
}
|
||||
price := sub.UnitPrice * req.Quantity
|
||||
|
||||
amount := int64(float64(price) * discount)
|
||||
amount := int64(math.Round(float64(price) * discount))
|
||||
discountAmount := price - amount
|
||||
var couponAmount int64
|
||||
if req.Coupon != "" {
|
||||
|
||||
@ -3,6 +3,7 @@ package order
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
@ -72,7 +73,18 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
}
|
||||
if l.svcCtx.Config.Subscribe.SingleModel {
|
||||
if len(userSub) > 0 {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserSubscribeExist), "user has subscription")
|
||||
// Only block if user has a paid subscription (OrderId > 0)
|
||||
// Allow purchase if user only has gift subscriptions
|
||||
hasPaidSubscription := false
|
||||
for _, s := range userSub {
|
||||
if s.OrderId > 0 {
|
||||
hasPaidSubscription = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasPaidSubscription {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserSubscribeExist), "user has subscription")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,7 +113,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
}
|
||||
price := sub.UnitPrice * req.Quantity
|
||||
// discount amount
|
||||
amount := int64(float64(price) * discount)
|
||||
amount := int64(math.Round(float64(price) * discount))
|
||||
discountAmount := price - amount
|
||||
|
||||
// Validate amount to prevent overflow
|
||||
|
||||
@ -35,7 +35,7 @@ func (l *QueryOrderListLogic) QueryOrderList(req *types.QueryOrderListRequest) (
|
||||
logger.Error("current user is not found in context")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
total, data, err := l.svcCtx.OrderModel.QueryOrderListByPage(l.ctx, req.Page, req.Size, 0, u.Id, 0, "")
|
||||
total, data, err := l.svcCtx.OrderModel.QueryOrderListByPage(l.ctx, req.Page, req.Size, uint8(req.Status), u.Id, 0, req.Search)
|
||||
if err != nil {
|
||||
l.Errorw("[QueryOrderListLogic] Query order list failed", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query order list failed")
|
||||
|
||||
@ -3,6 +3,7 @@ package order
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
@ -79,7 +80,7 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
|
||||
discount = getDiscount(dis, req.Quantity)
|
||||
}
|
||||
price := sub.UnitPrice * req.Quantity
|
||||
amount := int64(float64(price) * discount)
|
||||
amount := int64(math.Round(float64(price) * discount))
|
||||
discountAmount := price - amount
|
||||
|
||||
// Validate amount to prevent overflow
|
||||
|
||||
@ -3,6 +3,7 @@ package portal
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@ -52,8 +53,28 @@ func (l *GetSubscriptionLogic) GetSubscription(req *types.GetSubscriptionRequest
|
||||
var discount []types.SubscribeDiscount
|
||||
_ = json.Unmarshal([]byte(item.Discount), &discount)
|
||||
sub.Discount = discount
|
||||
list[i] = sub
|
||||
}
|
||||
|
||||
// Calculate node count
|
||||
nodeIds := tool.StringToInt64Slice(item.Nodes)
|
||||
var nodeTags []string
|
||||
if item.NodeTags != "" {
|
||||
tags := strings.Split(item.NodeTags, ",")
|
||||
for _, tag := range tags {
|
||||
if strings.TrimSpace(tag) != "" {
|
||||
nodeTags = append(nodeTags, strings.TrimSpace(tag))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodeCount, err := l.svcCtx.NodeModel.CountNodesByIdsAndTags(l.ctx, nodeIds, nodeTags)
|
||||
if err != nil {
|
||||
l.Logger.Error("[GetSubscription] count nodes failed: ", logger.Field("error", err.Error()))
|
||||
sub.NodeCount = 0
|
||||
} else {
|
||||
sub.NodeCount = nodeCount
|
||||
}
|
||||
|
||||
list[i] = sub
|
||||
}
|
||||
resp.List = list
|
||||
|
||||
@ -3,6 +3,7 @@ package portal
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
|
||||
@ -43,7 +44,7 @@ func (l *PrePurchaseOrderLogic) PrePurchaseOrder(req *types.PrePurchaseOrderRequ
|
||||
discount = getDiscount(dis, req.Quantity)
|
||||
}
|
||||
price := sub.UnitPrice * req.Quantity
|
||||
amount := int64(float64(price) * discount)
|
||||
amount := int64(math.Round(float64(price) * discount))
|
||||
discountAmount := price - amount
|
||||
var coupon int64
|
||||
if req.Coupon != "" {
|
||||
|
||||
@ -3,7 +3,10 @@ package portal
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
@ -26,6 +29,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/payment/alipay"
|
||||
"github.com/perfect-panel/server/pkg/payment/epay"
|
||||
"github.com/perfect-panel/server/pkg/payment/stripe"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@ -73,6 +77,13 @@ func (l *PurchaseCheckoutLogic) PurchaseCheckout(req *types.CheckoutOrderRequest
|
||||
}
|
||||
// Route to appropriate payment handler based on payment platform
|
||||
switch paymentPlatform.ParsePlatform(orderInfo.Method) {
|
||||
case paymentPlatform.AppleIAP:
|
||||
productId := fmt.Sprintf("merchant.hifastvpn.day%d", orderInfo.Quantity)
|
||||
resp = &types.CheckoutOrderResponse{
|
||||
Type: "apple_iap",
|
||||
ProductIds: []string{productId},
|
||||
}
|
||||
return resp, nil
|
||||
case paymentPlatform.EPay:
|
||||
// Process EPay payment - generates payment URL for redirect
|
||||
url, err := l.epayPayment(paymentConfig, orderInfo, req.ReturnUrl)
|
||||
@ -186,13 +197,20 @@ func (l *PurchaseCheckoutLogic) alipayF2fPayment(pay *payment.Payment, info *ord
|
||||
l.Errorw("[PurchaseCheckout] queryExchangeRate error", logger.Field("error", err.Error()))
|
||||
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "queryExchangeRate error: %s", err.Error())
|
||||
}
|
||||
convertAmount := int64(amount * 100) // Convert to cents for API
|
||||
convertAmount := int64(math.Round(amount * 100))
|
||||
l.Infow("alipay amount",
|
||||
logger.Field("src_cents", info.Amount),
|
||||
logger.Field("decimal", amount),
|
||||
logger.Field("cents", convertAmount),
|
||||
)
|
||||
|
||||
// Create pre-payment trade and generate QR code
|
||||
QRCode, err := client.PreCreateTrade(l.ctx, alipay.Order{
|
||||
o := alipay.Order{
|
||||
OrderNo: info.OrderNo,
|
||||
Amount: convertAmount,
|
||||
})
|
||||
}
|
||||
l.Infow("alipay request", logger.Field("order", o))
|
||||
QRCode, err := client.PreCreateTrade(l.ctx, o)
|
||||
if err != nil {
|
||||
l.Errorw("[PurchaseCheckout] PreCreateTrade error", logger.Field("error", err.Error()))
|
||||
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "PreCreateTrade error: %s", err.Error())
|
||||
@ -218,25 +236,50 @@ func (l *PurchaseCheckoutLogic) stripePayment(config string, info *order.Order,
|
||||
WebhookSecret: stripeConfig.WebhookSecret,
|
||||
})
|
||||
|
||||
// Convert order amount to CNY using current exchange rate
|
||||
amount, err := l.queryExchangeRate("CNY", info.Amount)
|
||||
currency := "USD"
|
||||
sysCurrency, _ := l.svcCtx.SystemModel.GetCurrencyConfig(l.ctx)
|
||||
if sysCurrency != nil {
|
||||
configs := struct {
|
||||
CurrencyUnit string
|
||||
CurrencySymbol string
|
||||
AccessKey string
|
||||
}{}
|
||||
tool.SystemConfigSliceReflectToStruct(sysCurrency, &configs)
|
||||
if configs.CurrencyUnit != "" {
|
||||
currency = configs.CurrencyUnit
|
||||
}
|
||||
}
|
||||
|
||||
// Convert order amount to configured currency using current exchange rate
|
||||
amount, err := l.queryExchangeRate(strings.ToUpper(currency), info.Amount)
|
||||
if err != nil {
|
||||
l.Errorw("[PurchaseCheckout] queryExchangeRate error", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "queryExchangeRate error: %s", err.Error())
|
||||
}
|
||||
convertAmount := int64(amount * 100) // Convert to cents for Stripe API
|
||||
convertAmount := int64(math.Round(amount * 100))
|
||||
l.Infow("stripe amount",
|
||||
logger.Field("src_cents", info.Amount),
|
||||
logger.Field("decimal", amount),
|
||||
logger.Field("cents", convertAmount),
|
||||
logger.Field("currency", currency),
|
||||
)
|
||||
|
||||
// Create Stripe payment sheet for client-side processing
|
||||
result, err := client.CreatePaymentSheet(&stripe.Order{
|
||||
// Map apple_pay to card for Stripe API, but keep apple_pay in config/response
|
||||
paymentMethod := stripeConfig.Payment
|
||||
if paymentMethod == "apple_pay" {
|
||||
paymentMethod = "card"
|
||||
}
|
||||
ord := &stripe.Order{
|
||||
OrderNo: info.OrderNo,
|
||||
Subscribe: strconv.FormatInt(info.SubscribeId, 10),
|
||||
Amount: convertAmount,
|
||||
Currency: "cny",
|
||||
Payment: stripeConfig.Payment,
|
||||
},
|
||||
&stripe.User{
|
||||
Email: identifier,
|
||||
})
|
||||
Currency: strings.ToLower(currency),
|
||||
Payment: paymentMethod,
|
||||
}
|
||||
usr := &stripe.User{Email: identifier}
|
||||
l.Infow("stripe request", logger.Field("order", ord), logger.Field("user", usr))
|
||||
result, err := client.CreatePaymentSheet(ord, usr)
|
||||
if err != nil {
|
||||
l.Errorw("[PurchaseCheckout] CreatePaymentSheet error", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "CreatePaymentSheet error: %s", err.Error())
|
||||
@ -282,6 +325,11 @@ func (l *PurchaseCheckoutLogic) epayPayment(config *payment.Payment, info *order
|
||||
} else {
|
||||
amount = float64(info.Amount) / float64(100)
|
||||
}
|
||||
amount = math.Round(amount*100) / 100
|
||||
l.Infow("epay amount",
|
||||
logger.Field("src_cents", info.Amount),
|
||||
logger.Field("decimal", amount),
|
||||
)
|
||||
|
||||
// gateway mod
|
||||
isGatewayMod := report.IsGatewayMode()
|
||||
@ -342,6 +390,11 @@ func (l *PurchaseCheckoutLogic) CryptoSaaSPayment(config *payment.Payment, info
|
||||
} else {
|
||||
amount = float64(info.Amount) / float64(100)
|
||||
}
|
||||
amount = math.Round(amount*100) / 100
|
||||
l.Infow("crypto amount",
|
||||
logger.Field("src_cents", info.Amount),
|
||||
logger.Field("decimal", amount),
|
||||
)
|
||||
|
||||
// gateway mod
|
||||
isGatewayMod := report.IsGatewayMode()
|
||||
@ -395,18 +448,53 @@ func (l *PurchaseCheckoutLogic) queryExchangeRate(to string, src int64) (amount
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
// Retrieve system currency configuration from DB for FixedRate fallback
|
||||
currency, dbErr := l.svcCtx.SystemModel.GetCurrencyConfig(l.ctx)
|
||||
var fixedRate string
|
||||
if dbErr == nil && currency != nil {
|
||||
configs := struct {
|
||||
CurrencyUnit string
|
||||
CurrencySymbol string
|
||||
AccessKey string
|
||||
FixedRate string
|
||||
}{}
|
||||
tool.SystemConfigSliceReflectToStruct(currency, &configs)
|
||||
fixedRate = strings.TrimSpace(configs.FixedRate)
|
||||
}
|
||||
|
||||
// Skip conversion if no exchange rate API key configured
|
||||
if l.svcCtx.Config.Currency.AccessKey == "" {
|
||||
if to == "CNY" && strings.TrimSpace(l.svcCtx.Config.Currency.Unit) == "USD" && fixedRate != "" {
|
||||
r := tool.FormatStringToFloat(fixedRate)
|
||||
if r > 0 {
|
||||
l.Infow("exchangeRate.fixed", logger.Field("rate", r))
|
||||
return amount * r, nil
|
||||
}
|
||||
}
|
||||
l.Infof("[PurchaseCheckout] AccessKey is empty, skip conversion")
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
// Convert currency if system currency differs from target currency
|
||||
result, err := exchangeRate.GetExchangeRete(l.svcCtx.Config.Currency.Unit, to, l.svcCtx.Config.Currency.AccessKey, 1)
|
||||
if err != nil {
|
||||
l.Logger.Error("[PurchaseCheckout] QueryExchangeRate error", logger.Field("error", err.Error()))
|
||||
return 0, err
|
||||
if to == "CNY" && strings.TrimSpace(l.svcCtx.Config.Currency.Unit) == "USD" && fixedRate != "" {
|
||||
r := tool.FormatStringToFloat(fixedRate)
|
||||
if r > 0 {
|
||||
l.Infow("exchangeRate.fixed.fallback", logger.Field("rate", r), logger.Field("error", err.Error()))
|
||||
return amount * r, nil
|
||||
}
|
||||
}
|
||||
// fallback: try without access key
|
||||
result2, err2 := exchangeRate.GetExchangeRete(l.svcCtx.Config.Currency.Unit, to, "", 1)
|
||||
if err2 != nil {
|
||||
l.Logger.Error("[PurchaseCheckout] QueryExchangeRate error", logger.Field("error", err.Error()))
|
||||
return 0, err
|
||||
}
|
||||
result = result2
|
||||
}
|
||||
l.svcCtx.ExchangeRate = result
|
||||
l.Infow("exchangeRate", logger.Field("from", l.svcCtx.Config.Currency.Unit), logger.Field("to", to), logger.Field("rate", result))
|
||||
return result * amount, nil
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
@ -73,7 +74,7 @@ func (l *PurchaseLogic) Purchase(req *types.PortalPurchaseRequest) (resp *types.
|
||||
}
|
||||
price := sub.UnitPrice * req.Quantity
|
||||
// discount amount
|
||||
amount := int64(float64(price) * discount)
|
||||
amount := int64(math.Round(float64(price) * discount))
|
||||
discountAmount := price - amount
|
||||
|
||||
var couponAmount int64 = 0
|
||||
@ -150,7 +151,7 @@ func (l *PurchaseLogic) Purchase(req *types.PortalPurchaseRequest) (resp *types.
|
||||
}
|
||||
content, _ := tempOrder.Marshal()
|
||||
|
||||
if _, err = l.svcCtx.Redis.Set(l.ctx, fmt.Sprintf(constant.TempOrderCacheKey, orderInfo.OrderNo), string(content), 24*time.Hour).Result(); err != nil {
|
||||
if _, err = l.svcCtx.Redis.Set(l.ctx, fmt.Sprintf(constant.TempOrderCacheKey, orderInfo.OrderNo), string(content), CloseOrderTimeMinutes*time.Minute).Result(); err != nil {
|
||||
l.Errorw("[Purchase] Redis set error", logger.Field("error", err.Error()), logger.Field("order_no", orderInfo.OrderNo))
|
||||
return err
|
||||
}
|
||||
|
||||
172
internal/logic/public/user/bindEmailWithVerificationLogic.go
Normal file
172
internal/logic/public/user/bindEmailWithVerificationLogic.go
Normal file
@ -0,0 +1,172 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"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/uuidx"
|
||||
"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) {
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Code string `json:"code"`
|
||||
LastAt int64 `json:"lastAt"`
|
||||
}
|
||||
var verified bool
|
||||
scenes := []string{constant.Security.String(), constant.Register.String()}
|
||||
for _, scene := range scenes {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
|
||||
value, getErr := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if getErr != nil || value == "" {
|
||||
continue
|
||||
}
|
||||
var p payload
|
||||
if err := json.Unmarshal([]byte(value), &p); err != nil {
|
||||
continue
|
||||
}
|
||||
if p.Code == req.Code && time.Now().Unix()-p.LastAt <= l.svcCtx.Config.VerifyCode.ExpireTime {
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, cacheKey).Err()
|
||||
verified = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !verified {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error or expired")
|
||||
}
|
||||
|
||||
familyHelper := newFamilyBindingHelper(l.ctx, l.svcCtx)
|
||||
currentEmailMethod, err := familyHelper.getUserEmailMethod(u.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentEmailMethod != nil {
|
||||
if currentEmailMethod.AuthIdentifier == req.Email {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user")
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "email already bound to another account")
|
||||
}
|
||||
|
||||
existingMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query email bind status failed")
|
||||
}
|
||||
|
||||
authInfo := &user.AuthMethods{
|
||||
UserId: u.Id,
|
||||
AuthType: "email",
|
||||
AuthIdentifier: req.Email,
|
||||
Verified: true,
|
||||
}
|
||||
if err = l.svcCtx.DB.Create(authInfo).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "bind email failed: %v", err)
|
||||
}
|
||||
token, err := l.refreshBindSessionToken(u.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.BindEmailWithVerificationResponse{
|
||||
Success: true,
|
||||
Message: "email bound successfully",
|
||||
Token: token,
|
||||
UserId: u.Id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if existingMethod.UserId == u.Id {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user")
|
||||
}
|
||||
|
||||
if err = familyHelper.validateJoinFamily(existingMethod.UserId, u.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
joinResult, err := familyHelper.joinFamily(existingMethod.UserId, u.Id, "bind_email_with_verification")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := l.refreshBindSessionToken(u.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.BindEmailWithVerificationResponse{
|
||||
Success: true,
|
||||
Message: "joined family successfully",
|
||||
Token: token,
|
||||
UserId: u.Id,
|
||||
FamilyJoined: true,
|
||||
FamilyId: joinResult.FamilyId,
|
||||
OwnerUserId: joinResult.OwnerUserId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *BindEmailWithVerificationLogic) refreshBindSessionToken(userId int64) (string, error) {
|
||||
sessionId, _ := l.ctx.Value(constant.CtxKeySessionID).(string)
|
||||
if sessionId == "" {
|
||||
sessionId = uuidx.NewUUID().String()
|
||||
}
|
||||
|
||||
opts := []jwt.Option{
|
||||
jwt.WithOption("UserId", userId),
|
||||
jwt.WithOption("SessionId", sessionId),
|
||||
}
|
||||
if loginType, ok := l.ctx.Value(constant.CtxLoginType).(string); ok && loginType != "" {
|
||||
opts = append(opts, jwt.WithOption("CtxLoginType", loginType))
|
||||
}
|
||||
if identifier, ok := l.ctx.Value(constant.CtxKeyIdentifier).(string); ok && identifier != "" {
|
||||
opts = append(opts, jwt.WithOption("identifier", identifier))
|
||||
}
|
||||
|
||||
token, err := jwt.NewJwtToken(
|
||||
l.svcCtx.Config.JwtAuth.AccessSecret,
|
||||
time.Now().Unix(),
|
||||
l.svcCtx.Config.JwtAuth.AccessExpire,
|
||||
opts...,
|
||||
)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
||||
}
|
||||
|
||||
expire := time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire) * time.Second
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userId, expire).Err(); err != nil {
|
||||
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
68
internal/logic/public/user/bindInviteCodeLogic.go
Normal file
68
internal/logic/public/user/bindInviteCodeLogic.go
Normal file
@ -0,0 +1,68 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type BindInviteCodeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// Bind Invite Code
|
||||
func NewBindInviteCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BindInviteCodeLogic {
|
||||
return &BindInviteCodeLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *BindInviteCodeLogic) BindInviteCode(req *types.BindInviteCodeRequest) error {
|
||||
// 获取当前用户
|
||||
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
logger.Error("current user is not found in context")
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
// 检查用户是否已经绑定过邀请码
|
||||
if currentUser.RefererId != 0 {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserBindInviteCodeExist), "用户已绑定邀请人")
|
||||
}
|
||||
|
||||
// 查找邀请人
|
||||
referrer, err := l.svcCtx.UserModel.FindOneByReferCode(l.ctx, req.InviteCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InviteCodeError, "无邀请码"), "invite code not found")
|
||||
}
|
||||
logger.WithContext(l.ctx).Error(err)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query referrer failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 检查是否是自己的邀请码
|
||||
if referrer.Id == currentUser.Id {
|
||||
return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InviteCodeError, "不允许绑定自己"), "cannot bind your own invite code")
|
||||
}
|
||||
|
||||
// 更新用户的RefererId
|
||||
currentUser.RefererId = referrer.Id
|
||||
err = l.svcCtx.UserModel.Update(l.ctx, currentUser)
|
||||
if err != nil {
|
||||
logger.WithContext(l.ctx).Error(err)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update referrer id failed: %v", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
374
internal/logic/public/user/deleteAccountLogic.go
Normal file
374
internal/logic/public/user/deleteAccountLogic.go
Normal file
@ -0,0 +1,374 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"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/logger"
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DeleteAccountLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// NewDeleteAccountLogic 创建注销账号逻辑实例
|
||||
func NewDeleteAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteAccountLogic {
|
||||
return &DeleteAccountLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAccount 注销当前设备账号逻辑 (改为精准解绑)
|
||||
func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse, err error) {
|
||||
// 获取当前用户
|
||||
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
// 获取当前调用设备 ID
|
||||
currentDeviceId, _ := l.ctx.Value(constant.CtxKeyDeviceID).(int64)
|
||||
|
||||
resp = &types.DeleteAccountResponse{}
|
||||
var newUserId int64
|
||||
|
||||
// 如果没有识别到设备 ID (可能是旧版 Token),则执行安全注销:仅清除 Session
|
||||
if currentDeviceId == 0 {
|
||||
l.Infow("未识别到设备 ID,仅清理当前会话", logger.Field("user_id", currentUser.Id))
|
||||
l.clearCurrentSession(currentUser.Id)
|
||||
resp.Success = true
|
||||
resp.Message = "会话已清除"
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 开始数据库事务
|
||||
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
||||
// 1. 查找当前设备
|
||||
var currentDevice user.Device
|
||||
if err := tx.Where("id = ? AND user_id = ?", currentDeviceId, currentUser.Id).First(¤tDevice).Error; err != nil {
|
||||
l.Infow("当前请求设备记录不存在或归属不匹配", logger.Field("device_id", currentDeviceId), logger.Field("error", err.Error()))
|
||||
return nil // 不抛错,直接走清理 Session 流程
|
||||
}
|
||||
|
||||
// 2. 检查用户是否有其他认证方式 (如邮箱) 或 其他设备
|
||||
var authMethodsCount int64
|
||||
tx.Model(&user.AuthMethods{}).Where("user_id = ?", currentUser.Id).Count(&authMethodsCount)
|
||||
|
||||
var devicesCount int64
|
||||
tx.Model(&user.Device{}).Where("user_id = ?", currentUser.Id).Count(&devicesCount)
|
||||
|
||||
// 判定是否是主账号解绑:如果除了当前设备外,还有邮箱或其他设备,则只解绑当前设备
|
||||
isMainAccount := authMethodsCount > 1 || devicesCount > 1
|
||||
|
||||
if isMainAccount {
|
||||
l.Infow("主账号解绑,仅迁移当前设备", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
|
||||
|
||||
// 【重要】先删除旧的认证记录,再创建新用户,避免唯一键冲突
|
||||
// 从原用户删除当前设备的认证方式 (按 Identifier 准确删除)
|
||||
if err := tx.Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", currentUser.Id, "device", currentDevice.Identifier).Delete(&user.AuthMethods{}).Error; err != nil {
|
||||
return errors.Wrap(err, "删除原设备认证失败")
|
||||
}
|
||||
|
||||
// 从原用户删除当前设备记录
|
||||
if err := tx.Where("id = ?", currentDeviceId).Delete(&user.Device{}).Error; err != nil {
|
||||
return errors.Wrap(err, "删除原设备记录失败")
|
||||
}
|
||||
|
||||
// 为当前设备创建新用户并迁移
|
||||
newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newUserId = newUser.Id
|
||||
|
||||
} else {
|
||||
l.Infow("纯设备账号注销,执行物理删除并重置", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
|
||||
|
||||
exitHelper := newFamilyExitHelper(l.ctx, l.svcCtx)
|
||||
if err := exitHelper.removeUserFromActiveFamily(tx, currentUser.Id, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 完全删除原用户相关资产
|
||||
tx.Where("user_id = ?", currentUser.Id).Delete(&user.AuthMethods{})
|
||||
tx.Where("user_id = ?", currentUser.Id).Delete(&user.Device{})
|
||||
tx.Where("user_id = ?", currentUser.Id).Delete(&user.Subscribe{})
|
||||
tx.Delete(&user.User{}, currentUser.Id)
|
||||
|
||||
// 重新注册一个新用户
|
||||
newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newUserId = newUser.Id
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 最终清理当前 Session
|
||||
l.clearCurrentSession(currentUser.Id)
|
||||
|
||||
resp.Success = true
|
||||
resp.Message = "注销成功"
|
||||
resp.UserId = newUserId
|
||||
resp.Code = 200
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// DeleteAccountAll 注销账号逻辑 (全部解绑默认创建账号)
|
||||
func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountResponse, err error) {
|
||||
// 获取当前用户
|
||||
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
// 获取当前调用设备 ID
|
||||
currentDeviceId, _ := l.ctx.Value(constant.CtxKeyDeviceID).(int64)
|
||||
|
||||
resp = &types.DeleteAccountResponse{}
|
||||
var newUserId int64
|
||||
|
||||
// 开始数据库事务
|
||||
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
||||
// 1. 预先查找该用户下的所有设备记录 (因为稍后要迁移)
|
||||
var userDevices []user.Device
|
||||
if err := tx.Where("user_id = ?", currentUser.Id).Find(&userDevices).Error; err != nil {
|
||||
l.Errorw("查询用户设备列表失败", logger.Field("user_id", currentUser.Id), logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果没有识别到调用设备 ID,记录日志但继续执行 (全量注销不应受限)
|
||||
if currentDeviceId == 0 {
|
||||
l.Infow("未识别到当前设备 ID,将执行全量注销并尝试迁移所有已知设备", logger.Field("user_id", currentUser.Id), logger.Field("found_devices", len(userDevices)))
|
||||
}
|
||||
|
||||
exitHelper := newFamilyExitHelper(l.ctx, l.svcCtx)
|
||||
if err := exitHelper.removeUserFromActiveFamily(tx, currentUser.Id, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.Infow("执行账号全量注销-迁移设备并删除旧数据", logger.Field("user_id", currentUser.Id), logger.Field("device_count", len(userDevices)))
|
||||
|
||||
// 2. 循环为每个设备创建新用户并迁移记录 (保留设备ID)
|
||||
for _, dev := range userDevices {
|
||||
// A. 创建新匿名用户
|
||||
newUser, err := l.createAnonymousUser(tx)
|
||||
if err != nil {
|
||||
l.Errorw("为设备分配新用户主体失败", logger.Field("device_id", dev.Id), logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
|
||||
// B. 迁移设备记录 (Update user_id)
|
||||
if err := tx.Model(&user.Device{}).Where("id = ?", dev.Id).Update("user_id", newUser.Id).Error; err != nil {
|
||||
l.Errorw("迁移设备记录失败", logger.Field("device_id", dev.Id), logger.Field("error", err.Error()))
|
||||
return errors.Wrap(err, "迁移设备记录失败")
|
||||
}
|
||||
|
||||
// C. 迁移设备认证方式 (Update user_id)
|
||||
if err := tx.Model(&user.AuthMethods{}).
|
||||
Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", currentUser.Id, "device", dev.Identifier).
|
||||
Update("user_id", newUser.Id).Error; err != nil {
|
||||
l.Errorw("迁移设备认证失败", logger.Field("device_id", dev.Id), logger.Field("error", err.Error()))
|
||||
return errors.Wrap(err, "迁移设备认证失败")
|
||||
}
|
||||
|
||||
// 如果是当前请求的设备,记录其新 UserID 返回给前端
|
||||
if dev.Id == currentDeviceId || dev.Identifier == l.getIdentifierByDeviceID(userDevices, currentDeviceId) {
|
||||
newUserId = newUser.Id
|
||||
}
|
||||
l.Infow("旧设备已迁移至新匿名账号",
|
||||
logger.Field("old_user_id", currentUser.Id),
|
||||
logger.Field("new_user_id", newUser.Id),
|
||||
logger.Field("device_id", dev.Id),
|
||||
logger.Field("identifier", dev.Identifier))
|
||||
}
|
||||
|
||||
// 3. 删除旧账号的剩余数据
|
||||
// 删除剩余的认证方式 (排除已迁移的device类型,剩下的如email/mobile等)
|
||||
// 注意:刚才已经把由currentUser拥有的device类型auth都迁移走了,所以这里直接删剩下的即可
|
||||
if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.AuthMethods{}).Error; err != nil {
|
||||
return errors.Wrap(err, "删除剩余认证方式失败")
|
||||
}
|
||||
|
||||
// 设备记录已经全部迁移,理论上 user_id = currentUser.Id 的 device 应该没了,但为了保险可以删一下(或者是0)
|
||||
if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.Device{}).Error; err != nil {
|
||||
return errors.Wrap(err, "删除残留设备记录失败")
|
||||
}
|
||||
|
||||
// 删除所有订阅
|
||||
if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.Subscribe{}).Error; err != nil {
|
||||
return errors.Wrap(err, "删除订阅失败")
|
||||
}
|
||||
|
||||
// 删除用户主体
|
||||
if err := tx.Delete(&user.User{}, currentUser.Id).Error; err != nil {
|
||||
return errors.Wrap(err, "删除用户失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 最终清理所有 Session (踢掉所有设备)
|
||||
l.clearAllSessions(currentUser.Id)
|
||||
|
||||
resp.Success = true
|
||||
resp.Message = "注销成功"
|
||||
resp.UserId = newUserId
|
||||
resp.Code = 200
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 辅助方法:通过 ID 查找 Identifier (以防 currentDeviceId 只是 ID)
|
||||
func (l *DeleteAccountLogic) getIdentifierByDeviceID(devices []user.Device, id int64) string {
|
||||
for _, d := range devices {
|
||||
if d.Id == id {
|
||||
return d.Identifier
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// clearCurrentSession 清理当前请求的会话
|
||||
func (l *DeleteAccountLogic) clearCurrentSession(userId int64) {
|
||||
if sessionId, ok := l.ctx.Value(constant.CtxKeySessionID).(string); ok && sessionId != "" {
|
||||
sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err()
|
||||
// 从用户会话集合中移除当前session
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
|
||||
_ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, sessionId).Err()
|
||||
|
||||
l.Infow("[SessionMonitor] 注销账号清除 Session",
|
||||
logger.Field("user_id", userId),
|
||||
logger.Field("session_id", sessionId))
|
||||
}
|
||||
}
|
||||
|
||||
// clearAllSessions 清理指定用户的所有会话
|
||||
func (l *DeleteAccountLogic) clearAllSessions(userId int64) {
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
|
||||
|
||||
// 获取所有 session id
|
||||
sessions, err := l.svcCtx.Redis.ZRange(l.ctx, sessionsKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
l.Errorw("获取用户会话列表失败", logger.Field("user_id", userId), logger.Field("error", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 删除每个 session 的详情 key
|
||||
for _, sid := range sessions {
|
||||
sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sid)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err()
|
||||
|
||||
// 同时尝试删除 detail key (如果存在)
|
||||
detailKey := fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sid)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, detailKey).Err()
|
||||
}
|
||||
|
||||
// 删除用户的 session 集合 key
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionsKey).Err()
|
||||
|
||||
l.Infow("[SessionMonitor] 注销账号-清除所有Session", logger.Field("user_id", userId), logger.Field("count", len(sessions)))
|
||||
}
|
||||
|
||||
// generateReferCode 生成推荐码
|
||||
func generateReferCode() string {
|
||||
bytes := make([]byte, 4)
|
||||
rand.Read(bytes)
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
func (l *DeleteAccountLogic) registerUserAndDevice(tx *gorm.DB, identifier, ip, userAgent string) (*user.User, error) {
|
||||
// 1. 创建新用户
|
||||
userInfo := &user.User{
|
||||
Salt: "default",
|
||||
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
|
||||
}
|
||||
if err := tx.Create(userInfo).Error; err != nil {
|
||||
l.Errorw("failed to create user", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create user failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. 更新推荐码
|
||||
userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id)
|
||||
if err := tx.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil {
|
||||
l.Errorw("failed to update refer code", logger.Field("user_id", userInfo.Id), logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err)
|
||||
}
|
||||
|
||||
// 3. 创建设备认证方式
|
||||
authMethod := &user.AuthMethods{
|
||||
UserId: userInfo.Id,
|
||||
AuthType: "device",
|
||||
AuthIdentifier: identifier,
|
||||
Verified: true,
|
||||
}
|
||||
if err := tx.Create(authMethod).Error; err != nil {
|
||||
l.Errorw("failed to create device auth method", logger.Field("user_id", userInfo.Id), logger.Field("identifier", identifier), logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create device auth method failed: %v", err)
|
||||
}
|
||||
|
||||
// 4. 插入设备记录
|
||||
deviceInfo := &user.Device{
|
||||
Ip: ip,
|
||||
UserId: userInfo.Id,
|
||||
UserAgent: userAgent,
|
||||
Identifier: identifier,
|
||||
Enabled: true,
|
||||
Online: false,
|
||||
}
|
||||
if err := tx.Create(deviceInfo).Error; err != nil {
|
||||
l.Errorw("failed to insert device", logger.Field("user_id", userInfo.Id), logger.Field("identifier", identifier), logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert device failed: %v", err)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// createAnonymousUser 创建一个新的匿名用户主体 (仅User表)
|
||||
func (l *DeleteAccountLogic) createAnonymousUser(tx *gorm.DB) (*user.User, error) {
|
||||
// 1. 创建新用户
|
||||
userInfo := &user.User{
|
||||
Salt: "default",
|
||||
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
|
||||
}
|
||||
if err := tx.Create(userInfo).Error; err != nil {
|
||||
l.Errorw("failed to create user", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create user failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. 更新推荐码
|
||||
userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id)
|
||||
if err := tx.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil {
|
||||
l.Errorw("failed to update refer code", logger.Field("user_id", userInfo.Id), logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err)
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
233
internal/logic/public/user/familyBindingHelper.go
Normal file
233
internal/logic/public/user/familyBindingHelper.go
Normal file
@ -0,0 +1,233 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type familyJoinResult struct {
|
||||
FamilyId int64
|
||||
OwnerUserId int64
|
||||
}
|
||||
|
||||
type familyBindingHelper struct {
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func newFamilyBindingHelper(ctx context.Context, svcCtx *svc.ServiceContext) *familyBindingHelper {
|
||||
return &familyBindingHelper{
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *familyBindingHelper) getUserEmailMethod(userId int64) (*user.AuthMethods, error) {
|
||||
method, err := h.svcCtx.UserModel.FindUserAuthMethodByUserId(h.ctx, "email", userId)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user email auth method failed")
|
||||
}
|
||||
return method, nil
|
||||
}
|
||||
|
||||
func (h *familyBindingHelper) validateJoinFamily(ownerUserId, memberUserId int64) error {
|
||||
if ownerUserId == memberUserId {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already bound to this family")
|
||||
}
|
||||
|
||||
var ownerFamily user.UserFamily
|
||||
err := h.svcCtx.DB.WithContext(h.ctx).
|
||||
Unscoped().
|
||||
Model(&user.UserFamily{}).
|
||||
Where("owner_user_id = ? AND status = ?", ownerUserId, user.FamilyStatusActive).
|
||||
First(&ownerFamily).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query owner family failed")
|
||||
}
|
||||
|
||||
var memberRecord user.UserFamilyMember
|
||||
err = h.svcCtx.DB.WithContext(h.ctx).
|
||||
Unscoped().
|
||||
Model(&user.UserFamilyMember{}).
|
||||
Where("user_id = ?", memberUserId).
|
||||
First(&memberRecord).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query member family relation failed")
|
||||
}
|
||||
if err == nil {
|
||||
if ownerFamily.Id != 0 && memberRecord.FamilyId == ownerFamily.Id {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already in this family")
|
||||
}
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "user already belongs to another family")
|
||||
}
|
||||
|
||||
if ownerFamily.Id == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var activeCount int64
|
||||
if err = h.svcCtx.DB.WithContext(h.ctx).
|
||||
Model(&user.UserFamilyMember{}).
|
||||
Where("family_id = ? AND status = ?", ownerFamily.Id, user.FamilyMemberActive).
|
||||
Count(&activeCount).Error; err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count family members failed")
|
||||
}
|
||||
if activeCount >= ownerFamily.MaxMembers {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyMemberLimitExceeded), "family member limit exceeded")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *familyBindingHelper) joinFamily(ownerUserId, memberUserId int64, source string) (*familyJoinResult, error) {
|
||||
if ownerUserId == memberUserId {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already bound to this family")
|
||||
}
|
||||
|
||||
result := &familyJoinResult{
|
||||
OwnerUserId: ownerUserId,
|
||||
}
|
||||
|
||||
err := h.svcCtx.DB.WithContext(h.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
ownerFamily, err := h.getOrCreateOwnerFamily(tx, ownerUserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.FamilyId = ownerFamily.Id
|
||||
|
||||
if err = h.ensureOwnerMember(tx, ownerFamily.Id, ownerUserId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var memberRecord user.UserFamilyMember
|
||||
err = tx.Unscoped().Model(&user.UserFamilyMember{}).Where("user_id = ?", memberUserId).First(&memberRecord).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query member family relation failed")
|
||||
}
|
||||
memberExists := err == nil
|
||||
|
||||
if memberExists {
|
||||
if memberRecord.FamilyId == ownerFamily.Id {
|
||||
if memberRecord.Status == user.FamilyMemberActive {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already in this family")
|
||||
}
|
||||
} else {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "user already belongs to another family")
|
||||
}
|
||||
}
|
||||
|
||||
var activeCount int64
|
||||
if err = tx.Model(&user.UserFamilyMember{}).
|
||||
Where("family_id = ? AND status = ?", ownerFamily.Id, user.FamilyMemberActive).
|
||||
Count(&activeCount).Error; err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count family members failed")
|
||||
}
|
||||
if activeCount >= ownerFamily.MaxMembers {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyMemberLimitExceeded), "family member limit exceeded")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !memberExists {
|
||||
memberRecord = user.UserFamilyMember{
|
||||
FamilyId: ownerFamily.Id,
|
||||
UserId: memberUserId,
|
||||
Role: user.FamilyRoleMember,
|
||||
Status: user.FamilyMemberActive,
|
||||
JoinSource: source,
|
||||
JoinedAt: now,
|
||||
}
|
||||
if err = tx.Create(&memberRecord).Error; err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create family member failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
memberRecord.Status = user.FamilyMemberActive
|
||||
memberRecord.Role = user.FamilyRoleMember
|
||||
memberRecord.JoinSource = source
|
||||
memberRecord.JoinedAt = now
|
||||
memberRecord.LeftAt = nil
|
||||
if err = tx.Save(&memberRecord).Error; err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update family member failed")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (h *familyBindingHelper) getOrCreateOwnerFamily(tx *gorm.DB, ownerUserId int64) (*user.UserFamily, error) {
|
||||
var ownerFamily user.UserFamily
|
||||
err := tx.Unscoped().Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&user.UserFamily{}).
|
||||
Where("owner_user_id = ?", ownerUserId).
|
||||
First(&ownerFamily).Error
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query owner family failed")
|
||||
}
|
||||
ownerFamily = user.UserFamily{
|
||||
OwnerUserId: ownerUserId,
|
||||
MaxMembers: user.DefaultFamilyMaxSize,
|
||||
Status: user.FamilyStatusActive,
|
||||
}
|
||||
if err = tx.Create(&ownerFamily).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create owner family failed")
|
||||
}
|
||||
} else if ownerFamily.Status != user.FamilyStatusActive || ownerFamily.DeletedAt.Valid {
|
||||
ownerFamily.Status = user.FamilyStatusActive
|
||||
ownerFamily.DeletedAt = gorm.DeletedAt{}
|
||||
if err = tx.Unscoped().Save(&ownerFamily).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "activate owner family failed")
|
||||
}
|
||||
}
|
||||
return &ownerFamily, nil
|
||||
}
|
||||
|
||||
func (h *familyBindingHelper) ensureOwnerMember(tx *gorm.DB, familyId, ownerUserId int64) error {
|
||||
var ownerMember user.UserFamilyMember
|
||||
err := tx.Unscoped().Model(&user.UserFamilyMember{}).Where("user_id = ?", ownerUserId).First(&ownerMember).Error
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query owner family member failed")
|
||||
}
|
||||
ownerMember = user.UserFamilyMember{
|
||||
FamilyId: familyId,
|
||||
UserId: ownerUserId,
|
||||
Role: user.FamilyRoleOwner,
|
||||
Status: user.FamilyMemberActive,
|
||||
JoinSource: "owner_init",
|
||||
JoinedAt: time.Now(),
|
||||
}
|
||||
if err = tx.Create(&ownerMember).Error; err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create owner family member failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if ownerMember.FamilyId != familyId {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "owner already belongs to another family")
|
||||
}
|
||||
if ownerMember.Status != user.FamilyMemberActive || ownerMember.Role != user.FamilyRoleOwner || ownerMember.DeletedAt.Valid {
|
||||
ownerMember.Status = user.FamilyMemberActive
|
||||
ownerMember.Role = user.FamilyRoleOwner
|
||||
ownerMember.LeftAt = nil
|
||||
ownerMember.DeletedAt = gorm.DeletedAt{}
|
||||
if err = tx.Unscoped().Save(&ownerMember).Error; err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update owner family member failed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
73
internal/logic/public/user/familyExitHelper.go
Normal file
73
internal/logic/public/user/familyExitHelper.go
Normal file
@ -0,0 +1,73 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const familyStatusDisabled uint8 = 0
|
||||
|
||||
type familyExitHelper struct {
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func newFamilyExitHelper(ctx context.Context, svcCtx *svc.ServiceContext) *familyExitHelper {
|
||||
return &familyExitHelper{
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *familyExitHelper) removeUserFromActiveFamily(tx *gorm.DB, userID int64, dissolveWhenOwner bool) error {
|
||||
var relation user.UserFamilyMember
|
||||
err := tx.Unscoped().
|
||||
Model(&user.UserFamilyMember{}).
|
||||
Where("user_id = ? AND status = ?", userID, user.FamilyMemberActive).
|
||||
First(&relation).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family member relation failed")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if relation.Role == user.FamilyRoleOwner && dissolveWhenOwner {
|
||||
if err = tx.Model(&user.UserFamilyMember{}).
|
||||
Where("family_id = ? AND status = ?", relation.FamilyId, user.FamilyMemberActive).
|
||||
Updates(map[string]interface{}{
|
||||
"status": user.FamilyMemberRemoved,
|
||||
"left_at": now,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "remove owner family members failed")
|
||||
}
|
||||
|
||||
if err = tx.Model(&user.UserFamily{}).
|
||||
Where("id = ?", relation.FamilyId).
|
||||
Updates(map[string]interface{}{
|
||||
"status": familyStatusDisabled,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "disable owner family failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = tx.Model(&user.UserFamilyMember{}).
|
||||
Where("id = ?", relation.Id).
|
||||
Updates(map[string]interface{}{
|
||||
"status": user.FamilyMemberRemoved,
|
||||
"left_at": now,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "remove family member failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
68
internal/logic/public/user/familyScopeHelper.go
Normal file
68
internal/logic/public/user/familyScopeHelper.go
Normal file
@ -0,0 +1,68 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type familyScopeHelper struct {
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func newFamilyScopeHelper(ctx context.Context, svcCtx *svc.ServiceContext) *familyScopeHelper {
|
||||
return &familyScopeHelper{
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *familyScopeHelper) resolveScopedUserIds(currentUserId int64) ([]int64, error) {
|
||||
familyId, err := h.getCurrentFamilyId(currentUserId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if familyId == 0 {
|
||||
return []int64{currentUserId}, nil
|
||||
}
|
||||
|
||||
var userIds []int64
|
||||
err = h.svcCtx.DB.WithContext(h.ctx).
|
||||
Model(&user.UserFamilyMember{}).
|
||||
Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL AND user_family.status = ?", user.FamilyStatusActive).
|
||||
Where("user_family_member.family_id = ? AND user_family_member.status = ?", familyId, user.FamilyMemberActive).
|
||||
Pluck("user_family_member.user_id", &userIds).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family members failed")
|
||||
}
|
||||
if len(userIds) == 0 {
|
||||
return []int64{currentUserId}, nil
|
||||
}
|
||||
if !tool.Contains(userIds, currentUserId) {
|
||||
userIds = append(userIds, currentUserId)
|
||||
}
|
||||
return tool.RemoveDuplicateElements(userIds...), nil
|
||||
}
|
||||
|
||||
func (h *familyScopeHelper) getCurrentFamilyId(currentUserId int64) (int64, error) {
|
||||
var relation user.UserFamilyMember
|
||||
err := h.svcCtx.DB.WithContext(h.ctx).
|
||||
Model(&user.UserFamilyMember{}).
|
||||
Select("user_family_member.family_id").
|
||||
Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL AND user_family.status = ?", user.FamilyStatusActive).
|
||||
Where("user_family_member.user_id = ? AND user_family_member.status = ?", currentUserId, user.FamilyMemberActive).
|
||||
First(&relation).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query current family failed")
|
||||
}
|
||||
return relation.FamilyId, nil
|
||||
}
|
||||
94
internal/logic/public/user/getAgentDownloadsLogic.go
Normal file
94
internal/logic/public/user/getAgentDownloadsLogic.go
Normal file
@ -0,0 +1,94 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type GetAgentDownloadsLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// NewGetAgentDownloadsLogic 创建 GetAgentDownloadsLogic 实例
|
||||
func NewGetAgentDownloadsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAgentDownloadsLogic {
|
||||
return &GetAgentDownloadsLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
// PlatformStats 各平台设备统计结果
|
||||
type PlatformStats struct {
|
||||
Android int64 `gorm:"column:android"`
|
||||
IOS int64 `gorm:"column:ios"`
|
||||
Mac int64 `gorm:"column:mac"`
|
||||
Windows int64 `gorm:"column:windows"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
}
|
||||
|
||||
// GetAgentDownloads 获取用户代理下载统计数据
|
||||
// 基于用户邀请码查询被邀请用户的设备UA来统计各平台安装量
|
||||
func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsRequest) (resp *types.GetAgentDownloadsResponse, err error) {
|
||||
// 1. 从 context 获取用户信息
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
l.Errorw("[GetAgentDownloads] user not found in context")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
// 2. 通过数据库查询各平台设备安装量
|
||||
// 基于 user_device 表的 user_agent 字段判断平台
|
||||
// UA格式: HiVPN/1.0.0 (平台; 设备; 版本) Flutter
|
||||
var stats PlatformStats
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("user u").
|
||||
Select(`
|
||||
SUM(CASE WHEN d.user_agent LIKE '%(Android;%' THEN 1 ELSE 0 END) AS android,
|
||||
SUM(CASE WHEN d.user_agent LIKE '%(iOS;%' THEN 1 ELSE 0 END) AS ios,
|
||||
SUM(CASE WHEN d.user_agent LIKE '%(macOS;%' THEN 1 ELSE 0 END) AS mac,
|
||||
SUM(CASE WHEN d.user_agent LIKE '%(Windows;%' THEN 1 ELSE 0 END) AS windows,
|
||||
COUNT(*) AS total
|
||||
`).
|
||||
Joins("JOIN user_device d ON u.id = d.user_id").
|
||||
Where("u.referer_id = ?", u.Id).
|
||||
Scan(&stats).Error
|
||||
|
||||
if err != nil {
|
||||
l.Errorw("[GetAgentDownloads] query platform stats failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", u.Id))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
|
||||
"query platform stats failed: %v", err.Error())
|
||||
}
|
||||
|
||||
l.Infow("[GetAgentDownloads] platform stats fetched successfully",
|
||||
logger.Field("user_id", u.Id),
|
||||
logger.Field("refer_code", u.ReferCode),
|
||||
logger.Field("android", stats.Android),
|
||||
logger.Field("ios", stats.IOS),
|
||||
logger.Field("mac", stats.Mac),
|
||||
logger.Field("windows", stats.Windows),
|
||||
logger.Field("total", stats.Total))
|
||||
|
||||
// 3. 构造响应
|
||||
return &types.GetAgentDownloadsResponse{
|
||||
Total: stats.Total,
|
||||
Platforms: &types.PlatformDownloads{
|
||||
IOS: stats.IOS,
|
||||
Android: stats.Android,
|
||||
Windows: stats.Windows,
|
||||
Mac: stats.Mac,
|
||||
},
|
||||
ComparisonRate: nil, // 不再计算环比
|
||||
}, nil
|
||||
}
|
||||
182
internal/logic/public/user/getAgentRealtimeLogic.go
Normal file
182
internal/logic/public/user/getAgentRealtimeLogic.go
Normal file
@ -0,0 +1,182 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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/logger"
|
||||
"github.com/perfect-panel/server/pkg/loki"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type GetAgentRealtimeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetAgentRealtimeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAgentRealtimeLogic {
|
||||
return &GetAgentRealtimeLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequest) (resp *types.GetAgentRealtimeResponse, err error) {
|
||||
// 1. Get current user
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
l.Errorw("[GetAgentRealtime] user not found in context")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
var views, lastMonthViews int64
|
||||
var installs int64
|
||||
var paidCount int64
|
||||
|
||||
// 2. 从 Loki 获取 views(nginx 访问日志)
|
||||
lokiCfg := l.svcCtx.Config.Loki
|
||||
if lokiCfg.Enable && lokiCfg.URL != "" && u.ReferCode != "" {
|
||||
lokiClient := loki.NewClient(lokiCfg.URL)
|
||||
lokiStats, err := lokiClient.GetInviteCodeStats(l.ctx, u.ReferCode, 30)
|
||||
if err != nil {
|
||||
l.Errorw("[GetAgentRealtime] Failed to fetch Loki stats",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", u.Id),
|
||||
logger.Field("refer_code", u.ReferCode))
|
||||
// 不返回错误,继续使用已有数据
|
||||
} else {
|
||||
views = lokiStats.MacClicks + lokiStats.WindowsClicks
|
||||
lastMonthViews = lokiStats.LastMonthMac + lokiStats.LastMonthWindows
|
||||
l.Infow("[GetAgentRealtime] Fetched Loki stats successfully",
|
||||
logger.Field("user_id", u.Id),
|
||||
logger.Field("refer_code", u.ReferCode),
|
||||
logger.Field("views", views),
|
||||
logger.Field("last_month_views", lastMonthViews))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 从数据库获取安装量(被邀请注册用户数)
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&user.User{}).
|
||||
Where("referer_id = ?", u.Id).
|
||||
Count(&installs).Error
|
||||
if err != nil {
|
||||
l.Errorw("[GetAgentRealtime] Failed to count installs",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", u.Id))
|
||||
installs = 0
|
||||
}
|
||||
|
||||
// 4. 获取付费用户数
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("`order`").
|
||||
Joins("LEFT JOIN user ON user.id = `order`.user_id").
|
||||
Where("user.referer_id = ? AND `order`.status IN ?", u.Id, []int{2, 5}).
|
||||
Distinct("`order`.user_id").
|
||||
Count(&paidCount).Error
|
||||
if err != nil {
|
||||
l.Errorw("[GetAgentRealtime] Failed to count paid users",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", u.Id))
|
||||
paidCount = 0
|
||||
}
|
||||
|
||||
// 5. 计算环比增长率
|
||||
growthRate := calculateGrowthRate([]int{int(lastMonthViews), int(views)})
|
||||
|
||||
// 6. 计算付费用户环比增长率
|
||||
paidGrowthRate := l.calculatePaidGrowthRate(u.Id)
|
||||
|
||||
return &types.GetAgentRealtimeResponse{
|
||||
Total: views,
|
||||
Clicks: views,
|
||||
Views: views,
|
||||
Installs: installs,
|
||||
PaidCount: paidCount,
|
||||
GrowthRate: growthRate,
|
||||
PaidGrowthRate: paidGrowthRate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// calculatePaidGrowthRate 计算付费用户的环比增长率
|
||||
func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64) string {
|
||||
db := l.svcCtx.DB
|
||||
|
||||
// 获取本月第一天和上月第一天
|
||||
now := time.Now()
|
||||
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
lastMonthStart := currentMonthStart.AddDate(0, -1, 0)
|
||||
|
||||
// 查询本月付费用户数(本月有新订单的)
|
||||
var currentMonthCount int64
|
||||
err := db.Table("`order` o").
|
||||
Joins("JOIN user u ON o.user_id = u.id").
|
||||
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.created_at >= ?",
|
||||
userId, 2, 5, currentMonthStart).
|
||||
Distinct("o.user_id").
|
||||
Count(¤tMonthCount).Error
|
||||
|
||||
if err != nil {
|
||||
l.Errorw("Failed to count current month paid users",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", userId))
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
// 查询上月付费用户数
|
||||
var lastMonthCount int64
|
||||
err = db.Table("`order` o").
|
||||
Joins("JOIN user u ON o.user_id = u.id").
|
||||
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.created_at >= ? AND o.created_at < ?",
|
||||
userId, 2, 5, lastMonthStart, currentMonthStart).
|
||||
Distinct("o.user_id").
|
||||
Count(&lastMonthCount).Error
|
||||
|
||||
if err != nil {
|
||||
l.Errorw("Failed to count last month paid users",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", userId))
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
// 计算增长率
|
||||
return calculateGrowthRate([]int{int(lastMonthCount), int(currentMonthCount)})
|
||||
}
|
||||
|
||||
// calculateGrowthRate 计算环比增长率
|
||||
// views: 月份数据数组,最后一个是本月,倒数第二个是上月
|
||||
func calculateGrowthRate(views []int) string {
|
||||
if len(views) < 2 {
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
currentMonth := views[len(views)-1]
|
||||
lastMonth := views[len(views)-2]
|
||||
|
||||
// 如果上月是0,无法计算百分比
|
||||
if lastMonth == 0 {
|
||||
if currentMonth == 0 {
|
||||
return "0%"
|
||||
}
|
||||
return "+100%"
|
||||
}
|
||||
|
||||
// 计算增长率
|
||||
growth := float64(currentMonth-lastMonth) / float64(lastMonth) * 100
|
||||
|
||||
// 格式化输出
|
||||
if growth > 0 {
|
||||
return fmt.Sprintf("+%.1f%%", growth)
|
||||
} else if growth < 0 {
|
||||
return fmt.Sprintf("%.1f%%", growth)
|
||||
}
|
||||
return "0%"
|
||||
}
|
||||
@ -28,7 +28,12 @@ func NewGetDeviceListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Get
|
||||
|
||||
func (l *GetDeviceListLogic) GetDeviceList() (resp *types.GetDeviceListResponse, err error) {
|
||||
userInfo := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
list, count, err := l.svcCtx.UserModel.QueryDeviceList(l.ctx, userInfo.Id)
|
||||
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
||||
scopeUserIds, err := scopeHelper.resolveScopedUserIds(userInfo.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, count, err := l.svcCtx.UserModel.QueryDeviceListByUserIds(l.ctx, scopeUserIds)
|
||||
userRespList := make([]types.UserDevice, 0)
|
||||
tool.DeepCopy(&userRespList, list)
|
||||
resp = &types.GetDeviceListResponse{
|
||||
|
||||
142
internal/logic/public/user/getInviteSalesLogic.go
Normal file
142
internal/logic/public/user/getInviteSalesLogic.go
Normal file
@ -0,0 +1,142 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strconv"
|
||||
|
||||
"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/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type GetInviteSalesLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetInviteSalesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetInviteSalesLogic {
|
||||
return &GetInviteSalesLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (resp *types.GetInviteSalesResponse, err error) {
|
||||
// 1. Get current user
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
l.Errorw("[GetInviteSales] user not found in context")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
userId := u.Id
|
||||
|
||||
// 2. Count total sales
|
||||
var totalSales int64
|
||||
db := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("`order` o").
|
||||
Joins("JOIN user u ON o.user_id = u.id").
|
||||
Where("u.referer_id = ? AND o.status = ?", userId, 5)
|
||||
|
||||
if req.StartTime > 0 {
|
||||
db = db.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
|
||||
}
|
||||
if req.EndTime > 0 {
|
||||
db = db.Where("o.updated_at <= FROM_UNIXTIME(?)", req.EndTime)
|
||||
}
|
||||
|
||||
err = db.Count(&totalSales).Error
|
||||
if err != nil {
|
||||
l.Errorw("[GetInviteSales] count sales failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", userId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
|
||||
"count sales failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 3. Pagination
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.Size < 1 {
|
||||
req.Size = 10
|
||||
}
|
||||
if req.Size > 100 {
|
||||
req.Size = 100
|
||||
}
|
||||
offset := (req.Page - 1) * req.Size
|
||||
|
||||
// 4. Get sales data
|
||||
type OrderWithUser struct {
|
||||
Amount int64 `gorm:"column:amount"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||
UserId int64 `gorm:"column:user_id"`
|
||||
ProductName string `gorm:"column:product_name"`
|
||||
Quantity int64 `gorm:"column:quantity"`
|
||||
}
|
||||
|
||||
var orderData []OrderWithUser
|
||||
query := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("`order` o").
|
||||
Select("o.amount, CAST(UNIX_TIMESTAMP(o.updated_at) * 1000 AS SIGNED) as updated_at, u.id as user_id, s.name as product_name, o.quantity").
|
||||
Joins("JOIN user u ON o.user_id = u.id").
|
||||
Joins("LEFT JOIN subscribe s ON o.subscribe_id = s.id").
|
||||
Where("u.referer_id = ? AND o.status = ?", userId, 5) // status 5: Finished
|
||||
|
||||
if req.StartTime > 0 {
|
||||
query = query.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
|
||||
}
|
||||
if req.EndTime > 0 {
|
||||
query = query.Where("o.updated_at <= FROM_UNIXTIME(?)", req.EndTime)
|
||||
}
|
||||
|
||||
err = query.Order("o.updated_at DESC").
|
||||
Limit(req.Size).
|
||||
Offset(offset).
|
||||
Scan(&orderData).Error
|
||||
if err != nil {
|
||||
l.Errorw("[GetInviteSales] query sales failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", userId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
|
||||
"query sales failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 5. Get sales list
|
||||
const HashSalt = "ppanel_invite_sales_v1" // Fixed Key
|
||||
var list []types.InvitedUserSale
|
||||
for _, order := range orderData {
|
||||
// Calculate unique numeric hash (FNV-64a)
|
||||
h := fnv.New64a()
|
||||
h.Write([]byte(HashSalt))
|
||||
h.Write([]byte(strconv.FormatInt(order.UserId, 10)))
|
||||
// Truncate to 10 digits using modulo 10^10
|
||||
hashVal := h.Sum64() % 10000000000
|
||||
userHashStr := fmt.Sprintf("%010d", hashVal)
|
||||
|
||||
// Format product name as "{{ quantity }}天VPN服务"
|
||||
productName := fmt.Sprintf("%d天VPN服务", order.Quantity)
|
||||
if order.Quantity <= 0 {
|
||||
productName = "1天VPN服务"
|
||||
}
|
||||
|
||||
list = append(list, types.InvitedUserSale{
|
||||
Amount: float64(order.Amount) / 100.0, // Convert cents to dollars
|
||||
UpdatedAt: order.UpdatedAt,
|
||||
UserHash: userHashStr,
|
||||
ProductName: productName,
|
||||
})
|
||||
}
|
||||
|
||||
return &types.GetInviteSalesResponse{
|
||||
Total: totalSales,
|
||||
List: list,
|
||||
}, nil
|
||||
}
|
||||
63
internal/logic/public/user/getSubscribeStatusLogic.go
Normal file
63
internal/logic/public/user/getSubscribeStatusLogic.go
Normal file
@ -0,0 +1,63 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type GetSubscribeStatusLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetSubscribeStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetSubscribeStatusLogic {
|
||||
return &GetSubscribeStatusLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetSubscribeStatusLogic) GetSubscribeStatus(req *types.GetSubscribeStatusRequest) (*types.GetSubscribeStatusResponse, error) {
|
||||
// 取当前用户
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok || u == nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid user token")
|
||||
}
|
||||
|
||||
// 1. 查 device 认证方式有没有套餐
|
||||
deviceStatus := false
|
||||
if len(u.UserDevices) > 0 {
|
||||
if dev, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, u.UserDevices[0].Identifier); err == nil && dev.Id > 0 {
|
||||
subscribes, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, dev.UserId)
|
||||
if err == nil {
|
||||
deviceStatus = len(subscribes) > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2.根据 req.email 查询 有没有套餐
|
||||
emailStatus := false
|
||||
if req.Email != "" {
|
||||
if auth, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email); err == nil && auth.Id > 0 {
|
||||
subscribes, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, auth.UserId)
|
||||
if err == nil {
|
||||
emailStatus = len(subscribes) > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
l.Infow("get subscribe status", logger.Field("userId", u.Id), logger.Field("device_status", deviceStatus), logger.Field("email_status", emailStatus))
|
||||
return &types.GetSubscribeStatusResponse{
|
||||
DeviceStatus: deviceStatus,
|
||||
EmailStatus: emailStatus,
|
||||
}, nil
|
||||
}
|
||||
78
internal/logic/public/user/getUserInviteStatsLogic.go
Normal file
78
internal/logic/public/user/getUserInviteStatsLogic.go
Normal file
@ -0,0 +1,78 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"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/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type GetUserInviteStatsLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// Get user invite statistics
|
||||
func NewGetUserInviteStatsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUserInviteStatsLogic {
|
||||
return &GetUserInviteStatsLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetUserInviteStatsLogic) GetUserInviteStats(req *types.GetUserInviteStatsRequest) (resp *types.GetUserInviteStatsResponse, err error) {
|
||||
// 1. 从 context 中获取当前登录用户
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
l.Errorw("[GetUserInviteStats] user not found in context")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
userId := u.Id
|
||||
|
||||
// 2. 获取历史邀请佣金 (FriendlyCount): 所有被邀请用户产生订单的佣金总和
|
||||
// 注意:这里复用了 friendly_count 字段名,实际含义是佣金总额
|
||||
var totalCommission sql.NullInt64
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("`order` o").
|
||||
Select("COALESCE(SUM(o.commission), 0) as total").
|
||||
Joins("JOIN user u ON o.user_id = u.id").
|
||||
Where("u.referer_id = ? AND o.status IN (?, ?)", userId, 2, 5). // 只统计已支付和已完成的订单
|
||||
Scan(&totalCommission).Error
|
||||
|
||||
if err != nil {
|
||||
l.Errorw("[GetUserInviteStats] sum commission failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", userId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
|
||||
"sum commission failed: %v", err.Error())
|
||||
}
|
||||
|
||||
friendlyCount := totalCommission.Int64
|
||||
|
||||
// 3. 获取历史邀请总数 (HistoryCount)
|
||||
var historyCount int64
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("user").
|
||||
Where("referer_id = ?", userId).
|
||||
Count(&historyCount).Error
|
||||
if err != nil {
|
||||
l.Errorw("[GetUserInviteStats] count history users failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", userId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
|
||||
"count history users failed: %v", err.Error())
|
||||
}
|
||||
|
||||
return &types.GetUserInviteStatsResponse{
|
||||
FriendlyCount: friendlyCount,
|
||||
HistoryCount: historyCount,
|
||||
}, nil
|
||||
}
|
||||
@ -2,9 +2,11 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/kutt"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
@ -61,9 +63,124 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
|
||||
})
|
||||
|
||||
resp.AuthMethods = userMethods
|
||||
|
||||
// 生成邀请短链接
|
||||
if l.svcCtx.Config.Kutt.Enable && resp.ReferCode != "" {
|
||||
shortLink := l.generateInviteShortLink(resp.ReferCode)
|
||||
if shortLink != "" {
|
||||
resp.ShareLink = shortLink
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// customData 用于解析 SiteConfig.CustomData JSON 字段
|
||||
// 包含从自定义数据中提取所需的配置项
|
||||
type customData struct {
|
||||
ShareUrl string `json:"shareUrl"` // 分享链接前缀 URL(目标落地页)
|
||||
Domain string `json:"domain"` // 短链接域名
|
||||
}
|
||||
|
||||
// getShareUrl 从 SiteConfig.CustomData 中获取 shareUrl
|
||||
//
|
||||
// 返回:
|
||||
// - string: 分享链接前缀 URL,如果获取失败则返回 Kutt.TargetURL 作为 fallback
|
||||
func (l *QueryUserInfoLogic) getShareUrl() string {
|
||||
siteConfig := l.svcCtx.Config.Site
|
||||
if siteConfig.CustomData != "" {
|
||||
var data customData
|
||||
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
|
||||
if data.ShareUrl != "" {
|
||||
return data.ShareUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback 到 Kutt.TargetURL
|
||||
return l.svcCtx.Config.Kutt.TargetURL
|
||||
}
|
||||
|
||||
// getDomain 从 SiteConfig.CustomData 中获取短链接域名
|
||||
//
|
||||
// 返回:
|
||||
// - string: 短链接域名,如果获取失败则返回 Kutt.Domain 作为 fallback
|
||||
func (l *QueryUserInfoLogic) getDomain() string {
|
||||
siteConfig := l.svcCtx.Config.Site
|
||||
if siteConfig.CustomData != "" {
|
||||
var data customData
|
||||
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
|
||||
if data.Domain != "" {
|
||||
return data.Domain
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback 到 Kutt.Domain
|
||||
return l.svcCtx.Config.Kutt.Domain
|
||||
}
|
||||
|
||||
// generateInviteShortLink 生成邀请短链接(带 Redis 缓存)
|
||||
//
|
||||
// 参数:
|
||||
// - inviteCode: 邀请码
|
||||
//
|
||||
// 返回:
|
||||
// - string: 短链接 URL,失败时返回空字符串
|
||||
func (l *QueryUserInfoLogic) generateInviteShortLink(inviteCode string) string {
|
||||
cfg := l.svcCtx.Config.Kutt
|
||||
shareUrl := l.getShareUrl()
|
||||
domain := l.getDomain()
|
||||
|
||||
// 检查必要配置
|
||||
if cfg.ApiURL == "" || cfg.ApiKey == "" {
|
||||
l.Sloww("Kutt config incomplete",
|
||||
logger.Field("api_url", cfg.ApiURL != ""),
|
||||
logger.Field("api_key", cfg.ApiKey != ""))
|
||||
return ""
|
||||
}
|
||||
if shareUrl == "" {
|
||||
l.Sloww("ShareUrl not configured in CustomData or Kutt.TargetURL")
|
||||
return ""
|
||||
}
|
||||
|
||||
// Redis 缓存 key
|
||||
cacheKey := "cache:invite:short_link:" + inviteCode
|
||||
|
||||
// 1. 尝试从 Redis 缓存读取
|
||||
cachedLink, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if err == nil && cachedLink != "" {
|
||||
l.Debugw("Hit cache for invite short link",
|
||||
logger.Field("invite_code", inviteCode),
|
||||
logger.Field("short_link", cachedLink))
|
||||
return cachedLink
|
||||
}
|
||||
|
||||
// 2. 缓存未命中,调用 Kutt API 创建短链接
|
||||
client := kutt.NewClient(cfg.ApiURL, cfg.ApiKey)
|
||||
shortLink, err := client.CreateInviteShortLink(l.ctx, shareUrl, inviteCode, domain)
|
||||
if err != nil {
|
||||
l.Errorw("Failed to create short link",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("invite_code", inviteCode),
|
||||
logger.Field("share_url", shareUrl))
|
||||
return ""
|
||||
}
|
||||
|
||||
// 3. 写入 Redis 缓存(永不过期,因为邀请码不变短链接也不会变)
|
||||
if err := l.svcCtx.Redis.Set(l.ctx, cacheKey, shortLink, 0).Err(); err != nil {
|
||||
l.Errorw("Failed to cache short link",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("invite_code", inviteCode))
|
||||
// 缓存失败不影响返回
|
||||
}
|
||||
|
||||
l.Infow("Created and cached invite short link",
|
||||
logger.Field("invite_code", inviteCode),
|
||||
logger.Field("short_link", shortLink),
|
||||
logger.Field("share_url", shareUrl))
|
||||
|
||||
return shortLink
|
||||
}
|
||||
|
||||
// getAuthTypePriority 获取认证类型的排序优先级
|
||||
// email: 1 (第一位)
|
||||
// mobile: 2 (第二位)
|
||||
|
||||
@ -62,6 +62,15 @@ func (l *QueryUserSubscribeLogic) QueryUserSubscribe() (resp *types.QueryUserSub
|
||||
|
||||
short, _ := tool.FixedUniqueString(item.Token, 8, "")
|
||||
sub.Short = short
|
||||
|
||||
// 查询订单金额,判断是否为赠送订单(amount=0)
|
||||
if item.OrderId > 0 {
|
||||
orderInfo, err := l.svcCtx.OrderModel.FindOne(l.ctx, item.OrderId)
|
||||
if err == nil && orderInfo != nil {
|
||||
sub.IsGift = orderInfo.Amount == 0
|
||||
}
|
||||
}
|
||||
|
||||
sub.ResetTime = calculateNextResetTime(&sub)
|
||||
resp.List = append(resp.List, sub)
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
@ -37,7 +38,13 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DeviceNotExist), "find device")
|
||||
}
|
||||
|
||||
if device.UserId != userInfo.Id {
|
||||
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
||||
scopeUserIds, err := scopeHelper.resolveScopedUserIds(userInfo.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !tool.Contains(scopeUserIds, device.UserId) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "device not belong to user")
|
||||
}
|
||||
|
||||
@ -64,15 +71,21 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete device online record err: %v", err)
|
||||
}
|
||||
var count int64
|
||||
err = tx.Model(user.AuthMethods{}).Where("user_id = ?", deleteDevice.UserId).Count(&count).Error
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count user auth methods err: %v", err)
|
||||
|
||||
if deleteDevice.UserId == userInfo.Id {
|
||||
shouldLeaveFamily, leaveCheckErr := l.shouldLeaveFamilyAfterUnbind(tx, userInfo.Id)
|
||||
if leaveCheckErr != nil {
|
||||
return leaveCheckErr
|
||||
}
|
||||
if shouldLeaveFamily {
|
||||
exitHelper := newFamilyExitHelper(l.ctx, l.svcCtx)
|
||||
if leaveErr := exitHelper.removeUserFromActiveFamily(tx, userInfo.Id, true); leaveErr != nil {
|
||||
return leaveErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if count < 1 {
|
||||
_ = tx.Where("id = ?", deleteDevice.UserId).Delete(&user.User{}).Error
|
||||
}
|
||||
l.svcCtx.DeviceManager.KickDevice(deleteDevice.UserId, deleteDevice.Identifier)
|
||||
|
||||
//remove device cache
|
||||
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, deleteDevice.Identifier)
|
||||
@ -84,3 +97,34 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (l *UnbindDeviceLogic) shouldLeaveFamilyAfterUnbind(tx *gorm.DB, userID int64) (bool, error) {
|
||||
var nonDeviceAuthCount int64
|
||||
if err := tx.Model(&user.AuthMethods{}).
|
||||
Where("user_id = ? AND auth_type <> ?", userID, "device").
|
||||
Count(&nonDeviceAuthCount).Error; err != nil {
|
||||
return false, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count non-device auth methods failed")
|
||||
}
|
||||
if nonDeviceAuthCount > 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var deviceAuthCount int64
|
||||
if err := tx.Model(&user.AuthMethods{}).
|
||||
Where("user_id = ? AND auth_type = ?", userID, "device").
|
||||
Count(&deviceAuthCount).Error; err != nil {
|
||||
return false, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count device auth methods failed")
|
||||
}
|
||||
if deviceAuthCount > 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var deviceCount int64
|
||||
if err := tx.Model(&user.Device{}).
|
||||
Where("user_id = ?", userID).
|
||||
Count(&deviceCount).Error; err != nil {
|
||||
return false, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count devices failed")
|
||||
}
|
||||
|
||||
return deviceCount == 0, nil
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
|
||||
@ -31,24 +32,42 @@ func NewUpdateBindEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *U
|
||||
}
|
||||
|
||||
func (l *UpdateBindEmailLogic) UpdateBindEmail(req *types.UpdateBindEmailRequest) error {
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
logger.Error("current user is not found in context")
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
method, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", u.Id)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
|
||||
|
||||
familyHelper := newFamilyBindingHelper(l.ctx, l.svcCtx)
|
||||
|
||||
method, err := familyHelper.getUserEmailMethod(u.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if method != nil {
|
||||
if method.AuthIdentifier == req.Email {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user")
|
||||
}
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "email already bound to another account")
|
||||
}
|
||||
|
||||
m, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
|
||||
}
|
||||
// email already bind
|
||||
if m.Id > 0 {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "email already bind")
|
||||
if m != nil && m.Id > 0 {
|
||||
if m.UserId == u.Id {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user")
|
||||
}
|
||||
if err = familyHelper.validateJoinFamily(m.UserId, u.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.EmailBindError), "email already bound to another user")
|
||||
}
|
||||
if method.Id == 0 {
|
||||
|
||||
if method == nil || method.Id == 0 {
|
||||
method = &user.AuthMethods{
|
||||
UserId: u.Id,
|
||||
AuthType: "email",
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
@ -36,6 +38,7 @@ type CacheKeyPayload struct {
|
||||
}
|
||||
|
||||
func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Security, req.Email)
|
||||
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if err != nil {
|
||||
@ -52,6 +55,9 @@ func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
|
||||
if payload.Code != req.Code {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||
}
|
||||
if time.Now().Unix()-payload.LastAt > l.svcCtx.Config.VerifyCode.ExpireTime {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
|
||||
@ -173,8 +173,6 @@ func (rw *ResponseWriter) Decrypt() bool {
|
||||
|
||||
func (rw *ResponseWriter) FlushAbort() {
|
||||
defer rw.c.Abort()
|
||||
responseBody := rw.body.String()
|
||||
fmt.Println("Original Response Body:", responseBody)
|
||||
rw.flush = true
|
||||
if rw.encryption {
|
||||
rw.Encrypt()
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
type Auth struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
Method string `gorm:"unique;type:varchar(255);not null;default:'';comment:platform"`
|
||||
Config string `gorm:"type:text;not null;comment:Auth Configuration"`
|
||||
Config string `gorm:"type:mediumtext;not null;comment:Auth Configuration"`
|
||||
Enabled *bool `gorm:"type:tinyint(1);not null;default:false;comment:Is Enabled"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||
@ -129,7 +129,7 @@ func (l *EmailAuthConfig) Marshal() string {
|
||||
if l.ExpirationEmailTemplate == "" {
|
||||
l.ExpirationEmailTemplate = email.DefaultExpirationEmailTemplate
|
||||
}
|
||||
if l.ExpirationEmailTemplate == "" {
|
||||
if l.MaintenanceEmailTemplate == "" {
|
||||
l.MaintenanceEmailTemplate = email.DefaultMaintenanceEmailTemplate
|
||||
}
|
||||
if l.TrafficExceedEmailTemplate == "" {
|
||||
|
||||
68
internal/model/iap/apple/default.go
Normal file
68
internal/model/iap/apple/default.go
Normal file
@ -0,0 +1,68 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/cache"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Model interface {
|
||||
Insert(ctx context.Context, data *Transaction, tx ...*gorm.DB) error
|
||||
FindByOriginalId(ctx context.Context, originalId string) (*Transaction, error)
|
||||
FindByUserAndProduct(ctx context.Context, userId int64, productId string) (*Transaction, error)
|
||||
}
|
||||
|
||||
type defaultModel struct {
|
||||
cache.CachedConn
|
||||
table string
|
||||
}
|
||||
|
||||
type customModel struct {
|
||||
*defaultModel
|
||||
}
|
||||
|
||||
func NewModel(db *gorm.DB, c *redis.Client) Model {
|
||||
return &customModel{
|
||||
defaultModel: &defaultModel{
|
||||
CachedConn: cache.NewConn(db, c),
|
||||
table: "`apple_iap_transactions`",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *defaultModel) jwsKey(jws string) string {
|
||||
sum := sha256.Sum256([]byte(jws))
|
||||
return fmt.Sprintf("cache:iap:jws:%s", hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func (m *customModel) Insert(ctx context.Context, data *Transaction, tx ...*gorm.DB) error {
|
||||
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
|
||||
if len(tx) > 0 {
|
||||
conn = tx[0]
|
||||
}
|
||||
return conn.Model(&Transaction{}).Create(data).Error
|
||||
}, m.jwsKey(data.JWSHash))
|
||||
}
|
||||
|
||||
func (m *customModel) FindByOriginalId(ctx context.Context, originalId string) (*Transaction, error) {
|
||||
var data Transaction
|
||||
key := fmt.Sprintf("cache:iap:original:%s", originalId)
|
||||
err := m.QueryCtx(ctx, &data, key, func(conn *gorm.DB, v interface{}) error {
|
||||
return conn.Model(&Transaction{}).Where("original_transaction_id = ?", originalId).First(&data).Error
|
||||
})
|
||||
return &data, err
|
||||
}
|
||||
|
||||
func (m *customModel) FindByUserAndProduct(ctx context.Context, userId int64, productId string) (*Transaction, error) {
|
||||
var data Transaction
|
||||
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, v interface{}) error {
|
||||
return conn.Model(&Transaction{}).Where("user_id = ? AND product_id = ?", userId, productId).Order("purchase_at DESC").First(&data).Error
|
||||
})
|
||||
return &data, err
|
||||
}
|
||||
|
||||
21
internal/model/iap/apple/transaction.go
Normal file
21
internal/model/iap/apple/transaction.go
Normal file
@ -0,0 +1,21 @@
|
||||
package apple
|
||||
|
||||
import "time"
|
||||
|
||||
type Transaction struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"`
|
||||
OriginalTransactionId string `gorm:"type:varchar(255);uniqueIndex:uni_original;not null;comment:Original Transaction ID"`
|
||||
TransactionId string `gorm:"type:varchar(255);not null;comment:Transaction ID"`
|
||||
ProductId string `gorm:"type:varchar(255);not null;comment:Product ID"`
|
||||
PurchaseAt time.Time `gorm:"not null;comment:Purchase Time"`
|
||||
RevocationAt *time.Time `gorm:"comment:Revocation Time"`
|
||||
JWSHash string `gorm:"type:varchar(255);not null;comment:JWS Hash"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||
}
|
||||
|
||||
func (Transaction) TableName() string {
|
||||
return "apple_iap_transactions"
|
||||
}
|
||||
|
||||
50
internal/model/logmessage/default.go
Normal file
50
internal/model/logmessage/default.go
Normal file
@ -0,0 +1,50 @@
|
||||
package logmessage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type (
|
||||
Model interface {
|
||||
logMessageModel
|
||||
customLogMessageModel
|
||||
}
|
||||
logMessageModel interface {
|
||||
Insert(ctx context.Context, data *LogMessage) error
|
||||
FindOne(ctx context.Context, id int64) (*LogMessage, error)
|
||||
Update(ctx context.Context, data *LogMessage) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
customModel struct{
|
||||
*defaultModel
|
||||
}
|
||||
defaultModel struct{
|
||||
*gorm.DB
|
||||
}
|
||||
)
|
||||
|
||||
func newDefaultModel(db *gorm.DB) *defaultModel {
|
||||
return &defaultModel{DB: db}
|
||||
}
|
||||
|
||||
func (m *defaultModel) Insert(ctx context.Context, data *LogMessage) error {
|
||||
return m.WithContext(ctx).Create(data).Error
|
||||
}
|
||||
|
||||
func (m *defaultModel) FindOne(ctx context.Context, id int64) (*LogMessage, error) {
|
||||
var v LogMessage
|
||||
err := m.WithContext(ctx).Where("id = ?", id).First(&v).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (m *defaultModel) Update(ctx context.Context, data *LogMessage) error {
|
||||
return m.WithContext(ctx).Where("`id` = ?", data.Id).Save(data).Error
|
||||
}
|
||||
|
||||
func (m *defaultModel) Delete(ctx context.Context, id int64) error {
|
||||
return m.WithContext(ctx).Where("`id` = ?", id).Delete(&LogMessage{}).Error
|
||||
}
|
||||
27
internal/model/logmessage/entity.go
Normal file
27
internal/model/logmessage/entity.go
Normal file
@ -0,0 +1,27 @@
|
||||
package logmessage
|
||||
|
||||
import "time"
|
||||
|
||||
type LogMessage struct {
|
||||
Id int64 `gorm:"primaryKey;AUTO_INCREMENT"`
|
||||
Platform string `gorm:"type:varchar(32);not null"`
|
||||
AppVersion string `gorm:"type:varchar(32);default:null"`
|
||||
OsName string `gorm:"type:varchar(32);default:null"`
|
||||
OsVersion string `gorm:"type:varchar(32);default:null"`
|
||||
DeviceId string `gorm:"type:varchar(64);default:null"`
|
||||
UserId *int64 `gorm:"type:bigint;default:null"`
|
||||
SessionId string `gorm:"type:varchar(64);default:null"`
|
||||
Level uint8 `gorm:"type:tinyint(1);not null;default:3"`
|
||||
ErrorCode string `gorm:"type:varchar(64);default:null"`
|
||||
Message string `gorm:"type:text;not null"`
|
||||
Stack string `gorm:"type:mediumtext;default:null"`
|
||||
Context string `gorm:"type:json;default:null"`
|
||||
ClientIP string `gorm:"type:varchar(45);default:null"`
|
||||
UserAgent string `gorm:"type:varchar(255);default:null"`
|
||||
Locale string `gorm:"type:varchar(16);default:null"`
|
||||
Digest string `gorm:"type:varchar(64);uniqueIndex:uniq_digest;default:null"`
|
||||
OccurredAt *time.Time `gorm:"type:datetime;default:null"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
}
|
||||
|
||||
func (LogMessage) TableName() string { return "log_message" }
|
||||
52
internal/model/logmessage/model.go
Normal file
52
internal/model/logmessage/model.go
Normal file
@ -0,0 +1,52 @@
|
||||
package logmessage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewModel(db *gorm.DB) Model {
|
||||
return &customModel{ defaultModel: newDefaultModel(db) }
|
||||
}
|
||||
|
||||
type FilterParams struct {
|
||||
Page int
|
||||
Size int
|
||||
Platform string
|
||||
Level uint8
|
||||
UserID int64
|
||||
DeviceID string
|
||||
ErrorCode string
|
||||
Keyword string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
type customLogMessageModel interface {
|
||||
Filter(ctx context.Context, filter *FilterParams) ([]*LogMessage, int64, error)
|
||||
}
|
||||
|
||||
func (m *customModel) Filter(ctx context.Context, filter *FilterParams) ([]*LogMessage, int64, error) {
|
||||
tx := m.WithContext(ctx).Model(&LogMessage{}).Order("id DESC")
|
||||
if filter == nil {
|
||||
filter = &FilterParams{ Page: 1, Size: 10 }
|
||||
}
|
||||
if filter.Page < 1 { filter.Page = 1 }
|
||||
if filter.Size < 1 { filter.Size = 10 }
|
||||
if filter.Platform != "" { tx = tx.Where("`platform` = ?", filter.Platform) }
|
||||
if filter.Level != 0 { tx = tx.Where("`level` = ?", filter.Level) }
|
||||
if filter.UserID != 0 { tx = tx.Where("`user_id` = ?", filter.UserID) }
|
||||
if filter.DeviceID != "" { tx = tx.Where("`device_id` = ?", filter.DeviceID) }
|
||||
if filter.ErrorCode != "" { tx = tx.Where("`error_code` = ?", filter.ErrorCode) }
|
||||
if !filter.Start.IsZero() { tx = tx.Where("`created_at` >= ?", filter.Start) }
|
||||
if !filter.End.IsZero() { tx = tx.Where("`created_at` <= ?", filter.End) }
|
||||
if filter.Keyword != "" {
|
||||
like := "%" + filter.Keyword + "%"
|
||||
tx = tx.Where("`message` LIKE ? OR `stack` LIKE ?", like, like)
|
||||
}
|
||||
var total int64
|
||||
var rows []*LogMessage
|
||||
err := tx.Count(&total).Limit(filter.Size).Offset((filter.Page-1)*filter.Size).Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
@ -14,6 +14,7 @@ type customServerLogicModel interface {
|
||||
FilterNodeList(ctx context.Context, params *FilterNodeParams) (int64, []*Node, error)
|
||||
ClearNodeCache(ctx context.Context, params *FilterNodeParams) error
|
||||
ClearServerAllCache(ctx context.Context) error
|
||||
CountNodesByIdsAndTags(ctx context.Context, nodeIds []int64, tags []string) (int64, error)
|
||||
}
|
||||
|
||||
const (
|
||||
@ -129,7 +130,7 @@ func (m *customServerModel) ClearNodeCache(ctx context.Context, params *FilterNo
|
||||
return err
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
cacheKeys = append(keys, keys...)
|
||||
cacheKeys = append(cacheKeys, keys...)
|
||||
}
|
||||
cursor = newCursor
|
||||
if cursor == 0 {
|
||||
@ -152,7 +153,7 @@ func (m *customServerModel) ClearServerCache(ctx context.Context, serverId int64
|
||||
cacheKeys = append(cacheKeys, fmt.Sprintf("%s%d", ServerUserListCacheKey, serverId))
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, newCursor, err := m.Cache.Scan(ctx, 0, fmt.Sprintf("%s%d*", ServerConfigCacheKey, serverId), 100).Result()
|
||||
keys, newCursor, err := m.Cache.Scan(ctx, cursor, fmt.Sprintf("%s%d*", ServerConfigCacheKey, serverId), 100).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -175,18 +176,21 @@ func (m *customServerModel) ClearServerCache(ctx context.Context, serverId int64
|
||||
func (m *customServerModel) ClearServerAllCache(ctx context.Context) error {
|
||||
var cursor uint64
|
||||
var keys []string
|
||||
prefix := ServerUserListCacheKey + "*"
|
||||
for {
|
||||
scanKeys, newCursor, err := m.Cache.Scan(ctx, cursor, prefix, 999).Result()
|
||||
if err != nil {
|
||||
m.Logger.Error(ctx, fmt.Sprintf("ClearServerAllCache err:%v", err))
|
||||
break
|
||||
}
|
||||
m.Logger.Info(ctx, fmt.Sprintf("ClearServerAllCache query keys:%v", scanKeys))
|
||||
keys = append(keys, scanKeys...)
|
||||
cursor = newCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
prefixes := []string{ServerConfigCacheKey + "*", ServerUserListCacheKey + "*"}
|
||||
for _, prefix := range prefixes {
|
||||
cursor = 0
|
||||
for {
|
||||
scanKeys, newCursor, err := m.Cache.Scan(ctx, cursor, prefix, 999).Result()
|
||||
if err != nil {
|
||||
m.Logger.Error(ctx, fmt.Sprintf("ClearServerAllCache err:%v", err))
|
||||
break
|
||||
}
|
||||
m.Logger.Info(ctx, fmt.Sprintf("ClearServerAllCache query keys:%v", scanKeys))
|
||||
keys = append(keys, scanKeys...)
|
||||
cursor = newCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
@ -196,6 +200,29 @@ func (m *customServerModel) ClearServerAllCache(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountNodesByIdsAndTags 根据节点ID和标签计算启用的节点数量
|
||||
func (m *customServerModel) CountNodesByIdsAndTags(ctx context.Context, nodeIds []int64, tags []string) (int64, error) {
|
||||
var count int64
|
||||
query := m.WithContext(ctx).Model(&Node{}).Where("enabled = ?", true)
|
||||
|
||||
if len(nodeIds) > 0 || len(tags) > 0 {
|
||||
subQuery := m.WithContext(ctx).Model(&Node{}).Where("enabled = ?", true)
|
||||
|
||||
if len(nodeIds) > 0 && len(tags) > 0 {
|
||||
subQuery = subQuery.Where("id IN ? OR ?", nodeIds, InSet("tags", tags))
|
||||
} else if len(nodeIds) > 0 {
|
||||
subQuery = subQuery.Where("id IN ?", nodeIds)
|
||||
} else {
|
||||
subQuery = subQuery.Scopes(InSet("tags", tags))
|
||||
}
|
||||
|
||||
query = subQuery
|
||||
}
|
||||
|
||||
err := query.Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// InSet 支持多值 OR 查询
|
||||
func InSet(field string, values []string) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
|
||||
@ -12,6 +12,7 @@ type customPaymentLogicModel interface {
|
||||
FindAll(ctx context.Context) ([]*Payment, error)
|
||||
FindListByPage(ctx context.Context, page, size int, req *Filter) (int64, []*Payment, error)
|
||||
FindAvailableMethods(ctx context.Context) ([]*Payment, error)
|
||||
FindListByPlatform(ctx context.Context, platform string) ([]*Payment, error)
|
||||
}
|
||||
|
||||
// NewModel returns a model for the database table.
|
||||
@ -67,3 +68,11 @@ func (m *customPaymentModel) FindListByPage(ctx context.Context, page, size int,
|
||||
})
|
||||
return total, resp, err
|
||||
}
|
||||
|
||||
func (m *customPaymentModel) FindListByPlatform(ctx context.Context, platform string) ([]*Payment, error) {
|
||||
var resp []*Payment
|
||||
err := m.QueryNoCacheCtx(ctx, &resp, func(conn *gorm.DB, v interface{}) error {
|
||||
return conn.Model(&Payment{}).Where("mark = ?", platform).Find(v).Error
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@ -127,3 +127,26 @@ func (l *CryptoSaaSConfig) Unmarshal(data []byte) error {
|
||||
aux := (*Alias)(l)
|
||||
return json.Unmarshal(data, &aux)
|
||||
}
|
||||
|
||||
type AppleIAPConfig struct {
|
||||
ProductIds []string `json:"product_ids"`
|
||||
KeyID string `json:"key_id"`
|
||||
IssuerID string `json:"issuer_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
Sandbox bool `json:"sandbox"`
|
||||
}
|
||||
|
||||
func (l *AppleIAPConfig) Marshal() ([]byte, error) {
|
||||
type Alias AppleIAPConfig
|
||||
return json.Marshal(&struct {
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(l),
|
||||
})
|
||||
}
|
||||
|
||||
func (l *AppleIAPConfig) Unmarshal(data []byte) error {
|
||||
type Alias AppleIAPConfig
|
||||
aux := (*Alias)(l)
|
||||
return json.Unmarshal(data, &aux)
|
||||
}
|
||||
|
||||
@ -56,6 +56,18 @@ func (m *customUserModel) QueryDeviceList(ctx context.Context, userId int64) ([]
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
func (m *customUserModel) QueryDeviceListByUserIds(ctx context.Context, userIds []int64) ([]*Device, int64, error) {
|
||||
var list []*Device
|
||||
var total int64
|
||||
if len(userIds) == 0 {
|
||||
return list, total, nil
|
||||
}
|
||||
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
|
||||
return conn.Model(&Device{}).Where("`user_id` IN ?", userIds).Count(&total).Find(&list).Error
|
||||
})
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
func (m *customUserModel) UpdateDevice(ctx context.Context, data *Device, tx ...*gorm.DB) error {
|
||||
old, err := m.FindOneDevice(ctx, data.Id)
|
||||
if err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user