Compare commits
30 Commits
51765c794a
...
20260113
| Author | SHA1 | Date | |
|---|---|---|---|
| e5e60f73c2 | |||
| 14489b6afd | |||
| d45f4417ed | |||
| 7b33ab6e2a | |||
| 93c4d7b7d1 | |||
| 16b4300354 | |||
| c4f327562f | |||
| d0a3b36791 | |||
| ef64a876cd | |||
| 55c778b65b | |||
| b10d0d22e1 | |||
| 657c2930b1 | |||
| 5598181a48 | |||
| 4ffccd5ad8 | |||
| fd185bcfe1 | |||
| 9bf09c4b9a | |||
| b3edd7e2a6 | |||
| e42a5b80bf | |||
| 74f4a12422 | |||
| 2fdc9c8127 | |||
| e98709b511 | |||
| 5d7ca4b9bd | |||
| 9944ab7b8a | |||
| 041417a177 | |||
| d3541a89ae | |||
| d8f5628bb1 | |||
| 40a45199a5 | |||
| 5bc453b09f | |||
| 680951611f | |||
| ceb3b16dc5 |
@@ -1,151 +1,112 @@
|
||||
## 背景与现状
|
||||
- 技术栈:Go + gin 路由(internal/handler/routes.go)、GORM + MySQL(internal/svc/serviceContext.go)。
|
||||
- 现有日志:系统统一写入 `system_logs`(internal/model/log/log.go),并通过 `LogModel.FilterSystemLog` 提供查询(internal/model/log/model.go)。管理端已存在日志查询路由(internal/handler/routes.go:188-236)。
|
||||
- 新需求:新增专用表 `log_message`,用于 APP/PC/Web 客户端错误日志采集,避免与原有“消息发送/业务日志”混用(降低查询噪声、明确字段)。
|
||||
## 目标
|
||||
|
||||
## SQL 表设计(MySQL)
|
||||
- 表名:`log_message`
|
||||
- 目的:采集终端错误与异常信息,便于筛选、定位、汇总。
|
||||
- 字段:
|
||||
- `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
|
||||
- `platform` VARCHAR(32) NOT NULL(如 `android`/`ios`/`windows`/`mac`/`web`)
|
||||
- `app_version` VARCHAR(32) NULL
|
||||
- `os_name` VARCHAR(32) NULL(如 `Android`、`iOS`、`Windows`、`macOS`)
|
||||
- `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(1=fatal 2=error 3=warn 4=info)
|
||||
- `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(Web/PC 端可用)
|
||||
- `locale` VARCHAR(16) NULL(如 zh-CN、en-US)
|
||||
- `digest` VARCHAR(64) NULL(去重指纹:message+stack+error_code+app_version+platform 的哈希)
|
||||
- `occurred_at` DATETIME NULL(客户端发生时间)
|
||||
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP(服务端入库时间)
|
||||
- 索引:
|
||||
- `idx_platform_time(platform, created_at)`
|
||||
- `idx_user_time(user_id, created_at)`
|
||||
- `idx_device_time(device_id, created_at)`
|
||||
- `idx_error_code(error_code)`
|
||||
- `uniq_digest(digest)`(可选唯一,避免大量重复日志)
|
||||
- 迁移:
|
||||
- 新增:`initialize/migrate/database/02105_log_message.up.sql`
|
||||
- 回滚:`initialize/migrate/database/02105_log_message.down.sql`
|
||||
- DDL 示例:
|
||||
```
|
||||
CREATE TABLE `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;
|
||||
```
|
||||
* 不使用自动续期订阅;采用“非续期订阅”或“非消耗型”作为内购模式。
|
||||
|
||||
## 接口设计(采集)
|
||||
- 路径:`POST /v1/common/log/message/report`
|
||||
- 路由分组:`/v1/common`(复用 DeviceMiddleware,见 internal/handler/routes.go:625-655)
|
||||
- 鉴权:可匿名;若已登录从上下文解析 `user_id` 注入。启用 IP/设备维度的限流(Redis),避免刷量。
|
||||
- 请求体(JSON):
|
||||
- `platform` string 必填
|
||||
- `appVersion` string 可选
|
||||
- `osName` string 可选
|
||||
- `osVersion` string 可选
|
||||
- `deviceId` string 可选
|
||||
- `userId` number 可选(客户端不可信,以服务端解析为准)
|
||||
- `sessionId` string 可选
|
||||
- `level` number 可选(默认 3)
|
||||
- `errorCode` string 可选
|
||||
- `message` string 必填
|
||||
- `stack` string 可选
|
||||
- `context` object 可选
|
||||
- `occurredAt` number 可选(毫秒时间戳)
|
||||
- 返回(统一封装):`{ "code": 0, "msg": "OK", "data": { "id": 123 } }`(参考 pkg/result/httpResult.go)
|
||||
- 处理逻辑:
|
||||
- 从请求头解析 UA 与 IP 注入 `client_ip`、`user_agent`;校验字段长度与大小(如 `stack` 限制 1MB)。
|
||||
- 计算 `digest`(如 `sha256(message|stack|errorCode|appVersion|platform)`),若唯一索引冲突则返回已存在的 ID 或静默忽略(防重复)。
|
||||
- 使用 GORM 入库至 `log_message`。
|
||||
* 仅实现 Go 后端 API;客户端(iOS/StoreKit 2)按说明调用。
|
||||
|
||||
## 接口设计(管理端查询)
|
||||
- 列表:`GET /v1/admin/log/message/error/list`
|
||||
- 筛选:`platform`、`level`、`userId`、`deviceId`、`errorCode`、`keyword`(匹配 `message`/`stack`/`context`)、`start`/`end`(时间范围)
|
||||
- 分页:`page`、`size`
|
||||
- 详情:`GET /v1/admin/log/message/error/detail?id=...`
|
||||
- 路由分组:`/v1/admin/log`(与现有日志保持一致,见 internal/handler/routes.go:188-236)
|
||||
- 返回:列表返回 `total` 与 `list`(含核心字段),详情返回全部字段。
|
||||
## 产品模型
|
||||
|
||||
## 数据模型与方法(GORM 设计)
|
||||
- 目录:`internal/model/logmessage/`
|
||||
- 结构:
|
||||
- `type LogMessage struct { ... }`(字段与上表对应,`TableName() string { return "log_message" }`)
|
||||
- 接口:
|
||||
- `Insert(ctx context.Context, data *LogMessage) error`
|
||||
- `Filter(ctx context.Context, params *FilterParams) ([]*LogMessage, int64, error)`(支持多维筛选、分页、关键字模糊)
|
||||
- `FindOne(ctx context.Context, id int64) (*LogMessage, error)`
|
||||
- 逻辑:仿照现有 `internal/model/log/default.go` 与 `model.go` 的模式(组合 `defaultModel` + `customModel`),保证代码一致性。
|
||||
* 非续期订阅:固定时长通行证(如 30/90/365 天),产品ID:`com.airport.vpn.pass.30d|90d|365d`。
|
||||
|
||||
## 防护与合规
|
||||
- 限流:按 `device_id`、`client_ip` 维度做分钟/小时限流(重用 Redis,internal/svc/serviceContext.go 已初始化)。
|
||||
- 安全:
|
||||
- 敏感信息剔除(避免在 `context`/`stack` 中泄露密钥/密码)
|
||||
- 大字段截断与压缩策略(超限截断,或服务端配置开关)
|
||||
- 隐私:遵循最小化原则,不采集不必要的 PII;`user_id` 仅在登录上下文中由服务端注入。
|
||||
* 非消耗型(可选):一次性解锁某附加功能,产品ID:`com.airport.vpn.addon.xyz`。
|
||||
|
||||
## 客户端上报示例
|
||||
- 示例请求:
|
||||
```
|
||||
POST /v1/common/log/message/report
|
||||
{
|
||||
"platform": "android",
|
||||
"appVersion": "1.2.3",
|
||||
"osName": "Android",
|
||||
"osVersion": "14",
|
||||
"deviceId": "a1b2c3",
|
||||
"level": 2,
|
||||
"errorCode": "NETWORK_TIMEOUT",
|
||||
"message": "请求超时:/api/order/list",
|
||||
"stack": "TimeoutException at...",
|
||||
"context": { "api": "/api/order/list", "retry": 1 },
|
||||
"occurredAt": 1733145600000
|
||||
}
|
||||
```
|
||||
* 服务器以 `productId→权益/时长` 进行配置映射。
|
||||
|
||||
## 迁移与回滚
|
||||
- 新增 `02105_log_message.up.sql`:创建表与索引。
|
||||
- 回滚 `02105_log_message.down.sql`:`DROP TABLE IF EXISTS log_message;`
|
||||
## 后端API设计(Go/Gin)
|
||||
|
||||
## 与现有日志体系的关系
|
||||
- 管理端查询保持独立路由,避免与现有 `message/list`(邮件/短信发送日志)混淆(internal/handler/routes.go:207-213)。
|
||||
- 可选加写 `system_logs` 一条摘要(`Type` 新增 `TypeClientError`),用于仪表盘总览;但核心数据以 `log_message` 为准。
|
||||
* 路由注册:`internal/handler/routes.go`
|
||||
|
||||
* `GET /api/iap/apple/products`:返回前端展示的产品清单(含总价/描述/时长映射)
|
||||
|
||||
* `POST /api/iap/apple/transactions/attach`:绑定一次购买到用户账户(需登录)。入参:`signedTransactionJWS`
|
||||
|
||||
* `POST /api/iap/apple/restore`:恢复购买(批量接收 JWS 列表并绑定)
|
||||
|
||||
* `GET /api/iap/apple/status`:返回用户当前权益与到期时间(统一来源聚合)
|
||||
|
||||
* 逻辑目录:`internal/logic/iap/apple/*`
|
||||
|
||||
* `AttachTransactionLogic`:解析 JWS→校验 `bundleId/productId/purchaseDate`→根据 `productId` 映射权益与时长→更新订阅统一表
|
||||
|
||||
* `RestoreLogic`:对所有已购记录执行绑定去重(基于 `original_transaction_id`)
|
||||
|
||||
* `QueryStatusLogic`:聚合各来源订阅,返回有效权益(取最近到期/最高等级)
|
||||
|
||||
* 工具包:`pkg/iap/apple`
|
||||
|
||||
* `ParseTransactionJWS`:解析 JWS,提取 `transactionId/originalTransactionId/productId/purchaseDate/revocationDate`
|
||||
|
||||
* `VerifyBasic`:基础校验(`bundleId`、签名头部与证书链存在性);如客户端已 `transaction.verify()`,可采用“信任+服务器最小校验”的模式快速落地
|
||||
|
||||
* 配置:`doc/config-zh.md`
|
||||
|
||||
* `IAP_PRODUCT_MAP`:`productId → tier/duration`(例如:`30d→+30天`、`addon→解锁功能X`)
|
||||
|
||||
* `APPLE_IAP_BUNDLE_ID`:用于 JWS 内部校验
|
||||
|
||||
## 数据模型
|
||||
|
||||
* 新表:`apple_iap_transactions`
|
||||
|
||||
* `id`、`user_id`、`original_transaction_id`(唯一)、`transaction_id`、`product_id`、`purchase_at`、`revocation_at`、`jws_hash`
|
||||
|
||||
* 统一订阅表增强(现有 `SubscribeModel`)
|
||||
|
||||
* 新增来源:`source=apple_iap`、`external_id=original_transaction_id`、`tier`、`expires_at`
|
||||
|
||||
* 索引:`original_transaction_id` 唯一、`user_id+source`、`expires_at`
|
||||
|
||||
## 与现有系统融合
|
||||
|
||||
* `internal/svc/serviceContext.go`:初始化 IAP 模块与模型
|
||||
|
||||
* `QueryPurchaseOrderLogic/SubscribeModel`:聚合苹果IAP来源;冲突策略:按最高权益与最晚到期。
|
||||
|
||||
* 不产生命令行支付订单,仅记录订阅流水与审计(避免与 Stripe 等混淆)。
|
||||
|
||||
## 安全与合规
|
||||
|
||||
* 仅显示商店在可支付时;价格、描述清晰;使用系统确认表单。
|
||||
|
||||
* 服务器进行最小校验:`bundleId`、`productId`白名单、`purchaseDate`有效性;保存 `jws_hash` 做去重。
|
||||
|
||||
* 退款:在 App 内提供“请求退款”的帮助页并使用系统接口触发;后端无需额外API。
|
||||
|
||||
## 客户端使用说明(StoreKit 2)
|
||||
|
||||
* 产品拉取与展示:
|
||||
|
||||
* 通过已知 `productId` 列表调用 `Product.products(for:)`;展示总价与描述,检查 `canMakePayments`
|
||||
|
||||
* 购买:
|
||||
|
||||
* 调用 `purchase()`,系统确认表单弹出→返回 `Transaction`;执行 `await transaction.verify()`
|
||||
|
||||
* 成功后将 `transaction.signedData` POST 到 `/api/iap/apple/transactions/attach`
|
||||
|
||||
* 恢复:
|
||||
|
||||
* 调用 `Transaction.currentEntitlements`,遍历并验证每条 `Transaction`,将其 `signedData` 批量 POST 到 `/api/iap/apple/restore`
|
||||
|
||||
* 状态显示:
|
||||
|
||||
* 访问 `GET /api/iap/apple/status` 获取到期时间与权益用于 UI 展示
|
||||
|
||||
* 退款入口:
|
||||
|
||||
* 在购买帮助页直接使用 `beginRefundRequest(for:in:)`;文案简洁,按钮直达
|
||||
|
||||
## 测试与验收
|
||||
|
||||
* 单元测试:JWS 解析、`productId→权益/时长` 映射、去重策略。
|
||||
|
||||
* 集成测试:绑定/恢复接口鉴权与幂等、统一订阅查询结果。
|
||||
|
||||
* 沙盒:使用 iOS 沙盒购买与恢复;记录审计与日志。
|
||||
|
||||
## 里程碑
|
||||
|
||||
1. 基础能力:`products/status` 与 `transactions/attach` 落地
|
||||
2. 恢复与融合:`restore` + 统一订阅聚合
|
||||
3. 上线前验证:沙盒测试与文案、监控
|
||||
|
||||
## 后续实现要点
|
||||
- 路由注册:`/v1/common/log/message/report` 与 `/v1/admin/log/message/error/*`。
|
||||
- 校验与限流中间件:复用现有 `DeviceMiddleware` 与 Redis。
|
||||
- 单元测试:
|
||||
- 入库成功/去重冲突/字段截断
|
||||
- 多维筛选与分页
|
||||
- 文档:在项目说明文档补充采集字段、接口规范与数据保留策略。
|
||||
@@ -0,0 +1,44 @@
|
||||
# 用户管理系统优化方案 (最终确认版)
|
||||
|
||||
根据您的要求,我们将重点实现 `last_login_time` 字段的存储与返回,以及在列表接口中聚合会员套餐信息。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 1. 数据库变更
|
||||
- **文件**: `initialize/migrate/database/02121_add_user_last_login_time.up.sql`
|
||||
- **内容**:
|
||||
```sql
|
||||
ALTER TABLE user ADD COLUMN last_login_time DATETIME DEFAULT NULL COMMENT 'Last Login Time';
|
||||
```
|
||||
- **说明**: 相比查询日志表,直接在用户表增加字段能极大提高列表页查询性能。
|
||||
|
||||
### 2. API 定义更新
|
||||
- **文件**: `apis/types.api`
|
||||
- **内容**: 修改 `User` 结构体,增加以下返回字段:
|
||||
- `last_login_time` (int64): 最后活跃时间戳。
|
||||
- `member_status` (string): 会员状态(显示当前生效的订阅套餐名称,无订阅显示空或特定标识)。
|
||||
|
||||
### 3. 后端模型与逻辑更新
|
||||
#### 3.1 User 模型更新
|
||||
- **文件**: `internal/model/user/user.go`
|
||||
- **内容**: `User` 结构体增加 `LastLoginTime *time.Time` 字段。
|
||||
|
||||
#### 3.2 登录逻辑更新 (记录活跃时间)
|
||||
- **文件**: `internal/logic/auth/userLoginLogic.go` (及其他登录逻辑如 `emailLoginLogic.go`)
|
||||
- **内容**: 在登录成功后,异步或同步更新当前用户的 `last_login_time`。
|
||||
|
||||
#### 3.3 用户列表逻辑更新 (数据聚合)
|
||||
- **文件**: `internal/logic/admin/user/getUserListLogic.go`
|
||||
- **内容**:
|
||||
1. **获取用户列表**: 包含新增的 `LastLoginTime` 数据。
|
||||
2. **批量查询订阅**: 根据当前页的用户 ID 列表,批量查询其**活跃订阅** (Active Subscription)。
|
||||
3. **数据组装**:
|
||||
- 将 `LastLoginTime` 转换为时间戳返回。
|
||||
- 将订阅的 `Name` (套餐名) 赋值给 `member_status`。
|
||||
|
||||
### 4. 文档更新
|
||||
- **文件**: `doc/说明文档.md`
|
||||
- **内容**: 更新进度记录,标记完成“最后活跃”与“会员状态”字段开发。
|
||||
|
||||
## 验证与交付
|
||||
- 提供 `curl` 验证命令,确认 `/v1/admin/user/list` 接口返回的 JSON 中包含 `last_login_time` 和 `member_status`。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 最后上线时间字段显示修复计划
|
||||
|
||||
## 问题分析
|
||||
用户反馈调用 `curl` 接口后,返回的 JSON 中没有看到“最后上线时间” (`last_login_time`) 字段。
|
||||
|
||||
**原因可能如下:**
|
||||
1. **字段被 `omitempty` 隐藏**: 在 `internal/types/types.go` 中,`LastLoginTime` 字段定义为 `json:"last_login_time,omitempty"`。这意味着如果值为 `0`,该字段在 JSON 序列化时会被忽略,不返回给前端。
|
||||
2. **数据确实为空**: 用户从未登录过,且没有活跃订阅产生的流量记录,导致计算出的 `LastLoginTime` 为 `0`。
|
||||
3. **数据库迁移未生效**: 虽然我们之前修复了迁移文件冲突,但如果数据库中旧的迁移记录未清理或新字段 `last_login_time` 未真正添加成功,会导致数据读取失败(但此时通常会报错,而非字段缺失)。
|
||||
|
||||
## 解决方案
|
||||
为了确保接口始终返回该字段(即使是 0),我们需要移除 `omitempty` 标签,或者确认前端能处理缺失该字段的情况。考虑到用户明确要求“没看到”,建议移除 `omitempty`,让其显式返回 `0` 或时间戳。
|
||||
|
||||
同时,我们通过 SQL 检查数据库结构,确保字段已存在。
|
||||
|
||||
## 实施步骤
|
||||
1. **修改 API 定义**:
|
||||
- 文件: `internal/types/types.go` (及 `apis/types.api` 如果需要重新生成代码,但直接改 go 文件更快捷验证)
|
||||
- 操作: 将 `LastLoginTime int64 json:"last_login_time,omitempty"` 修改为 `json:"last_login_time"` (移除 `omitempty`)。
|
||||
- 同理处理 `MemberStatus` 字段。
|
||||
|
||||
2. **验证数据库字段**:
|
||||
- 使用 SQL 工具或日志确认 `user` 表中是否存在 `last_login_time` 列。
|
||||
|
||||
3. **验证接口**:
|
||||
- 再次调用 `curl`,确认即使值为 0 也会返回字段。
|
||||
|
||||
## 补充
|
||||
如果用户是指“有数据但没显示”,那可能是登录逻辑或流量更新逻辑未触发。但首要步骤是让字段显式返回,以便排查是“无数据”还是“字段被隐藏”。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 迁移文件重复问题修复计划
|
||||
|
||||
## 问题分析
|
||||
根据终端日志报错 `panic: failed to init driver with path database: duplicate migration file: 02121_apple_iap_transactions.down.sql`,系统启动失败的原因是存在**重复的迁移版本号**。
|
||||
|
||||
在 `initialize/migrate/database/` 目录下,存在两个版本号相同的迁移文件:
|
||||
1. `02121_add_user_last_login_time.up.sql` (我们刚刚创建的)
|
||||
2. `02121_apple_iap_transactions.up.sql` (已存在的)
|
||||
|
||||
由于 `golang-migrate` 要求版本号必须唯一,这两个文件都使用了 `02121` 前缀,导致冲突。
|
||||
|
||||
## 解决方案
|
||||
将我们新创建的 `add_user_last_login_time` 迁移文件的版本号递增为 `02122`。
|
||||
|
||||
## 实施步骤
|
||||
1. **重命名迁移文件**:
|
||||
- `02121_add_user_last_login_time.up.sql` -> `02122_add_user_last_login_time.up.sql`
|
||||
- `02121_add_user_last_login_time.down.sql` -> `02122_add_user_last_login_time.down.sql`
|
||||
|
||||
2. **验证**:
|
||||
- 确认目录下不再有重复前缀的文件。
|
||||
- 建议用户重新运行程序。
|
||||
|
||||
## 补充说明
|
||||
此操作仅涉及文件重命名,不修改文件内容,风险极低。
|
||||
@@ -0,0 +1,111 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name hifastapp.com www.hifastapp.com www.hifastvpn.com hifastvpn.com hifast.biz www.hifast.biz;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
# 统一 HTTP 转 HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name hifastvpn.com www.hifastvpn.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/hifastvpn.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/hifastvpn.com/privkey.pem; # managed by Certbot
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
root /var/www/down;
|
||||
index index.html index.htm;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass https://api.hifast.biz/;
|
||||
proxy_set_header Host api.hifast.biz;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /download/ {
|
||||
autoindex_exact_size off;
|
||||
autoindex_localtime on;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name hifastapp.com www.hifastapp.com;
|
||||
|
||||
# 使用 -0001 的新证书(通常包含 www)
|
||||
ssl_certificate /etc/letsencrypt/live/hifastapp.com-0001/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/hifastapp.com-0001/privkey.pem; # managed by Certbot
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
root /var/www/down;
|
||||
index index.html index.htm;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass https://api.hifast.biz/;
|
||||
proxy_set_header Host api.hifast.biz;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /download/ {
|
||||
autoindex_exact_size off;
|
||||
autoindex_localtime on;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name hifast.biz www.hifast.biz;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/hifast.biz/hifast.biz.cer;
|
||||
ssl_certificate_key /etc/letsencrypt/live/hifast.biz/hifast.biz.key;
|
||||
|
||||
root /var/www/lp;
|
||||
index index.html index.htm;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,16 @@ type (
|
||||
LoginType string `header:"Login-Type"`
|
||||
CfToken string `json:"cf_token,optional"`
|
||||
}
|
||||
EmailLoginRequest {
|
||||
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"`
|
||||
}
|
||||
LoginResponse {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
@@ -141,6 +151,10 @@ service ppanel {
|
||||
@handler CheckUser
|
||||
get /check (CheckUserRequest) returns (CheckUserResponse)
|
||||
|
||||
@doc "Email Login"
|
||||
@handler EmailLogin
|
||||
post /login/email (EmailLoginRequest) returns (LoginResponse)
|
||||
|
||||
@doc "User register"
|
||||
@handler UserRegister
|
||||
post /register (UserRegisterRequest) returns (LoginResponse)
|
||||
|
||||
+10
-1
@@ -87,6 +87,12 @@ type (
|
||||
Total int64 `json:"total"`
|
||||
List []SubscribeClient `json:"list"`
|
||||
}
|
||||
ContactRequest {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
OtherContact string `json:"other_contact,optional"`
|
||||
Notes string `json:"notes,optional"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@@ -99,6 +105,10 @@ service ppanel {
|
||||
@handler GetGlobalConfig
|
||||
get /site/config returns (GetGlobalConfigResponse)
|
||||
|
||||
@doc "Submit contact info"
|
||||
@handler SubmitContact
|
||||
post /contact (ContactRequest)
|
||||
|
||||
@doc "Get Tos Content"
|
||||
@handler GetTos
|
||||
get /site/tos returns (GetTosResponse)
|
||||
@@ -131,4 +141,3 @@ service ppanel {
|
||||
@handler GetClient
|
||||
get /client returns (GetSubscribeClientResponse)
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -26,6 +26,8 @@ type (
|
||||
EnableLoginNotify bool `json:"enable_login_notify"`
|
||||
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
||||
EnableTradeNotify bool `json:"enable_trade_notify"`
|
||||
LastLoginTime int64 `json:"last_login_time"`
|
||||
MemberStatus string `json:"member_status"`
|
||||
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
||||
UserDevices []UserDevice `json:"user_devices"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
@@ -632,8 +634,10 @@ type (
|
||||
PublishableKey string `json:"publishable_key"`
|
||||
}
|
||||
QueryOrderListRequest {
|
||||
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 uint8 `form:"status,omitempty"`
|
||||
Search string `form:"search,omitempty"`
|
||||
}
|
||||
QueryOrderListResponse {
|
||||
Total int64 `json:"total"`
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
"github.com/perfect-panel/server/pkg/service"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/trace"
|
||||
"github.com/perfect-panel/server/queue"
|
||||
"github.com/perfect-panel/server/scheduler"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -49,6 +50,7 @@ var startCmd = &cobra.Command{
|
||||
func run() {
|
||||
services := getServers()
|
||||
defer services.Stop()
|
||||
defer trace.StopAgent()
|
||||
go services.Start()
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
|
||||
@@ -89,6 +91,9 @@ func getServers() *service.Group {
|
||||
logger.Errorf("Logger setup failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// init trace
|
||||
trace.StartAgent(c.Trace)
|
||||
|
||||
// init service context
|
||||
ctx := svc.NewServiceContext(c)
|
||||
services := service.NewServiceGroup()
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 配置区域 - 请在此处填入您的真实信息进行测试
|
||||
const (
|
||||
// 必填:您的 Key ID (从 App Store Connect 获取)
|
||||
KeyID = "2C4X3HVPM8"
|
||||
|
||||
// 必填:您的 Issuer ID (从 App Store Connect 获取,通常是一个 UUID)
|
||||
IssuerID = "34f54810-5118-4b7f-8069-c8c1e012b7a9" // 请替换为您真实的 Issuer ID
|
||||
|
||||
// 必填:您的 Bundle ID (App 的包名)
|
||||
BundleID = "com.taw.hifastvpn" // 请替换为您真实的 Bundle ID
|
||||
|
||||
// 必填:用于测试的 Transaction ID (任意一个真实的交易 ID)
|
||||
TestTransactionID = "2000001083318819"
|
||||
|
||||
// 必填:是否为沙盒环境
|
||||
IsSandbox = true
|
||||
)
|
||||
|
||||
// P8 私钥内容 (硬编码用于测试)
|
||||
const PrivateKeyPEM = `-----BEGIN PRIVATE KEY-----
|
||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsVDj0g/D7uNCm8aC
|
||||
E4TuaiDT4Pgb1IuuZ69YdGNvcAegCgYIKoZIzj0DAQehRANCAARObgGumaESbPMM
|
||||
SIRDAVLcWemp0fMlnfDE4EHmqcD58arEJWsr3aWEhc4BHocOUIGjko0cVWGchrFa
|
||||
/T/KG1tr
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
func main() {
|
||||
log.Println("开始测试 Apple IAP API 连接...")
|
||||
log.Printf("环境: %v (Sandbox=%v)\n", func() string {
|
||||
if IsSandbox {
|
||||
return "沙盒 (Sandbox)"
|
||||
}
|
||||
return "生产 (Production)"
|
||||
}(), IsSandbox)
|
||||
log.Printf("KeyID: %s\n", KeyID)
|
||||
log.Printf("IssuerID: %s\n", IssuerID)
|
||||
log.Printf("BundleID: %s\n", BundleID)
|
||||
log.Printf("TransactionID: %s\n", TestTransactionID)
|
||||
|
||||
token, err := buildAPIToken()
|
||||
if err != nil {
|
||||
log.Fatalf("生成 JWT Token 失败: %v", err)
|
||||
}
|
||||
log.Println("JWT Token 生成成功")
|
||||
|
||||
// 发起请求
|
||||
host := "https://api.storekit.itunes.apple.com"
|
||||
if IsSandbox {
|
||||
host = "https://api.storekit-sandbox.itunes.apple.com"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/inApps/v1/transactions/%s", host, TestTransactionID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
log.Printf("正在请求: %s", url)
|
||||
start := time.Now()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
duration := time.Since(start)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
log.Printf("请求耗时: %v", duration)
|
||||
log.Printf("状态码: %d", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
log.Println("✅ 测试成功!API 调用正常。")
|
||||
log.Printf("响应内容: %s", string(body))
|
||||
} else {
|
||||
log.Println("❌ 测试失败!")
|
||||
log.Printf("错误响应: %s", string(body))
|
||||
if resp.StatusCode == 401 {
|
||||
log.Println("原因分析: 401 Unauthorized 通常表示:")
|
||||
log.Println("1. Key ID 或 Issuer ID 错误")
|
||||
log.Println("2. Bundle ID 不匹配")
|
||||
log.Println("3. 私钥错误")
|
||||
log.Println("4. Token 格式错误 (如算法或 Claims)")
|
||||
} else if resp.StatusCode == 404 {
|
||||
log.Println("原因分析: 404 Not Found 通常表示 Transaction ID 不存在或环境(沙盒/生产)选错了")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下面是复制过来的工具函数
|
||||
func buildAPIToken() (string, error) {
|
||||
header := map[string]interface{}{
|
||||
"alg": "ES256",
|
||||
"kid": KeyID,
|
||||
"typ": "JWT",
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
payload := map[string]interface{}{
|
||||
"iss": IssuerID,
|
||||
"iat": now,
|
||||
"exp": now + 60, // 测试 Token 有效期短一点即可
|
||||
"aud": "appstoreconnect-v1",
|
||||
}
|
||||
if BundleID != "" {
|
||||
payload["bid"] = BundleID
|
||||
}
|
||||
|
||||
hb, _ := json.Marshal(header)
|
||||
pb, _ := json.Marshal(payload)
|
||||
|
||||
enc := func(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
unsigned := fmt.Sprintf("%s.%s", enc(hb), enc(pb))
|
||||
|
||||
block, _ := pem.Decode([]byte(PrivateKeyPEM))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("invalid private key")
|
||||
}
|
||||
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
priv, ok := keyAny.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("private key is not ECDSA")
|
||||
}
|
||||
|
||||
digest := sha256Sum([]byte(unsigned))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, priv, digest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
curveBits := priv.Curve.Params().BitSize
|
||||
keyBytes := curveBits / 8
|
||||
if curveBits%8 > 0 {
|
||||
keyBytes += 1
|
||||
}
|
||||
rBytes := r.Bytes()
|
||||
rBytesPadded := make([]byte, keyBytes)
|
||||
copy(rBytesPadded[keyBytes-len(rBytes):], rBytes)
|
||||
|
||||
sBytes := s.Bytes()
|
||||
sBytesPadded := make([]byte, keyBytes)
|
||||
copy(sBytesPadded[keyBytes-len(sBytes):], sBytes)
|
||||
|
||||
sig := append(rBytesPadded, sBytesPadded...)
|
||||
return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
func sha256Sum(b []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(b)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pkgaes "github.com/perfect-panel/server/pkg/aes"
|
||||
)
|
||||
|
||||
// 替换为您实际的服务器地址
|
||||
const BaseURL = "https://api.hifast.biz"
|
||||
|
||||
// 替换为您实际的用户登录 Token (Authorization: Bearer <token>)
|
||||
const UserToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJEZXZpY2VJZCI6MzgzLCJMb2dpblR5cGUiOiJkZXZpY2UiLCJTZXNzaW9uSWQiOiIwMTliMmFmZC1jMjUwLTc1YmItODQzMy04NDMyNWVmZGRkMzMiLCJVc2VySWQiOjM4MywiZXhwIjoxNzY2NTU3NjMyLCJpYXQiOjE3NjU5NTI4MzJ9.kkcT4ojXG9qn_aVqMaGqUUXhHcZXHy49k5Vn05Et9OM"
|
||||
|
||||
// 替换为您在后台配置的设备通信密钥 (Security Secret)
|
||||
const DeviceSecret = "c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx"
|
||||
|
||||
// 替换为您要测试的 Transaction ID
|
||||
const TestTransactionID = "2000001083238483"
|
||||
|
||||
func main() {
|
||||
fmt.Println("开始测试 Restore 接口 (AES 加密模式)...")
|
||||
fmt.Printf("目标 Transaction ID: %s\n", TestTransactionID)
|
||||
|
||||
// 1. 构造原始请求数据
|
||||
payload := map[string]interface{}{
|
||||
"transactions": []string{TestTransactionID},
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
fmt.Printf("原始请求体: %s\n", string(payloadBytes))
|
||||
|
||||
// 2. 加密数据
|
||||
if DeviceSecret == "YOUR_DEVICE_SECRET_HERE" {
|
||||
log.Fatal("❌ 请在代码中设置 DeviceSecret (对应后台配置的 Security Secret)")
|
||||
}
|
||||
encryptedData, iv, err := pkgaes.Encrypt(payloadBytes, DeviceSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
|
||||
// 3. 构造最终的请求体 (符合 DeviceMiddleware 要求的格式)
|
||||
// DeviceMiddleware 期望的格式是: { "data": "Base64Cipher", "time": "Nonce/IV" }
|
||||
// 或者直接在 URL Query 中传 ?data=...&time=...
|
||||
// 这里我们模拟 POST JSON body 的方式
|
||||
finalPayload := map[string]interface{}{
|
||||
"data": encryptedData,
|
||||
"time": iv,
|
||||
}
|
||||
finalBytes, _ := json.Marshal(finalPayload)
|
||||
fmt.Printf("加密后请求体: %s\n", string(finalBytes))
|
||||
|
||||
url := fmt.Sprintf("%s/v1/public/iap/apple/restore", BaseURL)
|
||||
req, _ := http.NewRequest("POST", url, strings.NewReader(string(finalBytes)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 添加必要的 Header 以通过 DeviceMiddleware
|
||||
req.Header.Set("Login-Type", "device") // 触发 DeviceMiddleware 的解密逻辑
|
||||
// 注意:这里需要替换为真实有效的 Bearer Token,否则会报 401
|
||||
// 您可以先登录后台或者使用 cmd/test_apple_iap 工具生成的 token 也是不可用的,必须是业务系统的 token
|
||||
// 为了演示,这里留空,实际运行前请填入
|
||||
if UserToken != "YOUR_USER_TOKEN_HERE" {
|
||||
req.Header.Set("Authorization", "Bearer "+UserToken)
|
||||
} else {
|
||||
fmt.Println("⚠️ 警告: 未设置 UserToken,请求可能会失败 (401 Unauthorized)")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
duration := time.Since(start)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
fmt.Printf("请求耗时: %v\n", duration)
|
||||
fmt.Printf("状态码: %d\n", resp.StatusCode)
|
||||
fmt.Printf("响应内容: %s\n", string(body))
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err == nil {
|
||||
// 检查业务状态码
|
||||
if code, ok := result["code"].(float64); ok && int(code) != 200 {
|
||||
fmt.Printf("❌ 业务处理失败: code=%d, msg=%s\n", int(code), result["msg"])
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ Restore 接口调用成功!")
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
if success, ok := data["success"].(bool); ok && success {
|
||||
fmt.Println(" 业务处理成功: success=true")
|
||||
} else {
|
||||
fmt.Println(" 业务处理结果未知:", data)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" 无数据返回或格式不符")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("❌ 接口调用失败")
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# 项目加解密使用说明
|
||||
|
||||
本指南介绍了 PPanel Server 项目中使用的加解密机制,主要用于设备端(Device)通信的安全保障。
|
||||
|
||||
## 1. 核心算法
|
||||
项目使用 **AES-256-CBC** 加密算法。
|
||||
- **填充方式**:PKCS7 Padding。
|
||||
- **数据编码**:Base64。
|
||||
|
||||
## 2. 密钥(Key)与初始化向量(IV)生成逻辑
|
||||
|
||||
### 2.1 密钥生成 (Key Generation)
|
||||
密钥由一个预定义的 `SecuritySecret`(简称 Secret)生成:
|
||||
1. 对 Secret 进行 **SHA-256** 哈希。
|
||||
2. 取哈希结果的前 **32 字节** 作为 AES-256 的密钥。
|
||||
|
||||
### 2.2 初始化向量生成 (IV Generation)
|
||||
IV 是动态生成的,以增强安全性:
|
||||
1. 客户端或服务端生成一个随机字符串(Nonce,通常是纳秒级时间戳)。
|
||||
2. 对 Nonce 进行 **MD5** 哈希。
|
||||
3. 将 MD5 结果(十六进制字符串)与 Secret 拼接。
|
||||
4. 对拼接后的字符串按 2.1 节的方式生成密钥逻辑处理,取结果的前 **16 字节** 作为 IV。
|
||||
|
||||
> [!NOTE]
|
||||
> 在 API 通信中,Nonce 字符串通常通过请求参数中的 `time` 字段传递。
|
||||
|
||||
## 3. 身份识别与优先顺序
|
||||
|
||||
服务端通过 `Login-Type` 来识别是否需要进行加解密逻辑(值为 `device` 时触发)。
|
||||
|
||||
### 3.1 识别途径
|
||||
1. **Token 负载 (JWT Claims)**:Token 中包含 `LoginType` (值为 `device`) 和 `DeviceId`。
|
||||
2. **请求头 (Header)**:`Login-Type: device`。
|
||||
|
||||
### 3.2 优先顺序与场景
|
||||
- **已登录场景**:服务端优先从 **Token** 负载中读取 `LoginType`。如果 Token 合法且包含 `LoginType: device`,则启用加解密。
|
||||
- **未登录/登录中场景**:例如 `/v1/auth/login/device` 接口,由于此时没有有效 Token,服务端会检查 **Header** 中的 `Login-Type`。
|
||||
|
||||
> [!TIP]
|
||||
> 为了确保一致性,建议在设备端请求中**始终**携带 `Login-Type: device` 请求头,并在登录后确保存储的 Token 负载中也包含对应信息。
|
||||
|
||||
## 4. 中间件应用 (DeviceMiddleware)
|
||||
|
||||
|
||||
`DeviceMiddleware` 处理 `Login-Type: device` 的请求:
|
||||
- **请求解密**:
|
||||
- 检查 URL 参数或 JSON Body 中的 `data`(加密数据)和 `time`(Nonce)。
|
||||
- 使用配置的 Secret 和 Nonce 解密 `data`。
|
||||
- 将解密后的 JSON 重新注入到请求上下文中。
|
||||
- **响应加密**:
|
||||
- 拦截响应 Body。
|
||||
- 加密 Body 中的 `data` 字段。
|
||||
- 将响应格式化为:
|
||||
```json
|
||||
{
|
||||
"data": "ENCRYPTED_BASE64_STRING",
|
||||
"time": "NONCE_STRING"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Token 负载详情 (JWT Payload)
|
||||
当 `Login-Type` 为 `device` 时,JWT Token 会包含以下自定义字段:
|
||||
- `LoginType`: `"device"`
|
||||
- `DeviceId`: 设备的数据库唯一 ID。
|
||||
|
||||
## 6. 代码示例
|
||||
|
||||
|
||||
### Go 语言 (服务端)
|
||||
参考 [pkg/aes/aes.go](file:///Users/Apple/vpn/ppanel-server/pkg/aes/aes.go)
|
||||
|
||||
```go
|
||||
import pkgaes "github.com/perfect-panel/server/pkg/aes"
|
||||
|
||||
// 加密
|
||||
encrypt, nonce, err := pkgaes.Encrypt([]byte("plain text"), secret)
|
||||
|
||||
// 解密
|
||||
decrypt, err := pkgaes.Decrypt(cipherText, secret, nonce)
|
||||
```
|
||||
|
||||
### JavaScript (客户端示例)
|
||||
使用 `crypto-js` 库:
|
||||
|
||||
```javascript
|
||||
const CryptoJS = require("crypto-js");
|
||||
|
||||
function getIv(nonce, secret) {
|
||||
const md5Nonce = CryptoJS.MD5(nonce).toString();
|
||||
const ivStr = md5Nonce + secret;
|
||||
const key = CryptoJS.SHA256(ivStr);
|
||||
return CryptoJS.enc.Hex.parse(key.toString().substring(0, 32));
|
||||
}
|
||||
|
||||
function getKey(secret) {
|
||||
const key = CryptoJS.SHA256(secret);
|
||||
return CryptoJS.enc.Hex.parse(key.toString().substring(0, 64));
|
||||
}
|
||||
|
||||
// 加密示例
|
||||
const key = getKey(secret);
|
||||
const iv = getIv(nonce, secret);
|
||||
const encrypted = CryptoJS.AES.encrypt("plain text", key, {
|
||||
iv: iv,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
console.log(encrypted.toString()); // Base64
|
||||
```
|
||||
|
||||
## 5. 安全建议
|
||||
- 请务必在配置文件中修改默认的 `SecuritySecret`。
|
||||
- 确保 `time` (Nonce) 在每次请求时都是唯一的,以防止重放攻击和频率分析。
|
||||
@@ -15,6 +15,8 @@
|
||||
- 上报逻辑:`internal/logic/common/logMessageReportLogic.go`(限流、指纹去重、入库)。
|
||||
- 管理查询:`internal/logic/admin/log/getErrorLogMessageListLogic.go`、`getErrorLogMessageDetailLogic.go`。
|
||||
- 类型:`internal/types/types.go` 新增请求/响应结构。
|
||||
- 安全:详细加解密逻辑见 [加解密说明文档.md](file:///Users/Apple/vpn/ppanel-server/doc/加解密说明文档.md)。
|
||||
|
||||
|
||||
## 进度记录
|
||||
- 2025-12-02:
|
||||
@@ -23,6 +25,9 @@
|
||||
- 完成公共上报接口与限流、去重逻辑;编译验证通过。
|
||||
- 完成管理端列表与详情接口;编译验证通过。
|
||||
- 待办:根据运营需求调整限流阈值与日志保留策略。
|
||||
- 2026-01-08:
|
||||
- 完成「项目加解密使用说明」文档编写,涵盖 AES-256-CBC 实现及中间件逻辑。
|
||||
|
||||
|
||||
## 接口规范
|
||||
- 上报:`POST /v1/common/log/message/report`(详见 `doc/api/log_message_report.md`)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:latest
|
||||
container_name: jaeger
|
||||
ports:
|
||||
- "16686:16686"
|
||||
- "4317:4317"
|
||||
- "4318:4318"
|
||||
environment:
|
||||
# - SPAN_STORAGE_TYPE=elasticsearch
|
||||
# - ES_SERVER_URLS=http://elasticsearch:9200
|
||||
- LOG_LEVEL=debug
|
||||
- COLLECTOR_OTLP_ENABLED=true
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.8'
|
||||
memory: 500M
|
||||
reservations:
|
||||
cpus: '0.05'
|
||||
memory: 200M
|
||||
@@ -0,0 +1,41 @@
|
||||
# 设备移出和邀请码优化 - 验收报告
|
||||
|
||||
## 修复内容回顾
|
||||
|
||||
### 1. 设备移出后未自动退出
|
||||
- **修复点 1**:在 `bindEmailWithVerificationLogic.go` 中,当设备从一个用户迁移到另一个用户(如绑定邮箱时),立即调用 `KickDevice` 踢出原用户的 WebSocket 连接。
|
||||
- **修复点 2**:在设备迁移时,清理了 Redis 中的设备缓存和 Session 缓存,并从 `user_sessions` 集合中移除了 Session ID。
|
||||
- **修复点 3**:在 `unbindDeviceLogic.go` 中,解绑设备时补充了 `user_sessions` 集合的清理逻辑,确保 Session 被完全移除。
|
||||
|
||||
### 2. 邀请码错误提示不友好
|
||||
- **修复点**:在 `bindInviteCodeLogic.go` 中,捕获 `gorm.ErrRecordNotFound` 错误,并返回错误码 `20009` (InviteCodeError) 和提示 "无邀请码"。
|
||||
|
||||
---
|
||||
|
||||
## 验证结果
|
||||
|
||||
### 自动化验证
|
||||
- [x] 代码编译通过 (`go build ./...`)
|
||||
- [x] 静态检查通过
|
||||
|
||||
### 场景验证(逻辑推演)
|
||||
|
||||
**场景 1:设备B绑定邮箱后被移除**
|
||||
1. 设备B绑定邮箱,执行迁移逻辑。
|
||||
2. `KickDevice(originalUserId, deviceIdentifier)` 被调用 -> 设备B的 WebSocket 连接断开。
|
||||
3. Redis 中 `device:identifier` 和 `session:id` 被删除 -> Token 失效。
|
||||
4. 用户在设备A上操作移除设备B -> `unbindDeviceLogic` 执行 -> 再次尝试踢出和清理(防御性)。
|
||||
5. **结果**:设备B立即离线且无法继续使用。
|
||||
|
||||
**场景 2:输入错误邀请码**
|
||||
1. 调用绑定接口, `FindOneByReferCode` 返回 `RecordNotFound`。
|
||||
2. 逻辑捕获错误,返回 `InviteCodeError`。
|
||||
3. **结果**:前端收到 20009 错误码和 "无邀请码" 提示。
|
||||
|
||||
---
|
||||
|
||||
## 遗留问题 / 注意事项
|
||||
- 无
|
||||
|
||||
## 结论
|
||||
修复已完成,符合预期。
|
||||
@@ -0,0 +1,160 @@
|
||||
# 设备管理系统 Bug 分析 - 最终确认版
|
||||
|
||||
## 场景还原
|
||||
|
||||
### 用户操作流程
|
||||
|
||||
1. **设备A** 最初通过设备登录(DeviceLogin),系统自动创建用户1 + 设备A记录
|
||||
2. **设备B** 最初也通过设备登录,系统自动创建用户2 + 设备B记录
|
||||
3. **设备A** 绑定邮箱 xxx@example.com,用户1变为"邮箱+设备"用户
|
||||
4. **设备B** 绑定**同一个邮箱** xxx@example.com
|
||||
- 系统发现邮箱已存在,执行设备转移
|
||||
- 设备B 从用户2迁移到用户1
|
||||
- 用户2被删除
|
||||
- 现在用户1拥有:设备A + 设备B + 邮箱认证
|
||||
|
||||
5. **在设备A上操作**,从设备列表移除设备B
|
||||
6. **问题**:设备B没有被踢下线,仍然能使用
|
||||
|
||||
---
|
||||
|
||||
## 数据流分析
|
||||
|
||||
### 绑定邮箱后的状态(第4步后)
|
||||
|
||||
```
|
||||
User 表:
|
||||
┌─────┬───────────────┐
|
||||
│ Id │ 用户1 │
|
||||
└─────┴───────────────┘
|
||||
|
||||
user_device 表:
|
||||
┌─────────────┬───────────┐
|
||||
│ Identifier │ UserId │
|
||||
├─────────────┼───────────┤
|
||||
│ device-a │ 用户1 │
|
||||
│ device-b │ 用户1 │ <- 设备B迁移到用户1
|
||||
└─────────────┴───────────┘
|
||||
|
||||
user_auth_methods 表:
|
||||
┌────────────┬────────────────┬───────────┐
|
||||
│ AuthType │ AuthIdentifier │ UserId │
|
||||
├────────────┼────────────────┼───────────┤
|
||||
│ device │ device-a │ 用户1 │
|
||||
│ device │ device-b │ 用户1 │
|
||||
│ email │ xxx@email.com │ 用户1 │
|
||||
└────────────┴────────────────┴───────────┘
|
||||
|
||||
DeviceManager (内存 WebSocket 连接):
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ userDevices sync.Map │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ 用户1 -> [Device{DeviceID="device-a", ...}] │
|
||||
│ 用户2 -> [Device{DeviceID="device-b", ...}] ❌ │ <- 问题!设备B的连接仍在用户2名下
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 问题根源
|
||||
|
||||
**设备B绑定邮箱时**(`bindEmailWithVerificationLogic.go`):
|
||||
- ✅ 数据库:设备B的 `UserId` 被更新为用户1
|
||||
- ❌ 内存:`DeviceManager` 中设备B的 WebSocket 连接仍然在**用户2**名下
|
||||
- ❌ 缓存:`device:device-b` -> 旧的 sessionId(可能关联用户2)
|
||||
|
||||
**解绑设备B时**(`unbindDeviceLogic.go`):
|
||||
```go
|
||||
// 第 48 行:验证设备属于当前用户
|
||||
if device.UserId != u.Id { // device.UserId=用户1, u.Id=用户1, 验证通过
|
||||
return errors.Wrapf(...)
|
||||
}
|
||||
|
||||
// 第 123 行:踢出设备
|
||||
l.svcCtx.DeviceManager.KickDevice(u.Id, identifier)
|
||||
// KickDevice(用户1, "device-b")
|
||||
```
|
||||
|
||||
**KickDevice 执行时**:
|
||||
```go
|
||||
func (dm *DeviceManager) KickDevice(userID int64, deviceID string) {
|
||||
val, ok := dm.userDevices.Load(userID) // 查找用户1的设备列表
|
||||
// 用户1的设备列表只有 device-a
|
||||
// 找不到 device-b!因为 device-b 的连接还在用户2名下
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 根本原因总结
|
||||
|
||||
| 操作 | 数据库 | DeviceManager 内存 | Redis 缓存 |
|
||||
|------|--------|-------------------|------------|
|
||||
| 设备B绑定邮箱 | ✅ 更新 UserId | ❌ 未更新 | ❌ 未清理 |
|
||||
| 解绑设备B | ✅ 创建新用户 | ❌ 找不到设备 | ✅ 尝试清理 |
|
||||
|
||||
**核心问题**:设备绑定邮箱(转移用户)时,没有更新 `DeviceManager` 中的连接归属。
|
||||
|
||||
---
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 方案1:在绑定邮箱时踢出旧连接(推荐)
|
||||
|
||||
在 `bindEmailWithVerificationLogic.go` 迁移设备后,踢出设备的旧连接:
|
||||
|
||||
```go
|
||||
// 迁移设备到邮箱用户后
|
||||
for _, device := range devices {
|
||||
// 更新设备归属
|
||||
device.UserId = emailUserId
|
||||
err = l.svcCtx.UserModel.UpdateDevice(l.ctx, device)
|
||||
|
||||
// 新增:踢出旧连接(使用原用户ID)
|
||||
l.svcCtx.DeviceManager.KickDevice(u.Id, device.Identifier)
|
||||
}
|
||||
```
|
||||
|
||||
### 方案2:在解绑时遍历所有用户查找设备
|
||||
|
||||
修改 `KickDevice` 或 `unbindDeviceLogic` 逻辑,不依赖用户ID查找设备。
|
||||
|
||||
### 方案3:清理 Redis 缓存使旧 Token 失效
|
||||
|
||||
确保设备转移后,旧的 session 和 device 缓存被清理:
|
||||
|
||||
```go
|
||||
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, device.Identifier)
|
||||
if sessionId, _ := l.svcCtx.Redis.Get(ctx, deviceCacheKey).Result(); sessionId != "" {
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
l.svcCtx.Redis.Del(ctx, deviceCacheKey, sessionIdCacheKey)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 推荐修复策略
|
||||
|
||||
**双管齐下**:
|
||||
|
||||
1. **修复 `bindEmailWithVerificationLogic.go`**:
|
||||
- 设备转移后立即踢出旧连接
|
||||
- 清理旧用户的缓存
|
||||
|
||||
2. **修复 `unbindDeviceLogic.go`**(防御性编程):
|
||||
- 补充 `user_sessions` 清理逻辑(参考 `deleteUserDeviceLogic.go`)
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `internal/logic/public/user/bindEmailWithVerificationLogic.go` | 设备转移后踢出旧连接 |
|
||||
| `internal/logic/public/user/unbindDeviceLogic.go` | 补充 user_sessions 清理 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 设备B绑定邮箱后,设备B的旧连接被踢出
|
||||
2. 从设备A解绑设备B后,设备B立即被踢下线
|
||||
3. 设备B的 Token 失效,无法继续调用 API
|
||||
@@ -0,0 +1,117 @@
|
||||
# 设备移出和邀请码优化 - 共识文档(更新版)
|
||||
|
||||
## 需求概述
|
||||
|
||||
修复两个 Bug:
|
||||
1. **Bug 1**:设备B绑定邮箱后被从设备A移除,设备B没有被踢下线
|
||||
2. **Bug 2**:输入不存在的邀请码时,提示信息不友好
|
||||
|
||||
---
|
||||
|
||||
## Bug 1:设备移出后未自动退出
|
||||
|
||||
### 根本原因
|
||||
|
||||
设备B绑定邮箱(迁移到邮箱用户)时:
|
||||
- ✅ 数据库更新了设备的 `UserId`
|
||||
- ❌ `DeviceManager` 内存中设备B的 WebSocket 连接仍在**原用户**名下
|
||||
- ❌ Redis 缓存中设备B的 session 未被清理
|
||||
|
||||
解绑设备B时,`KickDevice(用户1, "device-b")` 在用户1的设备列表中找不到 device-b(因为连接还在原用户名下)。
|
||||
|
||||
### 修复方案
|
||||
|
||||
**文件1:`bindEmailWithVerificationLogic.go`**
|
||||
|
||||
在设备迁移后,踢出旧连接并清理缓存:
|
||||
|
||||
```go
|
||||
// 第 139-158 行之后添加
|
||||
for _, device := range devices {
|
||||
device.UserId = emailUserId
|
||||
err = l.svcCtx.UserModel.UpdateDevice(l.ctx, device)
|
||||
// ...existing code...
|
||||
|
||||
// 新增:踢出旧连接并清理缓存
|
||||
l.svcCtx.DeviceManager.KickDevice(u.Id, device.Identifier)
|
||||
|
||||
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, device.Identifier)
|
||||
if sessionId, _ := l.svcCtx.Redis.Get(l.ctx, deviceCacheKey).Result(); sessionId != "" {
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, deviceCacheKey).Err()
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey).Err()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**文件2:`unbindDeviceLogic.go`**(防御性修复)
|
||||
|
||||
补充 `user_sessions` 清理逻辑,与 `deleteUserDeviceLogic.go` 保持一致:
|
||||
|
||||
```go
|
||||
// 第 118-122 行,补充 sessionsKey 清理
|
||||
if sessionId, rerr := l.svcCtx.Redis.Get(ctx, deviceCacheKey).Result(); rerr == nil && sessionId != "" {
|
||||
_ = l.svcCtx.Redis.Del(ctx, deviceCacheKey).Err()
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(ctx, sessionIdCacheKey).Err()
|
||||
// 新增:清理 user_sessions
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, device.UserId)
|
||||
_ = l.svcCtx.Redis.ZRem(ctx, sessionsKey, sessionId).Err()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bug 2:邀请码错误提示不友好
|
||||
|
||||
### 根本原因
|
||||
|
||||
`bindInviteCodeLogic.go` 中未区分"邀请码不存在"和"数据库错误"。
|
||||
|
||||
### 修复方案
|
||||
|
||||
```go
|
||||
// 第 44-47 行修改为
|
||||
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())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件汇总
|
||||
|
||||
| 文件 | 修改类型 | 优先级 |
|
||||
|------|----------|--------|
|
||||
| `internal/logic/public/user/bindEmailWithVerificationLogic.go` | 核心修复 | 高 |
|
||||
| `internal/logic/public/user/unbindDeviceLogic.go` | 防御性修复 | 中 |
|
||||
| `internal/logic/public/user/bindInviteCodeLogic.go` | Bug 修复 | 中 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
### Bug 1 验收
|
||||
- [ ] 设备B绑定邮箱后,设备B的旧 Token 失效
|
||||
- [ ] 设备B绑定邮箱后,设备B的 WebSocket 连接被断开
|
||||
- [ ] 在设备A上移除设备B后,设备B立即被踢下线
|
||||
- [ ] 设备B无法继续使用旧 Token 调用 API
|
||||
|
||||
### Bug 2 验收
|
||||
- [ ] 输入不存在的邀请码时,返回错误码 20009
|
||||
- [ ] 错误消息显示"无邀请码"
|
||||
|
||||
---
|
||||
|
||||
## 验证计划
|
||||
|
||||
1. **编译验证**:`go build ./...`
|
||||
2. **手动测试**:
|
||||
- 设备B绑定邮箱 → 检查是否被踢下线
|
||||
- 设备A移除设备B → 检查设备B是否被踢下线
|
||||
- 输入无效邀请码 → 检查错误提示
|
||||
@@ -0,0 +1,96 @@
|
||||
# 设备移出和邀请码优化 - 设计文档
|
||||
|
||||
## 整体架构
|
||||
|
||||
本次修复涉及两个独立的 bug,不需要修改架构,只需要修改具体的业务逻辑层代码。
|
||||
|
||||
### 组件关系图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "用户请求"
|
||||
A[客户端] --> B[HTTP Handler]
|
||||
end
|
||||
|
||||
subgraph "业务逻辑层"
|
||||
B --> C[unbindDeviceLogic]
|
||||
B --> D[bindInviteCodeLogic]
|
||||
end
|
||||
|
||||
subgraph "服务层"
|
||||
C --> E[DeviceManager.KickDevice]
|
||||
D --> F[UserModel.FindOneByReferCode]
|
||||
end
|
||||
|
||||
subgraph "数据层"
|
||||
E --> G[WebSocket连接管理]
|
||||
F --> H[GORM/数据库]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模块详细设计
|
||||
|
||||
### 模块1: UnbindDeviceLogic 修复
|
||||
|
||||
#### 当前数据流
|
||||
```
|
||||
1. 用户请求解绑设备
|
||||
2. 验证设备属于当前用户 (device.UserId == u.Id) ✅
|
||||
3. 事务中:创建新用户,迁移设备
|
||||
4. 调用 KickDevice(u.Id, identifier) ❌ <-- 用户ID错误
|
||||
```
|
||||
|
||||
#### 修复后数据流
|
||||
```
|
||||
1. 用户请求解绑设备
|
||||
2. 验证设备属于当前用户 ✅
|
||||
3. 保存原始用户ID: originalUserId := device.UserId ✅
|
||||
4. 事务中:创建新用户,迁移设备
|
||||
5. 调用 KickDevice(originalUserId, identifier) ✅ <-- 使用正确的用户ID
|
||||
```
|
||||
|
||||
#### 接口契约
|
||||
无变化,仅修改内部实现。
|
||||
|
||||
---
|
||||
|
||||
### 模块2: BindInviteCodeLogic 修复
|
||||
|
||||
#### 当前错误处理
|
||||
```go
|
||||
if err != nil {
|
||||
return xerr.DatabaseQueryError // 所有错误统一处理
|
||||
}
|
||||
```
|
||||
|
||||
#### 修复后错误处理
|
||||
```go
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.InviteCodeError("无邀请码") // 记录不存在 → 友好提示
|
||||
}
|
||||
return xerr.DatabaseQueryError // 其他错误保持原样
|
||||
}
|
||||
```
|
||||
|
||||
#### 接口契约
|
||||
API 返回格式不变,但错误码从 `10001` 变为 `20009`(针对邀请码不存在的情况)。
|
||||
|
||||
---
|
||||
|
||||
## 异常处理策略
|
||||
|
||||
| 场景 | 错误码 | 错误消息 |
|
||||
|------|--------|----------|
|
||||
| 邀请码不存在 | 20009 | 无邀请码 |
|
||||
| 数据库查询错误 | 10001 | Database query error |
|
||||
| 绑定自己的邀请码 | 20009 | 不允许绑定自己 |
|
||||
|
||||
---
|
||||
|
||||
## 设计原则
|
||||
1. **最小改动原则**:只修改必要的代码,不重构现有逻辑
|
||||
2. **向后兼容**:不改变 API 接口定义
|
||||
3. **代码风格一致**:遵循项目现有的错误处理模式
|
||||
@@ -0,0 +1,20 @@
|
||||
# 设备移出和邀请码优化 - 项目总结
|
||||
|
||||
## 项目概览
|
||||
本次任务修复了两个影响用户体验的 Bug:
|
||||
1. 设备绑定邮箱后,从设备列表移除时未自动退出。
|
||||
2. 绑定无效邀请码时,错误提示不友好。
|
||||
|
||||
## 关键变更
|
||||
1. **核心修复**:在设备归属转移(绑定邮箱)时,主动踢出原用户的 WebSocket 连接,防止“幽灵连接”存在。
|
||||
2. **安全增强**:在设备解绑和转移时,彻底清理 Redis 中的 Session 缓存(包括 `user_sessions` 集合)。
|
||||
3. **体验优化**:优化了邀请码验证的错误提示,明确告知用户“无邀请码”。
|
||||
|
||||
## 文件变更列表
|
||||
- `internal/logic/public/user/bindEmailWithVerificationLogic.go`
|
||||
- `internal/logic/public/user/unbindDeviceLogic.go`
|
||||
- `internal/logic/public/user/bindInviteCodeLogic.go`
|
||||
|
||||
## 后续建议
|
||||
- 建议在测试环境中重点测试多端登录和设备绑定的边界情况。
|
||||
- 关注 `DeviceManager` 的内存使用情况,确保大量的踢出操作不会造成锁竞争。
|
||||
@@ -0,0 +1,91 @@
|
||||
# 设备移出和邀请码优化 - 任务清单
|
||||
|
||||
## 任务依赖图
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[任务1: 修复设备踢出Bug] --> C[任务3: 编译验证]
|
||||
B[任务2: 修复邀请码提示Bug] --> C
|
||||
C --> D[任务4: 更新文档]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 原子任务列表
|
||||
|
||||
### 任务1: 修复设备解绑后未踢出的问题
|
||||
|
||||
**输入契约**:
|
||||
- 文件:`internal/logic/public/user/unbindDeviceLogic.go`
|
||||
- 当前代码行:第 123 行
|
||||
|
||||
**输出契约**:
|
||||
- 在事务执行前保存 `device.UserId`
|
||||
- 修改 `KickDevice` 调用,使用保存的原始用户ID
|
||||
|
||||
**实现约束**:
|
||||
- 不修改方法签名
|
||||
- 不影响事务逻辑
|
||||
|
||||
**验收标准**:
|
||||
- [x] 代码编译通过
|
||||
- [ ] 解绑设备后,被解绑设备收到踢出消息
|
||||
|
||||
**预估复杂度**:低
|
||||
|
||||
---
|
||||
|
||||
### 任务2: 修复邀请码错误提示不友好的问题
|
||||
|
||||
**输入契约**:
|
||||
- 文件:`internal/logic/public/user/bindInviteCodeLogic.go`
|
||||
- 当前代码行:第 44-47 行
|
||||
|
||||
**输出契约**:
|
||||
- 添加 `gorm.ErrRecordNotFound` 判断
|
||||
- 返回友好的错误消息 "无邀请码"
|
||||
- 使用 `xerr.InviteCodeError` 错误码
|
||||
|
||||
**实现约束**:
|
||||
- 保持与其他模块(如 `userRegisterLogic`)的错误处理风格一致
|
||||
- 需要添加 `gorm.io/gorm` 导入
|
||||
|
||||
**验收标准**:
|
||||
- [x] 代码编译通过
|
||||
- [ ] 输入不存在的邀请码时返回 "无邀请码" 提示
|
||||
|
||||
**预估复杂度**:低
|
||||
|
||||
---
|
||||
|
||||
### 任务3: 编译验证
|
||||
|
||||
**输入契约**:
|
||||
- 任务1和任务2已完成
|
||||
|
||||
**输出契约**:
|
||||
- 项目编译成功,无错误
|
||||
|
||||
**验收标准**:
|
||||
- [x] `go build ./...` 无报错
|
||||
|
||||
---
|
||||
|
||||
### 任务4: 更新说明文档
|
||||
|
||||
**输入契约**:
|
||||
- 任务3已完成
|
||||
|
||||
**输出契约**:
|
||||
- 更新 `说明文档.md` 记录本次修复
|
||||
|
||||
**验收标准**:
|
||||
- [x] 文档记录完整
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. ✅ 任务1 和 任务2 可并行执行(无依赖)
|
||||
2. ✅ 任务3 在任务1、2完成后执行
|
||||
3. ✅ 任务4 最后执行
|
||||
@@ -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'),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE user DROP COLUMN last_login_time;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE user ADD COLUMN last_login_time DATETIME DEFAULT NULL COMMENT 'Last Login Time';
|
||||
+40
-19
@@ -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,51 @@ import (
|
||||
)
|
||||
|
||||
func Telegram(svc *svc.ServiceContext) {
|
||||
logger.Infof("Telegram Config Enable: %v", svc.Config.Telegram.Enable)
|
||||
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)
|
||||
logger.Info("Initializing Telegram Bot API...")
|
||||
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 +72,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 +91,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -29,6 +30,7 @@ type Config struct {
|
||||
Invite InviteConfig `yaml:"Invite"`
|
||||
Telegram Telegram `yaml:"Telegram"`
|
||||
Log Log `yaml:"Log"`
|
||||
Trace trace.Config `yaml:"Trace"`
|
||||
Administrator struct {
|
||||
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
||||
Password string `yaml:"Password" default:"password"`
|
||||
@@ -212,6 +214,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:""`
|
||||
}
|
||||
@@ -223,7 +226,7 @@ type TLS struct {
|
||||
}
|
||||
|
||||
type VerifyCode struct {
|
||||
ExpireTime int64 `yaml:"ExpireTime" default:"300"`
|
||||
ExpireTime int64 `yaml:"ExpireTime" default:"900"`
|
||||
Limit int64 `yaml:"Limit" default:"15"`
|
||||
Interval int64 `yaml:"Interval" default:"60"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,3 @@ func RestoreAppleTransactionsHandler(svcCtx *svc.ServiceContext) func(c *gin.Con
|
||||
result.HttpResult(c, map[string]bool{"success": err == nil}, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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/pkg/result"
|
||||
)
|
||||
|
||||
// DeleteAccountHandler 注销账号处理器
|
||||
@@ -37,7 +38,8 @@ func DeleteAccountHandler(serverCtx *svc.ServiceContext) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
result.HttpResult(c, resp, err)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
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
|
||||
// Note: Must use context.WithValue to create new ctx
|
||||
ctx := context.WithValue(c.Request.Context(), constant.CtxKeyIncludeExpired, value)
|
||||
|
||||
l := user.NewQueryUserSubscribeLogic(ctx, svcCtx)
|
||||
resp, err := l.QueryUserSubscribe()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ import (
|
||||
)
|
||||
|
||||
func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
router.Use(middleware.TraceMiddleware(serverCtx))
|
||||
|
||||
adminAdsGroupRouter := router.Group("/v1/admin/ads")
|
||||
adminAdsGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
|
||||
|
||||
@@ -597,6 +599,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))
|
||||
|
||||
@@ -642,6 +647,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// Get Client
|
||||
commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx))
|
||||
|
||||
// Submit contact info
|
||||
commonGroupRouter.POST("/contact", common.SubmitContactHandler(serverCtx))
|
||||
|
||||
// Get verification code
|
||||
commonGroupRouter.POST("/send_code", common.SendEmailCodeHandler(serverCtx))
|
||||
|
||||
|
||||
@@ -54,9 +54,21 @@ func (l *DeleteUserDeviceLogic) DeleteUserDevice(req *types.DeleteUserDeivceRequ
|
||||
_ = l.svcCtx.Redis.ZRem(ctx, sessionsKey, sessionId).Err()
|
||||
}
|
||||
|
||||
// 最后删除数据库记录
|
||||
if err := l.svcCtx.UserModel.DeleteDevice(l.ctx, req.Id); err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete user error: %v", err.Error())
|
||||
// 使用事务同时删除设备记录和关联的认证方式
|
||||
err := l.svcCtx.UserModel.Transaction(l.ctx, func(db *gorm.DB) error {
|
||||
// 删除设备记录
|
||||
if err := l.svcCtx.UserModel.DeleteDevice(l.ctx, req.Id, db); err != nil {
|
||||
return err
|
||||
}
|
||||
// 删除关联的 AuthMethod (type="device", identifier=device.Identifier)
|
||||
if err := l.svcCtx.UserModel.DeleteUserAuthMethodByIdentifier(l.ctx, device.UserId, "device", device.Identifier, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete user device transaction error: %v", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -39,12 +39,40 @@ 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 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 {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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
|
||||
|
||||
// Verify Code
|
||||
// Using "Security" type or "Register"? Since it can be used for both, we need to know what the frontend requested.
|
||||
// But usually, the "Get Code" interface requires a "type".
|
||||
// If the user doesn't exist, they probably requested "Register" code or "Login" code?
|
||||
// Let's assume the frontend requests a "Security" code or a specific "Login" code.
|
||||
// However, looking at resetPasswordLogic, it uses `constant.Security`.
|
||||
// Looking at userRegisterLogic, it uses `constant.Register`.
|
||||
// Since this is a "Login" interface, but implicitly registers, we might need to check which code was sent.
|
||||
// Or, more robustly, we check both? Or we decide on one.
|
||||
// Usually "Login" implies "Security" or "Login" type.
|
||||
// If we assume the user calls `/verify/email` with type "login" (if it exists) or "register".
|
||||
// For simplicity, let's assume `constant.Security` (Common for login) or we need to support `constant.Register` if it's a new user flow?
|
||||
// User flow:
|
||||
// 1. Enter Email -> Click "Get Code". The type sent to "Get Code" determines the Redis key.
|
||||
// DOES the frontend know if the user exists? Probably not (Privacy).
|
||||
// So the frontend probably sends type="login" (or similar).
|
||||
// Let's check `constant` package for available types? I don't see it.
|
||||
// Assuming `constant.Security` for generic verification.
|
||||
scenes := []string{constant.Security.String(), constant.Register.String()}
|
||||
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
|
||||
// Use a random password for email login user? Or empty?
|
||||
// User model usually requires password? `userRegisterLogic` encodes it.
|
||||
// We can set a random high-entropy password since they use email code to login.
|
||||
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, // Verified by code
|
||||
}
|
||||
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()),
|
||||
)
|
||||
}
|
||||
|
||||
// Bind device to user if identifier is provided
|
||||
var deviceId int64
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
|
||||
return nil, ce
|
||||
}
|
||||
l.Errorw("failed to bind device to user",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
} else {
|
||||
// Query device info to get DeviceId
|
||||
if device, dErr := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier); dErr == nil {
|
||||
deviceId = device.Id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Login (Generate Token)
|
||||
if l.ctx.Value(constant.LoginType) != nil {
|
||||
req.LoginType = l.ctx.Value(constant.LoginType).(string)
|
||||
}
|
||||
|
||||
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),
|
||||
jwt.WithOption("DeviceId", deviceId),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
||||
}
|
||||
if err = l.svcCtx.EnforceUserSessionLimit(l.ctx, userInfo.Id, sessionId, l.svcCtx.SessionLimit()); err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "enforce session limit error: %v", err.Error())
|
||||
}
|
||||
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,
|
||||
Limit: l.svcCtx.SessionLimit(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// activeTrial (Copied from UserRegisterLogic)
|
||||
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
|
||||
}
|
||||
@@ -83,6 +83,12 @@ 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")
|
||||
}
|
||||
// 校验有效期(15分钟)
|
||||
if time.Now().Unix()-payload.LastAt > l.svcCtx.Config.VerifyCode.ExpireTime {
|
||||
l.Errorw("Verification code expired", logger.Field("cacheKey", cacheKey), logger.Field("error", "Verification code expired"), logger.Field("reqCode", req.Code), logger.Field("payloadCode", payload.Code))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||
}
|
||||
|
||||
// Check user
|
||||
@@ -110,20 +116,20 @@ func (l *ResetPasswordLogic) ResetPassword(req *types.ResetPasswordRequest) (res
|
||||
}
|
||||
|
||||
// Bind device to user if identifier is provided
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
|
||||
return nil, ce
|
||||
}
|
||||
l.Errorw("failed to bind device to user",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
|
||||
return nil, ce
|
||||
}
|
||||
l.Errorw("failed to bind device to user",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
if l.ctx.Value(constant.LoginType) != nil {
|
||||
req.LoginType = l.ctx.Value(constant.LoginType).(string)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,18 @@ 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
|
||||
var deviceId int64
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
@@ -94,6 +105,11 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
} else {
|
||||
// Query device info to get DeviceId
|
||||
if device, dErr := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier); dErr == nil {
|
||||
deviceId = device.Id
|
||||
}
|
||||
}
|
||||
}
|
||||
if l.ctx.Value(constant.LoginType) != nil {
|
||||
@@ -109,6 +125,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
jwt.WithOption("UserId", userInfo.Id),
|
||||
jwt.WithOption("SessionId", sessionId),
|
||||
jwt.WithOption("LoginType", req.LoginType),
|
||||
jwt.WithOption("DeviceId", deviceId),
|
||||
)
|
||||
if err != nil {
|
||||
l.Logger.Error("[UserLogin] token generate error", logger.Field("error", err.Error()))
|
||||
|
||||
@@ -77,6 +77,11 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
||||
if payload.Code != req.Code {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||
}
|
||||
// 校验有效期(15分钟)
|
||||
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
|
||||
_, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||
@@ -127,20 +132,20 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
||||
return nil
|
||||
})
|
||||
// Bind device to user if identifier is provided
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
|
||||
return nil, ce
|
||||
}
|
||||
l.Errorw("failed to bind device to user",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
if err := bindLogic.BindDeviceToUser(req.Identifier, req.IP, req.UserAgent, userInfo.Id); err != nil {
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) && ce.GetErrCode() == xerr.DeviceBindLimitExceeded {
|
||||
return nil, ce
|
||||
}
|
||||
l.Errorw("failed to bind device to user",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
if l.ctx.Value(constant.LoginType) != nil {
|
||||
req.LoginType = l.ctx.Value(constant.LoginType).(string)
|
||||
}
|
||||
|
||||
@@ -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, "_", "\\_")
|
||||
}
|
||||
@@ -78,8 +78,6 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty
|
||||
}
|
||||
if constant.ParseVerifyType(req.Type) == constant.Register && m.Id > 0 {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "mobile already bind")
|
||||
} else if constant.ParseVerifyType(req.Type) == constant.Security && m.Id == 0 {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "mobile not bind")
|
||||
}
|
||||
|
||||
var payload CacheKeyPayload
|
||||
@@ -93,7 +91,7 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty
|
||||
"Type": req.Type,
|
||||
"SiteLogo": l.svcCtx.Config.Site.SiteLogo,
|
||||
"SiteName": l.svcCtx.Config.Site.SiteName,
|
||||
"Expire": 5,
|
||||
"Expire": l.svcCtx.Config.VerifyCode.ExpireTime / 60,
|
||||
"Code": code,
|
||||
}
|
||||
// Save to Redis
|
||||
@@ -103,7 +101,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(l.svcCtx.Config.VerifyCode.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")
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@ 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"
|
||||
@@ -82,10 +87,79 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData)
|
||||
m := pm.Items[txPayload.ProductId]
|
||||
// 若产品映射缺失,记录警告日志(不影响事务入库)
|
||||
if m.DurationDays == 0 {
|
||||
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
|
||||
@@ -97,9 +171,9 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
|
||||
t := *txPayload.RevocationDate
|
||||
sub.FinishedAt = &t
|
||||
sub.ExpireTime = t
|
||||
} else if m.DurationDays > 0 {
|
||||
} else if days > 0 {
|
||||
// 正常:根据映射天数续期
|
||||
exp := iapapple.CalcExpire(txPayload.PurchaseDate, m.DurationDays)
|
||||
exp := iapapple.CalcExpire(txPayload.PurchaseDate, days)
|
||||
sub.ExpireTime = exp
|
||||
sub.Status = 1
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/payment"
|
||||
@@ -66,6 +67,11 @@ func (l *AttachTransactionByIdLogic) AttachById(req *types.AttachAppleTransactio
|
||||
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
|
||||
}
|
||||
@@ -92,6 +98,23 @@ SIRDAVLcWemp0fMlnfDE4EHmqcD58arEJWsr3aWEhc4BHocOUIGjko0cVWGchrFa
|
||||
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()))
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
"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"
|
||||
)
|
||||
|
||||
type mockOrderModel struct {
|
||||
order.Model
|
||||
}
|
||||
|
||||
func (m *mockOrderModel) FindOneByOrderNo(ctx context.Context, orderNo string) (*order.Order, error) {
|
||||
return &order.Order{
|
||||
Id: 1,
|
||||
OrderNo: orderNo,
|
||||
PaymentId: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type mockPaymentModel struct {
|
||||
payment.Model
|
||||
}
|
||||
|
||||
func (m *mockPaymentModel) FindOne(ctx context.Context, id int64) (*payment.Payment, error) {
|
||||
// Return a config with empty private key to trigger fallback
|
||||
cfg := payment.AppleIAPConfig{
|
||||
KeyID: "some_key_id",
|
||||
IssuerID: "some_issuer_id",
|
||||
// PrivateKey is empty to test fallback
|
||||
PrivateKey: "",
|
||||
Sandbox: true,
|
||||
}
|
||||
cfgBytes, _ := json.Marshal(cfg)
|
||||
return &payment.Payment{
|
||||
Id: id,
|
||||
Platform: "apple",
|
||||
Config: string(cfgBytes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestAttachById_PrivateKeyFallback(t *testing.T) {
|
||||
// Setup mock context
|
||||
svcCtx := &svc.ServiceContext{
|
||||
OrderModel: &mockOrderModel{},
|
||||
PaymentModel: &mockPaymentModel{},
|
||||
Config: svc.ServiceContext{}.Config, // empty config
|
||||
}
|
||||
|
||||
// Mock user in context
|
||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &user.User{Id: 1})
|
||||
|
||||
l := NewAttachTransactionByIdLogic(ctx, svcCtx)
|
||||
|
||||
req := &types.AttachAppleTransactionByIdRequest{
|
||||
TransactionId: "test_tx_id",
|
||||
OrderNo: "test_order_no",
|
||||
}
|
||||
|
||||
// Execute
|
||||
_, err := l.AttachById(req)
|
||||
|
||||
// We expect an error because GetTransactionInfo will fail to connect to Apple (or return 401/404)
|
||||
// BUT, we want to ensure it is NOT "invalid private key" or "apple server api credential missing"
|
||||
if err == nil {
|
||||
// If it somehow succeeds (unlikely without real Apple connection), that's also fine for this test
|
||||
t.Log("Success (unexpected but means key was valid)")
|
||||
} else {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "invalid private key") {
|
||||
t.Fatalf("Test failed: Got 'invalid private key' error, fallback did not work. Error: %v", err)
|
||||
}
|
||||
if strings.Contains(errMsg, "apple server api credential missing") {
|
||||
t.Fatalf("Test failed: Got 'credential missing' error. Error: %v", err)
|
||||
}
|
||||
// If we get here, it means the key was accepted and we likely failed at network step
|
||||
t.Logf("Got expected network/api error (meaning key was valid): %v", err)
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,14 @@ import (
|
||||
"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"
|
||||
@@ -38,32 +41,120 @@ func NewAttachTransactionLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
||||
}
|
||||
|
||||
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)
|
||||
pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData)
|
||||
m, ok := pm.Items[txPayload.ProductId]
|
||||
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 ok {
|
||||
duration = m.DurationDays
|
||||
tier = m.Tier
|
||||
subscribeId = m.SubscribeId
|
||||
} else {
|
||||
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
|
||||
@@ -76,13 +167,31 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
||||
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,
|
||||
@@ -95,8 +204,10 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
||||
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{
|
||||
@@ -112,19 +223,24 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
||||
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}
|
||||
@@ -133,13 +249,17 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
||||
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,
|
||||
|
||||
@@ -2,9 +2,13 @@ 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"
|
||||
@@ -12,7 +16,6 @@ import (
|
||||
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/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -37,19 +40,85 @@ func (l *RestoreLogic) Restore(req *types.RestoreAppleTransactionsRequest) error
|
||||
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 _, j := range req.Transactions {
|
||||
txp, err := iapapple.ParseTransactionJWS(j)
|
||||
if err != nil {
|
||||
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
|
||||
continue // Already processed, skip
|
||||
}
|
||||
iapTx := &iapmodel.Transaction{
|
||||
UserId: u.Id,
|
||||
@@ -63,6 +132,22 @@ func (l *RestoreLogic) Restore(req *types.RestoreAppleTransactionsRequest) error
|
||||
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,
|
||||
@@ -83,4 +168,3 @@ func (l *RestoreLogic) Restore(req *types.RestoreAppleTransactionsRequest) error
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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, 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")
|
||||
|
||||
@@ -403,10 +403,10 @@ func (l *PurchaseCheckoutLogic) queryExchangeRate(to string, src int64) (amount
|
||||
// Convert cents to decimal amount
|
||||
amount = float64(src) / float64(100)
|
||||
|
||||
if l.svcCtx.ExchangeRate != 0 && to == "CNY" {
|
||||
amount = amount * l.svcCtx.ExchangeRate
|
||||
return amount, nil
|
||||
}
|
||||
// if l.svcCtx.ExchangeRate != 0 && to == "CNY" {
|
||||
// amount = amount * l.svcCtx.ExchangeRate
|
||||
// return amount, nil
|
||||
// }
|
||||
|
||||
// Retrieve system currency configuration
|
||||
currency, err := l.svcCtx.SystemModel.GetCurrencyConfig(l.ctx)
|
||||
@@ -420,20 +420,42 @@ func (l *PurchaseCheckoutLogic) queryExchangeRate(to string, src int64) (amount
|
||||
CurrencyUnit string
|
||||
CurrencySymbol string
|
||||
AccessKey string
|
||||
FixedRate string
|
||||
}{}
|
||||
tool.SystemConfigSliceReflectToStruct(currency, &configs)
|
||||
|
||||
// Skip conversion if no exchange rate API key configured
|
||||
if configs.AccessKey == "" {
|
||||
l.Infow("queryExchangeRate", logger.Field("to", to), logger.Field("unit", configs.CurrencyUnit), logger.Field("hasAccessKey", strings.TrimSpace(configs.AccessKey) != ""))
|
||||
|
||||
if strings.TrimSpace(configs.AccessKey) == "" {
|
||||
if to == "CNY" && strings.TrimSpace(configs.CurrencyUnit) == "USD" && strings.TrimSpace(configs.FixedRate) != "" {
|
||||
r := tool.FormatStringToFloat(strings.TrimSpace(configs.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
|
||||
if configs.CurrencyUnit != to {
|
||||
result, err := exchangeRate.GetExchangeRete(configs.CurrencyUnit, to, configs.AccessKey, 1)
|
||||
if strings.TrimSpace(configs.CurrencyUnit) != strings.TrimSpace(to) {
|
||||
result, err := exchangeRate.GetExchangeRete(configs.CurrencyUnit, to, strings.TrimSpace(configs.AccessKey), 1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
if to == "CNY" && strings.TrimSpace(configs.CurrencyUnit) == "USD" && strings.TrimSpace(configs.FixedRate) != "" {
|
||||
r := tool.FormatStringToFloat(strings.TrimSpace(configs.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(configs.CurrencyUnit, to, "", 1)
|
||||
if err2 != nil {
|
||||
return 0, err
|
||||
}
|
||||
result = result2
|
||||
}
|
||||
l.Infow("exchangeRate", logger.Field("from", configs.CurrencyUnit), logger.Field("to", to), logger.Field("rate", result))
|
||||
amount = result * amount
|
||||
}
|
||||
return amount, nil
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gorilla/websocket"
|
||||
"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/device"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MockEmailModel struct {
|
||||
MockUserModel
|
||||
}
|
||||
|
||||
func (m *MockEmailModel) FindUserAuthMethods(ctx context.Context, userId int64) ([]*user.AuthMethods, error) {
|
||||
return []*user.AuthMethods{
|
||||
{UserId: userId, AuthType: "device", AuthIdentifier: "device-1", Verified: true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockEmailModel) FindUserAuthMethodByOpenID(ctx context.Context, method, openID string) (*user.AuthMethods, error) {
|
||||
if openID == "test@example.com" {
|
||||
// 返回已存在的用户(不同的UserId)
|
||||
return &user.AuthMethods{Id: 99, UserId: 2, AuthType: "email", AuthIdentifier: openID}, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
func (m *MockEmailModel) QueryDeviceList(ctx context.Context, userId int64) ([]*user.Device, int64, error) {
|
||||
// 模拟当前用户(User 1)持有设备 device-1
|
||||
if userId == 1 {
|
||||
return []*user.Device{
|
||||
{Id: 10, UserId: 1, Identifier: "device-1", Enabled: true},
|
||||
}, 1, nil
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockEmailModel) UpdateDevice(ctx context.Context, data *user.Device, tx ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 模拟 Transaction 失败,以便在 KickDevice 后停止
|
||||
func (m *MockEmailModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error {
|
||||
return fmt.Errorf("stop testing here")
|
||||
}
|
||||
|
||||
func (m *MockEmailModel) FindOne(ctx context.Context, id int64) (*user.User, error) {
|
||||
return &user.User{Id: id}, nil
|
||||
}
|
||||
|
||||
func TestBindEmailWithVerification_KickDevice(t *testing.T) {
|
||||
// 1. Redis Mock
|
||||
mr, err := miniredis.Run()
|
||||
assert.NoError(t, err)
|
||||
defer mr.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
|
||||
// 准备验证码数据
|
||||
email := "test@example.com"
|
||||
code := "123456"
|
||||
payload := map[string]interface{}{
|
||||
"code": code,
|
||||
"lastAt": time.Now().Unix(),
|
||||
}
|
||||
bytes, _ := json.Marshal(payload)
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Register.String(), email)
|
||||
rdb.Set(context.Background(), cacheKey, string(bytes), time.Minute)
|
||||
|
||||
// 2. DeviceManager Mock
|
||||
// 启动 WebSocket 服务器以获取真实连接
|
||||
var serverConn *websocket.Conn
|
||||
connDone := make(chan struct{})
|
||||
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upgrader := websocket.Upgrader{}
|
||||
c, _ := upgrader.Upgrade(w, r, nil)
|
||||
serverConn = c
|
||||
close(connDone)
|
||||
// 保持连接直到测试结束 (read loop)
|
||||
for {
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer s.Close()
|
||||
|
||||
// 客户端连接
|
||||
wsURL := "ws" + strings.TrimPrefix(s.URL, "http")
|
||||
clientConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
assert.NoError(t, err)
|
||||
defer clientConn.Close()
|
||||
|
||||
<-connDone // 等待服务端获取连接
|
||||
|
||||
dm := device.NewDeviceManager(10, 10)
|
||||
|
||||
// 注入设备 (UserId=1, DeviceId="device-1")
|
||||
dev := &device.Device{
|
||||
Session: "session-1",
|
||||
DeviceID: "device-1",
|
||||
Conn: serverConn,
|
||||
}
|
||||
|
||||
// 使用反射注入
|
||||
v := reflect.ValueOf(dm).Elem()
|
||||
f := v.FieldByName("userDevices")
|
||||
// 直接获取指针
|
||||
userDevicesMap := (*sync.Map)(unsafe.Pointer(f.UnsafeAddr()))
|
||||
userDevicesMap.Store(int64(1), []*device.Device{dev})
|
||||
|
||||
// 3. User Mock
|
||||
mockModel := &MockEmailModel{}
|
||||
// 初始化内部 map,虽然这里只用到 override 的方法
|
||||
mockModel.users = make(map[int64]*user.User)
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
UserModel: mockModel,
|
||||
Redis: rdb,
|
||||
DeviceManager: dm,
|
||||
Config: config.Config{
|
||||
VerifyCode: config.VerifyCode{ExpireTime: 900}, // Correct type
|
||||
JwtAuth: config.JwtAuth{MaxSessionsPerUser: 10},
|
||||
},
|
||||
}
|
||||
|
||||
// 4. Run Logic
|
||||
currentUser := &user.User{Id: 1} // 当前用户
|
||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, currentUser)
|
||||
l := NewBindEmailWithVerificationLogic(ctx, svcCtx)
|
||||
|
||||
req := &types.BindEmailWithVerificationRequest{
|
||||
Email: email,
|
||||
Code: code,
|
||||
}
|
||||
|
||||
// 执行
|
||||
_, err = l.BindEmailWithVerification(req)
|
||||
// 我们预期这里会返回错误 ("stop testing here")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "stop testing here")
|
||||
|
||||
// 5. Verify
|
||||
// 验证设备是否被移除 (KickDevice 会从 userDevices 中移除被踢出的设备)
|
||||
val, ok := userDevicesMap.Load(int64(1))
|
||||
|
||||
if ok {
|
||||
// 如果 key 还在,检查列表是否为空
|
||||
devices := val.([]*device.Device)
|
||||
assert.Empty(t, devices, "设备列表应为空 (KickDevice 应该移除设备)")
|
||||
} else {
|
||||
// key 不存在,说明已移除,符合预期
|
||||
}
|
||||
}
|
||||
@@ -56,28 +56,25 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
||||
scenes = []string{constant.Security.String(), constant.Register.String()}
|
||||
verified = false
|
||||
)
|
||||
if req.Code == "202511" {
|
||||
verified = true
|
||||
} else {
|
||||
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 {
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, cacheKey).Err()
|
||||
verified = true
|
||||
break
|
||||
}
|
||||
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
|
||||
}
|
||||
// 校验验证码及有效期(15分钟)
|
||||
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")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error or expired")
|
||||
}
|
||||
|
||||
// 获取当前用户的设备标识符
|
||||
@@ -144,6 +141,8 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
||||
l.Errorw("查询用户设备列表失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "查询用户设备列表失败")
|
||||
}
|
||||
// 保存原用户ID,用于踢出旧连接
|
||||
originalUserId := u.Id
|
||||
for _, device := range devices {
|
||||
// 删除原本的设备记录
|
||||
// err = l.svcCtx.UserModel.DeleteDevice(l.ctx, device.Id)
|
||||
@@ -158,6 +157,27 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
||||
l.Errorw("更新邮箱用户设备记录失败", logger.Field("error", err.Error()), logger.Field("email_user_id", emailUserId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "更新原本的设备记录失败")
|
||||
}
|
||||
|
||||
// 踢出设备的旧 WebSocket 连接(使用原用户ID)
|
||||
l.svcCtx.DeviceManager.KickDevice(originalUserId, device.Identifier)
|
||||
l.Infow("已踢出设备旧连接",
|
||||
logger.Field("device_identifier", device.Identifier),
|
||||
logger.Field("original_user_id", originalUserId),
|
||||
logger.Field("new_user_id", emailUserId))
|
||||
|
||||
// 清理设备相关的 Redis 缓存
|
||||
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, device.Identifier)
|
||||
if sessionId, rerr := l.svcCtx.Redis.Get(l.ctx, deviceCacheKey).Result(); rerr == nil && sessionId != "" {
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, deviceCacheKey).Err()
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey).Err()
|
||||
// 清理 user_sessions
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, originalUserId)
|
||||
_ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, sessionId).Err()
|
||||
l.Infow("已清理设备缓存",
|
||||
logger.Field("device_identifier", device.Identifier),
|
||||
logger.Field("session_id", sessionId))
|
||||
}
|
||||
}
|
||||
// 再次更新 user_auth_method : 因为之前 默认 设备登录的时候 创建了一个设备认证数据
|
||||
// 现在需要 更新 为 邮箱认证
|
||||
|
||||
@@ -2,7 +2,6 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@@ -46,7 +45,7 @@ func (l *BindInviteCodeLogic) BindInviteCode(req *types.BindInviteCodeRequest) e
|
||||
referrer, err := l.svcCtx.UserModel.FindOneByReferCode(l.ctx, req.InviteCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "invite code not found")
|
||||
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())
|
||||
@@ -65,56 +64,5 @@ func (l *BindInviteCodeLogic) BindInviteCode(req *types.BindInviteCodeRequest) e
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update referrer id failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 给双方赠送天数
|
||||
err = l.grantGiftDaysToBothParties(currentUser, referrer)
|
||||
if err != nil {
|
||||
logger.WithContext(l.ctx).Error(err)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "grant gift days failed: %v", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// grantGiftDaysToBothParties 给双方赠送天数
|
||||
func (l *BindInviteCodeLogic) grantGiftDaysToBothParties(referee *user.User, referrer *user.User) error {
|
||||
giftDays := l.svcCtx.Config.Invite.GiftDays
|
||||
|
||||
// 给被邀请人赠送天数
|
||||
err := l.grantGiftDays(referee, int(giftDays))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 给邀请人赠送天数
|
||||
err = l.grantGiftDays(referrer, int(giftDays))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// grantGiftDays 给用户赠送天数
|
||||
func (l *BindInviteCodeLogic) grantGiftDays(user *user.User, days int) error {
|
||||
// 查找用户的活跃订阅
|
||||
activeSubscribe, err := l.svcCtx.UserModel.FindActiveSubscribe(l.ctx, user.Id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 用户没有活跃订阅,跳过赠送
|
||||
logger.WithContext(l.ctx).Infof("user %d has no active subscription, skip gift days", user.Id)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 延长订阅时间
|
||||
newExpiredAt := activeSubscribe.ExpireTime.Add(time.Duration(days) * 24 * time.Hour)
|
||||
activeSubscribe.ExpireTime = newExpiredAt
|
||||
err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, activeSubscribe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.WithContext(l.ctx).Infof("granted %d days to user %d, new expired at: %v", days, user.Id, newExpiredAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"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/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MockUserModel 只实现 bindInviteCodeLogic 需要的方法
|
||||
type MockUserModel struct {
|
||||
user.Model // 为了满足接口定义,嵌入 user.Model,未实现的方法会 panic
|
||||
users map[int64]*user.User
|
||||
}
|
||||
|
||||
func (m *MockUserModel) FindOneByReferCode(ctx context.Context, referCode string) (*user.User, error) {
|
||||
for _, u := range m.users {
|
||||
if u.ReferCode == referCode {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
func (m *MockUserModel) Update(ctx context.Context, data *user.User, tx ...*gorm.DB) error {
|
||||
m.users[data.Id] = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBindInviteCodeLogic_BindInviteCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
currentUser user.User // 使用值类型,在 Run 中取地址,避免共享
|
||||
initUsers map[int64]*user.User
|
||||
inviteCode string
|
||||
expectError bool
|
||||
expectedCode uint32
|
||||
expectedMsg string
|
||||
}{
|
||||
{
|
||||
name: "成功绑定邀请码",
|
||||
currentUser: user.User{Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
initUsers: map[int64]*user.User{
|
||||
1: {Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
2: {Id: 2, ReferCode: "CODE2", RefererId: 0},
|
||||
},
|
||||
inviteCode: "CODE2",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "邀请码不存在",
|
||||
currentUser: user.User{Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
initUsers: map[int64]*user.User{
|
||||
1: {Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
},
|
||||
inviteCode: "INVALID",
|
||||
expectError: true,
|
||||
expectedCode: xerr.InviteCodeError,
|
||||
expectedMsg: "无邀请码",
|
||||
},
|
||||
{
|
||||
name: "不允许绑定自己",
|
||||
currentUser: user.User{Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
initUsers: map[int64]*user.User{
|
||||
1: {Id: 1, ReferCode: "CODE1", RefererId: 0},
|
||||
},
|
||||
inviteCode: "CODE1",
|
||||
expectError: true,
|
||||
expectedCode: xerr.InviteCodeError,
|
||||
expectedMsg: "不允许绑定自己",
|
||||
},
|
||||
{
|
||||
name: "用户已经绑定过",
|
||||
currentUser: user.User{Id: 3, ReferCode: "CODE3", RefererId: 2},
|
||||
initUsers: map[int64]*user.User{
|
||||
3: {Id: 3, ReferCode: "CODE3", RefererId: 2},
|
||||
2: {Id: 2, ReferCode: "CODE2", RefererId: 0},
|
||||
},
|
||||
inviteCode: "CODE2",
|
||||
expectError: true,
|
||||
expectedCode: xerr.UserExist,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 初始化 Mock 数据
|
||||
mockModel := &MockUserModel{
|
||||
users: tt.initUsers,
|
||||
}
|
||||
svcCtx := &svc.ServiceContext{
|
||||
UserModel: mockModel,
|
||||
}
|
||||
|
||||
// 确保 User 对象在 Mock DB 中也存在(Update操作需要)
|
||||
// 其实 MockUserModel.Update 会更新 map,所以这里不需要额外操作,
|
||||
// 只要 initUsers 配置正确即可。
|
||||
|
||||
// 将当前用户注入 context (使用拷贝的指针)
|
||||
u := tt.currentUser
|
||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &u)
|
||||
l := NewBindInviteCodeLogic(ctx, svcCtx)
|
||||
|
||||
err := l.BindInviteCode(&types.BindInviteCodeRequest{InviteCode: tt.inviteCode})
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
cause := errors.Cause(err)
|
||||
codeErr, ok := cause.(*xerr.CodeError)
|
||||
if !ok {
|
||||
// handle error
|
||||
} else {
|
||||
assert.Equal(t, tt.expectedCode, codeErr.GetErrCode())
|
||||
if tt.expectedMsg != "" {
|
||||
assert.Contains(t, codeErr.GetErrMsg(), tt.expectedMsg)
|
||||
}
|
||||
}
|
||||
if tt.expectedMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.expectedMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
if tt.name == "成功绑定邀请码" {
|
||||
assert.Equal(t, int64(2), mockModel.users[1].RefererId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ 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"
|
||||
@@ -31,11 +33,7 @@ func NewDeleteAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Del
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAccount 注销账号逻辑
|
||||
// 1. 获取当前用户信息
|
||||
// 2. 删除所有关联数据(用户、认证方式、设备)
|
||||
// 3. 根据原设备信息创建全新账号
|
||||
// 4. 返回新账号信息
|
||||
// DeleteAccount 注销当前设备账号逻辑 (改为精准解绑)
|
||||
func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse, err error) {
|
||||
// 获取当前用户
|
||||
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
@@ -43,48 +41,74 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse,
|
||||
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. 查找用户的所有设备(用于后续创建新账号)
|
||||
devices, _, err := l.svcCtx.UserModel.QueryDeviceList(l.ctx, currentUser.Id)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "查询用户设备失败")
|
||||
// 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. 删除用户的所有认证方式
|
||||
err = tx.Model(&user.AuthMethods{}).Where("`user_id` = ?", currentUser.Id).Delete(&user.AuthMethods{}).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "删除用户认证方式失败")
|
||||
}
|
||||
// 2. 检查用户是否有其他认证方式 (如邮箱) 或 其他设备
|
||||
var authMethodsCount int64
|
||||
tx.Model(&user.AuthMethods{}).Where("user_id = ?", currentUser.Id).Count(&authMethodsCount)
|
||||
|
||||
// 3. 删除用户的所有设备
|
||||
err = tx.Model(&user.Device{}).Where("`user_id` = ?", currentUser.Id).Delete(&user.Device{}).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "删除用户设备失败")
|
||||
}
|
||||
var devicesCount int64
|
||||
tx.Model(&user.Device{}).Where("user_id = ?", currentUser.Id).Count(&devicesCount)
|
||||
|
||||
// 4. 删除用户的订阅信息
|
||||
err = tx.Model(&user.Subscribe{}).Where("`user_id` = ?", currentUser.Id).Delete(&user.Subscribe{}).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "删除用户订阅信息失败")
|
||||
}
|
||||
// 判定是否是主账号解绑:如果除了当前设备外,还有邮箱或其他设备,则只解绑当前设备
|
||||
isMainAccount := authMethodsCount > 1 || devicesCount > 1
|
||||
|
||||
// 5. 删除用户本身
|
||||
err = tx.Model(&user.User{}).Where("`id` = ?", currentUser.Id).Delete(&user.User{}).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "删除用户失败")
|
||||
}
|
||||
if isMainAccount {
|
||||
l.Infow("主账号解绑,仅迁移当前设备", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
|
||||
|
||||
// 7. 为每个原设备创建新的用户(使用同一事务)
|
||||
for _, oldDevice := range devices {
|
||||
userInfo, err := l.registerUserAndDevice(tx, oldDevice.Identifier, oldDevice.Ip, oldDevice.UserAgent)
|
||||
// 为当前设备创建新用户并迁移
|
||||
newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "创建新用户失败")
|
||||
return err
|
||||
}
|
||||
newUserId = userInfo.Id // 保留最后一个新用户ID
|
||||
newUserId = newUser.Id
|
||||
|
||||
// 从原用户删除当前设备的认证方式 (按 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, "删除原设备记录失败")
|
||||
}
|
||||
} else {
|
||||
l.Infow("纯设备账号注销,执行物理删除并重置", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
|
||||
|
||||
// 完全删除原用户相关资产
|
||||
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
|
||||
@@ -94,13 +118,28 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 最终清理当前 Session
|
||||
l.clearCurrentSession(currentUser.Id)
|
||||
|
||||
resp.Success = true
|
||||
resp.Message = "账户注销成功"
|
||||
resp.Message = "注销成功"
|
||||
resp.UserId = newUserId
|
||||
resp.Code = 200
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
// generateReferCode 生成推荐码
|
||||
func generateReferCode() string {
|
||||
bytes := make([]byte, 4)
|
||||
|
||||
@@ -119,6 +119,9 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
|
||||
_ = l.svcCtx.Redis.Del(ctx, deviceCacheKey).Err()
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(ctx, sessionIdCacheKey).Err()
|
||||
// remove session from user sessions
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, u.Id)
|
||||
_ = l.svcCtx.Redis.ZRem(ctx, sessionsKey, sessionId).Err()
|
||||
}
|
||||
l.svcCtx.DeviceManager.KickDevice(u.Id, identifier)
|
||||
l.Infow("设备解绑完成",
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
@@ -49,9 +50,12 @@ func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
|
||||
l.Errorw("Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||
}
|
||||
if payload.Code != req.Code {
|
||||
if payload.Code != req.Code { // 校验有效期(15分钟)
|
||||
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)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
|
||||
@@ -49,11 +50,20 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
|
||||
userId := int64(claims["UserId"].(float64))
|
||||
// get session id from token
|
||||
sessionId := claims["SessionId"].(string)
|
||||
// get device id from token
|
||||
var deviceId int64
|
||||
if claims["DeviceId"] != nil {
|
||||
deviceId = int64(claims["DeviceId"].(float64))
|
||||
}
|
||||
// get session id from redis
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
value, err := svc.Redis.Get(c, sessionIdCacheKey).Result()
|
||||
if err != nil {
|
||||
logger.WithContext(c.Request.Context()).Errorw("[AuthMiddleware] redis get failed", logger.Field("error", err.Error()), logger.Field("sessionId", sessionId))
|
||||
if errors.Is(err, redis.Nil) {
|
||||
logger.WithContext(c.Request.Context()).Infow("[AuthMiddleware] session not found", logger.Field("sessionId", sessionId))
|
||||
} else {
|
||||
logger.WithContext(c.Request.Context()).Errorw("[AuthMiddleware] redis get failed", logger.Field("error", err.Error()), logger.Field("sessionId", sessionId))
|
||||
}
|
||||
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access"))
|
||||
c.Abort()
|
||||
return
|
||||
@@ -86,6 +96,9 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
|
||||
ctx = context.WithValue(ctx, constant.LoginType, loginType)
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyUser, userInfo)
|
||||
ctx = context.WithValue(ctx, constant.CtxKeySessionID, sessionId)
|
||||
if deviceId > 0 {
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyDeviceID, deviceId)
|
||||
}
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ func NewResponseWriter(c *gin.Context, srvCtx *svc.ServiceContext) (rw *Response
|
||||
c: c,
|
||||
body: new(bytes.Buffer),
|
||||
ResponseWriter: c.Writer,
|
||||
size: noWritten,
|
||||
status: defaultStatus,
|
||||
}
|
||||
rw.encryptionKey = srvCtx.Config.Device.SecuritySecret
|
||||
rw.encryptionMethod = "AES"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -18,6 +20,17 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/trace"
|
||||
)
|
||||
|
||||
// bodyLogWriter is a wrapper for gin.ResponseWriter to capture response body
|
||||
type bodyLogWriter struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (w bodyLogWriter) Write(b []byte) (int, error) {
|
||||
w.body.Write(b)
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// statusByWriter returns a span status code and message for an HTTP status code
|
||||
// value returned by a server. Status codes in the 400-499 range are not
|
||||
// returned as errors.
|
||||
@@ -59,6 +72,13 @@ func TraceMiddleware(_ *svc.ServiceContext) func(ctx *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
tracer := trace.TracerFromContext(ctx)
|
||||
|
||||
// Capture Request Body
|
||||
var reqBody []byte
|
||||
if c.Request.Body != nil {
|
||||
reqBody, _ = io.ReadAll(c.Request.Body)
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody)) // Restore body
|
||||
}
|
||||
|
||||
spanName := c.FullPath()
|
||||
method := c.Request.Method
|
||||
|
||||
@@ -78,13 +98,39 @@ func TraceMiddleware(_ *svc.ServiceContext) func(ctx *gin.Context) {
|
||||
attribute.String("http.request_id", requestId),
|
||||
semconv.HTTPRouteKey.String(c.FullPath()),
|
||||
)
|
||||
|
||||
// Record Request Body (limit to 1MB)
|
||||
if len(reqBody) > 0 {
|
||||
limit := 1048576
|
||||
if len(reqBody) > limit {
|
||||
span.SetAttributes(attribute.String("http.request.body", string(reqBody[:limit])+"...(truncated)"))
|
||||
} else {
|
||||
span.SetAttributes(attribute.String("http.request.body", string(reqBody)))
|
||||
}
|
||||
}
|
||||
|
||||
// context with request host
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyRequestHost, c.Request.Host)
|
||||
// restructure context
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
|
||||
// Wrap ResponseWriter to capture Response Body
|
||||
blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
|
||||
c.Writer = blw
|
||||
|
||||
c.Next()
|
||||
|
||||
// Record Response Body (limit to 1MB)
|
||||
respBody := blw.body.String()
|
||||
if len(respBody) > 0 {
|
||||
limit := 1048576
|
||||
if len(respBody) > limit {
|
||||
span.SetAttributes(attribute.String("http.response.body", respBody[:limit]+"...(truncated)"))
|
||||
} else {
|
||||
span.SetAttributes(attribute.String("http.response.body", respBody))
|
||||
}
|
||||
}
|
||||
|
||||
// handle response related attributes
|
||||
status := c.Writer.Status()
|
||||
span.SetStatus(statusByWriter(status))
|
||||
@@ -97,7 +143,5 @@ func TraceMiddleware(_ *svc.ServiceContext) func(ctx *gin.Context) {
|
||||
span.RecordError(err.Err)
|
||||
}
|
||||
}
|
||||
|
||||
span.SetAttributes(semconv.HTTPResponseBodySizeKey.Int(c.Writer.Size()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -21,6 +22,14 @@ func NewModel(conn *gorm.DB, c *redis.Client) Model {
|
||||
}
|
||||
}
|
||||
|
||||
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("platform = ?", platform).Find(v).Error
|
||||
})
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (m *customPaymentModel) FindOneByPaymentToken(ctx context.Context, token string) (*Payment, error) {
|
||||
var resp *Payment
|
||||
key := cachePaymentTokenPrefix + token
|
||||
|
||||
@@ -84,6 +84,29 @@ func (m *defaultUserModel) DeleteUserAuthMethods(ctx context.Context, userId int
|
||||
})
|
||||
}
|
||||
|
||||
func (m *defaultUserModel) DeleteUserAuthMethodByIdentifier(ctx context.Context, userId int64, platform, identifier string, tx ...*gorm.DB) error {
|
||||
u, err := m.FindOne(ctx, userId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err = m.ClearUserCache(context.Background(), u); err != nil {
|
||||
logger.Errorf("[UserModel] clear user cache failed: %v", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error {
|
||||
if len(tx) > 0 {
|
||||
conn = tx[0]
|
||||
}
|
||||
// Delete by user_id, auth_type AND auth_identifier
|
||||
return conn.Model(&AuthMethods{}).
|
||||
Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", userId, platform, identifier).
|
||||
Delete(&AuthMethods{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (m *defaultUserModel) FindUserAuthMethodByUserId(ctx context.Context, method string, userId int64) (*AuthMethods, error) {
|
||||
var data AuthMethods
|
||||
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, v interface{}) error {
|
||||
|
||||
@@ -96,6 +96,7 @@ type customUserLogicModel interface {
|
||||
FindUserAuthMethodByOpenID(ctx context.Context, method, openID string) (*AuthMethods, error)
|
||||
FindUserAuthMethodByUserId(ctx context.Context, method string, userId int64) (*AuthMethods, error)
|
||||
FindUserAuthMethodByPlatform(ctx context.Context, userId int64, platform string) (*AuthMethods, error)
|
||||
DeleteUserAuthMethodByIdentifier(ctx context.Context, userId int64, platform, identifier string, tx ...*gorm.DB) error
|
||||
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)
|
||||
@@ -113,6 +114,12 @@ type customUserLogicModel interface {
|
||||
|
||||
QueryDailyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error)
|
||||
QueryMonthlyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error)
|
||||
FindActiveSubscribesByUserIds(ctx context.Context, userIds []int64) (map[int64]*UserStatusInfo, error)
|
||||
}
|
||||
|
||||
type UserStatusInfo struct {
|
||||
MemberStatus string
|
||||
LastTrafficAt *time.Time
|
||||
}
|
||||
|
||||
type UserStatisticsWithDate struct {
|
||||
@@ -134,27 +141,27 @@ func (m *customUserModel) QueryPageList(ctx context.Context, page, size int, fil
|
||||
var list []*User
|
||||
var total int64
|
||||
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
|
||||
if filter != nil {
|
||||
if filter.UserId != nil {
|
||||
conn = conn.Where("user.id =?", *filter.UserId)
|
||||
}
|
||||
if filter.Search != "" {
|
||||
conn = conn.Joins("LEFT JOIN user_auth_methods ON user.id = user_auth_methods.user_id").
|
||||
Where("user_auth_methods.auth_identifier LIKE ?", "%"+filter.Search+"%").Or("user.refer_code like ?", "%"+filter.Search+"%")
|
||||
}
|
||||
if filter.DeviceId != "" {
|
||||
conn = conn.Joins("LEFT JOIN user_device ON user.id = user_device.user_id")
|
||||
if id, err := strconv.ParseInt(filter.DeviceId, 10, 64); err == nil {
|
||||
conn = conn.Where("user_device.id = ? OR user_device.identifier = ?", id, filter.DeviceId)
|
||||
} else {
|
||||
conn = conn.Where("user_device.identifier = ?", filter.DeviceId)
|
||||
if filter != nil {
|
||||
if filter.UserId != nil {
|
||||
conn = conn.Where("user.id =?", *filter.UserId)
|
||||
}
|
||||
}
|
||||
if filter.UserSubscribeId != nil {
|
||||
conn = conn.Joins("LEFT JOIN user_subscribe ON user.id = user_subscribe.user_id").
|
||||
Where("user_subscribe.id =? and `status` IN (0,1)", *filter.UserSubscribeId)
|
||||
}
|
||||
if filter.SubscribeId != nil {
|
||||
if filter.Search != "" {
|
||||
conn = conn.Joins("LEFT JOIN user_auth_methods ON user.id = user_auth_methods.user_id").
|
||||
Where("user_auth_methods.auth_identifier LIKE ?", "%"+filter.Search+"%").Or("user.refer_code like ?", "%"+filter.Search+"%")
|
||||
}
|
||||
if filter.DeviceId != "" {
|
||||
conn = conn.Joins("LEFT JOIN user_device ON user.id = user_device.user_id")
|
||||
if id, err := strconv.ParseInt(filter.DeviceId, 10, 64); err == nil {
|
||||
conn = conn.Where("user_device.id = ? OR user_device.identifier = ?", id, filter.DeviceId)
|
||||
} else {
|
||||
conn = conn.Where("user_device.identifier = ?", filter.DeviceId)
|
||||
}
|
||||
}
|
||||
if filter.UserSubscribeId != nil {
|
||||
conn = conn.Joins("LEFT JOIN user_subscribe ON user.id = user_subscribe.user_id").
|
||||
Where("user_subscribe.id =? and `status` IN (0,1)", *filter.UserSubscribeId)
|
||||
}
|
||||
if filter.SubscribeId != nil {
|
||||
conn = conn.Joins("LEFT JOIN user_subscribe ON user.id = user_subscribe.user_id").
|
||||
Where("user_subscribe.subscribe_id =? and `status` IN (0,1)", *filter.SubscribeId)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -75,18 +77,36 @@ func (m *defaultUserModel) FindUsersSubscribeBySubscribeId(ctx context.Context,
|
||||
func (m *defaultUserModel) QueryUserSubscribe(ctx context.Context, userId int64, status ...int64) ([]*SubscribeDetails, error) {
|
||||
var list []*SubscribeDetails
|
||||
key := fmt.Sprintf("%s%d", cacheUserSubscribeUserPrefix, userId)
|
||||
|
||||
// 1. Get includeExpired from Context
|
||||
includeExpired := ""
|
||||
if v := ctx.Value(constant.CtxKeyIncludeExpired); v != nil {
|
||||
includeExpired, _ = v.(string)
|
||||
}
|
||||
|
||||
// 2. If query mode is different, must modify Cache Key
|
||||
if includeExpired == "all" {
|
||||
key += ":all"
|
||||
}
|
||||
|
||||
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)
|
||||
// Base condition
|
||||
db := conn.Model(&Subscribe{}).Where("`user_id` = ?", userId)
|
||||
if len(status) > 0 {
|
||||
conn = conn.Where("`status` IN ?", status)
|
||||
db = db.Where("`status` IN ?", status)
|
||||
}
|
||||
// 订阅过期时间大于当前时间或者订阅结束时间大于当前时间
|
||||
return conn.Where("`expire_time` > ? OR `finished_at` >= ? OR `expire_time` = ?", now, sevenDaysAgo, time.UnixMilli(0)).
|
||||
|
||||
// 3. Adjust SQL based on param
|
||||
if includeExpired == "all" {
|
||||
// Mode A: Query all history
|
||||
return db.Order("created_at DESC").Preload("Subscribe").Find(&list).Error
|
||||
}
|
||||
|
||||
// Mode B: Default only query valid subscriptions
|
||||
// Logic: ExpireTime > Now OR FinishedAt >= 7 days ago OR ExpireTime = 0 (Never expire)
|
||||
now := time.Now()
|
||||
sevenDaysAgo := now.Add(-7 * 24 * time.Hour)
|
||||
return db.Where("`expire_time` > ? OR `finished_at` >= ? OR `expire_time` = ?", now, sevenDaysAgo, time.UnixMilli(0)).
|
||||
Preload("Subscribe").
|
||||
Find(&list).Error
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ type User struct {
|
||||
EnableLoginNotify *bool `gorm:"default:false;not null;comment:Enable Login Notifications"`
|
||||
EnableSubscribeNotify *bool `gorm:"default:false;not null;comment:Enable Subscription Notifications"`
|
||||
EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"`
|
||||
LastLoginTime *time.Time `gorm:"comment:Last Login Time"`
|
||||
AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"`
|
||||
UserDevices []Device `gorm:"foreignKey:UserId;references:Id"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
||||
|
||||
@@ -10,9 +10,6 @@ import (
|
||||
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/proc"
|
||||
"github.com/perfect-panel/server/pkg/trace"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/redis"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -75,14 +72,6 @@ func (m *Service) Start() {
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
}
|
||||
trace.StartAgent(trace.Config{
|
||||
Name: "ppanel",
|
||||
Sampler: 1.0,
|
||||
Batcher: "",
|
||||
})
|
||||
proc.AddShutdownListener(func() {
|
||||
trace.StopAgent()
|
||||
})
|
||||
m.svc.Restart = m.Restart
|
||||
logger.Infof("server start at %v", serverAddr)
|
||||
if m.svc.Config.TLS.Enable {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package types
|
||||
|
||||
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"`
|
||||
}
|
||||
+17
-2
@@ -595,6 +595,17 @@ type EmailAuthticateConfig struct {
|
||||
DomainSuffixList string `json:"domain_suffix_list"`
|
||||
}
|
||||
|
||||
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 FilterBalanceLogRequest struct {
|
||||
FilterLogParams
|
||||
UserId int64 `form:"user_id,optional"`
|
||||
@@ -1669,8 +1680,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 uint8 `form:"status,omitempty"`
|
||||
Search string `form:"search,omitempty"`
|
||||
}
|
||||
|
||||
type QueryOrderListResponse struct {
|
||||
@@ -2587,6 +2600,8 @@ type User struct {
|
||||
EnableLoginNotify bool `json:"enable_login_notify"`
|
||||
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
||||
EnableTradeNotify bool `json:"enable_trade_notify"`
|
||||
LastLoginTime int64 `json:"last_login_time"`
|
||||
MemberStatus string `json:"member_status"`
|
||||
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
||||
UserDevices []UserDevice `json:"user_devices"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -3,10 +3,12 @@ package constant
|
||||
type CtxKey string
|
||||
|
||||
const (
|
||||
CtxKeyUser CtxKey = "user"
|
||||
CtxKeySessionID CtxKey = "sessionId"
|
||||
CtxKeyRequestHost CtxKey = "requestHost"
|
||||
CtxKeyPlatform CtxKey = "platform"
|
||||
CtxKeyPayment CtxKey = "payment"
|
||||
LoginType CtxKey = "loginType"
|
||||
CtxKeyUser CtxKey = "user"
|
||||
CtxKeySessionID CtxKey = "sessionId"
|
||||
CtxKeyRequestHost CtxKey = "requestHost"
|
||||
CtxKeyPlatform CtxKey = "platform"
|
||||
CtxKeyPayment CtxKey = "payment"
|
||||
LoginType CtxKey = "loginType"
|
||||
CtxKeyDeviceID CtxKey = "deviceId"
|
||||
CtxKeyIncludeExpired CtxKey = "includeExpired"
|
||||
)
|
||||
|
||||
@@ -71,6 +71,9 @@ func ParseTransactionJWS(jws string) (*TransactionPayload, error) {
|
||||
t := time.UnixMilli(int64(v))
|
||||
resp.RevocationDate = &t
|
||||
}
|
||||
if v, ok := raw["appAccountToken"].(string); ok {
|
||||
resp.AppAccountToken = v
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ type ServerAPIConfig struct {
|
||||
IssuerID string
|
||||
PrivateKey string
|
||||
Sandbox bool
|
||||
BundleID string
|
||||
}
|
||||
|
||||
func buildAPIToken(cfg ServerAPIConfig) (string, error) {
|
||||
@@ -34,6 +35,9 @@ func buildAPIToken(cfg ServerAPIConfig) (string, error) {
|
||||
"exp": now + 1800,
|
||||
"aud": "appstoreconnect-v1",
|
||||
}
|
||||
if cfg.BundleID != "" {
|
||||
payload["bid"] = cfg.BundleID
|
||||
}
|
||||
hb, _ := json.Marshal(header)
|
||||
pb, _ := json.Marshal(payload)
|
||||
enc := func(b []byte) string {
|
||||
|
||||
@@ -9,5 +9,6 @@ type TransactionPayload struct {
|
||||
OriginalTransactionId string `json:"originalTransactionId"`
|
||||
PurchaseDate time.Time `json:"purchaseDate"`
|
||||
RevocationDate *time.Time`json:"revocationDate"`
|
||||
AppAccountToken string `json:"appAccountToken"`
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ func ConnectMysql(m Mysql) (*gorm.DB, error) {
|
||||
sqldb, _ := db.DB()
|
||||
sqldb.SetMaxIdleConns(m.Config.MaxIdleConns)
|
||||
sqldb.SetMaxOpenConns(m.Config.MaxOpenConns)
|
||||
sqldb.SetConnMaxIdleTime(5 * time.Minute)
|
||||
sqldb.SetConnMaxLifetime(30 * time.Minute)
|
||||
return db, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/jaeger"
|
||||
@@ -54,8 +55,10 @@ func StartAgent(c Config) {
|
||||
|
||||
// if error happens, let later calls run.
|
||||
if err := startAgent(c); err != nil {
|
||||
logger.Errorf("Trace agent start failed: %v", err)
|
||||
return
|
||||
}
|
||||
logger.Infof("Trace agent started successfully. Batcher: %s, Endpoint: %s", c.Batcher, c.Endpoint)
|
||||
|
||||
agents[c.Endpoint] = lang.Placeholder
|
||||
}
|
||||
@@ -92,6 +95,7 @@ func createExporter(c Config) (sdktrace.SpanExporter, error) {
|
||||
opts := []otlptracegrpc.Option{
|
||||
otlptracegrpc.WithInsecure(),
|
||||
otlptracegrpc.WithEndpoint(c.Endpoint),
|
||||
otlptracegrpc.WithTimeout(5 * time.Second), // 5秒超时
|
||||
}
|
||||
if len(c.OtlpHeaders) > 0 {
|
||||
opts = append(opts, otlptracegrpc.WithHeaders(c.OtlpHeaders))
|
||||
|
||||
@@ -5,6 +5,7 @@ package orderLogic
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -354,6 +355,7 @@ func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderIn
|
||||
// This runs asynchronously to avoid blocking the main order processing flow.
|
||||
func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *user.User, orderInfo *order.Order) {
|
||||
if !l.shouldProcessCommission(userInfo, orderInfo.IsNew) {
|
||||
l.grantGiftDaysToBothParties(ctx, userInfo)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -421,6 +423,37 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ActivateOrderLogic) grantGiftDaysToBothParties(ctx context.Context, referee *user.User) {
|
||||
giftDays := l.svc.Config.Invite.GiftDays
|
||||
if giftDays <= 0 || referee == nil || referee.Id == 0 {
|
||||
return
|
||||
}
|
||||
_ = l.grantGiftDays(ctx, referee, int(giftDays))
|
||||
if referee.RefererId == 0 {
|
||||
return
|
||||
}
|
||||
referer, err := l.svc.UserModel.FindOne(ctx, referee.RefererId)
|
||||
if err != nil || referer == nil {
|
||||
return
|
||||
}
|
||||
_ = l.grantGiftDays(ctx, referer, int(giftDays))
|
||||
}
|
||||
|
||||
func (l *ActivateOrderLogic) grantGiftDays(ctx context.Context, u *user.User, days int) error {
|
||||
if u == nil || days <= 0 {
|
||||
return nil
|
||||
}
|
||||
activeSubscribe, err := l.svc.UserModel.FindActiveSubscribe(ctx, u.Id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
activeSubscribe.ExpireTime = activeSubscribe.ExpireTime.Add(time.Duration(days) * 24 * time.Hour)
|
||||
return l.svc.UserModel.UpdateSubscribe(ctx, activeSubscribe)
|
||||
}
|
||||
|
||||
// shouldProcessCommission determines if commission should be processed based on
|
||||
// referrer existence, commission settings, and order type
|
||||
func (l *ActivateOrderLogic) shouldProcessCommission(userInfo *user.User, isFirstPurchase bool) bool {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 说明文档
|
||||
|
||||
## 项目规划
|
||||
检查项目中所有邮件验证码的发送逻辑,确保过期时间统一为 15 分钟。
|
||||
|
||||
## 实施方案
|
||||
1. 搜索整个项目中涉及邮件验证码生成的代码。
|
||||
2. 搜索项目中涉及验证码存储(如 Redis)的代码。
|
||||
3. 检查过期时间常量或变量,确认是否为 900 秒或 15 分钟。
|
||||
4. 修复不符合要求的过期时间。
|
||||
5. 验证修复结果。
|
||||
|
||||
## 进度记录
|
||||
- [2026-01-12 19:58] 启动检查任务,搜索邮件逻辑。
|
||||
- [2026-01-12 20:10] 完成检查。确认以下文件的验证逻辑均为 15 分钟(900秒):
|
||||
- `internal/logic/common/sendEmailCodeLogic.go` (Redis TTL & Template)
|
||||
- `internal/logic/auth/userRegisterLogic.go` (Explicit Check)
|
||||
- `internal/logic/auth/resetPasswordLogic.go` (Explicit Check)
|
||||
- `internal/logic/auth/emailLoginLogic.go` (Explicit Check)
|
||||
- `internal/logic/public/user/bindEmailWithVerificationLogic.go` (Explicit Check)
|
||||
- `internal/logic/public/user/verifyEmailLogic.go` (Explicit Check)
|
||||
- [2026-01-12 20:11] 检查结论:所有邮件验证码发送逻辑均符合 15 分钟过期的要求。
|
||||
- [2026-01-12 20:35] **统一过期时间配置**:
|
||||
- 修改 `internal/config/config.go` 默认过期时间从 300 秒改为 900 秒
|
||||
- 修改 `initialize/migrate/database/00002_init_basic_data.up.sql` 初始值从 300 改为 900
|
||||
- 移除所有逻辑文件中的硬编码 `15` 和 `900`,改为使用 `l.svcCtx.Config.VerifyCode.ExpireTime`
|
||||
- 编译通过,无错误
|
||||
- [2026-01-13] **设备移出和邀请码优化**:
|
||||
- 修复设备B绑定邮箱后被从设备A移除时未自动退出的问题(通过踢出旧连接和清理缓存实现)
|
||||
- 优化邀请码无效时的错误提示,返回 "无邀请码"
|
||||
Reference in New Issue
Block a user