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/.env.example b/.env.example index 1bc724a..6951e3f 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,8 @@ MYSQL_ROOT_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD # Grafana 管理员密码 GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD -# PPanel Server 镜像标签(留空使用 latest) -PPANEL_SERVER_TAG=latest +# PPanel Server 镜像标签(由 CI/CD 传入不可变 tag,如 git SHA) +PPANEL_SERVER_TAG=CHANGE_ME_TO_GIT_SHA # AWS 区域(香港) AWS_REGION=ap-east-1 diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 21854a9..6057447 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -21,8 +21,8 @@ env: # SSH私钥(Gitea Secret 名称:AWS) SSH_KEY: ${{ secrets.AWS }} # TG通知 - TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0 - TG_CHAT_ID: "-494024380311" + TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }} + TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }} # Go构建变量 SERVICE: vpn SERVICE_STYLE: vpn @@ -43,7 +43,7 @@ jobs: # 步骤1: 下载代码 - name: 📥 下载代码 uses: actions/checkout@v4 - + # 步骤2: 设置动态环境变量 - name: ⚙️ 设置动态环境变量 run: | @@ -51,19 +51,22 @@ jobs: echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV echo "DEPLOY_PATH=/opt/ppanel" >> $GITHUB_ENV + echo "DEPLOY_ENV_LABEL=🚀 服务已成功部署到生产环境" >> $GITHUB_ENV echo "为 main 分支设置生产环境变量" 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 "DEPLOY_ENV_LABEL=🧪 服务已成功部署到测试环境" >> $GITHUB_ENV echo "为 internal 分支设置开发环境变量" else echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV echo "CONTAINER_NAME=ppanel-server-${{ github.ref_name }}" >> $GITHUB_ENV echo "DEPLOY_PATH=/root/vpn_server_other" >> $GITHUB_ENV + echo "DEPLOY_ENV_LABEL=🔧 服务已成功部署到其他环境" >> $GITHUB_ENV echo "为其他分支 (${{ github.ref_name }}) 设置环境变量" fi - + # 步骤3: 安装系统工具 (curl, jq) 并升级 Docker CLI 到 1.44+ - name: 🔧 安装系统工具并升级 Docker CLI run: | @@ -111,35 +114,49 @@ jobs: docker --version || true docker version || true echo "客户端 API 版本:" $(docker version --format '{{.Client.APIVersion}}') - - # 步骤4: 构建并发布到镜像仓库 - - name: 📤 构建并发布到镜像仓库 + + # 步骤4: 构建镜像 + - name: 🏗️ 构建镜像 run: | - echo "开始构建并推送镜像..." + echo "开始构建镜像..." echo "仓库: ${{ env.REPO }}" echo "版本标签: ${{ env.VERSION }}" echo "分支标签: ${{ env.DOCKER_TAG_SUFFIX }}" - # 构建镜像,同时打上版本和分支两个标签 + BUILD_TAG_ARGS="-t ${{ env.REPO }}:${{ env.VERSION }}" + if [ "${{ github.event_name }}" = "push" ]; then + BUILD_TAG_ARGS="$BUILD_TAG_ARGS -t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}" + else + echo "PR事件仅构建版本标签,不推送镜像、不部署" + fi + docker build -f Dockerfile \ --platform linux/amd64 \ --build-arg TARGETARCH=amd64 \ --build-arg VERSION=${{ env.VERSION }} \ --build-arg BUILDTIME=${{ env.BUILDTIME }} \ - -t ${{ env.REPO }}:${{ env.VERSION }} \ - -t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }} \ + $BUILD_TAG_ARGS \ . - + + echo "镜像构建完成" + + # 步骤5: 发布到镜像仓库 + - name: 📤 发布到镜像仓库 + if: github.event_name == 'push' + run: | + echo "开始推送镜像..." + echo "推送版本标签镜像: ${{ env.REPO }}:${{ env.VERSION }}" docker push ${{ env.REPO }}:${{ env.VERSION }} - + echo "推送分支标签镜像: ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}" docker push ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }} - + echo "镜像推送完成" - - # 调试: 打印部署目标(不输出敏感信息) + + # 步骤6: 调试 - 打印部署目标(不输出敏感信息) - name: 🔍 调试 - 打印部署目标 + if: github.event_name == 'push' run: | echo "========== 部署目标调试 ==========" echo "当前分支: ${{ github.ref_name }}" @@ -150,8 +167,9 @@ jobs: echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}" echo "=====================================" - # 步骤5: 传输配置文件 + # 步骤7: 传输配置文件 - name: 📂 传输配置文件 + if: github.event_name == 'push' uses: appleboy/scp-action@v0.1.7 with: host: ${{ env.SSH_HOST }} @@ -161,8 +179,9 @@ jobs: source: "docker-compose.cloud.yml" target: "/tmp/ppanel-deploy/" - # 步骤6: 连接服务器更新并启动 + # 步骤8: 连接服务器更新、健康检查并按需回滚 - name: 🚀 连接服务器更新并启动 + if: github.event_name == 'push' uses: appleboy/ssh-action@v1.0.3 with: host: ${{ env.SSH_HOST }} @@ -172,68 +191,147 @@ jobs: timeout: 300s command_timeout: 600s script: | + set -e + echo "连接服务器成功,开始部署..." echo "部署目录: ${{ env.DEPLOY_PATH }}" echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}" echo "登录用户: ${{ env.SSH_USER }}" + HEALTHCHECK_URL="http://127.0.0.1:8080/v1/common/heartbeat" + NEW_TAG="${{ env.DOCKER_TAG_SUFFIX }}" + ROLLBACK_TAG="rollback-${{ env.VERSION }}" + SUDO="" + if [ "${{ github.ref_name }}" = "main" ]; then + SUDO="sudo" + fi + + docker_cmd() { + if [ -n "$SUDO" ]; then + sudo docker "$@" + else + docker "$@" + fi + } + + compose_with_tag() { + tag="$1" + shift + if [ -n "$SUDO" ]; then + sudo env PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@" + else + PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@" + fi + } + + write_previous_tag() { + if [ -n "$SUDO" ]; then + printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" | sudo tee .previous-ppanel-image-tag >/dev/null + else + printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" > .previous-ppanel-image-tag + fi + } + + health_check() { + attempt=1 + while [ "$attempt" -le 3 ]; do + if curl -sf "$HEALTHCHECK_URL"; then + echo + return 0 + fi + + echo "健康检查第 ${attempt}/3 次失败,10s 后重试..." + attempt=$((attempt + 1)) + sleep 10 + done + + return 1 + } + if [ "${{ github.ref_name }}" = "main" ]; then sudo mkdir -p ${{ env.DEPLOY_PATH }} sudo cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml - cd ${{ env.DEPLOY_PATH }} - echo "📥 拉取镜像..." - sudo docker-compose -f docker-compose.cloud.yml pull ppanel-server - echo "🚀 启动服务..." - sudo docker-compose -f docker-compose.cloud.yml up -d ppanel-server - sudo docker image prune -f || true else mkdir -p ${{ env.DEPLOY_PATH }} cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml - cd ${{ env.DEPLOY_PATH }} - echo "📥 拉取镜像..." - docker-compose -f docker-compose.cloud.yml pull ppanel-server - echo "🚀 启动服务..." - docker-compose -f docker-compose.cloud.yml up -d ppanel-server - docker image prune -f || true fi - - echo "✅ 部署命令执行完成" - - # 步骤6: TG通知 (成功) + + cd ${{ env.DEPLOY_PATH }} + + PREVIOUS_IMAGE_TAG="$(docker_cmd inspect --format '{{.Config.Image}}' ppanel-server 2>/dev/null || true)" + PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)" + echo "上一版本镜像tag: ${PREVIOUS_IMAGE_TAG:-未发现}" + echo "上一版本镜像ID: ${PREVIOUS_IMAGE_ID:-未发现}" + write_previous_tag + + echo "📥 拉取镜像: ${{ env.REPO }}:${NEW_TAG}" + compose_with_tag "$NEW_TAG" pull ppanel-server + echo "🚀 启动服务..." + compose_with_tag "$NEW_TAG" up -d ppanel-server + + echo "🩺 部署后健康检查: ${HEALTHCHECK_URL}" + if health_check; then + docker_cmd image prune -f || true + echo "✅ 部署后健康检查通过" + echo "✅ 部署命令执行完成" + exit 0 + fi + + echo "❌ 部署后健康检查连续 3 次失败,开始回滚..." + if [ -n "$PREVIOUS_IMAGE_ID" ]; then + docker_cmd tag "$PREVIOUS_IMAGE_ID" "${{ env.REPO }}:${ROLLBACK_TAG}" + echo "回滚镜像tag: ${{ env.REPO }}:${ROLLBACK_TAG}" + compose_with_tag "$ROLLBACK_TAG" up -d ppanel-server + + echo "🩺 回滚后健康检查: ${HEALTHCHECK_URL}" + if health_check; then + echo "✅ 回滚后健康检查通过" + else + echo "❌ 回滚后健康检查仍失败" + fi + else + echo "未找到上一版本镜像ID,无法自动回滚" + fi + + docker_cmd image prune -f || true + exit 1 + + # 步骤9: TG通知 (成功) - name: 📱 发送成功通知到Telegram - if: success() + if: success() && github.event_name == 'push' uses: appleboy/telegram-action@master with: token: ${{ env.TG_BOT_TOKEN }} to: ${{ env.TG_CHAT_ID }} message: | ✅ 部署成功! - + 📦 项目: ${{ github.repository }} 🌿 分支: ${{ github.ref_name }} 📝 提交: ${{ github.sha }} 👤 提交者: ${{ github.actor }} 🕐 时间: ${{ github.event.head_commit.timestamp }} - - 🚀 服务已成功部署到生产环境 + + ${{ env.DEPLOY_ENV_LABEL }} + 🩺 健康检查: 通过 (http://127.0.0.1:8080/v1/common/heartbeat) parse_mode: Markdown - - # 步骤5: TG通知 (失败) + + # 步骤10: TG通知 (失败) - name: 📱 发送失败通知到Telegram - if: failure() + if: failure() && github.event_name == 'push' uses: appleboy/telegram-action@master with: token: ${{ env.TG_BOT_TOKEN }} to: ${{ env.TG_CHAT_ID }} message: | ❌ 部署失败! - + 📦 项目: ${{ github.repository }} 🌿 分支: ${{ github.ref_name }} 📝 提交: ${{ github.sha }} 👤 提交者: ${{ github.actor }} 🕐 时间: ${{ github.event.head_commit.timestamp }} - + + 🩺 健康检查: 失败或未完成;若新版本健康检查连续 3 次失败,已自动尝试回滚并重新检查 ⚠️ 请检查构建日志获取详细信息 parse_mode: Markdown - diff --git a/apis/admin/log.api b/apis/admin/log.api index 5210c68..e1f5b96 100644 --- a/apis/admin/log.api +++ b/apis/admin/log.api @@ -268,6 +268,30 @@ type ( OccurredAt int64 `json:"occurred_at"` CreatedAt int64 `json:"created_at"` } + GetLogMessageRawRequest { + Id int64 `form:"id" validate:"required"` + } + GetLogMessageRawResponse { + Id int64 `json:"id"` + Platform string `json:"platform"` + AppVersion string `json:"app_version"` + OsName string `json:"os_name"` + OsVersion string `json:"os_version"` + DeviceId string `json:"device_id"` + UserId *int64 `json:"user_id"` + SessionId string `json:"session_id"` + Level uint8 `json:"level"` + ErrorCode string `json:"error_code"` + Message string `json:"message"` + Stack string `json:"stack"` + Context interface{} `json:"context"` + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Locale string `json:"locale"` + Digest string `json:"digest"` + OccurredAt int64 `json:"occurred_at"` + CreatedAt int64 `json:"created_at"` + } ) @server ( @@ -347,5 +371,9 @@ service ppanel { @doc "Get error log message detail" @handler GetErrorLogMessageDetail get /error_message/detail returns (GetErrorLogMessageDetailResponse) + + @doc "Get log message raw detail (temporary)" + @handler GetLogMessageRaw + get /message/detail (GetLogMessageRawRequest) returns (GetLogMessageRawResponse) } diff --git a/apis/admin/user.api b/apis/admin/user.api index 6965d40..bb585ff 100644 --- a/apis/admin/user.api +++ b/apis/admin/user.api @@ -38,20 +38,20 @@ type ( Id int64 `form:"id" validate:"required"` } UpdateUserBasiceInfoRequest { - UserId int64 `json:"user_id" validate:"required"` - Password string `json:"password"` - Avatar string `json:"avatar"` - Balance int64 `json:"balance"` - Commission int64 `json:"commission"` - ReferralPercentage uint8 `json:"referral_percentage"` - OnlyFirstPurchase *bool `json:"only_first_purchase"` - GiftAmount int64 `json:"gift_amount"` - Telegram int64 `json:"telegram"` - ReferCode string `json:"refer_code"` - RefererId int64 `json:"referer_id"` - Enable *bool `json:"enable"` - IsAdmin *bool `json:"is_admin"` - Remark string `json:"remark"` + UserId int64 `json:"user_id" validate:"required"` + Password string `json:"password"` + Avatar string `json:"avatar"` + Balance *int64 `json:"balance"` + Commission *int64 `json:"commission"` + ReferralPercentage uint8 `json:"referral_percentage"` + OnlyFirstPurchase *bool `json:"only_first_purchase"` + GiftAmount *int64 `json:"gift_amount"` + Telegram int64 `json:"telegram"` + ReferCode string `json:"refer_code"` + RefererId *int64 `json:"referer_id"` + Enable *bool `json:"enable"` + IsAdmin *bool `json:"is_admin"` + Remark *string `json:"remark"` } UpdateUserNotifySettingRequest { UserId int64 `json:"user_id" validate:"required"` @@ -230,6 +230,24 @@ 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"` + Method *uint8 `form:"method,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 +388,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/public/user.api b/apis/public/user.api index e55919f..f88fc26 100644 --- a/apis/public/user.api +++ b/apis/public/user.api @@ -109,8 +109,11 @@ type ( Rules []string `json:"rules" validate:"required"` } CommissionWithdrawRequest { - Amount int64 `json:"amount"` - Content string `json:"content"` + Amount int64 `json:"amount"` + Content string `json:"content"` + Method uint8 `json:"method" validate:"oneof=0 1 2 3"` + Account string `json:"account,omitempty"` + QrCodeUrl string `json:"qr_code_url,omitempty"` } WithdrawalLog { Id int64 `json:"id"` @@ -119,9 +122,15 @@ type ( Content string `json:"content"` Status uint8 `json:"status"` Reason string `json:"reason,omitempty"` + Method uint8 `json:"method"` + Account string `json:"account"` + QrCodeUrl string `json:"qr_code_url"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } + CancelWithdrawalRequest { + WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` + } QueryWithdrawalLogListRequest { Page int `form:"page"` Size int `form:"size"` @@ -352,6 +361,10 @@ service ppanel { @handler CommissionWithdraw post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog) + @doc "Cancel pending withdrawal" + @handler CancelWithdrawal + post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog) + @doc "Query Withdrawal Log" @handler QueryWithdrawalLog get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse) 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/docker-compose.cloud.yml b/docker-compose.cloud.yml index 9259e3e..de93cc1 100644 --- a/docker-compose.cloud.yml +++ b/docker-compose.cloud.yml @@ -19,9 +19,10 @@ services: # ---------------------------------------------------- # 1. 业务后端 (PPanel Server) # host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo + # PPANEL_SERVER_TAG 由 CI/CD 传入不可变镜像标签(如 git SHA) # ---------------------------------------------------- ppanel-server: - image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:-latest} + image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag} container_name: ppanel-server restart: always volumes: @@ -119,7 +120,7 @@ services: # 或配置 Nginx 反代(建议加认证) # ---------------------------------------------------- grafana: - image: grafana/grafana:latest + image: grafana/grafana:13.0.1 container_name: ppanel-grafana restart: always ports: @@ -154,7 +155,7 @@ services: # 6. Prometheus (指标采集) # ---------------------------------------------------- prometheus: - image: prom/prometheus:latest + image: prom/prometheus:v3.11.3 container_name: ppanel-prometheus restart: always ports: @@ -179,7 +180,7 @@ services: # 7. Nginx Exporter (监控宿主机 Nginx) # ---------------------------------------------------- nginx-exporter: - image: nginx/nginx-prometheus-exporter:latest + image: nginx/nginx-prometheus-exporter:1.5.0 container_name: ppanel-nginx-exporter restart: always command: @@ -198,7 +199,7 @@ services: # 8. Node Exporter (宿主机监控) # ---------------------------------------------------- node-exporter: - image: prom/node-exporter:latest + image: prom/node-exporter:v1.11.1 container_name: ppanel-node-exporter restart: always volumes: @@ -221,7 +222,7 @@ services: # 9. cAdvisor (容器监控) # ---------------------------------------------------- cadvisor: - image: gcr.io/cadvisor/cadvisor:latest + image: gcr.io/cadvisor/cadvisor:v0.55.1 container_name: ppanel-cadvisor restart: always volumes: 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/migrate/database/02123_withdrawal_method.down.sql b/initialize/migrate/database/02123_withdrawal_method.down.sql new file mode 100644 index 0000000..83b2a18 --- /dev/null +++ b/initialize/migrate/database/02123_withdrawal_method.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE `withdrawals` + DROP COLUMN `qr_code_url`, + DROP COLUMN `account`, + DROP COLUMN `method`; diff --git a/initialize/migrate/database/02123_withdrawal_method.up.sql b/initialize/migrate/database/02123_withdrawal_method.up.sql new file mode 100644 index 0000000..57f3104 --- /dev/null +++ b/initialize/migrate/database/02123_withdrawal_method.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE `withdrawals` + ADD COLUMN `method` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '收款方式 0:其他 1:支付宝 2:微信 3:银行卡' AFTER `content`, + ADD COLUMN `account` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '收款账号' AFTER `method`, + ADD COLUMN `qr_code_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '收款码图片URL' AFTER `account`; diff --git a/initialize/migrate/database/02150_refund_pending_withdrawals.down.sql b/initialize/migrate/database/02150_refund_pending_withdrawals.down.sql index 9f643e3..2c8eecc 100644 --- a/initialize/migrate/database/02150_refund_pending_withdrawals.down.sql +++ b/initialize/migrate/database/02150_refund_pending_withdrawals.down.sql @@ -6,7 +6,7 @@ UPDATE `user` u JOIN ( SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total - FROM user_withdrawal + FROM withdrawals WHERE status = 0 GROUP BY user_id ) p ON u.id = p.user_id @@ -14,6 +14,6 @@ SET u.commission = u.commission - p.pending_total WHERE p.pending_total > 0; -- Remove the migration log entries written by the up migration. -DELETE FROM system_log -WHERE type = 3 +DELETE FROM system_logs +WHERE type = 33 AND content LIKE '%migration: refund pending withdrawal commission (HIF-22)%'; diff --git a/initialize/migrate/database/02150_refund_pending_withdrawals.up.sql b/initialize/migrate/database/02150_refund_pending_withdrawals.up.sql index 0a90422..79ec196 100644 --- a/initialize/migrate/database/02150_refund_pending_withdrawals.up.sql +++ b/initialize/migrate/database/02150_refund_pending_withdrawals.up.sql @@ -11,11 +11,31 @@ -- Running this script multiple times is safe only if no new pending -- withdrawals are created between runs; deploy new code immediately after. +-- Compatibility: some historical databases missed migration 02122, so the +-- withdrawals table may not exist yet. Create it idempotently before the +-- refund logic so this migration can self-heal older installations. +CREATE TABLE IF NOT EXISTS `withdrawals` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Primary Key', + `user_id` BIGINT NOT NULL COMMENT 'User ID', + `amount` BIGINT NOT NULL COMMENT 'Withdrawal Amount', + `content` TEXT COMMENT 'Withdrawal Content', + `status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Withdrawal Status', + `reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Rejection Reason', + `created_at` DATETIME NOT NULL COMMENT 'Creation Time', + `updated_at` DATETIME NOT NULL COMMENT 'Update Time', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT IGNORE INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`) +VALUES + ('invite', 'WithdrawalMethod', '', 'string', 'withdrawal method', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637'); + -- Step 1: refund commission for all users with pending withdrawals. UPDATE `user` u JOIN ( SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total - FROM user_withdrawal + FROM withdrawals WHERE status = 0 GROUP BY user_id ) p ON u.id = p.user_id @@ -23,9 +43,9 @@ SET u.commission = u.commission + p.pending_total WHERE p.pending_total > 0; -- Step 2: write a migration log entry for each refunded user. -INSERT INTO system_log (type, date, object_id, content, created_at) +INSERT INTO system_logs (type, date, object_id, content, created_at) SELECT - 3 AS type, + 33 AS type, DATE(NOW()) AS date, p.user_id AS object_id, JSON_OBJECT( @@ -38,7 +58,7 @@ SELECT NOW() AS created_at FROM ( SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total - FROM user_withdrawal + FROM withdrawals WHERE status = 0 GROUP BY user_id HAVING pending_total > 0 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/log/getLogMessageRawHandler.go b/internal/handler/admin/log/getLogMessageRawHandler.go new file mode 100644 index 0000000..2060c33 --- /dev/null +++ b/internal/handler/admin/log/getLogMessageRawHandler.go @@ -0,0 +1,23 @@ +package log + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/log" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func GetLogMessageRawHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetLogMessageRawRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := log.NewGetLogMessageRawLogic(c.Request.Context(), svcCtx) + resp, err := l.GetLogMessageRaw(&req) + result.HttpResult(c, resp, err) + } +} 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/public/user/cancelWithdrawalHandler.go b/internal/handler/public/user/cancelWithdrawalHandler.go new file mode 100644 index 0000000..e3edad2 --- /dev/null +++ b/internal/handler/public/user/cancelWithdrawalHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Cancel Withdrawal +func CancelWithdrawalHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.CancelWithdrawalRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewCancelWithdrawalLogic(c.Request.Context(), svcCtx) + resp, err := l.CancelWithdrawal(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 67e9212..7b61ba9 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -259,6 +259,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Get error log message detail adminLogGroupRouter.GET("/error_message/detail", adminLog.GetErrorLogMessageDetailHandler(serverCtx)) + // Get log message raw detail (temporary) + adminLogGroupRouter.GET("/message/detail", adminLog.GetLogMessageRawHandler(serverCtx)) + // Get error log message list adminLogGroupRouter.GET("/error_message/list", adminLog.GetErrorLogMessageListHandler(serverCtx)) @@ -713,6 +716,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") @@ -1047,6 +1059,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Commission Withdraw publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx)) + // Cancel Withdrawal + publicUserGroupRouter.POST("/withdrawal_cancel", publicUser.CancelWithdrawalHandler(serverCtx)) + // Delete Current User Account publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx)) diff --git a/internal/logic/admin/log/getLogMessageRawLogic.go b/internal/logic/admin/log/getLogMessageRawLogic.go new file mode 100644 index 0000000..1997ba5 --- /dev/null +++ b/internal/logic/admin/log/getLogMessageRawLogic.go @@ -0,0 +1,66 @@ +package log + +import ( + "context" + "encoding/json" + + "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 GetLogMessageRawLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetLogMessageRawLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetLogMessageRawLogic { + return &GetLogMessageRawLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetLogMessageRawLogic) GetLogMessageRaw(req *types.GetLogMessageRawRequest) (resp *types.GetLogMessageRawResponse, err error) { + row, err := l.svcCtx.LogMessageModel.FindOne(l.ctx, req.Id) + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindOne log_message id=%d: %v", req.Id, err) + } + + var contextJSON json.RawMessage + if row.Context != "" { + if json.Valid([]byte(row.Context)) { + contextJSON = json.RawMessage(row.Context) + } else { + b, _ := json.Marshal(row.Context) + contextJSON = b + } + } + + var occurredAt int64 + if row.OccurredAt != nil { + occurredAt = row.OccurredAt.UnixMilli() + } + + return &types.GetLogMessageRawResponse{ + Id: row.Id, + Platform: row.Platform, + AppVersion: row.AppVersion, + OsName: row.OsName, + OsVersion: row.OsVersion, + DeviceId: row.DeviceId, + UserId: row.UserId, + SessionId: row.SessionId, + Level: row.Level, + ErrorCode: row.ErrorCode, + Message: row.Message, + Stack: row.Stack, + Context: contextJSON, + ClientIP: row.ClientIP, + UserAgent: row.UserAgent, + Locale: row.Locale, + Digest: row.Digest, + OccurredAt: occurredAt, + CreatedAt: row.CreatedAt.UnixMilli(), + }, nil +} 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..fb83f48 --- /dev/null +++ b/internal/logic/admin/user/getWithdrawalListLogic.go @@ -0,0 +1,80 @@ +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) + } + if req.Method != nil { + query = query.Where("method = ?", *req.Method) + } + + 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, + Method: row.Method, + Account: row.Account, + QrCodeUrl: row.QrCodeUrl, + 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..3d5e504 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" @@ -45,13 +46,13 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi } err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error { - if userInfo.Balance != req.Balance { - change := req.Balance - userInfo.Balance + if req.Balance != nil && userInfo.Balance != *req.Balance { + change := *req.Balance - userInfo.Balance balanceLog := log.Balance{ Type: log.BalanceTypeAdjust, Amount: change, OrderNo: "", - Balance: req.Balance, + Balance: *req.Balance, Timestamp: time.Now().UnixMilli(), } content, _ := balanceLog.Marshal() @@ -65,14 +66,14 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi if err != nil { return err } - userInfo.Balance = req.Balance + userInfo.Balance = *req.Balance } - if userInfo.GiftAmount != req.GiftAmount { - change := req.GiftAmount - userInfo.GiftAmount + if req.GiftAmount != nil && userInfo.GiftAmount != *req.GiftAmount { + change := *req.GiftAmount - userInfo.GiftAmount if change != 0 { var changeType uint16 - if userInfo.GiftAmount < req.GiftAmount { + if userInfo.GiftAmount < *req.GiftAmount { changeType = log.GiftTypeIncrease } else { changeType = log.GiftTypeReduce @@ -80,7 +81,7 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi giftLog := log.Gift{ Type: changeType, Amount: change, - Balance: req.GiftAmount, + Balance: *req.GiftAmount, Remark: "Admin adjustment", Timestamp: time.Now().UnixMilli(), } @@ -95,29 +96,27 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi if err != nil { return err } - userInfo.GiftAmount = req.GiftAmount + userInfo.GiftAmount = *req.GiftAmount } } - if req.Commission != userInfo.Commission { - - commentLog := log.Commission{ - Type: log.CommissionTypeAdjust, - Amount: req.Commission - userInfo.Commission, - Timestamp: time.Now().UnixMilli(), + if req.Commission != nil && *req.Commission != userInfo.Commission { + remark := "" + if req.Remark != nil { + remark = *req.Remark } - - 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 { + if isWithdrawalScene(remark) { + logWithdrawalGuard(l.Logger, userInfo.Id) + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene") + } + change := *req.Commission - userInfo.Commission + if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil { return err } - userInfo.Commission = req.Commission + if err = logicCommon.WriteCommissionLog(tx, userInfo.Id, log.CommissionTypeAdjust, change, ""); err != nil { + return err + } + userInfo.Commission = *req.Commission } if req.Avatar != "" { userInfo.Avatar = req.Avatar @@ -125,15 +124,17 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi if req.ReferCode != "" { userInfo.ReferCode = req.ReferCode } - userInfo.RefererId = req.RefererId + if req.RefererId != nil { + userInfo.RefererId = *req.RefererId + } if req.Enable != nil { userInfo.Enable = req.Enable } if req.IsAdmin != nil { userInfo.IsAdmin = req.IsAdmin } - if req.Remark != "" { - userInfo.Remark = req.Remark + if req.Remark != nil { + userInfo.Remark = *req.Remark } if req.OnlyFirstPurchase != nil { userInfo.OnlyFirstPurchase = req.OnlyFirstPurchase diff --git a/internal/logic/admin/user/withdrawalCommon.go b/internal/logic/admin/user/withdrawalCommon.go index a5e394b..d24db69 100644 --- a/internal/logic/admin/user/withdrawalCommon.go +++ b/internal/logic/admin/user/withdrawalCommon.go @@ -37,9 +37,9 @@ func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdraw } if err := tx.Model(&usermodel.Withdrawal{}). - Where("id = ? AND status = 0", withdrawalID). + Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending). Updates(map[string]interface{}{ - "status": 1, + "status": usermodel.WithdrawalStatusApproved, "reason": "", }).Error; err != nil { return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "approve withdrawal failed: %v", err) @@ -80,9 +80,9 @@ func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawa // Commission was NOT deducted at application time under the new logic, // so rejection requires no refund — only a status update. return tx.Model(&usermodel.Withdrawal{}). - Where("id = ? AND status = 0", withdrawalID). + Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending). Updates(map[string]interface{}{ - "status": 2, + "status": usermodel.WithdrawalStatusRejected, "reason": reason, }).Error }) 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/common/withdrawal.go b/internal/logic/common/withdrawal.go index 6a8cbe4..3d2304a 100644 --- a/internal/logic/common/withdrawal.go +++ b/internal/logic/common/withdrawal.go @@ -41,7 +41,7 @@ func LoadPendingWithdrawalForUpdate(ctx context.Context, tx *gorm.DB, withdrawal First(&withdrawal).Error; err != nil { return nil, err } - if withdrawal.Status != 0 { + if withdrawal.Status != usermodel.WithdrawalStatusPending { return nil, errors.New("withdrawal status invalid") } return &withdrawal, nil 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/cancelWithdrawalLogic.go b/internal/logic/public/user/cancelWithdrawalLogic.go new file mode 100644 index 0000000..a3f5135 --- /dev/null +++ b/internal/logic/public/user/cancelWithdrawalLogic.go @@ -0,0 +1,89 @@ +package user + +import ( + "context" + + logicCommon "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/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type CancelWithdrawalLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCancelWithdrawalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CancelWithdrawalLogic { + return &CancelWithdrawalLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CancelWithdrawalLogic) CancelWithdrawal(req *types.CancelWithdrawalRequest) (resp *types.WithdrawalLog, err error) { + 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") + } + + var withdrawal *user.Withdrawal + err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + var txErr error + withdrawal, txErr = logicCommon.LoadPendingWithdrawalForUpdate(l.ctx, tx, req.WithdrawalId) + if txErr != nil { + if txErr.Error() == "withdrawal status invalid" { + return errors.Wrapf(xerr.NewErrCode(xerr.WithdrawalStatusInvalid), "withdrawal %d is not in pending state", req.WithdrawalId) + } + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", txErr) + } + + if withdrawal.UserId != u.Id { + return errors.Wrapf(xerr.NewErrCode(xerr.PermissionDenied), "user %d cannot cancel withdrawal belonging to user %d", u.Id, withdrawal.UserId) + } + + if txErr = tx.Model(&user.Withdrawal{}). + Where("id = ? AND status = ?", req.WithdrawalId, user.WithdrawalStatusPending). + Update("status", user.WithdrawalStatusCancelled).Error; txErr != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "cancel withdrawal failed: %v", txErr) + } + + if txErr = l.svcCtx.UserModel.UpdateCommission(l.ctx, withdrawal.UserId, withdrawal.Amount, tx); txErr != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", txErr) + } + + if txErr = logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdrawCancel, withdrawal.Amount, ""); txErr != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", txErr) + } + + return nil + }) + if err != nil { + return nil, err + } + + _ = l.svcCtx.UserModel.ClearUserCache(l.ctx, u) + + return &types.WithdrawalLog{ + Id: withdrawal.Id, + UserId: withdrawal.UserId, + Amount: withdrawal.Amount, + Content: withdrawal.Content, + Status: user.WithdrawalStatusCancelled, + Reason: "", + Method: withdrawal.Method, + Account: withdrawal.Account, + QrCodeUrl: withdrawal.QrCodeUrl, + CreatedAt: withdrawal.CreatedAt.UnixMilli(), + UpdatedAt: withdrawal.UpdatedAt.UnixMilli(), + }, nil +} diff --git a/internal/logic/public/user/commissionWithdrawLogic.go b/internal/logic/public/user/commissionWithdrawLogic.go index b813aa0..64546c1 100644 --- a/internal/logic/public/user/commissionWithdrawLogic.go +++ b/internal/logic/public/user/commissionWithdrawLogic.go @@ -35,12 +35,28 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } + // Validate payment method fields + switch req.Method { + case user.WithdrawalMethodAlipay, user.WithdrawalMethodWechat: + if req.QrCodeUrl == "" { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "qr_code_url is required for method %d", req.Method) + } + case user.WithdrawalMethodBank: + if req.Account == "" { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for bank transfer") + } + default: // WithdrawalMethodOther + if req.Account == "" && req.Content == "" { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account or content is required for other methods") + } + } + // Sum all pending (status=0) withdrawals to compute available balance. // Available = commission - pendingTotal; commission is only deducted on approval. var pendingTotal int64 if err = l.svcCtx.DB.WithContext(l.ctx). Model(&user.Withdrawal{}). - Where("user_id = ? AND status = 0", u.Id). + Where("user_id = ? AND status = ?", u.Id, user.WithdrawalStatusPending). Select("COALESCE(SUM(amount), 0)"). Scan(&pendingTotal).Error; err != nil { l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err) @@ -56,11 +72,14 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr var w user.Withdrawal err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { w = user.Withdrawal{ - UserId: u.Id, - Amount: req.Amount, - Content: req.Content, - Status: 0, - Reason: "", + UserId: u.Id, + Amount: req.Amount, + Content: req.Content, + Status: user.WithdrawalStatusPending, + Reason: "", + Method: req.Method, + Account: req.Account, + QrCodeUrl: req.QrCodeUrl, } return tx.Create(&w).Error }) @@ -70,11 +89,15 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr } return &types.WithdrawalLog{ + Id: w.Id, UserId: u.Id, Amount: req.Amount, Content: req.Content, - Status: 0, + Status: user.WithdrawalStatusPending, Reason: "", + Method: req.Method, + Account: req.Account, + QrCodeUrl: req.QrCodeUrl, CreatedAt: w.CreatedAt.UnixMilli(), UpdatedAt: w.UpdatedAt.UnixMilli(), }, nil 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..9219f0f 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,52 @@ 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, + Method: row.Method, + Account: row.Account, + QrCodeUrl: row.QrCodeUrl, + 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..0573d2c 100644 --- a/internal/model/log/log.go +++ b/internal/model/log/log.go @@ -50,6 +50,8 @@ const ( CommissionTypeWithdraw uint16 = 334 // withdraw CommissionTypeAdjust uint16 = 335 // Admin Adjust CommissionTypeConvertBalance uint16 = 336 // Convert to Balance + CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund + CommissionTypeWithdrawCancel uint16 = 338 // 用户取消提现退佣金 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/order/model.go b/internal/model/order/model.go index d858b3b..89d1b5f 100644 --- a/internal/model/order/model.go +++ b/internal/model/order/model.go @@ -160,8 +160,8 @@ func (m *customOrderModel) QueryMonthlyOrders(ctx context.Context, date time.Tim Where("status IN ? AND created_at BETWEEN ? AND ? AND method != ?", []int64{2, 5}, firstDay, lastDay, "balance"). Select( "SUM(amount) as amount_total, " + - "SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " + - "SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount", + "SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " + + "SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount", ). Scan(v).Error }) @@ -177,8 +177,8 @@ func (m *customOrderModel) QueryDateOrders(ctx context.Context, date time.Time) Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance"). Select( "SUM(amount) as amount_total, " + - "SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " + - "SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount", + "SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " + + "SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount", ). Scan(v).Error }) @@ -192,8 +192,8 @@ func (m *customOrderModel) QueryTotalOrders(ctx context.Context) (OrdersTotal, e return conn.Model(&Order{}). Select(` SUM(amount) AS amount_total, - SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount, - SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount + SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, + SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount `). Where("status IN ? AND method != ?", []int64{2, 5}, "balance"). Scan(&result).Error @@ -214,8 +214,8 @@ func (m *customOrderModel) QueryMonthlyUserCounts(ctx context.Context, date time err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error { return conn.Model(&Order{}). Select(` - COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users, - COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users + COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, + COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users `). Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", []int64{2, 5}, firstDay, nextMonth, "balance"). @@ -232,8 +232,8 @@ func (m *customOrderModel) QueryDateUserCounts(ctx context.Context, date time.Ti err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error { return conn.Model(&Order{}). Select(` - COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users, - COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users + COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, + COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users `). Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance"). @@ -249,8 +249,8 @@ func (m *customOrderModel) QueryTotalUserCounts(ctx context.Context) (int64, int return conn.Model(&Order{}). Where("status IN ? AND method != ?", []int64{2, 5}, "balance"). Select(` - COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users, - COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users + COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, + COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users `). Scan(&counts).Error }) @@ -282,8 +282,8 @@ func (m *customOrderModel) QueryDailyOrdersList(ctx context.Context, date time.T Select(` DATE_FORMAT(created_at, '%Y-%m-%d') AS date, SUM(amount) AS amount_total, - SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount, - SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount + SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, + SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount `). Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", []int64{2, 5}, firstDay, nextDay, "balance"). @@ -326,8 +326,8 @@ func (m *customOrderModel) QueryMonthlyOrdersList(ctx context.Context, date time Select(` DATE_FORMAT(created_at, '%Y-%m') AS date, SUM(amount) AS amount_total, - SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount, - SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount + SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, + SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount `). Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", []int64{2, 5}, start, end, "balance"). 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/model/user/user.go b/internal/model/user/user.go index c9ff2bd..caafcb0 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -167,12 +167,15 @@ type Withdrawal struct { UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"` Amount int64 `gorm:"not null;comment:Withdrawal Amount"` Content string `gorm:"type:text;comment:Withdrawal Content"` - Status uint8 `gorm:"type:tinyint(1);default:0;comment:Withdrawal Status: 0: Pending 1: Approved 2: Rejected"` + Status uint8 `gorm:"type:tinyint(1);default:0;comment:Withdrawal Status: 0: Pending 1: Approved 2: Rejected 3: Cancelled"` Reason string `gorm:"type:varchar(500);default:'';comment:Rejection Reason"` + Method uint8 `gorm:"type:tinyint(1);default:0;comment:收款方式 0:其他 1:支付宝 2:微信 3:银行卡"` + Account string `gorm:"type:varchar(255);default:'';comment:收款账号"` + QrCodeUrl string `gorm:"type:varchar(500);default:'';comment:收款码图片URL"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` UpdatedAt time.Time `gorm:"comment:Update Time"` } func (*Withdrawal) TableName() string { - return "user_withdrawal" + return "withdrawals" } diff --git a/internal/model/user/withdrawal_const.go b/internal/model/user/withdrawal_const.go new file mode 100644 index 0000000..cd08a24 --- /dev/null +++ b/internal/model/user/withdrawal_const.go @@ -0,0 +1,15 @@ +package user + +const ( + WithdrawalStatusPending uint8 = 0 // 待审核 + WithdrawalStatusApproved uint8 = 1 // 已通过 + WithdrawalStatusRejected uint8 = 2 // 已拒绝 + WithdrawalStatusCancelled uint8 = 3 // 已取消(用户自行撤回) +) + +const ( + WithdrawalMethodOther uint8 = 0 // 其他 + WithdrawalMethodAlipay uint8 = 1 // 支付宝 + WithdrawalMethodWechat uint8 = 2 // 微信 + WithdrawalMethodBank uint8 = 3 // 银行卡 +) diff --git a/internal/types/types.go b/internal/types/types.go index 2deb523..1717659 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -3,6 +3,8 @@ package types +import "encoding/json" + type ActivateOrderRequest struct { OrderNo string `json:"order_no" validate:"required"` } @@ -294,8 +296,11 @@ type CommissionLog struct { } type CommissionWithdrawRequest struct { - Amount int64 `json:"amount"` - Content string `json:"content"` + Amount int64 `json:"amount"` + Content string `json:"content"` + Method uint8 `json:"method" validate:"oneof=0 1 2 3"` + Account string `json:"account,omitempty"` + QrCodeUrl string `json:"qr_code_url,omitempty"` } type ConnectionRecords struct { @@ -2313,6 +2318,10 @@ type QueryUserSubscribeNodeListResponse struct { List []UserSubscribeInfo `json:"list"` } +type CancelWithdrawalRequest struct { + WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` +} + type QueryWithdrawalLogListRequest struct { Page int `form:"page"` Size int `form:"size"` @@ -2323,6 +2332,28 @@ 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"` + Method *uint8 `form:"method,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"` @@ -3241,20 +3272,20 @@ type UpdateUserAuthMethodRequest struct { } type UpdateUserBasiceInfoRequest struct { - UserId int64 `json:"user_id" validate:"required"` - Password string `json:"password"` - Avatar string `json:"avatar"` - Balance int64 `json:"balance"` - Commission int64 `json:"commission"` - ReferralPercentage uint8 `json:"referral_percentage"` - OnlyFirstPurchase *bool `json:"only_first_purchase"` - GiftAmount int64 `json:"gift_amount"` - Telegram int64 `json:"telegram"` - ReferCode string `json:"refer_code"` - RefererId int64 `json:"referer_id"` - Enable *bool `json:"enable"` - IsAdmin *bool `json:"is_admin"` - Remark string `json:"remark"` + UserId int64 `json:"user_id" validate:"required"` + Password string `json:"password"` + Avatar string `json:"avatar"` + Balance *int64 `json:"balance"` + Commission *int64 `json:"commission"` + ReferralPercentage uint8 `json:"referral_percentage"` + OnlyFirstPurchase *bool `json:"only_first_purchase"` + GiftAmount *int64 `json:"gift_amount"` + Telegram int64 `json:"telegram"` + ReferCode string `json:"refer_code"` + RefererId *int64 `json:"referer_id"` + Enable *bool `json:"enable"` + IsAdmin *bool `json:"is_admin"` + Remark *string `json:"remark"` } type UpdateUserNotifyRequest struct { @@ -3317,6 +3348,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"` @@ -3624,6 +3656,9 @@ type WithdrawalLog struct { Content string `json:"content"` Status uint8 `json:"status"` Reason string `json:"reason,omitempty"` + Method uint8 `json:"method"` + Account string `json:"account"` + QrCodeUrl string `json:"qr_code_url"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } @@ -3658,3 +3693,29 @@ type GetAdminUserInviteListResponse struct { Total int64 `json:"total"` List []AdminInvitedUser `json:"list"` } + +type GetLogMessageRawRequest struct { + Id int64 `form:"id" validate:"required"` +} + +type GetLogMessageRawResponse struct { + Id int64 `json:"id"` + Platform string `json:"platform"` + AppVersion string `json:"app_version"` + OsName string `json:"os_name"` + OsVersion string `json:"os_version"` + DeviceId string `json:"device_id"` + UserId *int64 `json:"user_id"` + SessionId string `json:"session_id"` + Level uint8 `json:"level"` + ErrorCode string `json:"error_code"` + Message string `json:"message"` + Stack string `json:"stack"` + Context json.RawMessage `json:"context"` + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Locale string `json:"locale"` + Digest string `json:"digest"` + OccurredAt int64 `json:"occurred_at"` + CreatedAt int64 `json:"created_at"` +} diff --git a/internal/types/update_user_basic_info_request_test.go b/internal/types/update_user_basic_info_request_test.go new file mode 100644 index 0000000..4d0efc5 --- /dev/null +++ b/internal/types/update_user_basic_info_request_test.go @@ -0,0 +1,35 @@ +package types + +import ( + "encoding/json" + "testing" +) + +func TestUpdateUserBasicInfoRequestRemarkPresence(t *testing.T) { + var omitted UpdateUserBasiceInfoRequest + if err := json.Unmarshal([]byte(`{"user_id":1}`), &omitted); err != nil { + t.Fatalf("unmarshal omitted remark: %v", err) + } + if omitted.Remark != nil { + t.Fatalf("omitted remark = %q, want nil", *omitted.Remark) + } + + var cleared UpdateUserBasiceInfoRequest + if err := json.Unmarshal([]byte(`{"user_id":1,"remark":""}`), &cleared); err != nil { + t.Fatalf("unmarshal empty remark: %v", err) + } + if cleared.Remark == nil { + t.Fatal("empty remark was decoded as nil, want explicit empty string") + } + if *cleared.Remark != "" { + t.Fatalf("empty remark = %q, want empty string", *cleared.Remark) + } + + var updated UpdateUserBasiceInfoRequest + if err := json.Unmarshal([]byte(`{"user_id":1,"remark":"new note"}`), &updated); err != nil { + t.Fatalf("unmarshal non-empty remark: %v", err) + } + if updated.Remark == nil || *updated.Remark != "new note" { + t.Fatalf("non-empty remark = %#v, want %q", updated.Remark, "new note") + } +} 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/docker-image-version-pins.md b/ops/docker-image-version-pins.md new file mode 100644 index 0000000..8568394 --- /dev/null +++ b/ops/docker-image-version-pins.md @@ -0,0 +1,33 @@ +# Docker Image Version Pins + +This file records the infrastructure image versions pinned in `docker-compose.cloud.yml`. +The versions below match the images observed on the test deployment on 2026-05-26. + +| Service | Image | Running version source | +| --- | --- | --- | +| grafana | `grafana/grafana:13.0.1` | `grafana version 13.0.1` | +| prometheus | `prom/prometheus:v3.11.3` | `prometheus, version 3.11.3` | +| nginx-exporter | `nginx/nginx-prometheus-exporter:1.5.0` | image label `org.opencontainers.image.version=1.5.0` | +| node-exporter | `prom/node-exporter:v1.11.1` | `node_exporter, version 1.11.1` | +| cadvisor | `gcr.io/cadvisor/cadvisor:v0.55.1` | `cAdvisor version v0.55.1` | + +The test deployment in `/root/bindbox/docker-compose.cloud.yml` also contains +live-only exporter services that are not present in this repository's +`docker-compose.cloud.yml`. They were pinned during staging validation: + +| Test-only service | Image | Running version source | +| --- | --- | --- | +| mysql-exporter | `prom/mysqld-exporter:v0.19.0` | `mysqld_exporter, version 0.19.0` | +| redis-exporter | `oliver006/redis_exporter:v1.82.0` | image label `org.opencontainers.image.version=v1.82.0` | + +`ppanel-server` intentionally remains variable and requires `PPANEL_SERVER_TAG` +from CI/CD so deployments use an immutable application image tag. + +## Rollback + +Restore the previous compose file from git and redeploy: + +```sh +git checkout HEAD~1 -- docker-compose.cloud.yml .env.example ops/docker-image-version-pins.md +docker compose -f docker-compose.cloud.yml up -d +``` 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/scripts/convert_recovery_orders.go b/scripts/convert_recovery_orders/main.go similarity index 100% rename from scripts/convert_recovery_orders.go rename to scripts/convert_recovery_orders/main.go diff --git a/scripts/reconcile_mihapay_orders.go b/scripts/reconcile_mihapay_orders/main.go similarity index 100% rename from scripts/reconcile_mihapay_orders.go rename to scripts/reconcile_mihapay_orders/main.go 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'