Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cfca8ef6b | |||
| f9fa4756e9 | |||
| 27f1203282 | |||
| f7f890c990 | |||
| 8b4e7561f4 |
+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
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -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"`
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1059,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))
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func (l *DeleteSubscribeApplicationLogic) DeleteSubscribeApplication(req *types.
|
|||||||
err := l.svcCtx.ClientModel.Delete(l.ctx, req.Id)
|
err := l.svcCtx.ClientModel.Delete(l.ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorf("Failed to delete subscribe application with ID %d: %v", req.Id, err)
|
l.Errorf("Failed to delete subscribe application with ID %d: %v", req.Id, err)
|
||||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func (l *ResetSortWithNodeLogic) ResetSortWithNode(req *types.ResetSortRequest)
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[NodeSort] Update Database Error: ", logger.Field("error", err.Error()))
|
l.Errorw("[NodeSort] Update Database Error: ", logger.Field("error", err.Error()))
|
||||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func (l *ResetSortWithServerLogic) ResetSortWithServer(req *types.ResetSortReque
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[NodeSort] Update Database Error: ", logger.Field("error", err.Error()))
|
l.Errorw("[NodeSort] Update Database Error: ", logger.Field("error", err.Error()))
|
||||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||||
}
|
}
|
||||||
return nil
|
return 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,8 +124,8 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
|||||||
if req.ReferCode != "" {
|
if req.ReferCode != "" {
|
||||||
userInfo.ReferCode = req.ReferCode
|
userInfo.ReferCode = req.ReferCode
|
||||||
}
|
}
|
||||||
if req.RefererId != 0 {
|
if req.RefererId != nil {
|
||||||
userInfo.RefererId = req.RefererId
|
userInfo.RefererId = *req.RefererId
|
||||||
}
|
}
|
||||||
if req.Enable != nil {
|
if req.Enable != nil {
|
||||||
userInfo.Enable = req.Enable
|
userInfo.Enable = req.Enable
|
||||||
@@ -129,8 +133,8 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
|||||||
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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,346 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
stderrors "errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
PromoRuleTypeNewUser = "new_user"
|
|
||||||
PromoRuleTypeInactiveUser = "inactive_user"
|
|
||||||
PromoRuleTypeCampaign = "campaign"
|
|
||||||
|
|
||||||
promoRulesEnabledCacheKey = "promo:rules:enabled"
|
|
||||||
promoSubscribeCachePrefix = "promo:subscribe:"
|
|
||||||
promoCacheTTL = 5 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
type PromoResult struct {
|
|
||||||
Eligible bool
|
|
||||||
RuleID int64
|
|
||||||
RuleName string
|
|
||||||
RuleType string
|
|
||||||
PromoPrice int64
|
|
||||||
ExpiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type promoRule struct {
|
|
||||||
Id int64 `gorm:"column:id" json:"id"`
|
|
||||||
Name string `gorm:"column:name" json:"name"`
|
|
||||||
Type string `gorm:"column:type" json:"type"`
|
|
||||||
Params string `gorm:"column:params" json:"params"`
|
|
||||||
Priority int64 `gorm:"column:priority" json:"priority"`
|
|
||||||
Enabled bool `gorm:"column:enabled" json:"enabled"`
|
|
||||||
StartTime *time.Time `gorm:"column:start_time" json:"start_time,omitempty"`
|
|
||||||
EndTime *time.Time `gorm:"column:end_time" json:"end_time,omitempty"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (promoRule) TableName() string {
|
|
||||||
return "promo_rule"
|
|
||||||
}
|
|
||||||
|
|
||||||
type subscribePromo struct {
|
|
||||||
Id int64 `gorm:"column:id" json:"id"`
|
|
||||||
SubscribeId int64 `gorm:"column:subscribe_id" json:"subscribe_id"`
|
|
||||||
Quantity int64 `gorm:"column:quantity" json:"quantity"`
|
|
||||||
PromoRuleId int64 `gorm:"column:promo_rule_id" json:"promo_rule_id"`
|
|
||||||
PromoPrice int64 `gorm:"column:promo_price" json:"promo_price"`
|
|
||||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (subscribePromo) TableName() string {
|
|
||||||
return "subscribe_promo"
|
|
||||||
}
|
|
||||||
|
|
||||||
type promoRuleParams struct {
|
|
||||||
WindowHours int64 `json:"window_hours"`
|
|
||||||
InactiveMonths int `json:"inactive_months"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type promoEligibilitySource interface {
|
|
||||||
UserCreatedAt(ctx context.Context, userID int64) (time.Time, error)
|
|
||||||
LastSubscribeExpireAt(ctx context.Context, userID int64) (time.Time, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type gormPromoEligibilitySource struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64, quantity int64) (*PromoResult, error) {
|
|
||||||
if svcCtx == nil || svcCtx.DB == nil {
|
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "service context is empty")
|
|
||||||
}
|
|
||||||
if userID <= 0 || subscribeID <= 0 || quantity <= 0 {
|
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "user id, subscribe id or quantity is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
rules, err := loadEnabledPromoRules(ctx, svcCtx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(rules) == 0 {
|
|
||||||
return &PromoResult{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
prices, err := loadSubscribePromos(ctx, svcCtx, subscribeID, quantity)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(prices) == 0 {
|
|
||||||
return &PromoResult{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return evaluatePromoRulesAt(ctx, rules, prices, &gormPromoEligibilitySource{db: svcCtx.DB}, userID, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluatePromoRulesAt(
|
|
||||||
ctx context.Context,
|
|
||||||
rules []promoRule,
|
|
||||||
prices []subscribePromo,
|
|
||||||
source promoEligibilitySource,
|
|
||||||
userID int64,
|
|
||||||
now time.Time,
|
|
||||||
) (*PromoResult, error) {
|
|
||||||
priceByRuleID := make(map[int64]int64, len(prices))
|
|
||||||
for _, item := range prices {
|
|
||||||
if item.PromoRuleId <= 0 || item.PromoPrice <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
priceByRuleID[item.PromoRuleId] = item.PromoPrice
|
|
||||||
}
|
|
||||||
|
|
||||||
var userCreatedAt time.Time
|
|
||||||
var userCreatedAtLoaded bool
|
|
||||||
var lastExpireAt time.Time
|
|
||||||
var lastExpireAtLoaded bool
|
|
||||||
|
|
||||||
for _, rule := range rules {
|
|
||||||
promoPrice, ok := priceByRuleID[rule.Id]
|
|
||||||
if !ok || !rule.isAvailableAt(now) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
params, err := rule.params()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var eligible bool
|
|
||||||
var expiresAt time.Time
|
|
||||||
switch rule.Type {
|
|
||||||
case PromoRuleTypeNewUser:
|
|
||||||
if !userCreatedAtLoaded {
|
|
||||||
userCreatedAt, err = source.UserCreatedAt(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
userCreatedAtLoaded = true
|
|
||||||
}
|
|
||||||
eligible, expiresAt = evaluatePromoNewUser(userCreatedAt, params, now)
|
|
||||||
case PromoRuleTypeInactiveUser:
|
|
||||||
if !lastExpireAtLoaded {
|
|
||||||
lastExpireAt, err = source.LastSubscribeExpireAt(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
lastExpireAtLoaded = true
|
|
||||||
}
|
|
||||||
eligible, expiresAt = evaluatePromoInactiveUser(lastExpireAt, params, rule, now)
|
|
||||||
case PromoRuleTypeCampaign:
|
|
||||||
eligible, expiresAt = evaluatePromoCampaign(rule)
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !eligible {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return &PromoResult{
|
|
||||||
Eligible: true,
|
|
||||||
RuleID: rule.Id,
|
|
||||||
RuleName: rule.Name,
|
|
||||||
RuleType: rule.Type,
|
|
||||||
PromoPrice: promoPrice,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PromoResult{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluatePromoNewUser(userCreatedAt time.Time, params promoRuleParams, now time.Time) (bool, time.Time) {
|
|
||||||
if userCreatedAt.IsZero() || params.WindowHours <= 0 {
|
|
||||||
return false, time.Time{}
|
|
||||||
}
|
|
||||||
expiresAt := userCreatedAt.Add(time.Duration(params.WindowHours) * time.Hour)
|
|
||||||
return now.Before(expiresAt), expiresAt
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluatePromoInactiveUser(lastExpireAt time.Time, params promoRuleParams, rule promoRule, now time.Time) (bool, time.Time) {
|
|
||||||
if params.InactiveMonths <= 0 {
|
|
||||||
return false, time.Time{}
|
|
||||||
}
|
|
||||||
if lastExpireAt.Equal(time.UnixMilli(0)) || lastExpireAt.After(now) {
|
|
||||||
return false, time.Time{}
|
|
||||||
}
|
|
||||||
if lastExpireAt.IsZero() {
|
|
||||||
return true, rule.expiresAt()
|
|
||||||
}
|
|
||||||
|
|
||||||
threshold := now.AddDate(0, -params.InactiveMonths, 0)
|
|
||||||
return !lastExpireAt.After(threshold), rule.expiresAt()
|
|
||||||
}
|
|
||||||
|
|
||||||
func evaluatePromoCampaign(rule promoRule) (bool, time.Time) {
|
|
||||||
return true, rule.expiresAt()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r promoRule) isAvailableAt(now time.Time) bool {
|
|
||||||
if r.Id <= 0 || !r.Enabled || r.DeletedAt.Valid {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if r.StartTime != nil && now.Before(*r.StartTime) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if r.EndTime != nil && now.After(*r.EndTime) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r promoRule) expiresAt() time.Time {
|
|
||||||
if r.EndTime == nil {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
return *r.EndTime
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r promoRule) params() (promoRuleParams, error) {
|
|
||||||
if r.Params == "" {
|
|
||||||
return promoRuleParams{}, nil
|
|
||||||
}
|
|
||||||
var params promoRuleParams
|
|
||||||
if err := json.Unmarshal([]byte(r.Params), ¶ms); err != nil {
|
|
||||||
return promoRuleParams{}, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "parse promo rule params failed")
|
|
||||||
}
|
|
||||||
return params, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *gormPromoEligibilitySource) UserCreatedAt(ctx context.Context, userID int64) (time.Time, error) {
|
|
||||||
var item user.User
|
|
||||||
err := s.db.WithContext(ctx).
|
|
||||||
Model(&user.User{}).
|
|
||||||
Where("id = ?", userID).
|
|
||||||
Take(&item).Error
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo user failed")
|
|
||||||
}
|
|
||||||
return item.CreatedAt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *gormPromoEligibilitySource) LastSubscribeExpireAt(ctx context.Context, userID int64) (time.Time, error) {
|
|
||||||
var item user.Subscribe
|
|
||||||
err := lastSubscribeExpireQuery(s.db.WithContext(ctx), userID).
|
|
||||||
Take(&item).Error
|
|
||||||
if err != nil {
|
|
||||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return time.Time{}, nil
|
|
||||||
}
|
|
||||||
return time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo user last subscription failed")
|
|
||||||
}
|
|
||||||
return item.ExpireTime, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func lastSubscribeExpireQuery(db *gorm.DB, userID int64) *gorm.DB {
|
|
||||||
return db.
|
|
||||||
Model(&user.Subscribe{}).
|
|
||||||
Where("user_id = ?", userID).
|
|
||||||
Order(clause.OrderBy{Expression: clause.Expr{
|
|
||||||
SQL: "CASE WHEN expire_time = ? THEN 0 ELSE 1 END ASC, expire_time DESC",
|
|
||||||
Vars: []interface{}{time.UnixMilli(0)},
|
|
||||||
}}).
|
|
||||||
Limit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadEnabledPromoRules(ctx context.Context, svcCtx *svc.ServiceContext) ([]promoRule, error) {
|
|
||||||
if cached, ok := getPromoCache[[]promoRule](ctx, svcCtx, promoRulesEnabledCacheKey); ok {
|
|
||||||
return cached, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var rules []promoRule
|
|
||||||
if err := svcCtx.DB.WithContext(ctx).
|
|
||||||
Model(&promoRule{}).
|
|
||||||
Where("enabled = ?", true).
|
|
||||||
Order("priority DESC").
|
|
||||||
Order("id ASC").
|
|
||||||
Find(&rules).Error; err != nil {
|
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query enabled promo rules failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
setPromoCache(ctx, svcCtx, promoRulesEnabledCacheKey, rules)
|
|
||||||
return rules, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadSubscribePromos(ctx context.Context, svcCtx *svc.ServiceContext, subscribeID int64, quantity int64) ([]subscribePromo, error) {
|
|
||||||
cacheKey := fmt.Sprintf("%s%d:%d", promoSubscribeCachePrefix, subscribeID, quantity)
|
|
||||||
if cached, ok := getPromoCache[[]subscribePromo](ctx, svcCtx, cacheKey); ok {
|
|
||||||
return cached, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var promos []subscribePromo
|
|
||||||
if err := subscribePromoQuery(svcCtx.DB.WithContext(ctx), subscribeID, quantity).
|
|
||||||
Find(&promos).Error; err != nil {
|
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query subscribe promos failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
setPromoCache(ctx, svcCtx, cacheKey, promos)
|
|
||||||
return promos, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribePromoQuery(db *gorm.DB, subscribeID int64, quantity int64) *gorm.DB {
|
|
||||||
return db.
|
|
||||||
Model(&subscribePromo{}).
|
|
||||||
Where("subscribe_id = ? AND quantity = ?", subscribeID, quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPromoCache[T any](ctx context.Context, svcCtx *svc.ServiceContext, key string) (T, bool) {
|
|
||||||
var zero T
|
|
||||||
if svcCtx == nil || svcCtx.Redis == nil {
|
|
||||||
return zero, false
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := svcCtx.Redis.Get(ctx, key).Result()
|
|
||||||
if err != nil {
|
|
||||||
return zero, false
|
|
||||||
}
|
|
||||||
|
|
||||||
var data T
|
|
||||||
if err = json.Unmarshal([]byte(value), &data); err != nil {
|
|
||||||
_ = svcCtx.Redis.Del(ctx, key).Err()
|
|
||||||
return zero, false
|
|
||||||
}
|
|
||||||
return data, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func setPromoCache(ctx context.Context, svcCtx *svc.ServiceContext, key string, value any) {
|
|
||||||
if svcCtx == nil || svcCtx.Redis == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload, err := json.Marshal(value)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = svcCtx.Redis.Set(ctx, key, string(payload), promoCacheTTL).Err()
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/driver/mysql"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakePromoEligibilitySource struct {
|
|
||||||
userCreatedAt time.Time
|
|
||||||
lastExpireAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s fakePromoEligibilitySource) UserCreatedAt(context.Context, int64) (time.Time, error) {
|
|
||||||
return s.userCreatedAt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s fakePromoEligibilitySource) LastSubscribeExpireAt(context.Context, int64) (time.Time, error) {
|
|
||||||
return s.lastExpireAt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluatePromoRulesAtPriorityFirstMatch(t *testing.T) {
|
|
||||||
now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)
|
|
||||||
campaignEnd := now.Add(24 * time.Hour)
|
|
||||||
rules := []promoRule{
|
|
||||||
{
|
|
||||||
Id: 2,
|
|
||||||
Name: "campaign",
|
|
||||||
Type: PromoRuleTypeCampaign,
|
|
||||||
Priority: 20,
|
|
||||||
Enabled: true,
|
|
||||||
EndTime: &campaignEnd,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: 1,
|
|
||||||
Name: "new user",
|
|
||||||
Type: PromoRuleTypeNewUser,
|
|
||||||
Params: `{"window_hours":168}`,
|
|
||||||
Priority: 10,
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
prices := []subscribePromo{
|
|
||||||
{SubscribeId: 100, Quantity: 1, PromoRuleId: 1, PromoPrice: 599},
|
|
||||||
{SubscribeId: 100, Quantity: 1, PromoRuleId: 2, PromoPrice: 499},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := evaluatePromoRulesAt(context.Background(), rules, prices, fakePromoEligibilitySource{
|
|
||||||
userCreatedAt: now.Add(-time.Hour),
|
|
||||||
}, 10, now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("evaluatePromoRulesAt error: %v", err)
|
|
||||||
}
|
|
||||||
if !got.Eligible || got.RuleID != 2 || got.PromoPrice != 499 || got.RuleType != PromoRuleTypeCampaign {
|
|
||||||
t.Fatalf("unexpected promo result: %+v", got)
|
|
||||||
}
|
|
||||||
if !got.ExpiresAt.Equal(campaignEnd) {
|
|
||||||
t.Fatalf("expires_at = %v, want %v", got.ExpiresAt, campaignEnd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluatePromoRulesAtSkipsUnavailableRules(t *testing.T) {
|
|
||||||
now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)
|
|
||||||
futureStart := now.Add(time.Hour)
|
|
||||||
expiredEnd := now.Add(-time.Hour)
|
|
||||||
rules := []promoRule{
|
|
||||||
{Id: 1, Type: PromoRuleTypeCampaign, Enabled: true, StartTime: &futureStart},
|
|
||||||
{Id: 2, Type: PromoRuleTypeCampaign, Enabled: true, EndTime: &expiredEnd},
|
|
||||||
{Id: 3, Type: PromoRuleTypeCampaign, Enabled: false},
|
|
||||||
{Id: 4, Type: "unknown", Enabled: true},
|
|
||||||
{Id: 5, Type: PromoRuleTypeCampaign, Enabled: true},
|
|
||||||
}
|
|
||||||
prices := []subscribePromo{
|
|
||||||
{SubscribeId: 100, Quantity: 1, PromoRuleId: 1, PromoPrice: 100},
|
|
||||||
{SubscribeId: 100, Quantity: 1, PromoRuleId: 2, PromoPrice: 100},
|
|
||||||
{SubscribeId: 100, Quantity: 1, PromoRuleId: 3, PromoPrice: 100},
|
|
||||||
{SubscribeId: 100, Quantity: 1, PromoRuleId: 4, PromoPrice: 100},
|
|
||||||
{SubscribeId: 100, Quantity: 1, PromoRuleId: 5, PromoPrice: 88},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := evaluatePromoRulesAt(context.Background(), rules, prices, fakePromoEligibilitySource{}, 10, now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("evaluatePromoRulesAt error: %v", err)
|
|
||||||
}
|
|
||||||
if !got.Eligible || got.RuleID != 5 || got.PromoPrice != 88 {
|
|
||||||
t.Fatalf("unexpected promo result: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluatePromoNewUser(t *testing.T) {
|
|
||||||
now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)
|
|
||||||
createdAt := now.Add(-23 * time.Hour)
|
|
||||||
|
|
||||||
eligible, expiresAt := evaluatePromoNewUser(createdAt, promoRuleParams{WindowHours: 24}, now)
|
|
||||||
if !eligible {
|
|
||||||
t.Fatal("new user should be eligible inside configured window")
|
|
||||||
}
|
|
||||||
if !expiresAt.Equal(createdAt.Add(24 * time.Hour)) {
|
|
||||||
t.Fatalf("expires_at = %v, want %v", expiresAt, createdAt.Add(24*time.Hour))
|
|
||||||
}
|
|
||||||
|
|
||||||
eligible, _ = evaluatePromoNewUser(createdAt, promoRuleParams{WindowHours: 12}, now)
|
|
||||||
if eligible {
|
|
||||||
t.Fatal("new user should not be eligible after configured window")
|
|
||||||
}
|
|
||||||
|
|
||||||
eligible, _ = evaluatePromoNewUser(createdAt, promoRuleParams{}, now)
|
|
||||||
if eligible {
|
|
||||||
t.Fatal("new user should not be eligible without positive window_hours")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluatePromoInactiveUser(t *testing.T) {
|
|
||||||
now := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC)
|
|
||||||
endTime := now.Add(48 * time.Hour)
|
|
||||||
rule := promoRule{EndTime: &endTime}
|
|
||||||
|
|
||||||
eligible, expiresAt := evaluatePromoInactiveUser(time.Time{}, promoRuleParams{InactiveMonths: 3}, rule, now)
|
|
||||||
if !eligible {
|
|
||||||
t.Fatal("never purchased user should be eligible for inactive promo")
|
|
||||||
}
|
|
||||||
if !expiresAt.Equal(endTime) {
|
|
||||||
t.Fatalf("expires_at = %v, want %v", expiresAt, endTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
eligible, _ = evaluatePromoInactiveUser(now.AddDate(0, -4, 0), promoRuleParams{InactiveMonths: 3}, rule, now)
|
|
||||||
if !eligible {
|
|
||||||
t.Fatal("expired before inactive threshold should be eligible")
|
|
||||||
}
|
|
||||||
|
|
||||||
eligible, _ = evaluatePromoInactiveUser(now.AddDate(0, -1, 0), promoRuleParams{InactiveMonths: 3}, rule, now)
|
|
||||||
if eligible {
|
|
||||||
t.Fatal("recently expired subscription should not be eligible")
|
|
||||||
}
|
|
||||||
|
|
||||||
eligible, _ = evaluatePromoInactiveUser(time.UnixMilli(0), promoRuleParams{InactiveMonths: 3}, rule, now)
|
|
||||||
if eligible {
|
|
||||||
t.Fatal("unlimited active subscription should not be eligible")
|
|
||||||
}
|
|
||||||
|
|
||||||
eligible, _ = evaluatePromoInactiveUser(now.Add(time.Hour), promoRuleParams{InactiveMonths: 3}, rule, now)
|
|
||||||
if eligible {
|
|
||||||
t.Fatal("currently active subscription should not be eligible")
|
|
||||||
}
|
|
||||||
|
|
||||||
eligible, _ = evaluatePromoInactiveUser(now.AddDate(0, -4, 0), promoRuleParams{}, rule, now)
|
|
||||||
if eligible {
|
|
||||||
t.Fatal("inactive promo should require positive inactive_months")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLastSubscribeExpireAtIncludesUnlimitedSubscription(t *testing.T) {
|
|
||||||
db, err := gorm.Open(mysql.New(mysql.Config{
|
|
||||||
DSN: "gorm:password@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local",
|
|
||||||
SkipInitializeWithVersion: true,
|
|
||||||
}), &gorm.Config{
|
|
||||||
DryRun: true,
|
|
||||||
DisableAutomaticPing: true,
|
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open gorm db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stmt := lastSubscribeExpireQuery(db, 10).Take(nil).Statement
|
|
||||||
sql := strings.ToLower(stmt.SQL.String())
|
|
||||||
if strings.Contains(sql, "expire_time <>") || strings.Contains(sql, "expire_time !=") {
|
|
||||||
t.Fatalf("last subscribe query should include unlimited subscription, sql: %s", stmt.SQL.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(sql, "case when expire_time = ? then 0 else 1 end asc") {
|
|
||||||
t.Fatalf("last subscribe query should prioritize unlimited subscription, sql: %s", stmt.SQL.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubscribePromoQueryMatchesQuantity(t *testing.T) {
|
|
||||||
db, err := gorm.Open(mysql.New(mysql.Config{
|
|
||||||
DSN: "gorm:password@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local",
|
|
||||||
SkipInitializeWithVersion: true,
|
|
||||||
}), &gorm.Config{
|
|
||||||
DryRun: true,
|
|
||||||
DisableAutomaticPing: true,
|
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open gorm db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stmt := subscribePromoQuery(db, 100, 3).Find(&[]subscribePromo{}).Statement
|
|
||||||
sql := strings.ToLower(stmt.SQL.String())
|
|
||||||
if !strings.Contains(sql, "subscribe_id = ?") {
|
|
||||||
t.Fatalf("subscribe promo query should filter subscribe_id, sql: %s", stmt.SQL.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(sql, "quantity = ?") {
|
|
||||||
t.Fatalf("subscribe promo query should filter quantity, sql: %s", stmt.SQL.String())
|
|
||||||
}
|
|
||||||
if got, want := len(stmt.Vars), 2; got != want {
|
|
||||||
t.Fatalf("query vars len = %d, want %d, vars: %#v", got, want, stmt.Vars)
|
|
||||||
}
|
|
||||||
if stmt.Vars[0] != int64(100) || stmt.Vars[1] != int64(3) {
|
|
||||||
t.Fatalf("query vars = %#v, want subscribe_id=100 quantity=3", stmt.Vars)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func (l *DeviceWsConnectLogic) DeviceWsConnect(c *gin.Context) error {
|
|||||||
_, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, identifier)
|
_, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, identifier)
|
||||||
if err != nil && !sysErr.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !sysErr.Is(err, gorm.ErrRecordNotFound) {
|
||||||
l.Errorf("DeviceWsConnectLogic DeviceWsConnect FindOneDeviceByIdentifier err: %v", err)
|
l.Errorf("DeviceWsConnectLogic DeviceWsConnect FindOneDeviceByIdentifier err: %v", err)
|
||||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
value = l.ctx.Value(constant.CtxKeyUser)
|
value = l.ctx.Value(constant.CtxKeyUser)
|
||||||
@@ -67,7 +67,7 @@ func (l *DeviceWsConnectLogic) DeviceWsConnect(c *gin.Context) error {
|
|||||||
err := l.svcCtx.UserModel.InsertDevice(l.ctx, &device)
|
err := l.svcCtx.UserModel.InsertDevice(l.ctx, &device)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorf("DeviceWsConnectLogic DeviceWsConnect InsertDevice err: %v", err)
|
l.Errorf("DeviceWsConnectLogic DeviceWsConnect InsertDevice err: %v", err)
|
||||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error())
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//默认在线设备1
|
//默认在线设备1
|
||||||
|
|||||||
@@ -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,8 +167,11 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 // 银行卡
|
||||||
|
)
|
||||||
+16
-5
@@ -298,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 {
|
||||||
@@ -2315,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"`
|
||||||
@@ -2330,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 {
|
||||||
@@ -3267,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 {
|
||||||
@@ -3648,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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build tools
|
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build tools
|
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
Reference in New Issue
Block a user