Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cfca8ef6b | |||
| f9fa4756e9 | |||
| 27f1203282 | |||
| f7f890c990 | |||
| 8b4e7561f4 | |||
| a6e9e2bdb8 | |||
| b798f520c8 | |||
| bae234fe15 | |||
| 79ab4460bc | |||
| 0169a16ada | |||
| b6b5bccde6 |
+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
|
||||||
|
|||||||
+128
-30
@@ -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: "-4940243803"
|
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/bindbox" >> $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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-14
@@ -38,20 +38,20 @@ type (
|
|||||||
Id int64 `form:"id" validate:"required"`
|
Id int64 `form:"id" validate:"required"`
|
||||||
}
|
}
|
||||||
UpdateUserBasiceInfoRequest {
|
UpdateUserBasiceInfoRequest {
|
||||||
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"`
|
||||||
@@ -235,6 +235,7 @@ type (
|
|||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
UserId *int64 `form:"user_id,omitempty"`
|
UserId *int64 `form:"user_id,omitempty"`
|
||||||
Status *uint8 `form:"status,omitempty"`
|
Status *uint8 `form:"status,omitempty"`
|
||||||
|
Method *uint8 `form:"method,omitempty"`
|
||||||
}
|
}
|
||||||
GetWithdrawalListResponse {
|
GetWithdrawalListResponse {
|
||||||
List []WithdrawalLog `json:"list"`
|
List []WithdrawalLog `json:"list"`
|
||||||
|
|||||||
+15
-2
@@ -109,8 +109,11 @@ type (
|
|||||||
Rules []string `json:"rules" validate:"required"`
|
Rules []string `json:"rules" validate:"required"`
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`;
|
||||||
@@ -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/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))
|
||||||
|
|
||||||
@@ -1056,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
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ func (l *GetWithdrawalListLogic) GetWithdrawalList(req *types.GetWithdrawalListR
|
|||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
query = query.Where("status = ?", *req.Status)
|
query = query.Where("status = ?", *req.Status)
|
||||||
}
|
}
|
||||||
|
if req.Method != nil {
|
||||||
|
query = query.Where("method = ?", *req.Method)
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := query.Count(&total).Error; err != nil {
|
||||||
@@ -62,6 +65,9 @@ func (l *GetWithdrawalListLogic) GetWithdrawalList(req *types.GetWithdrawalListR
|
|||||||
Content: row.Content,
|
Content: row.Content,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
Reason: row.Reason,
|
Reason: row.Reason,
|
||||||
|
Method: row.Method,
|
||||||
|
Account: row.Account,
|
||||||
|
QrCodeUrl: row.QrCodeUrl,
|
||||||
CreatedAt: row.CreatedAt.UnixMilli(),
|
CreatedAt: row.CreatedAt.UnixMilli(),
|
||||||
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,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()
|
||||||
@@ -66,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
|
||||||
@@ -81,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(),
|
||||||
}
|
}
|
||||||
@@ -96,23 +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 {
|
||||||
if isWithdrawalScene(req.Remark) {
|
remark := ""
|
||||||
|
if req.Remark != nil {
|
||||||
|
remark = *req.Remark
|
||||||
|
}
|
||||||
|
if isWithdrawalScene(remark) {
|
||||||
logWithdrawalGuard(l.Logger, userInfo.Id)
|
logWithdrawalGuard(l.Logger, userInfo.Id)
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
|
||||||
}
|
}
|
||||||
change := req.Commission - userInfo.Commission
|
change := *req.Commission - userInfo.Commission
|
||||||
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
|
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = logicCommon.WriteCommissionLog(tx, userInfo.Id, log.CommissionTypeAdjust, change, ""); err != nil {
|
if err = logicCommon.WriteCommissionLog(tx, userInfo.Id, log.CommissionTypeAdjust, change, ""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
userInfo.Commission = req.Commission
|
userInfo.Commission = *req.Commission
|
||||||
}
|
}
|
||||||
if req.Avatar != "" {
|
if req.Avatar != "" {
|
||||||
userInfo.Avatar = req.Avatar
|
userInfo.Avatar = req.Avatar
|
||||||
@@ -120,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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -56,11 +72,14 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
|||||||
var w user.Withdrawal
|
var w user.Withdrawal
|
||||||
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
w = user.Withdrawal{
|
w = user.Withdrawal{
|
||||||
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
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
|
|||||||
Content: row.Content,
|
Content: row.Content,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
Reason: row.Reason,
|
Reason: row.Reason,
|
||||||
|
Method: row.Method,
|
||||||
|
Account: row.Account,
|
||||||
|
QrCodeUrl: row.QrCodeUrl,
|
||||||
CreatedAt: row.CreatedAt.UnixMilli(),
|
CreatedAt: row.CreatedAt.UnixMilli(),
|
||||||
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const (
|
|||||||
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
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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").
|
||||||
|
|||||||
@@ -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 // 银行卡
|
||||||
|
)
|
||||||
+55
-16
@@ -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"`
|
||||||
}
|
}
|
||||||
@@ -294,8 +296,11 @@ 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"`
|
||||||
@@ -2328,6 +2337,7 @@ type GetWithdrawalListRequest struct {
|
|||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
UserId *int64 `form:"user_id,omitempty"`
|
UserId *int64 `form:"user_id,omitempty"`
|
||||||
Status *uint8 `form:"status,omitempty"`
|
Status *uint8 `form:"status,omitempty"`
|
||||||
|
Method *uint8 `form:"method,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetWithdrawalListResponse struct {
|
type GetWithdrawalListResponse struct {
|
||||||
@@ -3262,20 +3272,20 @@ type UpdateUserAuthMethodRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserBasiceInfoRequest struct {
|
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 {
|
||||||
@@ -3646,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"`
|
||||||
}
|
}
|
||||||
@@ -3680,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user