From 4d8516b2e1e3f4f83b29c87c0509dd8a4f9c3834 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Tue, 3 Mar 2026 09:32:22 -0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=8E=86=E5=8F=B2=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/plan/sync-features.md | 241 +++++++++++ .claude/team-plan/sync-remaining.md | 168 ++++++++ apis/types.api | 1 + .../database/00002_init_basic_data.up.sql | 2 +- .../database/02127_redemption_status.down.sql | 19 +- .../database/02127_redemption_status.up.sql | 19 +- .../database/02131_log_message.down.sql | 1 + .../migrate/database/02131_log_message.up.sql | 27 ++ .../02132_apple_iap_transactions.down.sql | 1 + .../02132_apple_iap_transactions.up.sql | 15 + .../02133_add_user_last_login_time.down.sql | 1 + .../02133_add_user_last_login_time.up.sql | 1 + .../02134_update_auth_method_config.down.sql | 1 + .../02134_update_auth_method_config.up.sql | 1 + .../database/02135_family_group.down.sql | 2 + .../database/02135_family_group.up.sql | 31 ++ initialize/schema_compat.go | 184 ++++++++ initialize/telegram.go | 57 ++- initialize/version.go | 34 +- internal/config/cacheKey.go | 3 + internal/config/config.go | 31 +- .../log/getErrorLogMessageDetailHandler.go | 17 + .../log/getErrorLogMessageListHandler.go | 19 + internal/handler/auth/emailLoginHandler.go | 31 ++ internal/handler/common/contactHandler.go | 24 ++ .../handler/common/getdownloadlinkhandler.go | 32 ++ .../handler/common/logMessageReportHandler.go | 24 ++ internal/handler/notify.go | 5 + .../handler/notify/appleIAPNotifyHandler.go | 23 + .../iap/apple/attachTransactionByIdHandler.go | 23 + .../iap/apple/attachTransactionHandler.go | 23 + .../public/iap/apple/getStatusHandler.go | 16 + .../public/iap/apple/restoreHandler.go | 23 + .../user/bindEmailWithVerificationHandler.go | 26 ++ .../public/user/bindInviteCodeHandler.go | 25 ++ .../public/user/deleteAccountHandler.go | 94 +++++ .../public/user/getAgentDownloadsHandler.go | 26 ++ .../public/user/getAgentRealtimeHandler.go | 26 ++ .../public/user/getInviteSalesHandler.go | 30 ++ .../public/user/getSubscribeStatusHandler.go | 24 ++ .../public/user/getUserInviteStatsHandler.go | 26 ++ .../public/user/queryUserSubscribeHandler.go | 10 +- internal/handler/routes.go | 52 +++ .../log/getErrorLogMessageDetailLogic.go | 49 +++ .../admin/log/getErrorLogMessageListLogic.go | 59 +++ .../admin/payment/createPaymentMethodLogic.go | 2 + .../system/updateVerifyCodeConfigLogic.go | 2 + internal/logic/admin/user/getUserListLogic.go | 29 ++ .../admin/user/updateUserBasicInfoLogic.go | 161 +++---- internal/logic/auth/bindDeviceLogic.go | 118 +----- internal/logic/auth/emailLoginLogic.go | 248 +++++++++++ internal/logic/auth/resetPasswordLogic.go | 6 + internal/logic/auth/userLoginLogic.go | 10 + internal/logic/auth/userRegisterLogic.go | 8 +- .../common/checkverificationcodelogic.go | 2 + internal/logic/common/contactLogic.go | 88 ++++ internal/logic/common/getdownloadlinklogic.go | 71 ++++ .../logic/common/logMessageReportLogic.go | 108 +++++ internal/logic/common/sendEmailCodeLogic.go | 12 +- internal/logic/notify/appleIAPNotifyLogic.go | 190 +++++++++ .../iap/apple/attachTransactionByIdLogic.go | 138 ++++++ .../iap/apple/attachTransactionLogic.go | 267 ++++++++++++ .../logic/public/iap/apple/getStatusLogic.go | 62 +++ .../logic/public/iap/apple/restoreLogic.go | 170 ++++++++ .../logic/public/order/preCreateOrderLogic.go | 3 +- internal/logic/public/order/purchaseLogic.go | 16 +- .../logic/public/order/queryOrderListLogic.go | 2 +- internal/logic/public/order/renewalLogic.go | 3 +- .../public/portal/getSubscriptionLogic.go | 23 +- .../public/portal/prePurchaseOrderLogic.go | 3 +- .../public/portal/purchaseCheckoutLogic.go | 118 +++++- internal/logic/public/portal/purchaseLogic.go | 5 +- .../user/bindEmailWithVerificationLogic.go | 172 ++++++++ .../logic/public/user/bindInviteCodeLogic.go | 68 +++ .../logic/public/user/deleteAccountLogic.go | 374 +++++++++++++++++ .../logic/public/user/familyBindingHelper.go | 233 +++++++++++ .../logic/public/user/familyExitHelper.go | 73 ++++ .../logic/public/user/familyScopeHelper.go | 68 +++ .../public/user/getAgentDownloadsLogic.go | 94 +++++ .../public/user/getAgentRealtimeLogic.go | 182 ++++++++ .../logic/public/user/getDeviceListLogic.go | 7 +- .../logic/public/user/getInviteSalesLogic.go | 142 +++++++ .../public/user/getSubscribeStatusLogic.go | 63 +++ .../public/user/getUserInviteStatsLogic.go | 78 ++++ .../logic/public/user/queryUserInfoLogic.go | 117 ++++++ .../public/user/queryUserSubscribeLogic.go | 9 + .../logic/public/user/unbindDeviceLogic.go | 60 ++- .../logic/public/user/updateBindEmailLogic.go | 33 +- .../logic/public/user/verifyEmailLogic.go | 6 + internal/middleware/deviceMiddleware.go | 2 - internal/model/auth/auth.go | 4 +- internal/model/iap/apple/default.go | 68 +++ internal/model/iap/apple/transaction.go | 21 + internal/model/logmessage/default.go | 50 +++ internal/model/logmessage/entity.go | 27 ++ internal/model/logmessage/model.go | 52 +++ internal/model/node/model.go | 55 ++- internal/model/payment/model.go | 9 + internal/model/payment/payment.go | 23 + internal/model/user/device.go | 12 + internal/model/user/family.go | 49 +++ internal/model/user/model.go | 22 + internal/model/user/model_ext.go | 45 ++ internal/model/user/subscribe.go | 26 +- internal/model/user/user.go | 3 + internal/svc/serviceContext.go | 6 + internal/types/types.go | 230 +++++++++- pkg/conf/default.go | 4 + pkg/constant/context.go | 17 +- pkg/constant/types.go | 4 + pkg/email/template.go | 208 ++++----- pkg/exchangeRate/exchangeRate.go | 21 +- pkg/iap/apple/errors.go | 6 + pkg/iap/apple/jws.go | 133 ++++++ pkg/iap/apple/jws_test.go | 35 ++ pkg/iap/apple/notification.go | 51 +++ pkg/iap/apple/productmap.go | 39 ++ pkg/iap/apple/serverapi.go | 131 ++++++ pkg/iap/apple/types.go | 14 + pkg/kutt/kutt.go | 249 +++++++++++ pkg/logger/gorm.go | 6 +- pkg/loki/loki.go | 159 +++++++ pkg/openinstall/channel_test.go | 68 +++ pkg/openinstall/client_test.go | 57 +++ pkg/openinstall/openinstall.go | 290 +++++++++++++ pkg/orm/mysql.go | 2 + pkg/payment/platform.go | 13 + pkg/tool/encryption.go | 2 + pkg/tool/sliceReflectToStruct.go | 24 +- pkg/trace/agent.go | 4 + pkg/xerr/errCode.go | 32 +- pkg/xerr/errMsg.go | 25 +- pkg/xerr/family_err_test.go | 19 + queue/logic/email/sendEmailLogic.go | 48 ++- queue/logic/order/activateOrderLogic.go | 171 +++++++- queue/logic/task/rateLogic.go | 8 + script/db_query.go | 78 ++++ script/family_delete_account_cleanup_check.sh | 346 +++++++++++++++ script/family_scenarios_34_quick.sh | 335 +++++++++++++++ script/family_scenarios_regression.sh | 394 ++++++++++++++++++ 140 files changed, 8399 insertions(+), 489 deletions(-) create mode 100644 .claude/plan/sync-features.md create mode 100644 .claude/team-plan/sync-remaining.md create mode 100644 initialize/migrate/database/02131_log_message.down.sql create mode 100644 initialize/migrate/database/02131_log_message.up.sql create mode 100644 initialize/migrate/database/02132_apple_iap_transactions.down.sql create mode 100644 initialize/migrate/database/02132_apple_iap_transactions.up.sql create mode 100644 initialize/migrate/database/02133_add_user_last_login_time.down.sql create mode 100644 initialize/migrate/database/02133_add_user_last_login_time.up.sql create mode 100644 initialize/migrate/database/02134_update_auth_method_config.down.sql create mode 100644 initialize/migrate/database/02134_update_auth_method_config.up.sql create mode 100644 initialize/migrate/database/02135_family_group.down.sql create mode 100644 initialize/migrate/database/02135_family_group.up.sql create mode 100644 initialize/schema_compat.go create mode 100644 internal/handler/admin/log/getErrorLogMessageDetailHandler.go create mode 100644 internal/handler/admin/log/getErrorLogMessageListHandler.go create mode 100644 internal/handler/auth/emailLoginHandler.go create mode 100644 internal/handler/common/contactHandler.go create mode 100644 internal/handler/common/getdownloadlinkhandler.go create mode 100644 internal/handler/common/logMessageReportHandler.go create mode 100644 internal/handler/notify/appleIAPNotifyHandler.go create mode 100644 internal/handler/public/iap/apple/attachTransactionByIdHandler.go create mode 100644 internal/handler/public/iap/apple/attachTransactionHandler.go create mode 100644 internal/handler/public/iap/apple/getStatusHandler.go create mode 100644 internal/handler/public/iap/apple/restoreHandler.go create mode 100644 internal/handler/public/user/bindEmailWithVerificationHandler.go create mode 100644 internal/handler/public/user/bindInviteCodeHandler.go create mode 100644 internal/handler/public/user/deleteAccountHandler.go create mode 100644 internal/handler/public/user/getAgentDownloadsHandler.go create mode 100644 internal/handler/public/user/getAgentRealtimeHandler.go create mode 100644 internal/handler/public/user/getInviteSalesHandler.go create mode 100644 internal/handler/public/user/getSubscribeStatusHandler.go create mode 100644 internal/handler/public/user/getUserInviteStatsHandler.go create mode 100644 internal/logic/admin/log/getErrorLogMessageDetailLogic.go create mode 100644 internal/logic/admin/log/getErrorLogMessageListLogic.go create mode 100644 internal/logic/auth/emailLoginLogic.go create mode 100644 internal/logic/common/contactLogic.go create mode 100644 internal/logic/common/getdownloadlinklogic.go create mode 100644 internal/logic/common/logMessageReportLogic.go create mode 100644 internal/logic/notify/appleIAPNotifyLogic.go create mode 100644 internal/logic/public/iap/apple/attachTransactionByIdLogic.go create mode 100644 internal/logic/public/iap/apple/attachTransactionLogic.go create mode 100644 internal/logic/public/iap/apple/getStatusLogic.go create mode 100644 internal/logic/public/iap/apple/restoreLogic.go create mode 100644 internal/logic/public/user/bindEmailWithVerificationLogic.go create mode 100644 internal/logic/public/user/bindInviteCodeLogic.go create mode 100644 internal/logic/public/user/deleteAccountLogic.go create mode 100644 internal/logic/public/user/familyBindingHelper.go create mode 100644 internal/logic/public/user/familyExitHelper.go create mode 100644 internal/logic/public/user/familyScopeHelper.go create mode 100644 internal/logic/public/user/getAgentDownloadsLogic.go create mode 100644 internal/logic/public/user/getAgentRealtimeLogic.go create mode 100644 internal/logic/public/user/getInviteSalesLogic.go create mode 100644 internal/logic/public/user/getSubscribeStatusLogic.go create mode 100644 internal/logic/public/user/getUserInviteStatsLogic.go create mode 100644 internal/model/iap/apple/default.go create mode 100644 internal/model/iap/apple/transaction.go create mode 100644 internal/model/logmessage/default.go create mode 100644 internal/model/logmessage/entity.go create mode 100644 internal/model/logmessage/model.go create mode 100644 internal/model/user/family.go create mode 100644 internal/model/user/model_ext.go create mode 100644 pkg/iap/apple/errors.go create mode 100644 pkg/iap/apple/jws.go create mode 100644 pkg/iap/apple/jws_test.go create mode 100644 pkg/iap/apple/notification.go create mode 100644 pkg/iap/apple/productmap.go create mode 100644 pkg/iap/apple/serverapi.go create mode 100644 pkg/iap/apple/types.go create mode 100644 pkg/kutt/kutt.go create mode 100644 pkg/loki/loki.go create mode 100644 pkg/openinstall/channel_test.go create mode 100644 pkg/openinstall/client_test.go create mode 100644 pkg/openinstall/openinstall.go create mode 100644 pkg/xerr/family_err_test.go create mode 100644 script/db_query.go create mode 100755 script/family_delete_account_cleanup_check.sh create mode 100755 script/family_scenarios_34_quick.sh create mode 100755 script/family_scenarios_regression.sh diff --git a/.claude/plan/sync-features.md b/.claude/plan/sync-features.md new file mode 100644 index 0000000..580b9ab --- /dev/null +++ b/.claude/plan/sync-features.md @@ -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 等开源版独有功能不回退 diff --git a/.claude/team-plan/sync-remaining.md b/.claude/team-plan/sync-remaining.md new file mode 100644 index 0000000..7e5dbce --- /dev/null +++ b/.claude/team-plan/sync-remaining.md @@ -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 可并行执行,无互相依赖。 diff --git a/apis/types.api b/apis/types.api index 1fc1725..3b52c4a 100644 --- a/apis/types.api +++ b/apis/types.api @@ -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"` diff --git a/initialize/migrate/database/00002_init_basic_data.up.sql b/initialize/migrate/database/00002_init_basic_data.up.sql index 83cdda6..a387b30 100644 --- a/initialize/migrate/database/00002_init_basic_data.up.sql +++ b/initialize/migrate/database/00002_init_basic_data.up.sql @@ -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'), diff --git a/initialize/migrate/database/02127_redemption_status.down.sql b/initialize/migrate/database/02127_redemption_status.down.sql index ffa70f2..183a55d 100644 --- a/initialize/migrate/database/02127_redemption_status.down.sql +++ b/initialize/migrate/database/02127_redemption_status.down.sql @@ -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; diff --git a/initialize/migrate/database/02127_redemption_status.up.sql b/initialize/migrate/database/02127_redemption_status.up.sql index fc66cd4..5ca0a4e 100644 --- a/initialize/migrate/database/02127_redemption_status.up.sql +++ b/initialize/migrate/database/02127_redemption_status.up.sql @@ -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; diff --git a/initialize/migrate/database/02131_log_message.down.sql b/initialize/migrate/database/02131_log_message.down.sql new file mode 100644 index 0000000..fcf3226 --- /dev/null +++ b/initialize/migrate/database/02131_log_message.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `log_message`; diff --git a/initialize/migrate/database/02131_log_message.up.sql b/initialize/migrate/database/02131_log_message.up.sql new file mode 100644 index 0000000..2ba1eb4 --- /dev/null +++ b/initialize/migrate/database/02131_log_message.up.sql @@ -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; diff --git a/initialize/migrate/database/02132_apple_iap_transactions.down.sql b/initialize/migrate/database/02132_apple_iap_transactions.down.sql new file mode 100644 index 0000000..172fdb7 --- /dev/null +++ b/initialize/migrate/database/02132_apple_iap_transactions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `apple_iap_transactions`; diff --git a/initialize/migrate/database/02132_apple_iap_transactions.up.sql b/initialize/migrate/database/02132_apple_iap_transactions.up.sql new file mode 100644 index 0000000..35a2d79 --- /dev/null +++ b/initialize/migrate/database/02132_apple_iap_transactions.up.sql @@ -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; diff --git a/initialize/migrate/database/02133_add_user_last_login_time.down.sql b/initialize/migrate/database/02133_add_user_last_login_time.down.sql new file mode 100644 index 0000000..ca8258a --- /dev/null +++ b/initialize/migrate/database/02133_add_user_last_login_time.down.sql @@ -0,0 +1 @@ +ALTER TABLE user DROP COLUMN last_login_time; diff --git a/initialize/migrate/database/02133_add_user_last_login_time.up.sql b/initialize/migrate/database/02133_add_user_last_login_time.up.sql new file mode 100644 index 0000000..7180c05 --- /dev/null +++ b/initialize/migrate/database/02133_add_user_last_login_time.up.sql @@ -0,0 +1 @@ +ALTER TABLE user ADD COLUMN last_login_time DATETIME DEFAULT NULL COMMENT 'Last Login Time'; diff --git a/initialize/migrate/database/02134_update_auth_method_config.down.sql b/initialize/migrate/database/02134_update_auth_method_config.down.sql new file mode 100644 index 0000000..07c57f1 --- /dev/null +++ b/initialize/migrate/database/02134_update_auth_method_config.down.sql @@ -0,0 +1 @@ +ALTER TABLE auth_method MODIFY config TEXT NOT NULL COMMENT 'Auth Configuration'; diff --git a/initialize/migrate/database/02134_update_auth_method_config.up.sql b/initialize/migrate/database/02134_update_auth_method_config.up.sql new file mode 100644 index 0000000..ea3d7fb --- /dev/null +++ b/initialize/migrate/database/02134_update_auth_method_config.up.sql @@ -0,0 +1 @@ +ALTER TABLE auth_method MODIFY config MEDIUMTEXT NOT NULL COMMENT 'Auth Configuration'; diff --git a/initialize/migrate/database/02135_family_group.down.sql b/initialize/migrate/database/02135_family_group.down.sql new file mode 100644 index 0000000..a362a4a --- /dev/null +++ b/initialize/migrate/database/02135_family_group.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS `user_family_member`; +DROP TABLE IF EXISTS `user_family`; diff --git a/initialize/migrate/database/02135_family_group.up.sql b/initialize/migrate/database/02135_family_group.up.sql new file mode 100644 index 0000000..51890c9 --- /dev/null +++ b/initialize/migrate/database/02135_family_group.up.sql @@ -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; diff --git a/initialize/schema_compat.go b/initialize/schema_compat.go new file mode 100644 index 0000000..329b0d1 --- /dev/null +++ b/initialize/schema_compat.go @@ -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) +} diff --git a/initialize/telegram.go b/initialize/telegram.go index 56c7086..ec8143d 100644 --- a/initialize/telegram.go +++ b/initialize/telegram.go @@ -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") + } } diff --git a/initialize/version.go b/initialize/version.go index 14d0875..80c9f21 100644 --- a/initialize/version.go +++ b/initialize/version.go @@ -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 diff --git a/internal/config/cacheKey.go b/internal/config/cacheKey.go index 3bce8bd..0e500e1 100644 --- a/internal/config/cacheKey.go +++ b/internal/config/cacheKey.go @@ -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:" diff --git a/internal/config/config.go b/internal/config/config.go index ad7c3ef..d63afcf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` } diff --git a/internal/handler/admin/log/getErrorLogMessageDetailHandler.go b/internal/handler/admin/log/getErrorLogMessageDetailHandler.go new file mode 100644 index 0000000..57c8499 --- /dev/null +++ b/internal/handler/admin/log/getErrorLogMessageDetailHandler.go @@ -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) + } +} diff --git a/internal/handler/admin/log/getErrorLogMessageListHandler.go b/internal/handler/admin/log/getErrorLogMessageListHandler.go new file mode 100644 index 0000000..f5a8623 --- /dev/null +++ b/internal/handler/admin/log/getErrorLogMessageListHandler.go @@ -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) + } +} diff --git a/internal/handler/auth/emailLoginHandler.go b/internal/handler/auth/emailLoginHandler.go new file mode 100644 index 0000000..4aea673 --- /dev/null +++ b/internal/handler/auth/emailLoginHandler.go @@ -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) + } +} diff --git a/internal/handler/common/contactHandler.go b/internal/handler/common/contactHandler.go new file mode 100644 index 0000000..b8ef1fe --- /dev/null +++ b/internal/handler/common/contactHandler.go @@ -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) + } +} diff --git a/internal/handler/common/getdownloadlinkhandler.go b/internal/handler/common/getdownloadlinkhandler.go new file mode 100644 index 0000000..f06b7a5 --- /dev/null +++ b/internal/handler/common/getdownloadlinkhandler.go @@ -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) + } +} diff --git a/internal/handler/common/logMessageReportHandler.go b/internal/handler/common/logMessageReportHandler.go new file mode 100644 index 0000000..c514676 --- /dev/null +++ b/internal/handler/common/logMessageReportHandler.go @@ -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) + } +} diff --git a/internal/handler/notify.go b/internal/handler/notify.go index 3055dcd..2e669f9 100644 --- a/internal/handler/notify.go +++ b/internal/handler/notify.go @@ -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)) + } + } diff --git a/internal/handler/notify/appleIAPNotifyHandler.go b/internal/handler/notify/appleIAPNotifyHandler.go new file mode 100644 index 0000000..b725fb2 --- /dev/null +++ b/internal/handler/notify/appleIAPNotifyHandler.go @@ -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) + } +} diff --git a/internal/handler/public/iap/apple/attachTransactionByIdHandler.go b/internal/handler/public/iap/apple/attachTransactionByIdHandler.go new file mode 100644 index 0000000..ab426a4 --- /dev/null +++ b/internal/handler/public/iap/apple/attachTransactionByIdHandler.go @@ -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) + } +} diff --git a/internal/handler/public/iap/apple/attachTransactionHandler.go b/internal/handler/public/iap/apple/attachTransactionHandler.go new file mode 100644 index 0000000..2110221 --- /dev/null +++ b/internal/handler/public/iap/apple/attachTransactionHandler.go @@ -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) + } +} diff --git a/internal/handler/public/iap/apple/getStatusHandler.go b/internal/handler/public/iap/apple/getStatusHandler.go new file mode 100644 index 0000000..9357aa1 --- /dev/null +++ b/internal/handler/public/iap/apple/getStatusHandler.go @@ -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) + } +} diff --git a/internal/handler/public/iap/apple/restoreHandler.go b/internal/handler/public/iap/apple/restoreHandler.go new file mode 100644 index 0000000..736cee5 --- /dev/null +++ b/internal/handler/public/iap/apple/restoreHandler.go @@ -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) + } +} diff --git a/internal/handler/public/user/bindEmailWithVerificationHandler.go b/internal/handler/public/user/bindEmailWithVerificationHandler.go new file mode 100644 index 0000000..3c55894 --- /dev/null +++ b/internal/handler/public/user/bindEmailWithVerificationHandler.go @@ -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) + } +} diff --git a/internal/handler/public/user/bindInviteCodeHandler.go b/internal/handler/public/user/bindInviteCodeHandler.go new file mode 100644 index 0000000..fa36150 --- /dev/null +++ b/internal/handler/public/user/bindInviteCodeHandler.go @@ -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) + } +} diff --git a/internal/handler/public/user/deleteAccountHandler.go b/internal/handler/public/user/deleteAccountHandler.go new file mode 100644 index 0000000..6a23e63 --- /dev/null +++ b/internal/handler/public/user/deleteAccountHandler.go @@ -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 +} diff --git a/internal/handler/public/user/getAgentDownloadsHandler.go b/internal/handler/public/user/getAgentDownloadsHandler.go new file mode 100644 index 0000000..c35e379 --- /dev/null +++ b/internal/handler/public/user/getAgentDownloadsHandler.go @@ -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) + } +} diff --git a/internal/handler/public/user/getAgentRealtimeHandler.go b/internal/handler/public/user/getAgentRealtimeHandler.go new file mode 100644 index 0000000..3568e3d --- /dev/null +++ b/internal/handler/public/user/getAgentRealtimeHandler.go @@ -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) + } +} diff --git a/internal/handler/public/user/getInviteSalesHandler.go b/internal/handler/public/user/getInviteSalesHandler.go new file mode 100644 index 0000000..ce88ff4 --- /dev/null +++ b/internal/handler/public/user/getInviteSalesHandler.go @@ -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) + } +} diff --git a/internal/handler/public/user/getSubscribeStatusHandler.go b/internal/handler/public/user/getSubscribeStatusHandler.go new file mode 100644 index 0000000..223ee50 --- /dev/null +++ b/internal/handler/public/user/getSubscribeStatusHandler.go @@ -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) + } +} diff --git a/internal/handler/public/user/getUserInviteStatsHandler.go b/internal/handler/public/user/getUserInviteStatsHandler.go new file mode 100644 index 0000000..25e0a6a --- /dev/null +++ b/internal/handler/public/user/getUserInviteStatsHandler.go @@ -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) + } +} diff --git a/internal/handler/public/user/queryUserSubscribeHandler.go b/internal/handler/public/user/queryUserSubscribeHandler.go index 5d7882a..add0c0e 100644 --- a/internal/handler/public/user/queryUserSubscribeHandler.go +++ b/internal/handler/public/user/queryUserSubscribeHandler.go @@ -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) } diff --git a/internal/handler/routes.go b/internal/handler/routes.go index ff26e9f..1317e33 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -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") diff --git a/internal/logic/admin/log/getErrorLogMessageDetailLogic.go b/internal/logic/admin/log/getErrorLogMessageDetailLogic.go new file mode 100644 index 0000000..85eb7b6 --- /dev/null +++ b/internal/logic/admin/log/getErrorLogMessageDetailLogic.go @@ -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 +} diff --git a/internal/logic/admin/log/getErrorLogMessageListLogic.go b/internal/logic/admin/log/getErrorLogMessageListLogic.go new file mode 100644 index 0000000..3c172ea --- /dev/null +++ b/internal/logic/admin/log/getErrorLogMessageListLogic.go @@ -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 +} diff --git a/internal/logic/admin/payment/createPaymentMethodLogic.go b/internal/logic/admin/payment/createPaymentMethodLogic.go index 23cd48d..11441c5 100644 --- a/internal/logic/admin/payment/createPaymentMethodLogic.go +++ b/internal/logic/admin/payment/createPaymentMethodLogic.go @@ -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 "" } diff --git a/internal/logic/admin/system/updateVerifyCodeConfigLogic.go b/internal/logic/admin/system/updateVerifyCodeConfigLogic.go index e089dd6..be8b1df 100644 --- a/internal/logic/admin/system/updateVerifyCodeConfigLogic.go +++ b/internal/logic/admin/system/updateVerifyCodeConfigLogic.go @@ -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 } diff --git a/internal/logic/admin/user/getUserListLogic.go b/internal/logic/admin/user/getUserListLogic.go index 0258cdc..49b27b3 100644 --- a/internal/logic/admin/user/getUserListLogic.go +++ b/internal/logic/admin/user/getUserListLogic.go @@ -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 { diff --git a/internal/logic/admin/user/updateUserBasicInfoLogic.go b/internal/logic/admin/user/updateUserBasicInfoLogic.go index faa7930..8ef0df3 100644 --- a/internal/logic/admin/user/updateUserBasicInfoLogic.go +++ b/internal/logic/admin/user/updateUserBasicInfoLogic.go @@ -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") } diff --git a/internal/logic/auth/bindDeviceLogic.go b/internal/logic/auth/bindDeviceLogic.go index 3aedd6f..6691393 100644 --- a/internal/logic/auth/bindDeviceLogic.go +++ b/internal/logic/auth/bindDeviceLogic.go @@ -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 }) diff --git a/internal/logic/auth/emailLoginLogic.go b/internal/logic/auth/emailLoginLogic.go new file mode 100644 index 0000000..8312cc2 --- /dev/null +++ b/internal/logic/auth/emailLoginLogic.go @@ -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 +} diff --git a/internal/logic/auth/resetPasswordLogic.go b/internal/logic/auth/resetPasswordLogic.go index 22db2c9..85800d7 100644 --- a/internal/logic/auth/resetPasswordLogic.go +++ b/internal/logic/auth/resetPasswordLogic.go @@ -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 diff --git a/internal/logic/auth/userLoginLogic.go b/internal/logic/auth/userLoginLogic.go index 4e6fac2..fc285eb 100644 --- a/internal/logic/auth/userLoginLogic.go +++ b/internal/logic/auth/userLoginLogic.go @@ -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) diff --git a/internal/logic/auth/userRegisterLogic.go b/internal/logic/auth/userRegisterLogic.go index 7172753..d960f6a 100644 --- a/internal/logic/auth/userRegisterLogic.go +++ b/internal/logic/auth/userRegisterLogic.go @@ -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) diff --git a/internal/logic/common/checkverificationcodelogic.go b/internal/logic/common/checkverificationcodelogic.go index 1cc5812..e0fb6cb 100644 --- a/internal/logic/common/checkverificationcodelogic.go +++ b/internal/logic/common/checkverificationcodelogic.go @@ -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() diff --git a/internal/logic/common/contactLogic.go b/internal/logic/common/contactLogic.go new file mode 100644 index 0000000..b71c539 --- /dev/null +++ b/internal/logic/common/contactLogic.go @@ -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, "_", "\\_") +} diff --git a/internal/logic/common/getdownloadlinklogic.go b/internal/logic/common/getdownloadlinklogic.go new file mode 100644 index 0000000..b710ed2 --- /dev/null +++ b/internal/logic/common/getdownloadlinklogic.go @@ -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 +} diff --git a/internal/logic/common/logMessageReportLogic.go b/internal/logic/common/logMessageReportLogic.go new file mode 100644 index 0000000..2427c18 --- /dev/null +++ b/internal/logic/common/logMessageReportLogic.go @@ -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 "" +} diff --git a/internal/logic/common/sendEmailCodeLogic.go b/internal/logic/common/sendEmailCodeLogic.go index c538d72..bfd248c 100644 --- a/internal/logic/common/sendEmailCodeLogic.go +++ b/internal/logic/common/sendEmailCodeLogic.go @@ -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") } diff --git a/internal/logic/notify/appleIAPNotifyLogic.go b/internal/logic/notify/appleIAPNotifyLogic.go new file mode 100644 index 0000000..0770db8 --- /dev/null +++ b/internal/logic/notify/appleIAPNotifyLogic.go @@ -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 + }) +} diff --git a/internal/logic/public/iap/apple/attachTransactionByIdLogic.go b/internal/logic/public/iap/apple/attachTransactionByIdLogic.go new file mode 100644 index 0000000..fbf8843 --- /dev/null +++ b/internal/logic/public/iap/apple/attachTransactionByIdLogic.go @@ -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 +} diff --git a/internal/logic/public/iap/apple/attachTransactionLogic.go b/internal/logic/public/iap/apple/attachTransactionLogic.go new file mode 100644 index 0000000..bceb8b6 --- /dev/null +++ b/internal/logic/public/iap/apple/attachTransactionLogic.go @@ -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 +} diff --git a/internal/logic/public/iap/apple/getStatusLogic.go b/internal/logic/public/iap/apple/getStatusLogic.go new file mode 100644 index 0000000..3ee6f62 --- /dev/null +++ b/internal/logic/public/iap/apple/getStatusLogic.go @@ -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 +} diff --git a/internal/logic/public/iap/apple/restoreLogic.go b/internal/logic/public/iap/apple/restoreLogic.go new file mode 100644 index 0000000..9364667 --- /dev/null +++ b/internal/logic/public/iap/apple/restoreLogic.go @@ -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 + }) +} diff --git a/internal/logic/public/order/preCreateOrderLogic.go b/internal/logic/public/order/preCreateOrderLogic.go index e0e48a4..5b3ce05 100644 --- a/internal/logic/public/order/preCreateOrderLogic.go +++ b/internal/logic/public/order/preCreateOrderLogic.go @@ -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 != "" { diff --git a/internal/logic/public/order/purchaseLogic.go b/internal/logic/public/order/purchaseLogic.go index d6ae038..95f32ed 100644 --- a/internal/logic/public/order/purchaseLogic.go +++ b/internal/logic/public/order/purchaseLogic.go @@ -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 diff --git a/internal/logic/public/order/queryOrderListLogic.go b/internal/logic/public/order/queryOrderListLogic.go index 13deaa3..206942f 100644 --- a/internal/logic/public/order/queryOrderListLogic.go +++ b/internal/logic/public/order/queryOrderListLogic.go @@ -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") diff --git a/internal/logic/public/order/renewalLogic.go b/internal/logic/public/order/renewalLogic.go index e384413..18766c7 100644 --- a/internal/logic/public/order/renewalLogic.go +++ b/internal/logic/public/order/renewalLogic.go @@ -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 diff --git a/internal/logic/public/portal/getSubscriptionLogic.go b/internal/logic/public/portal/getSubscriptionLogic.go index 51fbaa6..2e4607e 100644 --- a/internal/logic/public/portal/getSubscriptionLogic.go +++ b/internal/logic/public/portal/getSubscriptionLogic.go @@ -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 diff --git a/internal/logic/public/portal/prePurchaseOrderLogic.go b/internal/logic/public/portal/prePurchaseOrderLogic.go index b2a99ba..87eeced 100644 --- a/internal/logic/public/portal/prePurchaseOrderLogic.go +++ b/internal/logic/public/portal/prePurchaseOrderLogic.go @@ -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 != "" { diff --git a/internal/logic/public/portal/purchaseCheckoutLogic.go b/internal/logic/public/portal/purchaseCheckoutLogic.go index eb4c945..2370350 100644 --- a/internal/logic/public/portal/purchaseCheckoutLogic.go +++ b/internal/logic/public/portal/purchaseCheckoutLogic.go @@ -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 } diff --git a/internal/logic/public/portal/purchaseLogic.go b/internal/logic/public/portal/purchaseLogic.go index d588726..d8fb264 100644 --- a/internal/logic/public/portal/purchaseLogic.go +++ b/internal/logic/public/portal/purchaseLogic.go @@ -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 } diff --git a/internal/logic/public/user/bindEmailWithVerificationLogic.go b/internal/logic/public/user/bindEmailWithVerificationLogic.go new file mode 100644 index 0000000..063b270 --- /dev/null +++ b/internal/logic/public/user/bindEmailWithVerificationLogic.go @@ -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 +} diff --git a/internal/logic/public/user/bindInviteCodeLogic.go b/internal/logic/public/user/bindInviteCodeLogic.go new file mode 100644 index 0000000..ca2ab43 --- /dev/null +++ b/internal/logic/public/user/bindInviteCodeLogic.go @@ -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 +} diff --git a/internal/logic/public/user/deleteAccountLogic.go b/internal/logic/public/user/deleteAccountLogic.go new file mode 100644 index 0000000..fdc9517 --- /dev/null +++ b/internal/logic/public/user/deleteAccountLogic.go @@ -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 +} diff --git a/internal/logic/public/user/familyBindingHelper.go b/internal/logic/public/user/familyBindingHelper.go new file mode 100644 index 0000000..9f66b20 --- /dev/null +++ b/internal/logic/public/user/familyBindingHelper.go @@ -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 +} diff --git a/internal/logic/public/user/familyExitHelper.go b/internal/logic/public/user/familyExitHelper.go new file mode 100644 index 0000000..3248c10 --- /dev/null +++ b/internal/logic/public/user/familyExitHelper.go @@ -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 +} diff --git a/internal/logic/public/user/familyScopeHelper.go b/internal/logic/public/user/familyScopeHelper.go new file mode 100644 index 0000000..f1fd6eb --- /dev/null +++ b/internal/logic/public/user/familyScopeHelper.go @@ -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 +} diff --git a/internal/logic/public/user/getAgentDownloadsLogic.go b/internal/logic/public/user/getAgentDownloadsLogic.go new file mode 100644 index 0000000..e3811a7 --- /dev/null +++ b/internal/logic/public/user/getAgentDownloadsLogic.go @@ -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 +} diff --git a/internal/logic/public/user/getAgentRealtimeLogic.go b/internal/logic/public/user/getAgentRealtimeLogic.go new file mode 100644 index 0000000..47c8b2a --- /dev/null +++ b/internal/logic/public/user/getAgentRealtimeLogic.go @@ -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%" +} diff --git a/internal/logic/public/user/getDeviceListLogic.go b/internal/logic/public/user/getDeviceListLogic.go index 76722d5..0ace177 100644 --- a/internal/logic/public/user/getDeviceListLogic.go +++ b/internal/logic/public/user/getDeviceListLogic.go @@ -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{ diff --git a/internal/logic/public/user/getInviteSalesLogic.go b/internal/logic/public/user/getInviteSalesLogic.go new file mode 100644 index 0000000..50ef201 --- /dev/null +++ b/internal/logic/public/user/getInviteSalesLogic.go @@ -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 +} diff --git a/internal/logic/public/user/getSubscribeStatusLogic.go b/internal/logic/public/user/getSubscribeStatusLogic.go new file mode 100644 index 0000000..16f582a --- /dev/null +++ b/internal/logic/public/user/getSubscribeStatusLogic.go @@ -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 +} diff --git a/internal/logic/public/user/getUserInviteStatsLogic.go b/internal/logic/public/user/getUserInviteStatsLogic.go new file mode 100644 index 0000000..dc2106d --- /dev/null +++ b/internal/logic/public/user/getUserInviteStatsLogic.go @@ -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 +} diff --git a/internal/logic/public/user/queryUserInfoLogic.go b/internal/logic/public/user/queryUserInfoLogic.go index cf51020..4205a7e 100644 --- a/internal/logic/public/user/queryUserInfoLogic.go +++ b/internal/logic/public/user/queryUserInfoLogic.go @@ -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 (第二位) diff --git a/internal/logic/public/user/queryUserSubscribeLogic.go b/internal/logic/public/user/queryUserSubscribeLogic.go index 55e3770..7a1461f 100644 --- a/internal/logic/public/user/queryUserSubscribeLogic.go +++ b/internal/logic/public/user/queryUserSubscribeLogic.go @@ -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) } diff --git a/internal/logic/public/user/unbindDeviceLogic.go b/internal/logic/public/user/unbindDeviceLogic.go index c01b5e1..6081fb5 100644 --- a/internal/logic/public/user/unbindDeviceLogic.go +++ b/internal/logic/public/user/unbindDeviceLogic.go @@ -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 +} diff --git a/internal/logic/public/user/updateBindEmailLogic.go b/internal/logic/public/user/updateBindEmailLogic.go index f56ff8c..80e4729 100644 --- a/internal/logic/public/user/updateBindEmailLogic.go +++ b/internal/logic/public/user/updateBindEmailLogic.go @@ -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", diff --git a/internal/logic/public/user/verifyEmailLogic.go b/internal/logic/public/user/verifyEmailLogic.go index 4d48df1..89ebd30 100644 --- a/internal/logic/public/user/verifyEmailLogic.go +++ b/internal/logic/public/user/verifyEmailLogic.go @@ -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) diff --git a/internal/middleware/deviceMiddleware.go b/internal/middleware/deviceMiddleware.go index cd91a58..db5c18d 100644 --- a/internal/middleware/deviceMiddleware.go +++ b/internal/middleware/deviceMiddleware.go @@ -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() diff --git a/internal/model/auth/auth.go b/internal/model/auth/auth.go index f85070c..ffeab4c 100644 --- a/internal/model/auth/auth.go +++ b/internal/model/auth/auth.go @@ -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 == "" { diff --git a/internal/model/iap/apple/default.go b/internal/model/iap/apple/default.go new file mode 100644 index 0000000..3311c37 --- /dev/null +++ b/internal/model/iap/apple/default.go @@ -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 +} + diff --git a/internal/model/iap/apple/transaction.go b/internal/model/iap/apple/transaction.go new file mode 100644 index 0000000..51bf729 --- /dev/null +++ b/internal/model/iap/apple/transaction.go @@ -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" +} + diff --git a/internal/model/logmessage/default.go b/internal/model/logmessage/default.go new file mode 100644 index 0000000..46b876a --- /dev/null +++ b/internal/model/logmessage/default.go @@ -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 +} diff --git a/internal/model/logmessage/entity.go b/internal/model/logmessage/entity.go new file mode 100644 index 0000000..233e3d9 --- /dev/null +++ b/internal/model/logmessage/entity.go @@ -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" } diff --git a/internal/model/logmessage/model.go b/internal/model/logmessage/model.go new file mode 100644 index 0000000..217bfaf --- /dev/null +++ b/internal/model/logmessage/model.go @@ -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 +} diff --git a/internal/model/node/model.go b/internal/model/node/model.go index f5d9eb2..4647ddb 100644 --- a/internal/model/node/model.go +++ b/internal/model/node/model.go @@ -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 { diff --git a/internal/model/payment/model.go b/internal/model/payment/model.go index e273ca3..d8d0de1 100644 --- a/internal/model/payment/model.go +++ b/internal/model/payment/model.go @@ -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 +} diff --git a/internal/model/payment/payment.go b/internal/model/payment/payment.go index ad2f046..f08d3e8 100644 --- a/internal/model/payment/payment.go +++ b/internal/model/payment/payment.go @@ -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) +} diff --git a/internal/model/user/device.go b/internal/model/user/device.go index b1194a7..a318e3e 100644 --- a/internal/model/user/device.go +++ b/internal/model/user/device.go @@ -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 { diff --git a/internal/model/user/family.go b/internal/model/user/family.go new file mode 100644 index 0000000..e5b752b --- /dev/null +++ b/internal/model/user/family.go @@ -0,0 +1,49 @@ +package user + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + FamilyStatusActive uint8 = 1 + FamilyRoleOwner uint8 = 1 + FamilyRoleMember uint8 = 2 + FamilyMemberActive uint8 = 1 + FamilyMemberLeft uint8 = 2 + FamilyMemberRemoved uint8 = 3 + DefaultFamilyMaxSize int64 = 5 +) + +type UserFamily struct { + Id int64 `gorm:"primaryKey"` + OwnerUserId int64 `gorm:"uniqueIndex:uniq_owner_user_id;not null;comment:Owner User ID"` + MaxMembers int64 `gorm:"not null;default:5;comment:Max members in family"` + Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Status: 1=active, 0=disabled"` + CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:Deletion Time"` +} + +func (*UserFamily) TableName() string { + return "user_family" +} + +type UserFamilyMember struct { + Id int64 `gorm:"primaryKey"` + FamilyId int64 `gorm:"index:idx_family_status,priority:1;not null;comment:Family ID"` + UserId int64 `gorm:"uniqueIndex:uniq_user_id;not null;comment:Member User ID"` + Role uint8 `gorm:"type:tinyint(1);not null;default:2;comment:Role: 1=owner, 2=member"` + Status uint8 `gorm:"index:idx_family_status,priority:2;type:tinyint(1);not null;default:1;comment:Status: 1=active, 2=left, 3=removed"` + JoinSource string `gorm:"type:varchar(32);not null;default:'';comment:Join source"` + JoinedAt time.Time `gorm:"not null;comment:Joined time"` + LeftAt *time.Time `gorm:"default:NULL;comment:Left time"` + CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:Deletion Time"` +} + +func (*UserFamilyMember) TableName() string { + return "user_family_member" +} diff --git a/internal/model/user/model.go b/internal/model/user/model.go index 944869c..47707e5 100644 --- a/internal/model/user/model.go +++ b/internal/model/user/model.go @@ -99,6 +99,7 @@ type customUserLogicModel interface { FindOneByEmail(ctx context.Context, email string) (*User, error) FindOneDevice(ctx context.Context, id int64) (*Device, error) QueryDeviceList(ctx context.Context, userid int64) ([]*Device, int64, error) + QueryDeviceListByUserIds(ctx context.Context, userIds []int64) ([]*Device, int64, error) QueryDevicePageList(ctx context.Context, userid, subscribeId int64, page, size int) ([]*Device, int64, error) UpdateDevice(ctx context.Context, data *Device, tx ...*gorm.DB) error FindOneDeviceByIdentifier(ctx context.Context, id string) (*Device, error) @@ -110,6 +111,13 @@ type customUserLogicModel interface { QueryDailyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error) QueryMonthlyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error) + FindActiveSubscribe(ctx context.Context, userId int64) (*Subscribe, error) + FindActiveSubscribesByUserIds(ctx context.Context, userIds []int64) (map[int64]*UserStatusInfo, error) +} + +type UserStatusInfo struct { + MemberStatus string + LastTrafficAt *time.Time } type UserStatisticsWithDate struct { @@ -334,3 +342,17 @@ func (m *customUserModel) QueryMonthlyUserStatisticsList(ctx context.Context, da return results, err } + +// FindActiveSubscribe finds the active subscription for a user +func (m *customUserModel) FindActiveSubscribe(ctx context.Context, userId int64) (*Subscribe, error) { + var subscribe Subscribe + err := m.QueryNoCacheCtx(ctx, &subscribe, func(conn *gorm.DB, v interface{}) error { + return conn.Where("user_id = ? AND status IN (0, 1) AND expire_time > ?", userId, time.Now()). + Order("expire_time DESC"). + First(v).Error + }) + if err != nil { + return nil, err + } + return &subscribe, nil +} diff --git a/internal/model/user/model_ext.go b/internal/model/user/model_ext.go new file mode 100644 index 0000000..2f4f84e --- /dev/null +++ b/internal/model/user/model_ext.go @@ -0,0 +1,45 @@ +package user + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +// FindActiveSubscribesByUserIds Find active subscriptions for multiple users +func (m *customUserModel) FindActiveSubscribesByUserIds(ctx context.Context, userIds []int64) (map[int64]*UserStatusInfo, error) { + if len(userIds) == 0 { + return map[int64]*UserStatusInfo{}, nil + } + + type Result struct { + UserId int64 + Name string + UpdatedAt *time.Time + } + var results []Result + + // Query latest active subscription for each user + err := m.QueryNoCacheCtx(ctx, &results, func(conn *gorm.DB, v interface{}) error { + return conn.Table("user_subscribe"). + Select("user_subscribe.user_id, subscribe.name, user_subscribe.updated_at"). + Joins("LEFT JOIN subscribe ON user_subscribe.subscribe_id = subscribe.id"). + Where("user_subscribe.user_id IN ? AND user_subscribe.status IN (0, 1) AND user_subscribe.expire_time > ?", userIds, time.Now()). + Order("user_subscribe.created_at ASC"). // Ascending so we can overwrite in map to get the latest + Scan(v).Error + }) + + if err != nil { + return nil, err + } + + userMap := make(map[int64]*UserStatusInfo) + for _, r := range results { + userMap[r.UserId] = &UserStatusInfo{ + MemberStatus: r.Name, + LastTrafficAt: r.UpdatedAt, + } + } + return userMap, nil +} diff --git a/internal/model/user/subscribe.go b/internal/model/user/subscribe.go index 362fb99..d9c14e5 100644 --- a/internal/model/user/subscribe.go +++ b/internal/model/user/subscribe.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/perfect-panel/server/pkg/constant" "gorm.io/gorm" ) @@ -74,18 +75,29 @@ func (m *defaultUserModel) FindUsersSubscribeBySubscribeId(ctx context.Context, // QueryUserSubscribe returns a list of records that meet the conditions. func (m *defaultUserModel) QueryUserSubscribe(ctx context.Context, userId int64, status ...int64) ([]*SubscribeDetails, error) { var list []*SubscribeDetails - key := fmt.Sprintf("%s%d", cacheUserSubscribeUserPrefix, userId) + + // Check if includeExpired is set in context + includeExpired, _ := ctx.Value(constant.CtxKeyIncludeExpired).(string) + cacheKeySuffix := "" + if includeExpired == "all" { + cacheKeySuffix = ":all" + } + + key := fmt.Sprintf("%s%d%s", cacheUserSubscribeUserPrefix, userId, cacheKeySuffix) err := m.QueryCtx(ctx, &list, key, func(conn *gorm.DB, v interface{}) error { - // 获取当前时间 - now := time.Now() - // 获取当前时间向前推 7 天 - sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour) - // 基础条件查询 conn = conn.Model(&Subscribe{}).Where("`user_id` = ?", userId) if len(status) > 0 { conn = conn.Where("`status` IN ?", status) } - // 订阅过期时间大于当前时间或者订阅结束时间大于当前时间 + + if includeExpired == "all" { + // 查询所有订阅(包括已过期的) + return conn.Preload("Subscribe").Find(&list).Error + } + + // 默认只查询有效订阅 + now := time.Now() + sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour) return conn.Where("`expire_time` > ? OR `finished_at` >= ? OR `expire_time` = ?", now, sevenDaysAgo, time.UnixMilli(0)). Preload("Subscribe"). Find(&list).Error diff --git a/internal/model/user/user.go b/internal/model/user/user.go index 9077c87..5ac50d9 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -28,6 +28,9 @@ type User struct { AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"` UserDevices []Device `gorm:"foreignKey:UserId;references:Id"` Rules string `gorm:"type:TEXT;comment:User Rules"` + LastLoginTime *time.Time `gorm:"default:NULL;comment:Last Login Time"` + MemberStatus string `gorm:"type:varchar(20);default:'';comment:Member Status"` + Remark string `gorm:"type:varchar(255);default:'';comment:Remark"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` UpdatedAt time.Time `gorm:"comment:Update Time"` DeletedAt gorm.DeletedAt `gorm:"index;comment:Deletion Time"` diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index b78bea6..df4356b 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -15,7 +15,9 @@ import ( "github.com/perfect-panel/server/internal/model/auth" "github.com/perfect-panel/server/internal/model/coupon" "github.com/perfect-panel/server/internal/model/document" + iapapple "github.com/perfect-panel/server/internal/model/iap/apple" "github.com/perfect-panel/server/internal/model/log" + logmessage "github.com/perfect-panel/server/internal/model/logmessage" "github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/payment" "github.com/perfect-panel/server/internal/model/subscribe" @@ -45,6 +47,7 @@ type ServiceContext struct { AuthModel auth.Model AdsModel ads.Model LogModel log.Model + LogMessageModel logmessage.Model NodeModel node.Model UserModel user.Model OrderModel order.Model @@ -60,6 +63,7 @@ type ServiceContext struct { SubscribeModel subscribe.Model TrafficLogModel traffic.Model AnnouncementModel announcement.Model + IAPAppleTransactionModel iapapple.Model Restart func() error TelegramBot *tgbotapi.BotAPI @@ -116,6 +120,7 @@ func NewServiceContext(c config.Config) *ServiceContext { AuthLimiter: authLimiter, AdsModel: ads.NewModel(db, rds), LogModel: log.NewModel(db), + LogMessageModel: logmessage.NewModel(db), NodeModel: node.NewModel(db, rds), AuthModel: auth.NewModel(db, rds), UserModel: user.NewModel(db, rds), @@ -133,6 +138,7 @@ func NewServiceContext(c config.Config) *ServiceContext { TrafficLogModel: traffic.NewModel(db), AnnouncementModel: announcement.NewModel(db, rds), } + srv.IAPAppleTransactionModel = iapapple.NewModel(db, rds) srv.DeviceManager = NewDeviceManager(srv) return srv diff --git a/internal/types/types.go b/internal/types/types.go index 091e0b5..f7e9f60 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -229,6 +229,7 @@ type CheckoutOrderResponse struct { Type string `json:"type"` CheckoutUrl string `json:"checkout_url,omitempty"` Stripe *StripePayment `json:"stripe,omitempty"` + ProductIds []string `json:"product_ids,omitempty"` } type CloseOrderRequest struct { @@ -1651,8 +1652,10 @@ type QueryOrderDetailRequest struct { } type QueryOrderListRequest struct { - Page int `form:"page" validate:"required"` - Size int `form:"size" validate:"required"` + Page int `form:"page" validate:"required"` + Size int `form:"size" validate:"required"` + Status int `form:"status"` + Search string `form:"search"` } type QueryOrderListResponse struct { @@ -2145,6 +2148,7 @@ type Subscribe struct { 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"` @@ -2640,6 +2644,7 @@ type User struct { GiftAmount int64 `json:"gift_amount"` Telegram int64 `json:"telegram"` ReferCode string `json:"refer_code"` + ShareLink string `json:"share_link,omitempty"` RefererId int64 `json:"referer_id"` Enable bool `json:"enable"` IsAdmin bool `json:"is_admin,omitempty"` @@ -2654,6 +2659,8 @@ type User struct { UpdatedAt int64 `json:"updated_at"` DeletedAt int64 `json:"deleted_at,omitempty"` IsDel bool `json:"is_del,omitempty"` + LastLoginTime int64 `json:"last_login_time"` + MemberStatus string `json:"member_status"` } type UserAffiliate struct { @@ -2741,6 +2748,7 @@ type UserSubscribe struct { Token string `json:"token"` Status uint8 `json:"status"` Short string `json:"short"` + IsGift bool `json:"is_gift"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } @@ -2916,3 +2924,221 @@ type WithdrawalLog struct { CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } + +type EmailLoginRequest struct { + Identifier string `json:"identifier"` + Email string `json:"email" validate:"required"` + Code string `json:"code" validate:"required"` + Invite string `json:"invite,optional"` + IP string `header:"X-Original-Forwarded-For"` + UserAgent string `header:"User-Agent"` + LoginType string `header:"Login-Type"` + CfToken string `json:"cf_token,optional"` +} + +type BindEmailWithVerificationRequest struct { + Email string `json:"email" validate:"required"` + Code string `json:"code" validate:"required"` +} + +type BindEmailWithVerificationResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Token string `json:"token,omitempty"` + UserId int64 `json:"user_id,omitempty"` + FamilyJoined bool `json:"family_joined,omitempty"` + FamilyId int64 `json:"family_id,omitempty"` + OwnerUserId int64 `json:"owner_user_id,omitempty"` +} + +type BindInviteCodeRequest struct { + InviteCode string `json:"invite_code" validate:"required"` +} + +type GetSubscribeStatusResponse struct { + DeviceStatus bool `json:"device_status"` + EmailStatus bool `json:"email_status"` +} + +type GetSubscribeStatusRequest struct { + Email string `json:"email" validate:"required,email"` +} + +type DeleteAccountResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + UserId int64 `json:"user_id,omitempty"` + Code int `json:"code,omitempty"` +} + +type GetDownloadLinkRequest struct { + InviteCode string `form:"invite_code,optional"` + Platform string `form:"platform" validate:"required,oneof=windows mac ios android"` +} + +type GetDownloadLinkResponse struct { + Url string `json:"url"` +} + +type ContactRequest struct { + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required,email"` + OtherContact string `json:"other_contact,omitempty"` + Notes string `json:"notes,omitempty"` +} + +type ReportLogMessageRequest struct { + Platform string `json:"platform" validate:"required"` + AppVersion string `json:"appVersion"` + OsName string `json:"osName"` + OsVersion string `json:"osVersion"` + DeviceId string `json:"deviceId"` + UserId int64 `json:"userId"` + SessionId string `json:"sessionId"` + Level uint8 `json:"level"` + ErrorCode string `json:"errorCode"` + Message string `json:"message" validate:"required"` + Stack string `json:"stack"` + Context map[string]interface{} `json:"context"` + OccurredAt int64 `json:"occurredAt"` +} + +type ReportLogMessageResponse struct { + Id int64 `json:"id"` +} + +type GetErrorLogMessageListRequest struct { + Page int `form:"page"` + Size int `form:"size"` + Platform string `form:"platform"` + Level uint8 `form:"level"` + UserId int64 `form:"user_id"` + DeviceId string `form:"device_id"` + ErrorCode string `form:"error_code"` + Keyword string `form:"keyword"` + Start int64 `form:"start"` + End int64 `form:"end"` +} + +type ErrorLogMessage struct { + Id int64 `json:"id"` + Platform string `json:"platform"` + AppVersion string `json:"app_version"` + OsName string `json:"os_name"` + OsVersion string `json:"os_version"` + DeviceId string `json:"device_id"` + UserId int64 `json:"user_id"` + SessionId string `json:"session_id"` + Level uint8 `json:"level"` + ErrorCode string `json:"error_code"` + Message string `json:"message"` + CreatedAt int64 `json:"created_at"` +} + +type GetErrorLogMessageListResponse struct { + Total int64 `json:"total"` + List []ErrorLogMessage `json:"list"` +} + +type GetErrorLogMessageDetailResponse struct { + Id int64 `json:"id"` + Platform string `json:"platform"` + AppVersion string `json:"app_version"` + OsName string `json:"os_name"` + OsVersion string `json:"os_version"` + DeviceId string `json:"device_id"` + UserId int64 `json:"user_id"` + SessionId string `json:"session_id"` + Level uint8 `json:"level"` + ErrorCode string `json:"error_code"` + Message string `json:"message"` + Stack string `json:"stack"` + Context map[string]interface{} `json:"context"` + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Locale string `json:"locale"` + OccurredAt int64 `json:"occurred_at"` + CreatedAt int64 `json:"created_at"` +} + +type AttachAppleTransactionRequest struct { + SignedTransactionJWS string `json:"signed_transaction_jws" validate:"required"` + DurationDays int64 `json:"duration_days,omitempty"` + Tier string `json:"tier,omitempty"` + SubscribeId int64 `json:"subscribe_id,omitempty"` + OrderNo string `json:"order_no,omitempty"` +} + +type AttachAppleTransactionResponse struct { + ExpiresAt int64 `json:"expires_at"` + Tier string `json:"tier"` +} + +type AttachAppleTransactionByIdRequest struct { + TransactionId string `json:"transaction_id" validate:"required"` + OrderNo string `json:"order_no" validate:"required"` + Sandbox *bool `json:"sandbox,omitempty"` +} + +type RestoreAppleTransactionsRequest struct { + Transactions []string `json:"transactions" validate:"required"` +} + +type GetAppleStatusResponse struct { + Active bool `json:"active"` + ExpiresAt int64 `json:"expires_at"` + Tier string `json:"tier"` +} + +type GetAgentRealtimeRequest struct{} + +type GetAgentRealtimeResponse struct { + Total int64 `json:"total"` + Clicks int64 `json:"clicks"` + Views int64 `json:"views"` + Installs int64 `json:"installs"` + PaidCount int64 `json:"paid_count"` + GrowthRate string `json:"growth_rate"` + PaidGrowthRate string `json:"paid_growth_rate"` +} + +type GetAgentDownloadsRequest struct{} + +type GetAgentDownloadsResponse struct { + Total int64 `json:"total"` + Platforms *PlatformDownloads `json:"platforms"` + ComparisonRate *string `json:"comparison_rate,omitempty"` +} + +type PlatformDownloads struct { + IOS int64 `json:"ios"` + Android int64 `json:"android"` + Windows int64 `json:"windows"` + Mac int64 `json:"mac"` +} + +type GetUserInviteStatsRequest struct{} + +type GetUserInviteStatsResponse struct { + FriendlyCount int64 `json:"friendly_count"` + HistoryCount int64 `json:"history_count"` +} + +type GetInviteSalesRequest struct { + Page int `form:"page" validate:"required"` + Size int `form:"size" validate:"required"` + StartTime int64 `form:"start_time,optional"` + EndTime int64 `form:"end_time,optional"` +} + +type GetInviteSalesResponse struct { + Total int64 `json:"total"` + List []InvitedUserSale `json:"list"` +} + +type InvitedUserSale struct { + Amount float64 `json:"amount"` + UpdatedAt int64 `json:"update_at"` + UserHash string `json:"user_hash"` + ProductName string `json:"product_name"` +} diff --git a/pkg/conf/default.go b/pkg/conf/default.go index 9c6af19..ed8aa90 100644 --- a/pkg/conf/default.go +++ b/pkg/conf/default.go @@ -56,6 +56,10 @@ func parseDefaultValue(kind reflect.Kind, defaultValue string) any { var i uint32 _, _ = fmt.Sscanf(defaultValue, "%d", &i) return i + case reflect.Float64: + var f float64 + _, _ = fmt.Sscanf(defaultValue, "%f", &f) + return f default: fmt.Printf("类型 %v 没有处理, 值为: %v \n", kind, defaultValue) panic("unhandled default case") diff --git a/pkg/constant/context.go b/pkg/constant/context.go index b137584..1c368d4 100644 --- a/pkg/constant/context.go +++ b/pkg/constant/context.go @@ -3,11 +3,14 @@ package constant type CtxKey string const ( - CtxKeyUser CtxKey = "user" - CtxKeySessionID CtxKey = "sessionId" - CtxKeyRequestHost CtxKey = "requestHost" - CtxKeyPlatform CtxKey = "platform" - CtxKeyPayment CtxKey = "payment" - CtxLoginType CtxKey = "loginType" - CtxKeyIdentifier CtxKey = "identifier" + CtxKeyUser CtxKey = "user" + CtxKeySessionID CtxKey = "sessionId" + CtxKeyRequestHost CtxKey = "requestHost" + CtxKeyPlatform CtxKey = "platform" + CtxKeyPayment CtxKey = "payment" + CtxLoginType CtxKey = "loginType" + LoginType CtxKey = "loginType" + CtxKeyIdentifier CtxKey = "identifier" + CtxKeyDeviceID CtxKey = "deviceId" + CtxKeyIncludeExpired CtxKey = "includeExpired" ) diff --git a/pkg/constant/types.go b/pkg/constant/types.go index a2db39b..9c7014b 100644 --- a/pkg/constant/types.go +++ b/pkg/constant/types.go @@ -15,6 +15,8 @@ type VerifyType uint8 const ( Register VerifyType = iota + 1 Security + _ + DeleteAccount ) func ParseVerifyType(i uint8) VerifyType { @@ -27,6 +29,8 @@ func (v VerifyType) String() string { return "register" case Security: return "security" + case DeleteAccount: + return "delete_account" default: return "unknown" } diff --git a/pkg/email/template.go b/pkg/email/template.go index 5d2bd5b..00121a0 100644 --- a/pkg/email/template.go +++ b/pkg/email/template.go @@ -1,111 +1,71 @@ package email const ( - DefaultEmailVerifyTemplate = ` - - - - - - {{if eq .Type 1}}注册验证码 / Registration Verification Code{{else}}重置密码验证码 / Password - Reset Verification Code{{end}} - - - - -
-
- -

{{.SiteName}}

-
-
-

Hi, 尊敬的用户 / Dear User

-

- {{if eq .Type 1}} 感谢您注册!您的验证码是(请于{{.Expire}}分钟内使用): -
- Thank you for registering! Your verification code is (please use it within - {{.Expire}} minutes): {{else}} - 您正在重置密码。您的验证码是(请于{{.Expire}}分钟内使用): -
- You are resetting your password. Your verification code is (please use it within - {{.Expire}} minutes): {{end}} -

-
- {{.Code}} -
-

- 如果您未请求此验证码,请忽略此邮件。
If you did not request this code, please ignore - this email. -

-
- -
- + DefaultEmailVerifyTemplate = ` + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ {{.SiteName}} top-Logo +
+

+ 注销验证 +

+

+ 亲爱的用户, + + 我们收到了你在{{.SiteName}}的注销请求 + 请在系统提示时输入以下验证码: +

+ +
+ {{.Code}} +
+ +

+ 该验证码将在 {{.Expire}} 分钟 后过期。 + + 如果这不是你本人操作,请忽略本邮件,你的账户将保持安全。 + + 谢谢, + {{.SiteName}}团队 +

+
+ {{.SiteName}} bottom-Logo +
+
+ ` DefaultMaintenanceEmailTemplate = ` @@ -114,7 +74,7 @@ const ( - 系统维护通知 / System Maintenance Notice + 系统维护通知