diff --git a/.trae/documents/APP_PC 报错日志收集接口与表设计.md b/.trae/documents/APP_PC 报错日志收集接口与表设计.md index 73aeea5..fe5535f 100644 --- a/.trae/documents/APP_PC 报错日志收集接口与表设计.md +++ b/.trae/documents/APP_PC 报错日志收集接口与表设计.md @@ -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。 -- 单元测试: - - 入库成功/去重冲突/字段截断 - - 多维筛选与分页 -- 文档:在项目说明文档补充采集字段、接口规范与数据保留策略。 \ No newline at end of file diff --git a/apis/auth/auth.api b/apis/auth/auth.api index 8211bef..dfe6642 100644 --- a/apis/auth/auth.api +++ b/apis/auth/auth.api @@ -50,6 +50,15 @@ type ( LoginType string `header:"Login-Type"` CfToken string `json:"cf_token,optional"` } + EmailLoginRequest { + 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 +150,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) diff --git a/internal/handler/auth/emailLoginHandler.go b/internal/handler/auth/emailLoginHandler.go new file mode 100644 index 0000000..4aea673 --- /dev/null +++ b/internal/handler/auth/emailLoginHandler.go @@ -0,0 +1,31 @@ +package auth + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/auth" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func EmailLoginHandler(svcCtx *svc.ServiceContext) gin.HandlerFunc { + return func(c *gin.Context) { + var req types.EmailLoginRequest + if err := c.ShouldBind(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + + req.IP = c.ClientIP() + req.UserAgent = c.Request.UserAgent() + + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + + l := auth.NewEmailLoginLogic(c.Request.Context(), svcCtx) + resp, err := l.EmailLogin(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 428ada5..8ddf7e0 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -597,6 +597,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)) diff --git a/internal/logic/admin/user/deleteUserDeviceLogic.go b/internal/logic/admin/user/deleteUserDeviceLogic.go index 2c73009..ce9cd84 100644 --- a/internal/logic/admin/user/deleteUserDeviceLogic.go +++ b/internal/logic/admin/user/deleteUserDeviceLogic.go @@ -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 } diff --git a/internal/logic/auth/emailLoginLogic.go b/internal/logic/auth/emailLoginLogic.go new file mode 100644 index 0000000..b92482e --- /dev/null +++ b/internal/logic/auth/emailLoginLogic.go @@ -0,0 +1,247 @@ +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. + cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Security, req.Email) + value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result() + if err != nil { + l.Errorw("Verification code error (Redis get)", logger.Field("cacheKey", cacheKey), logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired") + } + + var payload common.CacheKeyPayload + if err := json.Unmarshal([]byte(value), &payload); err != nil { + l.Errorw("Unmarshal error", logger.Field("error", err.Error()), logger.Field("value", value)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error") + } + if payload.Code != req.Code { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code mismatch") + } + // Delete code after use? Or keep it? Usually delete. + l.svcCtx.Redis.Del(l.ctx, cacheKey) + + + // 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), + }) + } + } + }() + + // 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), + ) + 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 +} diff --git a/internal/model/user/authMethod.go b/internal/model/user/authMethod.go index 18ce951..e2864f8 100644 --- a/internal/model/user/authMethod.go +++ b/internal/model/user/authMethod.go @@ -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 { diff --git a/internal/model/user/model.go b/internal/model/user/model.go index f5c7752..0f254bc 100644 --- a/internal/model/user/model.go +++ b/internal/model/user/model.go @@ -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) @@ -134,27 +135,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) } diff --git a/internal/types/types.go b/internal/types/types.go index 6099628..b5d1190 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -595,6 +595,16 @@ type EmailAuthticateConfig struct { DomainSuffixList string `json:"domain_suffix_list"` } +type EmailLoginRequest struct { + 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"`