diff --git a/.codex-tmp/inspect_device_cache_roundtrip.go b/.codex-tmp/inspect_device_cache_roundtrip.go new file mode 100644 index 0000000..4a6f5c6 --- /dev/null +++ b/.codex-tmp/inspect_device_cache_roundtrip.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/conf" +) + +func main() { + var c config.Config + conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c) + ctx := svc.NewServiceContext(c) + const key = "cache:auth:method:device" + before, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result() + fmt.Printf("cache before=%q\n", before) + m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device") + fmt.Printf("model err=%v enabled_nil=%v", err, m == nil || m.Enabled == nil) + if m != nil && m.Enabled != nil { fmt.Printf(" enabled=%v", *m.Enabled) } + if m != nil { fmt.Printf(" config=%s", m.Config) } + fmt.Println() + after, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result() + fmt.Printf("cache after=%q\n", after) +} diff --git a/.codex-tmp/inspect_device_config.go b/.codex-tmp/inspect_device_config.go new file mode 100644 index 0000000..9299623 --- /dev/null +++ b/.codex-tmp/inspect_device_config.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + + initpkg "github.com/perfect-panel/server/initialize" + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/conf" +) + +func main() { + var c config.Config + conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c) + ctx := svc.NewServiceContext(c) + method, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device") + if err != nil { + panic(err) + } + fmt.Printf("db auth_method.enabled=%v config=%s\n", *method.Enabled, method.Config) + initpkg.Device(ctx) + fmt.Printf("ctx.Config.Device.Enable=%v SecuritySecret=%q EnableSecurity=%v\n", ctx.Config.Device.Enable, ctx.Config.Device.SecuritySecret, ctx.Config.Device.EnableSecurity) +} diff --git a/.codex-tmp/inspect_device_db_vs_model.go b/.codex-tmp/inspect_device_db_vs_model.go new file mode 100644 index 0000000..ee58a04 --- /dev/null +++ b/.codex-tmp/inspect_device_db_vs_model.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/conf" +) + +type row struct { + ID int64 + Method string + Enabled int + Config string +} + +func main() { + var c config.Config + conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c) + ctx := svc.NewServiceContext(c) + + var rows []row + if err := ctx.DB.Raw("SELECT id, method, enabled, config FROM auth_method WHERE method = ?", "device").Scan(&rows).Error; err != nil { + panic(err) + } + fmt.Printf("raw rows: %+v\n", rows) + + m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device") + fmt.Printf("model err=%v\n", err) + if err == nil && m != nil && m.Enabled != nil { + fmt.Printf("model row: id=%d method=%s enabled=%v config=%s\n", m.Id, m.Method, *m.Enabled, m.Config) + } else { + fmt.Printf("model row nil or no enabled ptr: %#v\n", m) + } +} diff --git a/.codex-tmp/inspect_device_gorm.go b/.codex-tmp/inspect_device_gorm.go new file mode 100644 index 0000000..7da8d6f --- /dev/null +++ b/.codex-tmp/inspect_device_gorm.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + + "github.com/perfect-panel/server/internal/config" + authmodel "github.com/perfect-panel/server/internal/model/auth" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/conf" +) + +func main() { + var c config.Config + conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c) + ctx := svc.NewServiceContext(c) + + var a1 authmodel.Auth + err1 := ctx.DB.Model(&authmodel.Auth{}).Where("method = ?", "device").First(&a1).Error + fmt.Printf("gorm direct err=%v enabled_nil=%v", err1, a1.Enabled == nil) + if a1.Enabled != nil { fmt.Printf(" enabled=%v", *a1.Enabled) } + fmt.Printf(" config=%s\n", a1.Config) + + var a2 authmodel.Auth + err2 := ctx.DB.Table("auth_method").Where("method = ?", "device").First(&a2).Error + fmt.Printf("gorm table err=%v enabled_nil=%v", err2, a2.Enabled == nil) + if a2.Enabled != nil { fmt.Printf(" enabled=%v", *a2.Enabled) } + fmt.Printf(" config=%s\n", a2.Config) +} diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 8685d7d..a5740a0 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -55,7 +55,7 @@ jobs: elif [ "${{ github.ref_name }}" = "internal" ]; then echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV echo "CONTAINER_NAME=ppanel-server-internal" >> $GITHUB_ENV - echo "DEPLOY_PATH=/root/hifast" >> $GITHUB_ENV + echo "DEPLOY_PATH=/root/bindbox" >> $GITHUB_ENV echo "为 internal 分支设置开发环境变量" else echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV diff --git a/apis/admin/user.api b/apis/admin/user.api index 6965d40..aa6243f 100644 --- a/apis/admin/user.api +++ b/apis/admin/user.api @@ -230,6 +230,23 @@ type ( FamilyId int64 `json:"family_id" validate:"required,gt=0"` Reason string `json:"reason,omitempty"` } + GetWithdrawalListRequest { + Page int `form:"page"` + Size int `form:"size"` + UserId *int64 `form:"user_id,omitempty"` + Status *uint8 `form:"status,omitempty"` + } + GetWithdrawalListResponse { + List []WithdrawalLog `json:"list"` + Total int64 `json:"total"` + } + ApproveWithdrawalRequest { + WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` + } + RejectWithdrawalRequest { + WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` + Reason string `json:"reason" validate:"required,max=500"` + } ) @server ( @@ -370,5 +387,16 @@ service ppanel { @doc "Dissolve family" @handler DissolveFamily put /family/dissolve (DissolveFamilyRequest) -} + @doc "Get withdrawal list" + @handler GetWithdrawalList + get /withdrawal/list (GetWithdrawalListRequest) returns (GetWithdrawalListResponse) + + @doc "Approve withdrawal" + @handler ApproveWithdrawal + post /withdrawal/approve (ApproveWithdrawalRequest) + + @doc "Reject withdrawal" + @handler RejectWithdrawal + post /withdrawal/reject (RejectWithdrawalRequest) +} diff --git a/apis/types.api b/apis/types.api index 609abab..88a347a 100644 --- a/apis/types.api +++ b/apis/types.api @@ -27,6 +27,7 @@ type ( EnableLoginNotify bool `json:"enable_login_notify"` EnableSubscribeNotify bool `json:"enable_subscribe_notify"` EnableTradeNotify bool `json:"enable_trade_notify"` + UseStatus bool `json:"use_status"` // Whether to show the "bind email to get free trial" prompt AuthMethods []UserAuthMethod `json:"auth_methods"` UserDevices []UserDevice `json:"user_devices"` Rules []string `json:"rules"` @@ -1006,4 +1007,3 @@ type ( ConfigSnapshot map[string]interface{} `json:"config_snapshot,omitempty"` } ) - diff --git a/deploy/aws/ap-east-1/configs/replica-ops.env.example b/deploy/aws/ap-east-1/configs/replica-ops.env.example index 5c86942..f6d7e59 100644 --- a/deploy/aws/ap-east-1/configs/replica-ops.env.example +++ b/deploy/aws/ap-east-1/configs/replica-ops.env.example @@ -17,7 +17,7 @@ REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_PASSWORD=CHANGE_ME -REDIS_SOURCE_HOST=43.198.248.161 +REDIS_SOURCE_HOST=18.163.33.75 REDIS_SOURCE_PORT=6379 REDIS_SOURCE_USER= REDIS_SOURCE_PASSWORD=CHANGE_ME diff --git a/doc/tapi-file-upload-zh.md b/doc/tapi-file-upload-zh.md new file mode 100644 index 0000000..c257041 --- /dev/null +++ b/doc/tapi-file-upload-zh.md @@ -0,0 +1,184 @@ +# TAPI 文件上传接入说明 + +本文档说明 `https://tapi.hifast.biz/v1/public/file/upload` 相关上传接口的推荐接入方式、签名规则与常见排查方式。 + +## 总览 + +上传能力包含两类接入方式: + +- 推荐方式:`init -> S3 PUT -> complete` +- 兼容方式:`/upload` multipart 直传 + +推荐优先使用预签名三段式,因为: + +- 现有签名串包含 `BODY_SHA256` +- `/upload` 是 `multipart/form-data` +- multipart 原始 body 的签名和调试成本更高 +- `init` 和 `complete` 是 JSON,更适合客户端和 Apifox 调试 + +## 签名生效逻辑 + +项目保持现有旧逻辑,不做强制签名改造: + +- `Signature.EnableSignature = false` 时:不校验签名 +- `Signature.EnableSignature = true` 且未携带 `X-App-Id` 时:不校验签名,兼容老客户端 +- `Signature.EnableSignature = true` 且携带 `X-App-Id` 时:必须同时携带并校验 + - `X-Timestamp` + - `X-Nonce` + - `X-Signature` + +这意味着: + +- 新客户端建议始终带完整签名头 +- 老客户端如果没有 `X-App-Id`,仍可按旧逻辑访问 + +## 签名头定义 + +- `X-App-Id`: 客户端标识,例如 `ios-client` +- `X-Timestamp`: Unix 秒级时间戳 +- `X-Nonce`: 每次请求唯一随机串 +- `X-Signature`: `HMAC-SHA256` 结果的十六进制小写字符串 + +## StringToSign 规则 + +StringToSign 由下面 7 段按换行符 `\n` 拼接: + +```text +METHOD +PATH +CANONICAL_QUERY +BODY_SHA256 +X-App-Id +X-Timestamp +X-Nonce +``` + +说明: + +- `METHOD`:HTTP 方法大写,例如 `POST` +- `PATH`:请求路径,例如 `/v1/public/file/upload/init` +- `CANONICAL_QUERY`:按 key 排序后的 query string,没有 query 则为空字符串 +- `BODY_SHA256`:请求体原始字节的 SHA-256 十六进制小写 +- 其余三项直接使用请求头值 + +签名计算方式: + +```text +signature = hex_lower(HMAC_SHA256(app_secret, string_to_sign)) +``` + +时间窗与防重放: + +- `X-Timestamp` 默认有效时间窗是 300 秒 +- `X-Nonce` 在有效时间窗内不能重复使用 + +## 推荐接入:预签名三段式 + +### 1. 初始化上传 + +请求: + +```bash +curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload/init' \ + -H 'Accept: application/json, text/plain, */*' \ + -H 'Content-Type: application/json' \ + -H 'authorization: your-token' \ + -H 'X-App-Id: ios-client' \ + -H 'X-Timestamp: 1778776400' \ + -H 'X-Nonce: nonce-001' \ + -H 'X-Signature: your-signature' \ + -d '{ + "biz_type": "app-package", + "file_name": "demo.zip", + "content_type": "application/zip", + "size": 123456, + "sha256": "" + }' +``` + +典型返回: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "file_id": "c29274ee26ab5aa211e0396e", + "object_key": "app-upload/app-package/519/2026/05/c29274ee26ab5aa211e0396e_demo.zip", + "upload_url": "https://bucket.s3.ap-east-1.amazonaws.com/...", + "method": "PUT", + "headers": { + "Content-Type": "application/zip" + }, + "expired_at": 1778776715 + } +} +``` + +### 2. 直传 S3 + +这一步是直接上传二进制文件到 S3,不走业务签名中间件。 + +```bash +curl -X PUT 'https://bucket.s3.ap-east-1.amazonaws.com/...' \ + -H 'Content-Type: application/zip' \ + --upload-file '/tmp/demo.zip' +``` + +说明: + +- `Content-Type` 需和 `init` 返回的 `headers.Content-Type` 一致 +- `upload_url` 有过期时间,通常 300 秒 +- 成功时 S3 常见返回 `200` 或 `204` + +### 3. 完成上传 + +```bash +curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload/complete' \ + -H 'Accept: application/json, text/plain, */*' \ + -H 'Content-Type: application/json' \ + -H 'authorization: your-token' \ + -H 'X-App-Id: ios-client' \ + -H 'X-Timestamp: 1778776405' \ + -H 'X-Nonce: nonce-002' \ + -H 'X-Signature: your-signature' \ + -d '{ + "file_id": "c29274ee26ab5aa211e0396e" + }' +``` + +## 兼容接入:单接口 multipart 直传 + +接口: + +- `POST /v1/public/file/upload` + +表单字段: + +- `biz_type` +- `file` + +说明: + +- 该接口继续保留,兼容旧客户端 +- 如果请求带了 `X-App-Id`,就按现有逻辑验签 +- 如果没有 `X-App-Id`,仍按旧逻辑放行 +- 如果要给该接口加签,签名时必须对原始 multipart body 计算 `BODY_SHA256` + +## 常见错误码 + +- `200`: 成功 +- `400`: 参数错误 +- `40008`: 缺少签名头 +- `40009`: 签名已过期 +- `40010`: 签名无效 +- `40011`: nonce 重放 +- `10001`: 上传元数据不存在或对象不存在 + +## 排查建议 + +- `40008`:确认带了 `X-App-Id` 后,也同时带上 `X-Timestamp / X-Nonce / X-Signature` +- `40009`:检查客户端时间是否偏差过大 +- `40010`:确认 `PATH`、query 排序、body 原始字节、secret 是否完全一致 +- `40011`:确保每次请求都生成新的 `X-Nonce` +- `complete` 失败:确认 S3 `PUT` 已成功,且上传大小与 `init.size` 一致 diff --git a/initialize/migrate/database/00001_init_schema.up.sql b/initialize/migrate/database/00001_init_schema.up.sql index ab5d0e4..490cd5e 100644 --- a/initialize/migrate/database/00001_init_schema.up.sql +++ b/initialize/migrate/database/00001_init_schema.up.sql @@ -449,7 +449,7 @@ CREATE TABLE IF NOT EXISTS `user_device` `subscribe_id` bigint DEFAULT NULL COMMENT 'Subscribe ID', `ip` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Ip.', `Identifier` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Identifier.', - `user_agent` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device User Agent.', + `user_agent` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device User Agent.', `online` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Online', `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'EnableDeviceNumber', `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', diff --git a/initialize/schema_compat.go b/initialize/schema_compat.go index ee908b2..4fca11a 100644 --- a/initialize/schema_compat.go +++ b/initialize/schema_compat.go @@ -20,6 +20,14 @@ type schemaColumnPatch struct { ddl string } +type schemaColumnDefinitionPatch struct { + table string + column string + dataType string + characterMaxLen *int64 + ddl string +} + func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error { tablePatches := []schemaTablePatch{ { @@ -142,6 +150,17 @@ func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error { }, } + varchar255 := int64(255) + columnDefinitionPatches := []schemaColumnDefinitionPatch{ + { + table: "user_device", + column: "user_agent", + dataType: "varchar", + characterMaxLen: &varchar255, + ddl: "ALTER TABLE `user_device` MODIFY COLUMN `user_agent` VARCHAR(255) NULL COMMENT 'Device User Agent.';", + }, + } + for _, patch := range tablePatches { exists, err := tableExists(ctx.DB, patch.table) if err != nil { @@ -199,6 +218,27 @@ func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error { logger.Infof("[SchemaCompat] created missing index: %s.%s", patch.table, patch.index) } + for _, patch := range columnDefinitionPatches { + tblExists, err := tableExists(ctx.DB, patch.table) + if err != nil { + return errors.Wrapf(err, "check table %s failed", patch.table) + } + if !tblExists { + continue + } + matches, err := columnDefinitionMatches(ctx.DB, patch.table, patch.column, patch.dataType, patch.characterMaxLen) + if err != nil { + return errors.Wrapf(err, "check column definition %s.%s failed", patch.table, patch.column) + } + if matches { + continue + } + if err = ctx.DB.Exec(patch.ddl).Error; err != nil { + return errors.Wrapf(err, "modify column %s.%s failed", patch.table, patch.column) + } + logger.Infof("[SchemaCompat] repaired column definition: %s.%s", patch.table, patch.column) + } + return nil } @@ -237,6 +277,38 @@ func indexExists(db *gorm.DB, table, index string) (bool, error) { return count > 0, nil } +func columnDefinitionMatches(db *gorm.DB, table, column, dataType string, characterMaxLen *int64) (bool, error) { + type columnMeta struct { + DataType string + CharacterMaximumLen *int64 + } + + var meta columnMeta + err := db.Raw( + `SELECT DATA_TYPE AS data_type, CHARACTER_MAXIMUM_LENGTH AS character_maximum_len +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`, + table, + column, + ).Scan(&meta).Error + if err != nil { + return false, err + } + if meta.DataType == "" { + return false, nil + } + if meta.DataType != dataType { + return false, nil + } + if characterMaxLen == nil { + return true, nil + } + if meta.CharacterMaximumLen == nil { + return false, nil + } + return *meta.CharacterMaximumLen == *characterMaxLen, nil +} + func _schemaCompatDebug(table, column string) string { if column == "" { return table diff --git a/internal/handler/admin/user/approveWithdrawalHandler.go b/internal/handler/admin/user/approveWithdrawalHandler.go new file mode 100644 index 0000000..c0d76ec --- /dev/null +++ b/internal/handler/admin/user/approveWithdrawalHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Approve withdrawal +func ApproveWithdrawalHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.ApproveWithdrawalRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewApproveWithdrawalLogic(c.Request.Context(), svcCtx) + err := l.ApproveWithdrawal(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/user/getWithdrawalListHandler.go b/internal/handler/admin/user/getWithdrawalListHandler.go new file mode 100644 index 0000000..a440c73 --- /dev/null +++ b/internal/handler/admin/user/getWithdrawalListHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Get withdrawal list +func GetWithdrawalListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetWithdrawalListRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewGetWithdrawalListLogic(c.Request.Context(), svcCtx) + resp, err := l.GetWithdrawalList(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/user/rejectWithdrawalHandler.go b/internal/handler/admin/user/rejectWithdrawalHandler.go new file mode 100644 index 0000000..9fac60c --- /dev/null +++ b/internal/handler/admin/user/rejectWithdrawalHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Reject withdrawal +func RejectWithdrawalHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.RejectWithdrawalRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewRejectWithdrawalLogic(c.Request.Context(), svcCtx) + err := l.RejectWithdrawal(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 67e9212..e295bbc 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -713,6 +713,15 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Get admin user invite list adminUserGroupRouter.GET("/invite/list", adminUser.GetAdminUserInviteListHandler(serverCtx)) + + // Get withdrawal list + adminUserGroupRouter.GET("/withdrawal/list", adminUser.GetWithdrawalListHandler(serverCtx)) + + // Approve withdrawal + adminUserGroupRouter.POST("/withdrawal/approve", adminUser.ApproveWithdrawalHandler(serverCtx)) + + // Reject withdrawal + adminUserGroupRouter.POST("/withdrawal/reject", adminUser.RejectWithdrawalHandler(serverCtx)) } authGroupRouter := router.Group("/v1/auth") diff --git a/internal/logic/admin/user/approveWithdrawalLogic.go b/internal/logic/admin/user/approveWithdrawalLogic.go new file mode 100644 index 0000000..694d150 --- /dev/null +++ b/internal/logic/admin/user/approveWithdrawalLogic.go @@ -0,0 +1,27 @@ +package user + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" +) + +type ApproveWithdrawalLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewApproveWithdrawalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApproveWithdrawalLogic { + return &ApproveWithdrawalLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ApproveWithdrawalLogic) ApproveWithdrawal(req *types.ApproveWithdrawalRequest) error { + return approveWithdrawal(l.ctx, l.svcCtx, req.WithdrawalId) +} diff --git a/internal/logic/admin/user/getWithdrawalListLogic.go b/internal/logic/admin/user/getWithdrawalListLogic.go new file mode 100644 index 0000000..ff262af --- /dev/null +++ b/internal/logic/admin/user/getWithdrawalListLogic.go @@ -0,0 +1,74 @@ +package user + +import ( + "context" + + usermodel "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/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetWithdrawalListLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetWithdrawalListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetWithdrawalListLogic { + return &GetWithdrawalListLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetWithdrawalListLogic) GetWithdrawalList(req *types.GetWithdrawalListRequest) (*types.GetWithdrawalListResponse, error) { + page := req.Page + size := req.Size + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 10 + } + + query := l.svcCtx.DB.WithContext(l.ctx).Model(&usermodel.Withdrawal{}) + if req.UserId != nil { + query = query.Where("user_id = ?", *req.UserId) + } + if req.Status != nil { + query = query.Where("status = ?", *req.Status) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count withdrawals failed: %v", err) + } + + var rows []usermodel.Withdrawal + if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query withdrawals failed: %v", err) + } + + list := make([]types.WithdrawalLog, 0, len(rows)) + for _, row := range rows { + list = append(list, types.WithdrawalLog{ + Id: row.Id, + UserId: row.UserId, + Amount: row.Amount, + Content: row.Content, + Status: row.Status, + Reason: row.Reason, + CreatedAt: row.CreatedAt.UnixMilli(), + UpdatedAt: row.UpdatedAt.UnixMilli(), + }) + } + + return &types.GetWithdrawalListResponse{ + List: list, + Total: total, + }, nil +} diff --git a/internal/logic/admin/user/rejectWithdrawalLogic.go b/internal/logic/admin/user/rejectWithdrawalLogic.go new file mode 100644 index 0000000..6d8f02a --- /dev/null +++ b/internal/logic/admin/user/rejectWithdrawalLogic.go @@ -0,0 +1,27 @@ +package user + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" +) + +type RejectWithdrawalLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRejectWithdrawalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RejectWithdrawalLogic { + return &RejectWithdrawalLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RejectWithdrawalLogic) RejectWithdrawal(req *types.RejectWithdrawalRequest) error { + return rejectWithdrawal(l.ctx, l.svcCtx, req.WithdrawalId, req.Reason) +} diff --git a/internal/logic/admin/user/updateUserBasicInfoLogic.go b/internal/logic/admin/user/updateUserBasicInfoLogic.go index 6a374c2..fcefa92 100644 --- a/internal/logic/admin/user/updateUserBasicInfoLogic.go +++ b/internal/logic/admin/user/updateUserBasicInfoLogic.go @@ -6,6 +6,7 @@ import ( "strings" "time" + logicCommon "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" @@ -100,21 +101,15 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi } if req.Commission != userInfo.Commission { - - commentLog := log.Commission{ - Type: log.CommissionTypeAdjust, - Amount: req.Commission - userInfo.Commission, - Timestamp: time.Now().UnixMilli(), + if isWithdrawalScene(req.Remark) { + logWithdrawalGuard(l.Logger, userInfo.Id) + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene") } - - content, _ := commentLog.Marshal() - err = tx.Create(&log.SystemLog{ - Type: log.TypeCommission.Uint8(), - Date: time.Now().Format(time.DateOnly), - ObjectID: userInfo.Id, - Content: string(content), - }).Error - if err != nil { + change := req.Commission - userInfo.Commission + if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil { + return err + } + if err = logicCommon.WriteCommissionLog(tx, userInfo.Id, log.CommissionTypeAdjust, change, ""); err != nil { return err } userInfo.Commission = req.Commission diff --git a/internal/logic/common/logMessageReportLogic.go b/internal/logic/common/logMessageReportLogic.go index 2427c18..c854d33 100644 --- a/internal/logic/common/logMessageReportLogic.go +++ b/internal/logic/common/logMessageReportLogic.go @@ -1,108 +1,120 @@ package common import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "net" - "strings" - "time" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net" + "strings" + "time" - "github.com/gin-gonic/gin" - logmessage "github.com/perfect-panel/server/internal/model/logmessage" - "github.com/perfect-panel/server/internal/svc" - "github.com/perfect-panel/server/internal/types" - "github.com/perfect-panel/server/pkg/logger" - "github.com/perfect-panel/server/pkg/xerr" - "github.com/pkg/errors" + "github.com/gin-gonic/gin" + logmessage "github.com/perfect-panel/server/internal/model/logmessage" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" ) type ReportLogMessageLogic struct { - logger.Logger - ctx context.Context - svcCtx *svc.ServiceContext + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext } func NewReportLogMessageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReportLogMessageLogic { - return &ReportLogMessageLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx } + return &ReportLogMessageLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} } func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequest, c *gin.Context) (resp *types.ReportLogMessageResponse, err error) { - ip := clientIP(c) - ua := c.GetHeader("User-Agent") - locale := c.GetHeader("Accept-Language") + ip := clientIP(c) + ua := c.GetHeader("User-Agent") + locale := c.GetHeader("Accept-Language") - // 简单限流:设备ID优先,其次IP - limitKey := "logmsg:" + strings.TrimSpace(req.DeviceId) - if limitKey == "logmsg:" { limitKey = "logmsg:" + ip } - count, _ := l.svcCtx.Redis.Incr(l.ctx, limitKey).Result() - if count == 1 { _ = l.svcCtx.Redis.Expire(l.ctx, limitKey, 60*time.Second).Err() } - if count > 120 { // 每分钟最多120条 - return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "too many reports") - } + // 简单限流:设备ID优先,其次IP + limitKey := "logmsg:" + strings.TrimSpace(req.DeviceId) + if limitKey == "logmsg:" { + limitKey = "logmsg:" + ip + } + count, _ := l.svcCtx.Redis.Incr(l.ctx, limitKey).Result() + if count == 1 { + _ = l.svcCtx.Redis.Expire(l.ctx, limitKey, 60*time.Second).Err() + } + if count > 120 { // 每分钟最多120条 + return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "too many reports") + } - // 指纹生成 - h := sha256.New() - h.Write([]byte(strings.Join([]string{req.Message, req.Stack, req.ErrorCode, req.AppVersion, req.Platform}, "|"))) - digest := hex.EncodeToString(h.Sum(nil)) + // 指纹生成 + h := sha256.New() + h.Write([]byte(strings.Join([]string{req.Message, req.Stack, req.ErrorCode, req.AppVersion, req.Platform}, "|"))) + digest := hex.EncodeToString(h.Sum(nil)) - var ctxStr string - if req.Context != nil { - if b, e := json.Marshal(req.Context); e == nil { - ctxStr = string(b) - } - } - var occurredAt *time.Time - if req.OccurredAt > 0 { - t := time.UnixMilli(req.OccurredAt) - occurredAt = &t - } + var ctxStr string + if req.Context != nil { + if b, e := json.Marshal(req.Context); e == nil { + ctxStr = string(b) + } + } + var occurredAt *time.Time + if req.OccurredAt > 0 { + t := time.UnixMilli(req.OccurredAt) + occurredAt = &t + } - var userIdPtr *int64 - if req.UserId > 0 { userIdPtr = &req.UserId } + var userIdPtr *int64 + if req.UserId > 0 { + userIdPtr = &req.UserId + } - row := &logmessage.LogMessage{ - Platform: req.Platform, - AppVersion: req.AppVersion, - OsName: req.OsName, - OsVersion: req.OsVersion, - DeviceId: req.DeviceId, - UserId: userIdPtr, - SessionId: req.SessionId, - Level: req.Level, - ErrorCode: req.ErrorCode, - Message: safeTruncate(req.Message, 1024*64), - Stack: safeTruncate(req.Stack, 1024*1024), - Context: ctxStr, - ClientIP: ip, - UserAgent: safeTruncate(ua, 255), - Locale: safeTruncate(locale, 16), - Digest: digest, - OccurredAt: occurredAt, - } + row := &logmessage.LogMessage{ + Platform: safeTruncate(req.Platform, 32), + AppVersion: safeTruncate(req.AppVersion, 64), + OsName: safeTruncate(req.OsName, 64), + OsVersion: safeTruncate(req.OsVersion, 64), + DeviceId: safeTruncate(req.DeviceId, 255), + UserId: userIdPtr, + SessionId: safeTruncate(req.SessionId, 255), + Level: req.Level, + ErrorCode: safeTruncate(req.ErrorCode, 128), + Message: safeTruncate(req.Message, 1024*64), + Stack: safeTruncate(req.Stack, 1024*1024), + Context: ctxStr, + ClientIP: ip, + UserAgent: safeTruncate(ua, 255), + Locale: safeTruncate(locale, 16), + Digest: digest, + OccurredAt: occurredAt, + } - if err = l.svcCtx.LogMessageModel.Insert(l.ctx, row); err != nil { - // 唯一指纹冲突时尝试查询已有记录返回ID - ex, _, findErr := l.svcCtx.LogMessageModel.Filter(l.ctx, &logmessage.FilterParams{ Keyword: req.Message, Page: 1, Size: 1 }) - if findErr == nil && len(ex) > 0 { - return &types.ReportLogMessageResponse{ Id: ex[0].Id }, nil - } - l.Errorf("[ReportLogMessage] insert error: %v", err) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "insert log_message failed: %v", err) - } - return &types.ReportLogMessageResponse{ Id: row.Id }, nil + if err = l.svcCtx.LogMessageModel.Insert(l.ctx, row); err != nil { + // 唯一指纹冲突时尝试查询已有记录返回ID + ex, _, findErr := l.svcCtx.LogMessageModel.Filter(l.ctx, &logmessage.FilterParams{Keyword: req.Message, Page: 1, Size: 1}) + if findErr == nil && len(ex) > 0 { + return &types.ReportLogMessageResponse{Id: ex[0].Id}, nil + } + l.Errorf("[ReportLogMessage] insert error: %v", err) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "insert log_message failed: %v", err) + } + return &types.ReportLogMessageResponse{Id: row.Id}, nil } func safeTruncate(s string, n int) string { - if len(s) <= n { return s } - return s[:n] + if len(s) <= n { + return s + } + return s[:n] } func clientIP(c *gin.Context) string { - ip := c.ClientIP() - if ip != "" { return ip } - host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr)) - if err == nil && host != "" { return host } - return "" + ip := c.ClientIP() + if ip != "" { + return ip + } + host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr)) + if err == nil && host != "" { + return host + } + return "" } diff --git a/internal/logic/public/file/common.go b/internal/logic/public/file/common.go index c7e51da..cd1e909 100644 --- a/internal/logic/public/file/common.go +++ b/internal/logic/public/file/common.go @@ -109,14 +109,14 @@ func buildObjectKey(prefix string, userID int64, bizType, fileID, fileName strin if prefix == "" { prefix = "app-upload" } - return fmt.Sprintf("%s/%s/%d/%04d/%02d/%s_%s", + return fmt.Sprintf("%s/%04d/%02d/%02d/%d/%s__%s", prefix, - strings.Trim(safeFileNameRegexp.ReplaceAllString(strings.ToLower(strings.TrimSpace(bizType)), "-"), "-"), - userID, now.Year(), int(now.Month()), - fileID, + now.Day(), + userID, safeFileName(fileName), + fileID, ) } diff --git a/internal/logic/public/user/queryUserInfoLogic.go b/internal/logic/public/user/queryUserInfoLogic.go index 5682b42..ee57146 100644 --- a/internal/logic/public/user/queryUserInfoLogic.go +++ b/internal/logic/public/user/queryUserInfoLogic.go @@ -6,7 +6,9 @@ import ( "sort" "strings" + authlogic "github.com/perfect-panel/server/internal/logic/auth" logicCommon "github.com/perfect-panel/server/internal/logic/common" + modelOrder "github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/uuidx" "github.com/perfect-panel/server/pkg/xerr" @@ -44,6 +46,7 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) { return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } tool.DeepCopy(resp, u) + resp.UseStatus = true // 用家庭范围查设备,而不是只看当前用户自己的 UserDevices scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx) @@ -65,6 +68,14 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) { } resp.UserDevices = userDevices } + + useStatus, useStatusErr := l.resolveBindEmailTrialUseStatus(u.Id, scopeUserIds) + if useStatusErr != nil { + l.Errorw("resolve bind email trial use status failed", logger.Field("user_id", u.Id), logger.Field("error", useStatusErr.Error())) + } else { + resp.UseStatus = useStatus + } + // refer_code 为空时自动生成 if resp.ReferCode == "" { resp.ReferCode = uuidx.UserInviteCode(u.Id) @@ -108,6 +119,49 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) { return resp, nil } +// resolveBindEmailTrialUseStatus determines whether userinfo should show the +// "bind email to get free trial" prompt. `true` means show the prompt. +func (l *QueryUserInfoLogic) resolveBindEmailTrialUseStatus(currentUserId int64, scopeUserIds []int64) (bool, error) { + if len(scopeUserIds) == 0 { + scopeUserIds = []int64{currentUserId} + } + + var hasBoundEmailCount int64 + if err := l.svcCtx.DB.WithContext(l.ctx). + Model(&user.AuthMethods{}). + Where("user_id IN ? AND auth_type = ? AND auth_identifier != ''", scopeUserIds, "email"). + Count(&hasBoundEmailCount).Error; err != nil { + return false, err + } + + var hasPurchaseCount int64 + if err := l.svcCtx.DB.WithContext(l.ctx). + Model(&modelOrder.Order{}). + Where("user_id IN ? AND type IN ? AND status IN ?", scopeUserIds, []int64{1, 2}, []int64{2, 5}). + Count(&hasPurchaseCount).Error; err != nil { + return false, err + } + + hasTrial := false + registerCfg := l.svcCtx.Config.Register + if authlogic.IsTrialConfigReady(registerCfg) && registerCfg.TrialSubscribe > 0 { + var hasTrialCount int64 + if err := l.svcCtx.DB.WithContext(l.ctx). + Model(&user.Subscribe{}). + Where("user_id IN ? AND subscribe_id = ?", scopeUserIds, registerCfg.TrialSubscribe). + Count(&hasTrialCount).Error; err != nil { + return false, err + } + hasTrial = hasTrialCount > 0 + } + + return shouldShowBindEmailTrialPrompt(hasBoundEmailCount > 0, hasPurchaseCount > 0, hasTrial), nil +} + +func shouldShowBindEmailTrialPrompt(hasBoundEmail, hasPurchased, hasTrial bool) bool { + return !hasBoundEmail && !hasPurchased && !hasTrial +} + func (l *QueryUserInfoLogic) fillFamilyContext(resp *types.User, userId int64) *user.AuthMethods { type familyRelation struct { FamilyId int64 diff --git a/internal/logic/public/user/queryUserInfoLogic_test.go b/internal/logic/public/user/queryUserInfoLogic_test.go new file mode 100644 index 0000000..369614a --- /dev/null +++ b/internal/logic/public/user/queryUserInfoLogic_test.go @@ -0,0 +1,67 @@ +package user + +import "testing" + +func TestShouldShowBindEmailTrialPrompt(t *testing.T) { + tests := []struct { + name string + hasBoundEmail bool + hasPurchased bool + hasTrial bool + want bool + }{ + { + name: "new user should see prompt", + want: true, + }, + { + name: "bound email should hide prompt", + hasBoundEmail: true, + want: false, + }, + { + name: "paid purchase should hide prompt", + hasPurchased: true, + want: false, + }, + { + name: "trial claimed should hide prompt", + hasTrial: true, + want: false, + }, + { + name: "bound email and purchase should hide prompt", + hasBoundEmail: true, + hasPurchased: true, + want: false, + }, + { + name: "bound email and trial should hide prompt", + hasBoundEmail: true, + hasTrial: true, + want: false, + }, + { + name: "purchase and trial should hide prompt", + hasPurchased: true, + hasTrial: true, + want: false, + }, + { + name: "all blockers should hide prompt", + hasBoundEmail: true, + hasPurchased: true, + hasTrial: true, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldShowBindEmailTrialPrompt(tt.hasBoundEmail, tt.hasPurchased, tt.hasTrial) + if got != tt.want { + t.Fatalf("shouldShowBindEmailTrialPrompt(%v, %v, %v) = %v, want %v", tt.hasBoundEmail, tt.hasPurchased, tt.hasTrial, got, tt.want) + } + }) + } +} diff --git a/internal/logic/public/user/queryWithdrawalLogLogic.go b/internal/logic/public/user/queryWithdrawalLogLogic.go index 1b1a583..79e8a2c 100644 --- a/internal/logic/public/user/queryWithdrawalLogLogic.go +++ b/internal/logic/public/user/queryWithdrawalLogLogic.go @@ -3,9 +3,13 @@ package user import ( "context" + "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" ) type QueryWithdrawalLogLogic struct { @@ -24,7 +28,49 @@ func NewQueryWithdrawalLogLogic(ctx context.Context, svcCtx *svc.ServiceContext) } func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalLogListRequest) (resp *types.QueryWithdrawalLogListResponse, err error) { - // todo: add your logic here and delete this line + u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok { + l.Error("current user is not found in context") + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } - return + page := req.Page + size := req.Size + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 10 + } + + query := l.svcCtx.DB.WithContext(l.ctx).Model(&user.Withdrawal{}).Where("user_id = ?", u.Id) + + var total int64 + if err = query.Count(&total).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count withdrawal logs failed: %v", err) + } + + var rows []user.Withdrawal + if err = query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query withdrawal logs failed: %v", err) + } + + list := make([]types.WithdrawalLog, 0, len(rows)) + for _, row := range rows { + list = append(list, types.WithdrawalLog{ + Id: row.Id, + UserId: row.UserId, + Amount: row.Amount, + Content: row.Content, + Status: row.Status, + Reason: row.Reason, + CreatedAt: row.CreatedAt.UnixMilli(), + UpdatedAt: row.UpdatedAt.UnixMilli(), + }) + } + + return &types.QueryWithdrawalLogListResponse{ + List: list, + Total: total, + }, nil } diff --git a/internal/middleware/deviceMiddleware.go b/internal/middleware/deviceMiddleware.go index 935c1c6..2ac7a27 100644 --- a/internal/middleware/deviceMiddleware.go +++ b/internal/middleware/deviceMiddleware.go @@ -31,6 +31,8 @@ const ( ctxDecryptedQueryKey = "decrypted_query" ctxEncryptedBodyKey = "encrypted_request_body" ctxDecryptedBodyKey = "decrypted_request_body" + + deviceDecryptSkipPathPublicFileUpload = "/v1/public/file/upload" ) func DeviceMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) { @@ -70,6 +72,14 @@ func DeviceMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) { } rw := NewResponseWriter(c, srvCtx) + if shouldSkipDeviceRequestDecrypt(c) { + c.Set(ctxDeviceDecryptStatusKey, "skipped") + c.Set(ctxDeviceDecryptReasonKey, "multipart_upload_passthrough") + c.Writer = rw + c.Next() + rw.FlushAbort() + return + } if !rw.Decrypt() { c.Set(ctxDeviceDecryptStatusKey, "failed") if _, exists := c.Get(ctxDeviceDecryptReasonKey); !exists { @@ -85,6 +95,13 @@ func DeviceMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) { } } +func shouldSkipDeviceRequestDecrypt(c *gin.Context) bool { + if c.Request.URL.Path != deviceDecryptSkipPathPublicFileUpload { + return false + } + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "multipart/form-data") +} + func NewResponseWriter(c *gin.Context, srvCtx *svc.ServiceContext) (rw *ResponseWriter) { rw = &ResponseWriter{ c: c, diff --git a/internal/model/log/log.go b/internal/model/log/log.go index a3cc36c..cb335ec 100644 --- a/internal/model/log/log.go +++ b/internal/model/log/log.go @@ -50,6 +50,7 @@ const ( CommissionTypeWithdraw uint16 = 334 // withdraw CommissionTypeAdjust uint16 = 335 // Admin Adjust CommissionTypeConvertBalance uint16 = 336 // Convert to Balance + CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund GiftTypeIncrease uint16 = 341 // Increase GiftTypeReduce uint16 = 342 // Reduce ) diff --git a/internal/model/logmessage/entity.go b/internal/model/logmessage/entity.go index 233e3d9..1607e6c 100644 --- a/internal/model/logmessage/entity.go +++ b/internal/model/logmessage/entity.go @@ -3,25 +3,25 @@ package logmessage import "time" type LogMessage struct { - Id int64 `gorm:"primaryKey;AUTO_INCREMENT"` - Platform string `gorm:"type:varchar(32);not null"` - AppVersion string `gorm:"type:varchar(32);default:null"` - OsName string `gorm:"type:varchar(32);default:null"` - OsVersion string `gorm:"type:varchar(32);default:null"` - DeviceId string `gorm:"type:varchar(64);default:null"` - UserId *int64 `gorm:"type:bigint;default:null"` - SessionId string `gorm:"type:varchar(64);default:null"` - Level uint8 `gorm:"type:tinyint(1);not null;default:3"` - ErrorCode string `gorm:"type:varchar(64);default:null"` - Message string `gorm:"type:text;not null"` - Stack string `gorm:"type:mediumtext;default:null"` - Context string `gorm:"type:json;default:null"` - ClientIP string `gorm:"type:varchar(45);default:null"` - UserAgent string `gorm:"type:varchar(255);default:null"` - Locale string `gorm:"type:varchar(16);default:null"` - Digest string `gorm:"type:varchar(64);uniqueIndex:uniq_digest;default:null"` - OccurredAt *time.Time `gorm:"type:datetime;default:null"` - CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + Id int64 `gorm:"primaryKey;AUTO_INCREMENT"` + Platform string `gorm:"type:varchar(32);not null"` + AppVersion string `gorm:"type:varchar(64);default:null"` + OsName string `gorm:"type:varchar(64);default:null"` + OsVersion string `gorm:"type:varchar(64);default:null"` + DeviceId string `gorm:"type:varchar(255);default:null"` + UserId *int64 `gorm:"type:bigint;default:null"` + SessionId string `gorm:"type:varchar(255);default:null"` + Level uint8 `gorm:"type:tinyint(1);not null;default:3"` + ErrorCode string `gorm:"type:varchar(128);default:null"` + Message string `gorm:"type:text;not null"` + Stack string `gorm:"type:mediumtext;default:null"` + Context string `gorm:"type:json;default:null"` + ClientIP string `gorm:"type:varchar(45);default:null"` + UserAgent string `gorm:"type:varchar(255);default:null"` + Locale string `gorm:"type:varchar(16);default:null"` + Digest string `gorm:"type:varchar(64);uniqueIndex:uniq_digest;default:null"` + OccurredAt *time.Time `gorm:"type:datetime;default:null"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` } func (LogMessage) TableName() string { return "log_message" } diff --git a/internal/model/user/default.go b/internal/model/user/default.go index 8eeb399..e98ffde 100644 --- a/internal/model/user/default.go +++ b/internal/model/user/default.go @@ -26,6 +26,7 @@ type ( Insert(ctx context.Context, data *User, tx ...*gorm.DB) error FindOne(ctx context.Context, id int64) (*User, error) Update(ctx context.Context, data *User, tx ...*gorm.DB) error + UpdateCommission(ctx context.Context, userId int64, delta int64, tx ...*gorm.DB) error Delete(ctx context.Context, id int64, tx ...*gorm.DB) error Transaction(ctx context.Context, fn func(db *gorm.DB) error) error } @@ -111,6 +112,22 @@ func (m *defaultUserModel) Update(ctx context.Context, data *User, tx ...*gorm.D return err } +func (m *defaultUserModel) UpdateCommission(ctx context.Context, userId int64, delta int64, tx ...*gorm.DB) error { + old, err := m.FindOne(ctx, userId) + if err != nil { + return err + } + + return m.ExecCtx(ctx, func(conn *gorm.DB) error { + if len(tx) > 0 { + conn = tx[0] + } + return conn.Model(&User{}). + Where("id = ?", userId). + UpdateColumn("commission", gorm.Expr("commission + ?", delta)).Error + }, m.getCacheKeys(old)...) +} + func (m *defaultUserModel) Delete(ctx context.Context, id int64, tx ...*gorm.DB) error { data, err := m.FindOne(ctx, id) if err != nil { diff --git a/internal/model/user/device.go b/internal/model/user/device.go index 3ad06aa..2d4eb11 100644 --- a/internal/model/user/device.go +++ b/internal/model/user/device.go @@ -8,6 +8,22 @@ import ( "gorm.io/gorm" ) +const userDeviceUserAgentMaxLength = 255 + +func normalizeDeviceForStorage(data *Device) { + if data == nil { + return + } + data.UserAgent = truncateForColumn(data.UserAgent, userDeviceUserAgentMaxLength) +} + +func truncateForColumn(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} + func (m *customUserModel) FindOneDevice(ctx context.Context, id int64) (*Device, error) { deviceIdKey := fmt.Sprintf("%s%v", cacheUserDeviceIdPrefix, id) var resp Device @@ -69,6 +85,7 @@ func (m *customUserModel) QueryDeviceListByUserIds(ctx context.Context, userIds } func (m *customUserModel) UpdateDevice(ctx context.Context, data *Device, tx ...*gorm.DB) error { + normalizeDeviceForStorage(data) old, err := m.FindOneDevice(ctx, data.Id) if err != nil { return err @@ -100,6 +117,7 @@ func (m *customUserModel) DeleteDevice(ctx context.Context, id int64, tx ...*gor } func (m *customUserModel) InsertDevice(ctx context.Context, data *Device, tx ...*gorm.DB) error { + normalizeDeviceForStorage(data) defer func() { if clearErr := m.ClearDeviceCache(ctx, data); clearErr != nil { // log cache clear error diff --git a/internal/types/types.go b/internal/types/types.go index 2deb523..0ca506a 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -2323,6 +2323,27 @@ type QueryWithdrawalLogListResponse struct { Total int64 `json:"total"` } +type GetWithdrawalListRequest struct { + Page int `form:"page"` + Size int `form:"size"` + UserId *int64 `form:"user_id,omitempty"` + Status *uint8 `form:"status,omitempty"` +} + +type GetWithdrawalListResponse struct { + List []WithdrawalLog `json:"list"` + Total int64 `json:"total"` +} + +type ApproveWithdrawalRequest struct { + WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` +} + +type RejectWithdrawalRequest struct { + WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` + Reason string `json:"reason" validate:"required,max=500"` +} + type QuotaTask struct { Id int64 `json:"id"` Subscribers []int64 `json:"subscribers"` @@ -3317,6 +3338,7 @@ type User struct { EnableLoginNotify bool `json:"enable_login_notify"` EnableSubscribeNotify bool `json:"enable_subscribe_notify"` EnableTradeNotify bool `json:"enable_trade_notify"` + UseStatus bool `json:"use_status"` AuthMethods []UserAuthMethod `json:"auth_methods"` UserDevices []UserDevice `json:"user_devices"` Rules []string `json:"rules"` diff --git a/ops/aws-rds-external-replica-runbook.md b/ops/aws-rds-external-replica-runbook.md index c1ca82e..d10b530 100644 --- a/ops/aws-rds-external-replica-runbook.md +++ b/ops/aws-rds-external-replica-runbook.md @@ -22,7 +22,7 @@ - Region: `ap-east-1` - AWS app EC2: - Name: `hifast-hk-app-01` - - Public IP: `43.198.248.161` + - Public IP: `18.163.33.75` - Private IP: `10.0.1.201` - AWS MySQL: - Type: `RDS MySQL` @@ -183,7 +183,7 @@ redis-cli INFO replication 重点看: - `role:slave` -- `master_host:43.198.248.161` +- `master_host:18.163.33.75` - `master_port:6379` - `master_link_status:up` @@ -416,7 +416,7 @@ SHOW REPLICA STATUS\G ```bash redis-cli CONFIG SET masterauth '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr' -redis-cli REPLICAOF 43.198.248.161 6379 +redis-cli REPLICAOF 18.163.33.75 6379 redis-cli CONFIG SET replica-read-only yes redis-cli INFO replication ``` @@ -426,7 +426,7 @@ redis-cli INFO replication 检查 `/etc/redis/redis.conf` 至少包含: ```conf -replicaof 43.198.248.161 6379 +replicaof 18.163.33.75 6379 masterauth 0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr replica-read-only yes ``` diff --git a/ops/hifast-aws-standby-architecture-zh.md b/ops/hifast-aws-standby-architecture-zh.md index dff3b8d..2cea7ad 100644 --- a/ops/hifast-aws-standby-architecture-zh.md +++ b/ops/hifast-aws-standby-architecture-zh.md @@ -66,7 +66,7 @@ - Instance ID: `i-079cd9d3ef3748714` - 角色:当前实际生产入口 / Nginx / 业务服务 / AWS 侧 Redis 主库宿主机 - 私网 IP: `10.0.1.201` -- 公网 IP: `43.198.248.161` +- 公网 IP: `18.163.33.75` - 业务服务运行方式:`Docker Compose` - 业务容器:`ppanel-server` - 部署目录:`/opt/ppanel` @@ -103,7 +103,7 @@ - 容器名:`hifast-redis` - 版本:`redis:8.2.1` - 访问端口:`6379` -- 主库出口地址:`43.198.248.161:6379` +- 主库出口地址:`18.163.33.75:6379` - 应用当前实际连接:`127.0.0.1:6379` - 认证方式:已启用密码认证 @@ -130,10 +130,10 @@ ```mermaid flowchart TB USER["用户 / 客户端"] --> DNS["域名 / DNS / 入口层"] - DNS --> APP["AWS EC2\nhifast-hk-app-01\n43.198.248.161\n10.0.1.201"] + DNS --> APP["AWS EC2\nhifast-hk-app-01\n18.163.33.75\n10.0.1.201"] APP --> RDS["AWS RDS MySQL\nhifast-mysql-prod-v2\n主库"] - APP --> REDISM["AWS Redis 主库\nDocker redis:8.2.1\n43.198.248.161:6379"] + APP --> REDISM["AWS Redis 主库\nDocker redis:8.2.1\n18.163.33.75:6379"] RDS -. MySQL 备用 / 同步 .-> MYSQLS["104.238.220.230\nMySQL 备用库"] REDISM -. Redis 主从复制 .-> REDISS["104.238.220.230\n原生 Redis 8.6.3\n从库"] @@ -238,7 +238,7 @@ App / Nginx ```mermaid flowchart LR - SG["hifast-hk-app-core-sg"] --> REDIS["AWS Redis 主库\n43.198.248.161:6379"] + SG["hifast-hk-app-core-sg"] --> REDIS["AWS Redis 主库\n18.163.33.75:6379"] STANDBY["104.238.220.230/32"] --> SG ``` @@ -311,7 +311,7 @@ RDS 当前状态已经比之前干净很多: `104.238.220.230` 连接 AWS Redis,不是通过 PEM 证书,也不是通过 SSH 登录 AWS 机器,而是直接作为 Redis 从库去访问 AWS Redis 主库: -- 目标地址:`43.198.248.161:6379` +- 目标地址:`18.163.33.75:6379` - 连接方式:`TCP` - 认证方式:`Redis 密码` - 网络前提:AWS EC2 安全组已放行 `104.238.220.230/32 -> 6379` @@ -325,7 +325,7 @@ RDS 当前状态已经比之前干净很多: 示意命令: ```bash -redis-cli -h 43.198.248.161 -p 6379 -a '' +redis-cli -h 18.163.33.75 -p 6379 -a '' ``` ### 4.6.2 104 连接 AWS MySQL RDS 的方式 @@ -380,7 +380,7 @@ mysql -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u admin - 部署方式:Docker - 版本:`8.2.1` -- 主库地址:`43.198.248.161:6379` +- 主库地址:`18.163.33.75:6379` - 运行容器:`hifast-redis` ### 5.2 104 Redis 从库 @@ -417,7 +417,7 @@ mysql -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u admin ```mermaid flowchart LR - REDISMASTER["AWS Redis 主库\n43.198.248.161:6379\nDocker redis:8.2.1"] + REDISMASTER["AWS Redis 主库\n18.163.33.75:6379\nDocker redis:8.2.1"] REDISSLAVE["104.238.220.230\n原生 Redis 8.6.3\nrole: slave"] REDISMASTER --> REDISSLAVE ``` diff --git a/迁移到命令.txt b/迁移到命令.txt new file mode 100644 index 0000000..780fb22 --- /dev/null +++ b/迁移到命令.txt @@ -0,0 +1,44 @@ +导出数据库: +mysqldump --socket=/var/run/mysqld/mysqld.sock -uroot -p --single-transaction --routines --triggers --events --set-gtid-purged=OFF --source-data=2 --no-tablespaces hifast > /root/data/hifast-final-0515.sql + +jpcV41ppanel + +gzip -1 /root/hifast-final-0515.sql + + + + + +导出redis: +redis-cli -a '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr' BGSAVE +sleep 5 +cp /var/lib/redis/dump.rdb /root/data/redis-backup-0515.rdb + + +导入数据库: +gunzip -c /root/hifast-final-0515.sql.gz | docker exec -i ppanel-mysql mysql -uroot -p'jpcV41ppanel' hifast + + +导入redis: +docker stop ppanel-redis +docker cp /root/data/redis-backup-0515.rdb ppanel-redis:/data/dump.rdb +docker start ppanel-redis +docker exec ppanel-redis redis-cli -a 'hifast67yj' DBSIZE + + + +docker stop ppanel-redis || true +docker rm -f ppanel-redis + +rm -f /root/data/dump.rdb +rm -rf /root/data/appendonlydir +rm -f /root/data/appendonly.aof* +mkdir -p /root/data + +docker pull redis:8.6.3 +docker run -d \ + --name ppanel-redis \ + --restart always \ + -p 6379:6379 \ + redis:8.6.3 \ + redis-server --requirepass 'hifast67yj'