merge: pull internal/internal with secrets for TG notifications
Build docker and publish / build (20.15.1) (push) Failing after 8m22s
Build docker and publish / build (20.15.1) (push) Failing after 8m22s
Resolved conflict in .gitea/workflows/docker.yml by keeping secrets-based TG_BOT_TOKEN and TG_CHAT_ID instead of hardcoded values.
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+2
-2
@@ -7,8 +7,8 @@ MYSQL_ROOT_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
|||||||
# Grafana 管理员密码
|
# Grafana 管理员密码
|
||||||
GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||||
|
|
||||||
# PPanel Server 镜像标签(留空使用 latest)
|
# PPanel Server 镜像标签(由 CI/CD 传入不可变 tag,如 git SHA)
|
||||||
PPANEL_SERVER_TAG=latest
|
PPANEL_SERVER_TAG=CHANGE_ME_TO_GIT_SHA
|
||||||
|
|
||||||
# AWS 区域(香港)
|
# AWS 区域(香港)
|
||||||
AWS_REGION=ap-east-1
|
AWS_REGION=ap-east-1
|
||||||
|
|||||||
+129
-31
@@ -21,8 +21,8 @@ env:
|
|||||||
# SSH私钥(Gitea Secret 名称:AWS)
|
# SSH私钥(Gitea Secret 名称:AWS)
|
||||||
SSH_KEY: ${{ secrets.AWS }}
|
SSH_KEY: ${{ secrets.AWS }}
|
||||||
# TG通知
|
# TG通知
|
||||||
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||||
TG_CHAT_ID: "-494024380311"
|
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||||
# Go构建变量
|
# Go构建变量
|
||||||
SERVICE: vpn
|
SERVICE: vpn
|
||||||
SERVICE_STYLE: vpn
|
SERVICE_STYLE: vpn
|
||||||
@@ -51,16 +51,19 @@ jobs:
|
|||||||
echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV
|
echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV
|
||||||
echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV
|
echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV
|
||||||
echo "DEPLOY_PATH=/opt/ppanel" >> $GITHUB_ENV
|
echo "DEPLOY_PATH=/opt/ppanel" >> $GITHUB_ENV
|
||||||
|
echo "DEPLOY_ENV_LABEL=🚀 服务已成功部署到生产环境" >> $GITHUB_ENV
|
||||||
echo "为 main 分支设置生产环境变量"
|
echo "为 main 分支设置生产环境变量"
|
||||||
elif [ "${{ github.ref_name }}" = "internal" ]; then
|
elif [ "${{ github.ref_name }}" = "internal" ]; then
|
||||||
echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV
|
echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV
|
||||||
echo "CONTAINER_NAME=ppanel-server-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 分支设置开发环境变量"
|
echo "为 internal 分支设置开发环境变量"
|
||||||
else
|
else
|
||||||
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
|
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||||
echo "CONTAINER_NAME=ppanel-server-${{ 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_PATH=/root/vpn_server_other" >> $GITHUB_ENV
|
||||||
|
echo "DEPLOY_ENV_LABEL=🔧 服务已成功部署到其他环境" >> $GITHUB_ENV
|
||||||
echo "为其他分支 (${{ github.ref_name }}) 设置环境变量"
|
echo "为其他分支 (${{ github.ref_name }}) 设置环境变量"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -112,24 +115,37 @@ jobs:
|
|||||||
docker version || true
|
docker version || true
|
||||||
echo "客户端 API 版本:" $(docker version --format '{{.Client.APIVersion}}')
|
echo "客户端 API 版本:" $(docker version --format '{{.Client.APIVersion}}')
|
||||||
|
|
||||||
# 步骤4: 构建并发布到镜像仓库
|
# 步骤4: 构建镜像
|
||||||
- name: 📤 构建并发布到镜像仓库
|
- name: 🏗️ 构建镜像
|
||||||
run: |
|
run: |
|
||||||
echo "开始构建并推送镜像..."
|
echo "开始构建镜像..."
|
||||||
echo "仓库: ${{ env.REPO }}"
|
echo "仓库: ${{ env.REPO }}"
|
||||||
echo "版本标签: ${{ env.VERSION }}"
|
echo "版本标签: ${{ env.VERSION }}"
|
||||||
echo "分支标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
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 \
|
docker build -f Dockerfile \
|
||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
--build-arg TARGETARCH=amd64 \
|
--build-arg TARGETARCH=amd64 \
|
||||||
--build-arg VERSION=${{ env.VERSION }} \
|
--build-arg VERSION=${{ env.VERSION }} \
|
||||||
--build-arg BUILDTIME=${{ env.BUILDTIME }} \
|
--build-arg BUILDTIME=${{ env.BUILDTIME }} \
|
||||||
-t ${{ env.REPO }}:${{ env.VERSION }} \
|
$BUILD_TAG_ARGS \
|
||||||
-t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }} \
|
|
||||||
.
|
.
|
||||||
|
|
||||||
|
echo "镜像构建完成"
|
||||||
|
|
||||||
|
# 步骤5: 发布到镜像仓库
|
||||||
|
- name: 📤 发布到镜像仓库
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: |
|
||||||
|
echo "开始推送镜像..."
|
||||||
|
|
||||||
echo "推送版本标签镜像: ${{ env.REPO }}:${{ env.VERSION }}"
|
echo "推送版本标签镜像: ${{ env.REPO }}:${{ env.VERSION }}"
|
||||||
docker push ${{ env.REPO }}:${{ env.VERSION }}
|
docker push ${{ env.REPO }}:${{ env.VERSION }}
|
||||||
|
|
||||||
@@ -138,8 +154,9 @@ jobs:
|
|||||||
|
|
||||||
echo "镜像推送完成"
|
echo "镜像推送完成"
|
||||||
|
|
||||||
# 调试: 打印部署目标(不输出敏感信息)
|
# 步骤6: 调试 - 打印部署目标(不输出敏感信息)
|
||||||
- name: 🔍 调试 - 打印部署目标
|
- name: 🔍 调试 - 打印部署目标
|
||||||
|
if: github.event_name == 'push'
|
||||||
run: |
|
run: |
|
||||||
echo "========== 部署目标调试 =========="
|
echo "========== 部署目标调试 =========="
|
||||||
echo "当前分支: ${{ github.ref_name }}"
|
echo "当前分支: ${{ github.ref_name }}"
|
||||||
@@ -150,8 +167,9 @@ jobs:
|
|||||||
echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}"
|
echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}"
|
||||||
echo "====================================="
|
echo "====================================="
|
||||||
|
|
||||||
# 步骤5: 传输配置文件
|
# 步骤7: 传输配置文件
|
||||||
- name: 📂 传输配置文件
|
- name: 📂 传输配置文件
|
||||||
|
if: github.event_name == 'push'
|
||||||
uses: appleboy/scp-action@v0.1.7
|
uses: appleboy/scp-action@v0.1.7
|
||||||
with:
|
with:
|
||||||
host: ${{ env.SSH_HOST }}
|
host: ${{ env.SSH_HOST }}
|
||||||
@@ -161,8 +179,9 @@ jobs:
|
|||||||
source: "docker-compose.cloud.yml"
|
source: "docker-compose.cloud.yml"
|
||||||
target: "/tmp/ppanel-deploy/"
|
target: "/tmp/ppanel-deploy/"
|
||||||
|
|
||||||
# 步骤6: 连接服务器更新并启动
|
# 步骤8: 连接服务器更新、健康检查并按需回滚
|
||||||
- name: 🚀 连接服务器更新并启动
|
- name: 🚀 连接服务器更新并启动
|
||||||
|
if: github.event_name == 'push'
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
with:
|
with:
|
||||||
host: ${{ env.SSH_HOST }}
|
host: ${{ env.SSH_HOST }}
|
||||||
@@ -172,36 +191,114 @@ jobs:
|
|||||||
timeout: 300s
|
timeout: 300s
|
||||||
command_timeout: 600s
|
command_timeout: 600s
|
||||||
script: |
|
script: |
|
||||||
|
set -e
|
||||||
|
|
||||||
echo "连接服务器成功,开始部署..."
|
echo "连接服务器成功,开始部署..."
|
||||||
echo "部署目录: ${{ env.DEPLOY_PATH }}"
|
echo "部署目录: ${{ env.DEPLOY_PATH }}"
|
||||||
echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
||||||
echo "登录用户: ${{ env.SSH_USER }}"
|
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
|
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
sudo mkdir -p ${{ env.DEPLOY_PATH }}
|
sudo mkdir -p ${{ env.DEPLOY_PATH }}
|
||||||
sudo cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
|
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
|
else
|
||||||
mkdir -p ${{ env.DEPLOY_PATH }}
|
mkdir -p ${{ env.DEPLOY_PATH }}
|
||||||
cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
|
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
|
fi
|
||||||
|
|
||||||
echo "✅ 部署命令执行完成"
|
cd ${{ env.DEPLOY_PATH }}
|
||||||
|
|
||||||
# 步骤6: TG通知 (成功)
|
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
|
- name: 📱 发送成功通知到Telegram
|
||||||
if: success()
|
if: success() && github.event_name == 'push'
|
||||||
uses: appleboy/telegram-action@master
|
uses: appleboy/telegram-action@master
|
||||||
with:
|
with:
|
||||||
token: ${{ env.TG_BOT_TOKEN }}
|
token: ${{ env.TG_BOT_TOKEN }}
|
||||||
@@ -215,12 +312,13 @@ jobs:
|
|||||||
👤 提交者: ${{ github.actor }}
|
👤 提交者: ${{ github.actor }}
|
||||||
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
||||||
|
|
||||||
🚀 服务已成功部署到生产环境
|
${{ env.DEPLOY_ENV_LABEL }}
|
||||||
|
🩺 健康检查: 通过 (http://127.0.0.1:8080/v1/common/heartbeat)
|
||||||
parse_mode: Markdown
|
parse_mode: Markdown
|
||||||
|
|
||||||
# 步骤5: TG通知 (失败)
|
# 步骤10: TG通知 (失败)
|
||||||
- name: 📱 发送失败通知到Telegram
|
- name: 📱 发送失败通知到Telegram
|
||||||
if: failure()
|
if: failure() && github.event_name == 'push'
|
||||||
uses: appleboy/telegram-action@master
|
uses: appleboy/telegram-action@master
|
||||||
with:
|
with:
|
||||||
token: ${{ env.TG_BOT_TOKEN }}
|
token: ${{ env.TG_BOT_TOKEN }}
|
||||||
@@ -234,6 +332,6 @@ jobs:
|
|||||||
👤 提交者: ${{ github.actor }}
|
👤 提交者: ${{ github.actor }}
|
||||||
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
||||||
|
|
||||||
|
🩺 健康检查: 失败或未完成;若新版本健康检查连续 3 次失败,已自动尝试回滚并重新检查
|
||||||
⚠️ 请检查构建日志获取详细信息
|
⚠️ 请检查构建日志获取详细信息
|
||||||
parse_mode: Markdown
|
parse_mode: Markdown
|
||||||
|
|
||||||
|
|||||||
@@ -268,6 +268,30 @@ type (
|
|||||||
OccurredAt int64 `json:"occurred_at"`
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
CreatedAt int64 `json:"created_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 (
|
@server (
|
||||||
@@ -347,5 +371,9 @@ service ppanel {
|
|||||||
@doc "Get error log message detail"
|
@doc "Get error log message detail"
|
||||||
@handler GetErrorLogMessageDetail
|
@handler GetErrorLogMessageDetail
|
||||||
get /error_message/detail returns (GetErrorLogMessageDetailResponse)
|
get /error_message/detail returns (GetErrorLogMessageDetailResponse)
|
||||||
|
|
||||||
|
@doc "Get log message raw detail (temporary)"
|
||||||
|
@handler GetLogMessageRaw
|
||||||
|
get /message/detail (GetLogMessageRawRequest) returns (GetLogMessageRawResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-6
@@ -41,17 +41,17 @@ type (
|
|||||||
UserId int64 `json:"user_id" validate:"required"`
|
UserId int64 `json:"user_id" validate:"required"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Balance int64 `json:"balance"`
|
Balance *int64 `json:"balance"`
|
||||||
Commission int64 `json:"commission"`
|
Commission *int64 `json:"commission"`
|
||||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||||
GiftAmount int64 `json:"gift_amount"`
|
GiftAmount *int64 `json:"gift_amount"`
|
||||||
Telegram int64 `json:"telegram"`
|
Telegram int64 `json:"telegram"`
|
||||||
ReferCode string `json:"refer_code"`
|
ReferCode string `json:"refer_code"`
|
||||||
RefererId int64 `json:"referer_id"`
|
RefererId *int64 `json:"referer_id"`
|
||||||
Enable *bool `json:"enable"`
|
Enable *bool `json:"enable"`
|
||||||
IsAdmin *bool `json:"is_admin"`
|
IsAdmin *bool `json:"is_admin"`
|
||||||
Remark string `json:"remark"`
|
Remark *string `json:"remark"`
|
||||||
}
|
}
|
||||||
UpdateUserNotifySettingRequest {
|
UpdateUserNotifySettingRequest {
|
||||||
UserId int64 `json:"user_id" validate:"required"`
|
UserId int64 `json:"user_id" validate:"required"`
|
||||||
@@ -230,6 +230,24 @@ type (
|
|||||||
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
|
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
|
||||||
Reason string `json:"reason,omitempty"`
|
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 (
|
@server (
|
||||||
@@ -370,5 +388,16 @@ service ppanel {
|
|||||||
@doc "Dissolve family"
|
@doc "Dissolve family"
|
||||||
@handler DissolveFamily
|
@handler DissolveFamily
|
||||||
put /family/dissolve (DissolveFamilyRequest)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ type (
|
|||||||
CommissionWithdrawRequest {
|
CommissionWithdrawRequest {
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
Content string `json:"content"`
|
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 {
|
WithdrawalLog {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
@@ -119,9 +122,15 @@ type (
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Status uint8 `json:"status"`
|
Status uint8 `json:"status"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Method uint8 `json:"method"`
|
||||||
|
Account string `json:"account"`
|
||||||
|
QrCodeUrl string `json:"qr_code_url"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
CancelWithdrawalRequest {
|
||||||
|
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||||
|
}
|
||||||
QueryWithdrawalLogListRequest {
|
QueryWithdrawalLogListRequest {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
@@ -352,6 +361,10 @@ service ppanel {
|
|||||||
@handler CommissionWithdraw
|
@handler CommissionWithdraw
|
||||||
post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog)
|
post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog)
|
||||||
|
|
||||||
|
@doc "Cancel pending withdrawal"
|
||||||
|
@handler CancelWithdrawal
|
||||||
|
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
|
||||||
|
|
||||||
@doc "Query Withdrawal Log"
|
@doc "Query Withdrawal Log"
|
||||||
@handler QueryWithdrawalLog
|
@handler QueryWithdrawalLog
|
||||||
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
|
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
|
||||||
|
|||||||
+1
-1
@@ -27,6 +27,7 @@ type (
|
|||||||
EnableLoginNotify bool `json:"enable_login_notify"`
|
EnableLoginNotify bool `json:"enable_login_notify"`
|
||||||
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
||||||
EnableTradeNotify bool `json:"enable_trade_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"`
|
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
||||||
UserDevices []UserDevice `json:"user_devices"`
|
UserDevices []UserDevice `json:"user_devices"`
|
||||||
Rules []string `json:"rules"`
|
Rules []string `json:"rules"`
|
||||||
@@ -1006,4 +1007,3 @@ type (
|
|||||||
ConfigSnapshot map[string]interface{} `json:"config_snapshot,omitempty"`
|
ConfigSnapshot map[string]interface{} `json:"config_snapshot,omitempty"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ REDIS_HOST=127.0.0.1
|
|||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=CHANGE_ME
|
REDIS_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
REDIS_SOURCE_HOST=43.198.248.161
|
REDIS_SOURCE_HOST=18.163.33.75
|
||||||
REDIS_SOURCE_PORT=6379
|
REDIS_SOURCE_PORT=6379
|
||||||
REDIS_SOURCE_USER=
|
REDIS_SOURCE_USER=
|
||||||
REDIS_SOURCE_PASSWORD=CHANGE_ME
|
REDIS_SOURCE_PASSWORD=CHANGE_ME
|
||||||
|
|||||||
@@ -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` 一致
|
||||||
@@ -19,9 +19,10 @@ services:
|
|||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
# 1. 业务后端 (PPanel Server)
|
# 1. 业务后端 (PPanel Server)
|
||||||
# host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo
|
# host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo
|
||||||
|
# PPANEL_SERVER_TAG 由 CI/CD 传入不可变镜像标签(如 git SHA)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
ppanel-server:
|
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
|
container_name: ppanel-server
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
@@ -119,7 +120,7 @@ services:
|
|||||||
# 或配置 Nginx 反代(建议加认证)
|
# 或配置 Nginx 反代(建议加认证)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
grafana:
|
grafana:
|
||||||
image: grafana/grafana:latest
|
image: grafana/grafana:13.0.1
|
||||||
container_name: ppanel-grafana
|
container_name: ppanel-grafana
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
@@ -154,7 +155,7 @@ services:
|
|||||||
# 6. Prometheus (指标采集)
|
# 6. Prometheus (指标采集)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
prometheus:
|
prometheus:
|
||||||
image: prom/prometheus:latest
|
image: prom/prometheus:v3.11.3
|
||||||
container_name: ppanel-prometheus
|
container_name: ppanel-prometheus
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
@@ -179,7 +180,7 @@ services:
|
|||||||
# 7. Nginx Exporter (监控宿主机 Nginx)
|
# 7. Nginx Exporter (监控宿主机 Nginx)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
nginx-exporter:
|
nginx-exporter:
|
||||||
image: nginx/nginx-prometheus-exporter:latest
|
image: nginx/nginx-prometheus-exporter:1.5.0
|
||||||
container_name: ppanel-nginx-exporter
|
container_name: ppanel-nginx-exporter
|
||||||
restart: always
|
restart: always
|
||||||
command:
|
command:
|
||||||
@@ -198,7 +199,7 @@ services:
|
|||||||
# 8. Node Exporter (宿主机监控)
|
# 8. Node Exporter (宿主机监控)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
node-exporter:
|
node-exporter:
|
||||||
image: prom/node-exporter:latest
|
image: prom/node-exporter:v1.11.1
|
||||||
container_name: ppanel-node-exporter
|
container_name: ppanel-node-exporter
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
@@ -221,7 +222,7 @@ services:
|
|||||||
# 9. cAdvisor (容器监控)
|
# 9. cAdvisor (容器监控)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
cadvisor:
|
cadvisor:
|
||||||
image: gcr.io/cadvisor/cadvisor:latest
|
image: gcr.io/cadvisor/cadvisor:v0.55.1
|
||||||
container_name: ppanel-cadvisor
|
container_name: ppanel-cadvisor
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ CREATE TABLE IF NOT EXISTS `user_device`
|
|||||||
`subscribe_id` bigint DEFAULT NULL COMMENT 'Subscribe ID',
|
`subscribe_id` bigint DEFAULT NULL COMMENT 'Subscribe ID',
|
||||||
`ip` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Ip.',
|
`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.',
|
`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',
|
`online` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Online',
|
||||||
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'EnableDeviceNumber',
|
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'EnableDeviceNumber',
|
||||||
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
|
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE `withdrawals`
|
||||||
|
DROP COLUMN `qr_code_url`,
|
||||||
|
DROP COLUMN `account`,
|
||||||
|
DROP COLUMN `method`;
|
||||||
@@ -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`;
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
UPDATE `user` u
|
UPDATE `user` u
|
||||||
JOIN (
|
JOIN (
|
||||||
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
|
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
|
||||||
FROM user_withdrawal
|
FROM withdrawals
|
||||||
WHERE status = 0
|
WHERE status = 0
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
) p ON u.id = p.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;
|
WHERE p.pending_total > 0;
|
||||||
|
|
||||||
-- Remove the migration log entries written by the up migration.
|
-- Remove the migration log entries written by the up migration.
|
||||||
DELETE FROM system_log
|
DELETE FROM system_logs
|
||||||
WHERE type = 3
|
WHERE type = 33
|
||||||
AND content LIKE '%migration: refund pending withdrawal commission (HIF-22)%';
|
AND content LIKE '%migration: refund pending withdrawal commission (HIF-22)%';
|
||||||
|
|||||||
@@ -11,11 +11,31 @@
|
|||||||
-- Running this script multiple times is safe only if no new pending
|
-- Running this script multiple times is safe only if no new pending
|
||||||
-- withdrawals are created between runs; deploy new code immediately after.
|
-- 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.
|
-- Step 1: refund commission for all users with pending withdrawals.
|
||||||
UPDATE `user` u
|
UPDATE `user` u
|
||||||
JOIN (
|
JOIN (
|
||||||
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
|
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
|
||||||
FROM user_withdrawal
|
FROM withdrawals
|
||||||
WHERE status = 0
|
WHERE status = 0
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
) p ON u.id = p.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;
|
WHERE p.pending_total > 0;
|
||||||
|
|
||||||
-- Step 2: write a migration log entry for each refunded user.
|
-- 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
|
SELECT
|
||||||
3 AS type,
|
33 AS type,
|
||||||
DATE(NOW()) AS date,
|
DATE(NOW()) AS date,
|
||||||
p.user_id AS object_id,
|
p.user_id AS object_id,
|
||||||
JSON_OBJECT(
|
JSON_OBJECT(
|
||||||
@@ -38,7 +58,7 @@ SELECT
|
|||||||
NOW() AS created_at
|
NOW() AS created_at
|
||||||
FROM (
|
FROM (
|
||||||
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
|
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
|
||||||
FROM user_withdrawal
|
FROM withdrawals
|
||||||
WHERE status = 0
|
WHERE status = 0
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
HAVING pending_total > 0
|
HAVING pending_total > 0
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ type schemaColumnPatch struct {
|
|||||||
ddl string
|
ddl string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type schemaColumnDefinitionPatch struct {
|
||||||
|
table string
|
||||||
|
column string
|
||||||
|
dataType string
|
||||||
|
characterMaxLen *int64
|
||||||
|
ddl string
|
||||||
|
}
|
||||||
|
|
||||||
func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error {
|
func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error {
|
||||||
tablePatches := []schemaTablePatch{
|
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 {
|
for _, patch := range tablePatches {
|
||||||
exists, err := tableExists(ctx.DB, patch.table)
|
exists, err := tableExists(ctx.DB, patch.table)
|
||||||
if err != nil {
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +277,38 @@ func indexExists(db *gorm.DB, table, index string) (bool, error) {
|
|||||||
return count > 0, nil
|
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 {
|
func _schemaCompatDebug(table, column string) string {
|
||||||
if column == "" {
|
if column == "" {
|
||||||
return table
|
return table
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,6 +259,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
// Get error log message detail
|
// Get error log message detail
|
||||||
adminLogGroupRouter.GET("/error_message/detail", adminLog.GetErrorLogMessageDetailHandler(serverCtx))
|
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
|
// Get error log message list
|
||||||
adminLogGroupRouter.GET("/error_message/list", adminLog.GetErrorLogMessageListHandler(serverCtx))
|
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
|
// Get admin user invite list
|
||||||
adminUserGroupRouter.GET("/invite/list", adminUser.GetAdminUserInviteListHandler(serverCtx))
|
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")
|
authGroupRouter := router.Group("/v1/auth")
|
||||||
@@ -1047,6 +1059,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
// Commission Withdraw
|
// Commission Withdraw
|
||||||
publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx))
|
publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx))
|
||||||
|
|
||||||
|
// Cancel Withdrawal
|
||||||
|
publicUserGroupRouter.POST("/withdrawal_cancel", publicUser.CancelWithdrawalHandler(serverCtx))
|
||||||
|
|
||||||
// Delete Current User Account
|
// Delete Current User Account
|
||||||
publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx))
|
publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"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 {
|
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
||||||
if userInfo.Balance != req.Balance {
|
if req.Balance != nil && userInfo.Balance != *req.Balance {
|
||||||
change := req.Balance - userInfo.Balance
|
change := *req.Balance - userInfo.Balance
|
||||||
balanceLog := log.Balance{
|
balanceLog := log.Balance{
|
||||||
Type: log.BalanceTypeAdjust,
|
Type: log.BalanceTypeAdjust,
|
||||||
Amount: change,
|
Amount: change,
|
||||||
OrderNo: "",
|
OrderNo: "",
|
||||||
Balance: req.Balance,
|
Balance: *req.Balance,
|
||||||
Timestamp: time.Now().UnixMilli(),
|
Timestamp: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
content, _ := balanceLog.Marshal()
|
content, _ := balanceLog.Marshal()
|
||||||
@@ -65,14 +66,14 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
userInfo.Balance = req.Balance
|
userInfo.Balance = *req.Balance
|
||||||
}
|
}
|
||||||
|
|
||||||
if userInfo.GiftAmount != req.GiftAmount {
|
if req.GiftAmount != nil && userInfo.GiftAmount != *req.GiftAmount {
|
||||||
change := req.GiftAmount - userInfo.GiftAmount
|
change := *req.GiftAmount - userInfo.GiftAmount
|
||||||
if change != 0 {
|
if change != 0 {
|
||||||
var changeType uint16
|
var changeType uint16
|
||||||
if userInfo.GiftAmount < req.GiftAmount {
|
if userInfo.GiftAmount < *req.GiftAmount {
|
||||||
changeType = log.GiftTypeIncrease
|
changeType = log.GiftTypeIncrease
|
||||||
} else {
|
} else {
|
||||||
changeType = log.GiftTypeReduce
|
changeType = log.GiftTypeReduce
|
||||||
@@ -80,7 +81,7 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
|||||||
giftLog := log.Gift{
|
giftLog := log.Gift{
|
||||||
Type: changeType,
|
Type: changeType,
|
||||||
Amount: change,
|
Amount: change,
|
||||||
Balance: req.GiftAmount,
|
Balance: *req.GiftAmount,
|
||||||
Remark: "Admin adjustment",
|
Remark: "Admin adjustment",
|
||||||
Timestamp: time.Now().UnixMilli(),
|
Timestamp: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
@@ -95,29 +96,27 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
userInfo.GiftAmount = req.GiftAmount
|
userInfo.GiftAmount = *req.GiftAmount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Commission != userInfo.Commission {
|
if req.Commission != nil && *req.Commission != userInfo.Commission {
|
||||||
|
remark := ""
|
||||||
commentLog := log.Commission{
|
if req.Remark != nil {
|
||||||
Type: log.CommissionTypeAdjust,
|
remark = *req.Remark
|
||||||
Amount: req.Commission - userInfo.Commission,
|
|
||||||
Timestamp: time.Now().UnixMilli(),
|
|
||||||
}
|
}
|
||||||
|
if isWithdrawalScene(remark) {
|
||||||
content, _ := commentLog.Marshal()
|
logWithdrawalGuard(l.Logger, userInfo.Id)
|
||||||
err = tx.Create(&log.SystemLog{
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
|
||||||
Type: log.TypeCommission.Uint8(),
|
}
|
||||||
Date: time.Now().Format(time.DateOnly),
|
change := *req.Commission - userInfo.Commission
|
||||||
ObjectID: userInfo.Id,
|
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
|
||||||
Content: string(content),
|
|
||||||
}).Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
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 != "" {
|
if req.Avatar != "" {
|
||||||
userInfo.Avatar = req.Avatar
|
userInfo.Avatar = req.Avatar
|
||||||
@@ -125,15 +124,17 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
|||||||
if req.ReferCode != "" {
|
if req.ReferCode != "" {
|
||||||
userInfo.ReferCode = req.ReferCode
|
userInfo.ReferCode = req.ReferCode
|
||||||
}
|
}
|
||||||
userInfo.RefererId = req.RefererId
|
if req.RefererId != nil {
|
||||||
|
userInfo.RefererId = *req.RefererId
|
||||||
|
}
|
||||||
if req.Enable != nil {
|
if req.Enable != nil {
|
||||||
userInfo.Enable = req.Enable
|
userInfo.Enable = req.Enable
|
||||||
}
|
}
|
||||||
if req.IsAdmin != nil {
|
if req.IsAdmin != nil {
|
||||||
userInfo.IsAdmin = req.IsAdmin
|
userInfo.IsAdmin = req.IsAdmin
|
||||||
}
|
}
|
||||||
if req.Remark != "" {
|
if req.Remark != nil {
|
||||||
userInfo.Remark = req.Remark
|
userInfo.Remark = *req.Remark
|
||||||
}
|
}
|
||||||
if req.OnlyFirstPurchase != nil {
|
if req.OnlyFirstPurchase != nil {
|
||||||
userInfo.OnlyFirstPurchase = req.OnlyFirstPurchase
|
userInfo.OnlyFirstPurchase = req.OnlyFirstPurchase
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdraw
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Model(&usermodel.Withdrawal{}).
|
if err := tx.Model(&usermodel.Withdrawal{}).
|
||||||
Where("id = ? AND status = 0", withdrawalID).
|
Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"status": 1,
|
"status": usermodel.WithdrawalStatusApproved,
|
||||||
"reason": "",
|
"reason": "",
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "approve withdrawal failed: %v", err)
|
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,
|
// Commission was NOT deducted at application time under the new logic,
|
||||||
// so rejection requires no refund — only a status update.
|
// so rejection requires no refund — only a status update.
|
||||||
return tx.Model(&usermodel.Withdrawal{}).
|
return tx.Model(&usermodel.Withdrawal{}).
|
||||||
Where("id = ? AND status = 0", withdrawalID).
|
Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"status": 2,
|
"status": usermodel.WithdrawalStatusRejected,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
}).Error
|
}).Error
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type ReportLogMessageLogic struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewReportLogMessageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReportLogMessageLogic {
|
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) {
|
func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequest, c *gin.Context) (resp *types.ReportLogMessageResponse, err error) {
|
||||||
@@ -35,9 +35,13 @@ func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequ
|
|||||||
|
|
||||||
// 简单限流:设备ID优先,其次IP
|
// 简单限流:设备ID优先,其次IP
|
||||||
limitKey := "logmsg:" + strings.TrimSpace(req.DeviceId)
|
limitKey := "logmsg:" + strings.TrimSpace(req.DeviceId)
|
||||||
if limitKey == "logmsg:" { limitKey = "logmsg:" + ip }
|
if limitKey == "logmsg:" {
|
||||||
|
limitKey = "logmsg:" + ip
|
||||||
|
}
|
||||||
count, _ := l.svcCtx.Redis.Incr(l.ctx, limitKey).Result()
|
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 == 1 {
|
||||||
|
_ = l.svcCtx.Redis.Expire(l.ctx, limitKey, 60*time.Second).Err()
|
||||||
|
}
|
||||||
if count > 120 { // 每分钟最多120条
|
if count > 120 { // 每分钟最多120条
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "too many reports")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "too many reports")
|
||||||
}
|
}
|
||||||
@@ -60,18 +64,20 @@ func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
var userIdPtr *int64
|
var userIdPtr *int64
|
||||||
if req.UserId > 0 { userIdPtr = &req.UserId }
|
if req.UserId > 0 {
|
||||||
|
userIdPtr = &req.UserId
|
||||||
|
}
|
||||||
|
|
||||||
row := &logmessage.LogMessage{
|
row := &logmessage.LogMessage{
|
||||||
Platform: req.Platform,
|
Platform: safeTruncate(req.Platform, 32),
|
||||||
AppVersion: req.AppVersion,
|
AppVersion: safeTruncate(req.AppVersion, 64),
|
||||||
OsName: req.OsName,
|
OsName: safeTruncate(req.OsName, 64),
|
||||||
OsVersion: req.OsVersion,
|
OsVersion: safeTruncate(req.OsVersion, 64),
|
||||||
DeviceId: req.DeviceId,
|
DeviceId: safeTruncate(req.DeviceId, 255),
|
||||||
UserId: userIdPtr,
|
UserId: userIdPtr,
|
||||||
SessionId: req.SessionId,
|
SessionId: safeTruncate(req.SessionId, 255),
|
||||||
Level: req.Level,
|
Level: req.Level,
|
||||||
ErrorCode: req.ErrorCode,
|
ErrorCode: safeTruncate(req.ErrorCode, 128),
|
||||||
Message: safeTruncate(req.Message, 1024*64),
|
Message: safeTruncate(req.Message, 1024*64),
|
||||||
Stack: safeTruncate(req.Stack, 1024*1024),
|
Stack: safeTruncate(req.Stack, 1024*1024),
|
||||||
Context: ctxStr,
|
Context: ctxStr,
|
||||||
@@ -84,25 +90,31 @@ func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequ
|
|||||||
|
|
||||||
if err = l.svcCtx.LogMessageModel.Insert(l.ctx, row); err != nil {
|
if err = l.svcCtx.LogMessageModel.Insert(l.ctx, row); err != nil {
|
||||||
// 唯一指纹冲突时尝试查询已有记录返回ID
|
// 唯一指纹冲突时尝试查询已有记录返回ID
|
||||||
ex, _, findErr := l.svcCtx.LogMessageModel.Filter(l.ctx, &logmessage.FilterParams{ Keyword: req.Message, Page: 1, Size: 1 })
|
ex, _, findErr := l.svcCtx.LogMessageModel.Filter(l.ctx, &logmessage.FilterParams{Keyword: req.Message, Page: 1, Size: 1})
|
||||||
if findErr == nil && len(ex) > 0 {
|
if findErr == nil && len(ex) > 0 {
|
||||||
return &types.ReportLogMessageResponse{ Id: ex[0].Id }, nil
|
return &types.ReportLogMessageResponse{Id: ex[0].Id}, nil
|
||||||
}
|
}
|
||||||
l.Errorf("[ReportLogMessage] insert error: %v", err)
|
l.Errorf("[ReportLogMessage] insert error: %v", err)
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "insert log_message failed: %v", err)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "insert log_message failed: %v", err)
|
||||||
}
|
}
|
||||||
return &types.ReportLogMessageResponse{ Id: row.Id }, nil
|
return &types.ReportLogMessageResponse{Id: row.Id}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeTruncate(s string, n int) string {
|
func safeTruncate(s string, n int) string {
|
||||||
if len(s) <= n { return s }
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
return s[:n]
|
return s[:n]
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientIP(c *gin.Context) string {
|
func clientIP(c *gin.Context) string {
|
||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
if ip != "" { return ip }
|
if ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
|
host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
|
||||||
if err == nil && host != "" { return host }
|
if err == nil && host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func LoadPendingWithdrawalForUpdate(ctx context.Context, tx *gorm.DB, withdrawal
|
|||||||
First(&withdrawal).Error; err != nil {
|
First(&withdrawal).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if withdrawal.Status != 0 {
|
if withdrawal.Status != usermodel.WithdrawalStatusPending {
|
||||||
return nil, errors.New("withdrawal status invalid")
|
return nil, errors.New("withdrawal status invalid")
|
||||||
}
|
}
|
||||||
return &withdrawal, nil
|
return &withdrawal, nil
|
||||||
|
|||||||
@@ -109,14 +109,14 @@ func buildObjectKey(prefix string, userID int64, bizType, fileID, fileName strin
|
|||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
prefix = "app-upload"
|
prefix = "app-upload"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s/%s/%d/%04d/%02d/%s_%s",
|
return fmt.Sprintf("%s/%04d/%02d/%02d/%d/%s__%s",
|
||||||
prefix,
|
prefix,
|
||||||
strings.Trim(safeFileNameRegexp.ReplaceAllString(strings.ToLower(strings.TrimSpace(bizType)), "-"), "-"),
|
|
||||||
userID,
|
|
||||||
now.Year(),
|
now.Year(),
|
||||||
int(now.Month()),
|
int(now.Month()),
|
||||||
fileID,
|
now.Day(),
|
||||||
|
userID,
|
||||||
safeFileName(fileName),
|
safeFileName(fileName),
|
||||||
|
fileID,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -35,12 +35,28 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
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.
|
// Sum all pending (status=0) withdrawals to compute available balance.
|
||||||
// Available = commission - pendingTotal; commission is only deducted on approval.
|
// Available = commission - pendingTotal; commission is only deducted on approval.
|
||||||
var pendingTotal int64
|
var pendingTotal int64
|
||||||
if err = l.svcCtx.DB.WithContext(l.ctx).
|
if err = l.svcCtx.DB.WithContext(l.ctx).
|
||||||
Model(&user.Withdrawal{}).
|
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)").
|
Select("COALESCE(SUM(amount), 0)").
|
||||||
Scan(&pendingTotal).Error; err != nil {
|
Scan(&pendingTotal).Error; err != nil {
|
||||||
l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err)
|
l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err)
|
||||||
@@ -59,8 +75,11 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
|||||||
UserId: u.Id,
|
UserId: u.Id,
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Status: 0,
|
Status: user.WithdrawalStatusPending,
|
||||||
Reason: "",
|
Reason: "",
|
||||||
|
Method: req.Method,
|
||||||
|
Account: req.Account,
|
||||||
|
QrCodeUrl: req.QrCodeUrl,
|
||||||
}
|
}
|
||||||
return tx.Create(&w).Error
|
return tx.Create(&w).Error
|
||||||
})
|
})
|
||||||
@@ -70,11 +89,15 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &types.WithdrawalLog{
|
return &types.WithdrawalLog{
|
||||||
|
Id: w.Id,
|
||||||
UserId: u.Id,
|
UserId: u.Id,
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Status: 0,
|
Status: user.WithdrawalStatusPending,
|
||||||
Reason: "",
|
Reason: "",
|
||||||
|
Method: req.Method,
|
||||||
|
Account: req.Account,
|
||||||
|
QrCodeUrl: req.QrCodeUrl,
|
||||||
CreatedAt: w.CreatedAt.UnixMilli(),
|
CreatedAt: w.CreatedAt.UnixMilli(),
|
||||||
UpdatedAt: w.UpdatedAt.UnixMilli(),
|
UpdatedAt: w.UpdatedAt.UnixMilli(),
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
authlogic "github.com/perfect-panel/server/internal/logic/auth"
|
||||||
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
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/constant"
|
||||||
"github.com/perfect-panel/server/pkg/uuidx"
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"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")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
}
|
}
|
||||||
tool.DeepCopy(resp, u)
|
tool.DeepCopy(resp, u)
|
||||||
|
resp.UseStatus = true
|
||||||
|
|
||||||
// 用家庭范围查设备,而不是只看当前用户自己的 UserDevices
|
// 用家庭范围查设备,而不是只看当前用户自己的 UserDevices
|
||||||
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
||||||
@@ -65,6 +68,14 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
|
|||||||
}
|
}
|
||||||
resp.UserDevices = userDevices
|
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 为空时自动生成
|
// refer_code 为空时自动生成
|
||||||
if resp.ReferCode == "" {
|
if resp.ReferCode == "" {
|
||||||
resp.ReferCode = uuidx.UserInviteCode(u.Id)
|
resp.ReferCode = uuidx.UserInviteCode(u.Id)
|
||||||
@@ -108,6 +119,49 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
|
|||||||
return resp, nil
|
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 {
|
func (l *QueryUserInfoLogic) fillFamilyContext(resp *types.User, userId int64) *user.AuthMethods {
|
||||||
type familyRelation struct {
|
type familyRelation struct {
|
||||||
FamilyId int64
|
FamilyId int64
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,13 @@ package user
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"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/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QueryWithdrawalLogLogic struct {
|
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) {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const (
|
|||||||
ctxDecryptedQueryKey = "decrypted_query"
|
ctxDecryptedQueryKey = "decrypted_query"
|
||||||
ctxEncryptedBodyKey = "encrypted_request_body"
|
ctxEncryptedBodyKey = "encrypted_request_body"
|
||||||
ctxDecryptedBodyKey = "decrypted_request_body"
|
ctxDecryptedBodyKey = "decrypted_request_body"
|
||||||
|
|
||||||
|
deviceDecryptSkipPathPublicFileUpload = "/v1/public/file/upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DeviceMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) {
|
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)
|
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() {
|
if !rw.Decrypt() {
|
||||||
c.Set(ctxDeviceDecryptStatusKey, "failed")
|
c.Set(ctxDeviceDecryptStatusKey, "failed")
|
||||||
if _, exists := c.Get(ctxDeviceDecryptReasonKey); !exists {
|
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) {
|
func NewResponseWriter(c *gin.Context, srvCtx *svc.ServiceContext) (rw *ResponseWriter) {
|
||||||
rw = &ResponseWriter{
|
rw = &ResponseWriter{
|
||||||
c: c,
|
c: c,
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ const (
|
|||||||
CommissionTypeWithdraw uint16 = 334 // withdraw
|
CommissionTypeWithdraw uint16 = 334 // withdraw
|
||||||
CommissionTypeAdjust uint16 = 335 // Admin Adjust
|
CommissionTypeAdjust uint16 = 335 // Admin Adjust
|
||||||
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
|
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
|
||||||
|
CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund
|
||||||
|
CommissionTypeWithdrawCancel uint16 = 338 // 用户取消提现退佣金
|
||||||
GiftTypeIncrease uint16 = 341 // Increase
|
GiftTypeIncrease uint16 = 341 // Increase
|
||||||
GiftTypeReduce uint16 = 342 // Reduce
|
GiftTypeReduce uint16 = 342 // Reduce
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import "time"
|
|||||||
type LogMessage struct {
|
type LogMessage struct {
|
||||||
Id int64 `gorm:"primaryKey;AUTO_INCREMENT"`
|
Id int64 `gorm:"primaryKey;AUTO_INCREMENT"`
|
||||||
Platform string `gorm:"type:varchar(32);not null"`
|
Platform string `gorm:"type:varchar(32);not null"`
|
||||||
AppVersion string `gorm:"type:varchar(32);default:null"`
|
AppVersion string `gorm:"type:varchar(64);default:null"`
|
||||||
OsName string `gorm:"type:varchar(32);default:null"`
|
OsName string `gorm:"type:varchar(64);default:null"`
|
||||||
OsVersion string `gorm:"type:varchar(32);default:null"`
|
OsVersion string `gorm:"type:varchar(64);default:null"`
|
||||||
DeviceId string `gorm:"type:varchar(64);default:null"`
|
DeviceId string `gorm:"type:varchar(255);default:null"`
|
||||||
UserId *int64 `gorm:"type:bigint;default:null"`
|
UserId *int64 `gorm:"type:bigint;default:null"`
|
||||||
SessionId string `gorm:"type:varchar(64);default:null"`
|
SessionId string `gorm:"type:varchar(255);default:null"`
|
||||||
Level uint8 `gorm:"type:tinyint(1);not null;default:3"`
|
Level uint8 `gorm:"type:tinyint(1);not null;default:3"`
|
||||||
ErrorCode string `gorm:"type:varchar(64);default:null"`
|
ErrorCode string `gorm:"type:varchar(128);default:null"`
|
||||||
Message string `gorm:"type:text;not null"`
|
Message string `gorm:"type:text;not null"`
|
||||||
Stack string `gorm:"type:mediumtext;default:null"`
|
Stack string `gorm:"type:mediumtext;default:null"`
|
||||||
Context string `gorm:"type:json;default:null"`
|
Context string `gorm:"type:json;default:null"`
|
||||||
|
|||||||
@@ -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").
|
Where("status IN ? AND created_at BETWEEN ? AND ? AND method != ?", []int64{2, 5}, firstDay, lastDay, "balance").
|
||||||
Select(
|
Select(
|
||||||
"SUM(amount) as amount_total, " +
|
"SUM(amount) as amount_total, " +
|
||||||
"SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
"SUM(CASE WHEN is_new = 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 = 0 THEN amount ELSE 0 END) as renewal_order_amount",
|
||||||
).
|
).
|
||||||
Scan(v).Error
|
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").
|
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance").
|
||||||
Select(
|
Select(
|
||||||
"SUM(amount) as amount_total, " +
|
"SUM(amount) as amount_total, " +
|
||||||
"SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
"SUM(CASE WHEN is_new = 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 = 0 THEN amount ELSE 0 END) as renewal_order_amount",
|
||||||
).
|
).
|
||||||
Scan(v).Error
|
Scan(v).Error
|
||||||
})
|
})
|
||||||
@@ -192,8 +192,8 @@ func (m *customOrderModel) QueryTotalOrders(ctx context.Context) (OrdersTotal, e
|
|||||||
return conn.Model(&Order{}).
|
return conn.Model(&Order{}).
|
||||||
Select(`
|
Select(`
|
||||||
SUM(amount) AS amount_total,
|
SUM(amount) AS amount_total,
|
||||||
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
SUM(CASE WHEN is_new = 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 = 0 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||||
`).
|
`).
|
||||||
Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
|
Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
|
||||||
Scan(&result).Error
|
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 {
|
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
|
||||||
return conn.Model(&Order{}).
|
return conn.Model(&Order{}).
|
||||||
Select(`
|
Select(`
|
||||||
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
|
COUNT(DISTINCT CASE WHEN is_new = 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 = 0 THEN user_id END) AS renewal_users
|
||||||
`).
|
`).
|
||||||
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
||||||
[]int64{2, 5}, firstDay, nextMonth, "balance").
|
[]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 {
|
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
|
||||||
return conn.Model(&Order{}).
|
return conn.Model(&Order{}).
|
||||||
Select(`
|
Select(`
|
||||||
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
|
COUNT(DISTINCT CASE WHEN is_new = 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 = 0 THEN user_id END) AS renewal_users
|
||||||
`).
|
`).
|
||||||
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?",
|
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?",
|
||||||
[]int64{2, 5}, dateStr, "balance").
|
[]int64{2, 5}, dateStr, "balance").
|
||||||
@@ -249,8 +249,8 @@ func (m *customOrderModel) QueryTotalUserCounts(ctx context.Context) (int64, int
|
|||||||
return conn.Model(&Order{}).
|
return conn.Model(&Order{}).
|
||||||
Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
|
Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
|
||||||
Select(`
|
Select(`
|
||||||
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
|
COUNT(DISTINCT CASE WHEN is_new = 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 = 0 THEN user_id END) AS renewal_users
|
||||||
`).
|
`).
|
||||||
Scan(&counts).Error
|
Scan(&counts).Error
|
||||||
})
|
})
|
||||||
@@ -282,8 +282,8 @@ func (m *customOrderModel) QueryDailyOrdersList(ctx context.Context, date time.T
|
|||||||
Select(`
|
Select(`
|
||||||
DATE_FORMAT(created_at, '%Y-%m-%d') AS date,
|
DATE_FORMAT(created_at, '%Y-%m-%d') AS date,
|
||||||
SUM(amount) AS amount_total,
|
SUM(amount) AS amount_total,
|
||||||
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
SUM(CASE WHEN is_new = 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 = 0 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||||
`).
|
`).
|
||||||
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
||||||
[]int64{2, 5}, firstDay, nextDay, "balance").
|
[]int64{2, 5}, firstDay, nextDay, "balance").
|
||||||
@@ -326,8 +326,8 @@ func (m *customOrderModel) QueryMonthlyOrdersList(ctx context.Context, date time
|
|||||||
Select(`
|
Select(`
|
||||||
DATE_FORMAT(created_at, '%Y-%m') AS date,
|
DATE_FORMAT(created_at, '%Y-%m') AS date,
|
||||||
SUM(amount) AS amount_total,
|
SUM(amount) AS amount_total,
|
||||||
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
SUM(CASE WHEN is_new = 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 = 0 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||||
`).
|
`).
|
||||||
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
||||||
[]int64{2, 5}, start, end, "balance").
|
[]int64{2, 5}, start, end, "balance").
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type (
|
|||||||
Insert(ctx context.Context, data *User, tx ...*gorm.DB) error
|
Insert(ctx context.Context, data *User, tx ...*gorm.DB) error
|
||||||
FindOne(ctx context.Context, id int64) (*User, error)
|
FindOne(ctx context.Context, id int64) (*User, error)
|
||||||
Update(ctx context.Context, data *User, tx ...*gorm.DB) 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
|
Delete(ctx context.Context, id int64, tx ...*gorm.DB) error
|
||||||
Transaction(ctx context.Context, fn func(db *gorm.DB) error) 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
|
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 {
|
func (m *defaultUserModel) Delete(ctx context.Context, id int64, tx ...*gorm.DB) error {
|
||||||
data, err := m.FindOne(ctx, id)
|
data, err := m.FindOne(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,6 +8,22 @@ import (
|
|||||||
"gorm.io/gorm"
|
"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) {
|
func (m *customUserModel) FindOneDevice(ctx context.Context, id int64) (*Device, error) {
|
||||||
deviceIdKey := fmt.Sprintf("%s%v", cacheUserDeviceIdPrefix, id)
|
deviceIdKey := fmt.Sprintf("%s%v", cacheUserDeviceIdPrefix, id)
|
||||||
var resp Device
|
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 {
|
func (m *customUserModel) UpdateDevice(ctx context.Context, data *Device, tx ...*gorm.DB) error {
|
||||||
|
normalizeDeviceForStorage(data)
|
||||||
old, err := m.FindOneDevice(ctx, data.Id)
|
old, err := m.FindOneDevice(ctx, data.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
func (m *customUserModel) InsertDevice(ctx context.Context, data *Device, tx ...*gorm.DB) error {
|
||||||
|
normalizeDeviceForStorage(data)
|
||||||
defer func() {
|
defer func() {
|
||||||
if clearErr := m.ClearDeviceCache(ctx, data); clearErr != nil {
|
if clearErr := m.ClearDeviceCache(ctx, data); clearErr != nil {
|
||||||
// log cache clear error
|
// log cache clear error
|
||||||
|
|||||||
@@ -167,12 +167,15 @@ type Withdrawal struct {
|
|||||||
UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"`
|
UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"`
|
||||||
Amount int64 `gorm:"not null;comment:Withdrawal Amount"`
|
Amount int64 `gorm:"not null;comment:Withdrawal Amount"`
|
||||||
Content string `gorm:"type:text;comment:Withdrawal Content"`
|
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"`
|
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"`
|
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
||||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*Withdrawal) TableName() string {
|
func (*Withdrawal) TableName() string {
|
||||||
return "user_withdrawal"
|
return "withdrawals"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 // 银行卡
|
||||||
|
)
|
||||||
+66
-5
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
package types
|
package types
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
type ActivateOrderRequest struct {
|
type ActivateOrderRequest struct {
|
||||||
OrderNo string `json:"order_no" validate:"required"`
|
OrderNo string `json:"order_no" validate:"required"`
|
||||||
}
|
}
|
||||||
@@ -296,6 +298,9 @@ type CommissionLog struct {
|
|||||||
type CommissionWithdrawRequest struct {
|
type CommissionWithdrawRequest struct {
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
Content string `json:"content"`
|
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 {
|
type ConnectionRecords struct {
|
||||||
@@ -2313,6 +2318,10 @@ type QueryUserSubscribeNodeListResponse struct {
|
|||||||
List []UserSubscribeInfo `json:"list"`
|
List []UserSubscribeInfo `json:"list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CancelWithdrawalRequest struct {
|
||||||
|
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||||
|
}
|
||||||
|
|
||||||
type QueryWithdrawalLogListRequest struct {
|
type QueryWithdrawalLogListRequest struct {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
@@ -2323,6 +2332,28 @@ type QueryWithdrawalLogListResponse struct {
|
|||||||
Total int64 `json:"total"`
|
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 {
|
type QuotaTask struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Subscribers []int64 `json:"subscribers"`
|
Subscribers []int64 `json:"subscribers"`
|
||||||
@@ -3244,17 +3275,17 @@ type UpdateUserBasiceInfoRequest struct {
|
|||||||
UserId int64 `json:"user_id" validate:"required"`
|
UserId int64 `json:"user_id" validate:"required"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Balance int64 `json:"balance"`
|
Balance *int64 `json:"balance"`
|
||||||
Commission int64 `json:"commission"`
|
Commission *int64 `json:"commission"`
|
||||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||||
GiftAmount int64 `json:"gift_amount"`
|
GiftAmount *int64 `json:"gift_amount"`
|
||||||
Telegram int64 `json:"telegram"`
|
Telegram int64 `json:"telegram"`
|
||||||
ReferCode string `json:"refer_code"`
|
ReferCode string `json:"refer_code"`
|
||||||
RefererId int64 `json:"referer_id"`
|
RefererId *int64 `json:"referer_id"`
|
||||||
Enable *bool `json:"enable"`
|
Enable *bool `json:"enable"`
|
||||||
IsAdmin *bool `json:"is_admin"`
|
IsAdmin *bool `json:"is_admin"`
|
||||||
Remark string `json:"remark"`
|
Remark *string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserNotifyRequest struct {
|
type UpdateUserNotifyRequest struct {
|
||||||
@@ -3317,6 +3348,7 @@ type User struct {
|
|||||||
EnableLoginNotify bool `json:"enable_login_notify"`
|
EnableLoginNotify bool `json:"enable_login_notify"`
|
||||||
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
||||||
EnableTradeNotify bool `json:"enable_trade_notify"`
|
EnableTradeNotify bool `json:"enable_trade_notify"`
|
||||||
|
UseStatus bool `json:"use_status"`
|
||||||
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
||||||
UserDevices []UserDevice `json:"user_devices"`
|
UserDevices []UserDevice `json:"user_devices"`
|
||||||
Rules []string `json:"rules"`
|
Rules []string `json:"rules"`
|
||||||
@@ -3624,6 +3656,9 @@ type WithdrawalLog struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Status uint8 `json:"status"`
|
Status uint8 `json:"status"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Method uint8 `json:"method"`
|
||||||
|
Account string `json:"account"`
|
||||||
|
QrCodeUrl string `json:"qr_code_url"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -3658,3 +3693,29 @@ type GetAdminUserInviteListResponse struct {
|
|||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
List []AdminInvitedUser `json:"list"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
- Region: `ap-east-1`
|
- Region: `ap-east-1`
|
||||||
- AWS app EC2:
|
- AWS app EC2:
|
||||||
- Name: `hifast-hk-app-01`
|
- Name: `hifast-hk-app-01`
|
||||||
- Public IP: `43.198.248.161`
|
- Public IP: `18.163.33.75`
|
||||||
- Private IP: `10.0.1.201`
|
- Private IP: `10.0.1.201`
|
||||||
- AWS MySQL:
|
- AWS MySQL:
|
||||||
- Type: `RDS MySQL`
|
- Type: `RDS MySQL`
|
||||||
@@ -183,7 +183,7 @@ redis-cli INFO replication
|
|||||||
重点看:
|
重点看:
|
||||||
|
|
||||||
- `role:slave`
|
- `role:slave`
|
||||||
- `master_host:43.198.248.161`
|
- `master_host:18.163.33.75`
|
||||||
- `master_port:6379`
|
- `master_port:6379`
|
||||||
- `master_link_status:up`
|
- `master_link_status:up`
|
||||||
|
|
||||||
@@ -416,7 +416,7 @@ SHOW REPLICA STATUS\G
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
redis-cli CONFIG SET masterauth '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr'
|
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 CONFIG SET replica-read-only yes
|
||||||
redis-cli INFO replication
|
redis-cli INFO replication
|
||||||
```
|
```
|
||||||
@@ -426,7 +426,7 @@ redis-cli INFO replication
|
|||||||
检查 `/etc/redis/redis.conf` 至少包含:
|
检查 `/etc/redis/redis.conf` 至少包含:
|
||||||
|
|
||||||
```conf
|
```conf
|
||||||
replicaof 43.198.248.161 6379
|
replicaof 18.163.33.75 6379
|
||||||
masterauth 0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr
|
masterauth 0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr
|
||||||
replica-read-only yes
|
replica-read-only yes
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
- Instance ID: `i-079cd9d3ef3748714`
|
- Instance ID: `i-079cd9d3ef3748714`
|
||||||
- 角色:当前实际生产入口 / Nginx / 业务服务 / AWS 侧 Redis 主库宿主机
|
- 角色:当前实际生产入口 / Nginx / 业务服务 / AWS 侧 Redis 主库宿主机
|
||||||
- 私网 IP: `10.0.1.201`
|
- 私网 IP: `10.0.1.201`
|
||||||
- 公网 IP: `43.198.248.161`
|
- 公网 IP: `18.163.33.75`
|
||||||
- 业务服务运行方式:`Docker Compose`
|
- 业务服务运行方式:`Docker Compose`
|
||||||
- 业务容器:`ppanel-server`
|
- 业务容器:`ppanel-server`
|
||||||
- 部署目录:`/opt/ppanel`
|
- 部署目录:`/opt/ppanel`
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
- 容器名:`hifast-redis`
|
- 容器名:`hifast-redis`
|
||||||
- 版本:`redis:8.2.1`
|
- 版本:`redis:8.2.1`
|
||||||
- 访问端口:`6379`
|
- 访问端口:`6379`
|
||||||
- 主库出口地址:`43.198.248.161:6379`
|
- 主库出口地址:`18.163.33.75:6379`
|
||||||
- 应用当前实际连接:`127.0.0.1:6379`
|
- 应用当前实际连接:`127.0.0.1:6379`
|
||||||
- 认证方式:已启用密码认证
|
- 认证方式:已启用密码认证
|
||||||
|
|
||||||
@@ -130,10 +130,10 @@
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
USER["用户 / 客户端"] --> DNS["域名 / DNS / 入口层"]
|
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 --> 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 备用库"]
|
RDS -. MySQL 备用 / 同步 .-> MYSQLS["104.238.220.230\nMySQL 备用库"]
|
||||||
REDISM -. Redis 主从复制 .-> REDISS["104.238.220.230\n原生 Redis 8.6.3\n从库"]
|
REDISM -. Redis 主从复制 .-> REDISS["104.238.220.230\n原生 Redis 8.6.3\n从库"]
|
||||||
@@ -238,7 +238,7 @@ App / Nginx
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
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
|
STANDBY["104.238.220.230/32"] --> SG
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ RDS 当前状态已经比之前干净很多:
|
|||||||
|
|
||||||
`104.238.220.230` 连接 AWS Redis,不是通过 PEM 证书,也不是通过 SSH 登录 AWS 机器,而是直接作为 Redis 从库去访问 AWS Redis 主库:
|
`104.238.220.230` 连接 AWS Redis,不是通过 PEM 证书,也不是通过 SSH 登录 AWS 机器,而是直接作为 Redis 从库去访问 AWS Redis 主库:
|
||||||
|
|
||||||
- 目标地址:`43.198.248.161:6379`
|
- 目标地址:`18.163.33.75:6379`
|
||||||
- 连接方式:`TCP`
|
- 连接方式:`TCP`
|
||||||
- 认证方式:`Redis 密码`
|
- 认证方式:`Redis 密码`
|
||||||
- 网络前提:AWS EC2 安全组已放行 `104.238.220.230/32 -> 6379`
|
- 网络前提:AWS EC2 安全组已放行 `104.238.220.230/32 -> 6379`
|
||||||
@@ -325,7 +325,7 @@ RDS 当前状态已经比之前干净很多:
|
|||||||
示意命令:
|
示意命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
redis-cli -h 43.198.248.161 -p 6379 -a '<REDIS_PASSWORD>'
|
redis-cli -h 18.163.33.75 -p 6379 -a '<REDIS_PASSWORD>'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.6.2 104 连接 AWS MySQL RDS 的方式
|
### 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
|
- 部署方式:Docker
|
||||||
- 版本:`8.2.1`
|
- 版本:`8.2.1`
|
||||||
- 主库地址:`43.198.248.161:6379`
|
- 主库地址:`18.163.33.75:6379`
|
||||||
- 运行容器:`hifast-redis`
|
- 运行容器:`hifast-redis`
|
||||||
|
|
||||||
### 5.2 104 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
|
```mermaid
|
||||||
flowchart LR
|
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"]
|
REDISSLAVE["104.238.220.230\n原生 Redis 8.6.3\nrole: slave"]
|
||||||
REDISMASTER --> REDISSLAVE
|
REDISMASTER --> REDISSLAVE
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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'
|
||||||
Reference in New Issue
Block a user