Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cfca8ef6b | |||
| f9fa4756e9 | |||
| 27f1203282 | |||
| f7f890c990 | |||
| 8b4e7561f4 |
+142
-44
@@ -21,8 +21,8 @@ env:
|
||||
# SSH私钥(Gitea Secret 名称:AWS)
|
||||
SSH_KEY: ${{ secrets.AWS }}
|
||||
# TG通知
|
||||
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
||||
TG_CHAT_ID: "-4940243803"
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
# Go构建变量
|
||||
SERVICE: vpn
|
||||
SERVICE_STYLE: vpn
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
# 步骤1: 下载代码
|
||||
- name: 📥 下载代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
# 步骤2: 设置动态环境变量
|
||||
- name: ⚙️ 设置动态环境变量
|
||||
run: |
|
||||
@@ -51,19 +51,22 @@ jobs:
|
||||
echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV
|
||||
echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV
|
||||
echo "DEPLOY_PATH=/opt/ppanel" >> $GITHUB_ENV
|
||||
echo "DEPLOY_ENV_LABEL=🚀 服务已成功部署到生产环境" >> $GITHUB_ENV
|
||||
echo "为 main 分支设置生产环境变量"
|
||||
elif [ "${{ github.ref_name }}" = "internal" ]; then
|
||||
echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV
|
||||
echo "CONTAINER_NAME=ppanel-server-internal" >> $GITHUB_ENV
|
||||
echo "DEPLOY_PATH=/root/bindbox" >> $GITHUB_ENV
|
||||
echo "DEPLOY_ENV_LABEL=🧪 服务已成功部署到测试环境" >> $GITHUB_ENV
|
||||
echo "为 internal 分支设置开发环境变量"
|
||||
else
|
||||
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "CONTAINER_NAME=ppanel-server-${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "DEPLOY_PATH=/root/vpn_server_other" >> $GITHUB_ENV
|
||||
echo "DEPLOY_ENV_LABEL=🔧 服务已成功部署到其他环境" >> $GITHUB_ENV
|
||||
echo "为其他分支 (${{ github.ref_name }}) 设置环境变量"
|
||||
fi
|
||||
|
||||
|
||||
# 步骤3: 安装系统工具 (curl, jq) 并升级 Docker CLI 到 1.44+
|
||||
- name: 🔧 安装系统工具并升级 Docker CLI
|
||||
run: |
|
||||
@@ -111,35 +114,49 @@ jobs:
|
||||
docker --version || true
|
||||
docker version || true
|
||||
echo "客户端 API 版本:" $(docker version --format '{{.Client.APIVersion}}')
|
||||
|
||||
# 步骤4: 构建并发布到镜像仓库
|
||||
- name: 📤 构建并发布到镜像仓库
|
||||
|
||||
# 步骤4: 构建镜像
|
||||
- name: 🏗️ 构建镜像
|
||||
run: |
|
||||
echo "开始构建并推送镜像..."
|
||||
echo "开始构建镜像..."
|
||||
echo "仓库: ${{ env.REPO }}"
|
||||
echo "版本标签: ${{ env.VERSION }}"
|
||||
echo "分支标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
||||
|
||||
# 构建镜像,同时打上版本和分支两个标签
|
||||
BUILD_TAG_ARGS="-t ${{ env.REPO }}:${{ env.VERSION }}"
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BUILD_TAG_ARGS="$BUILD_TAG_ARGS -t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
|
||||
else
|
||||
echo "PR事件仅构建版本标签,不推送镜像、不部署"
|
||||
fi
|
||||
|
||||
docker build -f Dockerfile \
|
||||
--platform linux/amd64 \
|
||||
--build-arg TARGETARCH=amd64 \
|
||||
--build-arg VERSION=${{ env.VERSION }} \
|
||||
--build-arg BUILDTIME=${{ env.BUILDTIME }} \
|
||||
-t ${{ env.REPO }}:${{ env.VERSION }} \
|
||||
-t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }} \
|
||||
$BUILD_TAG_ARGS \
|
||||
.
|
||||
|
||||
|
||||
echo "镜像构建完成"
|
||||
|
||||
# 步骤5: 发布到镜像仓库
|
||||
- name: 📤 发布到镜像仓库
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
echo "开始推送镜像..."
|
||||
|
||||
echo "推送版本标签镜像: ${{ env.REPO }}:${{ env.VERSION }}"
|
||||
docker push ${{ env.REPO }}:${{ env.VERSION }}
|
||||
|
||||
|
||||
echo "推送分支标签镜像: ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
|
||||
docker push ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}
|
||||
|
||||
|
||||
echo "镜像推送完成"
|
||||
|
||||
# 调试: 打印部署目标(不输出敏感信息)
|
||||
|
||||
# 步骤6: 调试 - 打印部署目标(不输出敏感信息)
|
||||
- name: 🔍 调试 - 打印部署目标
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
echo "========== 部署目标调试 =========="
|
||||
echo "当前分支: ${{ github.ref_name }}"
|
||||
@@ -150,8 +167,9 @@ jobs:
|
||||
echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}"
|
||||
echo "====================================="
|
||||
|
||||
# 步骤5: 传输配置文件
|
||||
# 步骤7: 传输配置文件
|
||||
- name: 📂 传输配置文件
|
||||
if: github.event_name == 'push'
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ env.SSH_HOST }}
|
||||
@@ -161,8 +179,9 @@ jobs:
|
||||
source: "docker-compose.cloud.yml"
|
||||
target: "/tmp/ppanel-deploy/"
|
||||
|
||||
# 步骤6: 连接服务器更新并启动
|
||||
# 步骤8: 连接服务器更新、健康检查并按需回滚
|
||||
- name: 🚀 连接服务器更新并启动
|
||||
if: github.event_name == 'push'
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ env.SSH_HOST }}
|
||||
@@ -172,68 +191,147 @@ jobs:
|
||||
timeout: 300s
|
||||
command_timeout: 600s
|
||||
script: |
|
||||
set -e
|
||||
|
||||
echo "连接服务器成功,开始部署..."
|
||||
echo "部署目录: ${{ env.DEPLOY_PATH }}"
|
||||
echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
||||
echo "登录用户: ${{ env.SSH_USER }}"
|
||||
|
||||
HEALTHCHECK_URL="http://127.0.0.1:8080/v1/common/heartbeat"
|
||||
NEW_TAG="${{ env.DOCKER_TAG_SUFFIX }}"
|
||||
ROLLBACK_TAG="rollback-${{ env.VERSION }}"
|
||||
SUDO=""
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
docker_cmd() {
|
||||
if [ -n "$SUDO" ]; then
|
||||
sudo docker "$@"
|
||||
else
|
||||
docker "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
compose_with_tag() {
|
||||
tag="$1"
|
||||
shift
|
||||
if [ -n "$SUDO" ]; then
|
||||
sudo env PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
|
||||
else
|
||||
PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
write_previous_tag() {
|
||||
if [ -n "$SUDO" ]; then
|
||||
printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" | sudo tee .previous-ppanel-image-tag >/dev/null
|
||||
else
|
||||
printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" > .previous-ppanel-image-tag
|
||||
fi
|
||||
}
|
||||
|
||||
health_check() {
|
||||
attempt=1
|
||||
while [ "$attempt" -le 3 ]; do
|
||||
if curl -sf "$HEALTHCHECK_URL"; then
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "健康检查第 ${attempt}/3 次失败,10s 后重试..."
|
||||
attempt=$((attempt + 1))
|
||||
sleep 10
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
sudo mkdir -p ${{ env.DEPLOY_PATH }}
|
||||
sudo cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
|
||||
cd ${{ env.DEPLOY_PATH }}
|
||||
echo "📥 拉取镜像..."
|
||||
sudo docker-compose -f docker-compose.cloud.yml pull ppanel-server
|
||||
echo "🚀 启动服务..."
|
||||
sudo docker-compose -f docker-compose.cloud.yml up -d ppanel-server
|
||||
sudo docker image prune -f || true
|
||||
else
|
||||
mkdir -p ${{ env.DEPLOY_PATH }}
|
||||
cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
|
||||
cd ${{ env.DEPLOY_PATH }}
|
||||
echo "📥 拉取镜像..."
|
||||
docker-compose -f docker-compose.cloud.yml pull ppanel-server
|
||||
echo "🚀 启动服务..."
|
||||
docker-compose -f docker-compose.cloud.yml up -d ppanel-server
|
||||
docker image prune -f || true
|
||||
fi
|
||||
|
||||
echo "✅ 部署命令执行完成"
|
||||
|
||||
# 步骤6: TG通知 (成功)
|
||||
|
||||
cd ${{ env.DEPLOY_PATH }}
|
||||
|
||||
PREVIOUS_IMAGE_TAG="$(docker_cmd inspect --format '{{.Config.Image}}' ppanel-server 2>/dev/null || true)"
|
||||
PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)"
|
||||
echo "上一版本镜像tag: ${PREVIOUS_IMAGE_TAG:-未发现}"
|
||||
echo "上一版本镜像ID: ${PREVIOUS_IMAGE_ID:-未发现}"
|
||||
write_previous_tag
|
||||
|
||||
echo "📥 拉取镜像: ${{ env.REPO }}:${NEW_TAG}"
|
||||
compose_with_tag "$NEW_TAG" pull ppanel-server
|
||||
echo "🚀 启动服务..."
|
||||
compose_with_tag "$NEW_TAG" up -d ppanel-server
|
||||
|
||||
echo "🩺 部署后健康检查: ${HEALTHCHECK_URL}"
|
||||
if health_check; then
|
||||
docker_cmd image prune -f || true
|
||||
echo "✅ 部署后健康检查通过"
|
||||
echo "✅ 部署命令执行完成"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "❌ 部署后健康检查连续 3 次失败,开始回滚..."
|
||||
if [ -n "$PREVIOUS_IMAGE_ID" ]; then
|
||||
docker_cmd tag "$PREVIOUS_IMAGE_ID" "${{ env.REPO }}:${ROLLBACK_TAG}"
|
||||
echo "回滚镜像tag: ${{ env.REPO }}:${ROLLBACK_TAG}"
|
||||
compose_with_tag "$ROLLBACK_TAG" up -d ppanel-server
|
||||
|
||||
echo "🩺 回滚后健康检查: ${HEALTHCHECK_URL}"
|
||||
if health_check; then
|
||||
echo "✅ 回滚后健康检查通过"
|
||||
else
|
||||
echo "❌ 回滚后健康检查仍失败"
|
||||
fi
|
||||
else
|
||||
echo "未找到上一版本镜像ID,无法自动回滚"
|
||||
fi
|
||||
|
||||
docker_cmd image prune -f || true
|
||||
exit 1
|
||||
|
||||
# 步骤9: TG通知 (成功)
|
||||
- name: 📱 发送成功通知到Telegram
|
||||
if: success()
|
||||
if: success() && github.event_name == 'push'
|
||||
uses: appleboy/telegram-action@master
|
||||
with:
|
||||
token: ${{ env.TG_BOT_TOKEN }}
|
||||
to: ${{ env.TG_CHAT_ID }}
|
||||
message: |
|
||||
✅ 部署成功!
|
||||
|
||||
|
||||
📦 项目: ${{ github.repository }}
|
||||
🌿 分支: ${{ github.ref_name }}
|
||||
📝 提交: ${{ github.sha }}
|
||||
👤 提交者: ${{ github.actor }}
|
||||
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
||||
|
||||
🚀 服务已成功部署到生产环境
|
||||
|
||||
${{ env.DEPLOY_ENV_LABEL }}
|
||||
🩺 健康检查: 通过 (http://127.0.0.1:8080/v1/common/heartbeat)
|
||||
parse_mode: Markdown
|
||||
|
||||
# 步骤5: TG通知 (失败)
|
||||
|
||||
# 步骤10: TG通知 (失败)
|
||||
- name: 📱 发送失败通知到Telegram
|
||||
if: failure()
|
||||
if: failure() && github.event_name == 'push'
|
||||
uses: appleboy/telegram-action@master
|
||||
with:
|
||||
token: ${{ env.TG_BOT_TOKEN }}
|
||||
to: ${{ env.TG_CHAT_ID }}
|
||||
message: |
|
||||
❌ 部署失败!
|
||||
|
||||
|
||||
📦 项目: ${{ github.repository }}
|
||||
🌿 分支: ${{ github.ref_name }}
|
||||
📝 提交: ${{ github.sha }}
|
||||
👤 提交者: ${{ github.actor }}
|
||||
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
||||
|
||||
|
||||
🩺 健康检查: 失败或未完成;若新版本健康检查连续 3 次失败,已自动尝试回滚并重新检查
|
||||
⚠️ 请检查构建日志获取详细信息
|
||||
parse_mode: Markdown
|
||||
|
||||
|
||||
+15
-14
@@ -38,20 +38,20 @@ type (
|
||||
Id int64 `form:"id" validate:"required"`
|
||||
}
|
||||
UpdateUserBasiceInfoRequest {
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
Password string `json:"password"`
|
||||
Avatar string `json:"avatar"`
|
||||
Balance int64 `json:"balance"`
|
||||
Commission int64 `json:"commission"`
|
||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
ReferCode string `json:"refer_code"`
|
||||
RefererId int64 `json:"referer_id"`
|
||||
Enable *bool `json:"enable"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Remark string `json:"remark"`
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
Password string `json:"password"`
|
||||
Avatar string `json:"avatar"`
|
||||
Balance *int64 `json:"balance"`
|
||||
Commission *int64 `json:"commission"`
|
||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||
GiftAmount *int64 `json:"gift_amount"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
ReferCode string `json:"refer_code"`
|
||||
RefererId *int64 `json:"referer_id"`
|
||||
Enable *bool `json:"enable"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Remark *string `json:"remark"`
|
||||
}
|
||||
UpdateUserNotifySettingRequest {
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
@@ -235,6 +235,7 @@ type (
|
||||
Size int `form:"size"`
|
||||
UserId *int64 `form:"user_id,omitempty"`
|
||||
Status *uint8 `form:"status,omitempty"`
|
||||
Method *uint8 `form:"method,omitempty"`
|
||||
}
|
||||
GetWithdrawalListResponse {
|
||||
List []WithdrawalLog `json:"list"`
|
||||
|
||||
+15
-2
@@ -109,8 +109,11 @@ type (
|
||||
Rules []string `json:"rules" validate:"required"`
|
||||
}
|
||||
CommissionWithdrawRequest {
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
Method uint8 `json:"method" validate:"oneof=0 1 2 3"`
|
||||
Account string `json:"account,omitempty"`
|
||||
QrCodeUrl string `json:"qr_code_url,omitempty"`
|
||||
}
|
||||
WithdrawalLog {
|
||||
Id int64 `json:"id"`
|
||||
@@ -119,9 +122,15 @@ type (
|
||||
Content string `json:"content"`
|
||||
Status uint8 `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Method uint8 `json:"method"`
|
||||
Account string `json:"account"`
|
||||
QrCodeUrl string `json:"qr_code_url"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
CancelWithdrawalRequest {
|
||||
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||
}
|
||||
QueryWithdrawalLogListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
@@ -352,6 +361,10 @@ service ppanel {
|
||||
@handler CommissionWithdraw
|
||||
post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog)
|
||||
|
||||
@doc "Cancel pending withdrawal"
|
||||
@handler CancelWithdrawal
|
||||
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
|
||||
|
||||
@doc "Query Withdrawal Log"
|
||||
@handler QueryWithdrawalLog
|
||||
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
|
||||
|
||||
@@ -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
|
||||
publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx))
|
||||
|
||||
// Cancel Withdrawal
|
||||
publicUserGroupRouter.POST("/withdrawal_cancel", publicUser.CancelWithdrawalHandler(serverCtx))
|
||||
|
||||
// Delete Current User Account
|
||||
publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx))
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ func (l *DeleteSubscribeApplicationLogic) DeleteSubscribeApplication(req *types.
|
||||
err := l.svcCtx.ClientModel.Delete(l.ctx, req.Id)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func (l *ResetSortWithNodeLogic) ResetSortWithNode(req *types.ResetSortRequest)
|
||||
})
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func (l *ResetSortWithServerLogic) ResetSortWithServer(req *types.ResetSortReque
|
||||
})
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ func (l *GetWithdrawalListLogic) GetWithdrawalList(req *types.GetWithdrawalListR
|
||||
if req.Status != nil {
|
||||
query = query.Where("status = ?", *req.Status)
|
||||
}
|
||||
if req.Method != nil {
|
||||
query = query.Where("method = ?", *req.Method)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
@@ -62,6 +65,9 @@ func (l *GetWithdrawalListLogic) GetWithdrawalList(req *types.GetWithdrawalListR
|
||||
Content: row.Content,
|
||||
Status: row.Status,
|
||||
Reason: row.Reason,
|
||||
Method: row.Method,
|
||||
Account: row.Account,
|
||||
QrCodeUrl: row.QrCodeUrl,
|
||||
CreatedAt: row.CreatedAt.UnixMilli(),
|
||||
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
||||
})
|
||||
|
||||
@@ -46,13 +46,13 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
}
|
||||
|
||||
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
||||
if userInfo.Balance != req.Balance {
|
||||
change := req.Balance - userInfo.Balance
|
||||
if req.Balance != nil && userInfo.Balance != *req.Balance {
|
||||
change := *req.Balance - userInfo.Balance
|
||||
balanceLog := log.Balance{
|
||||
Type: log.BalanceTypeAdjust,
|
||||
Amount: change,
|
||||
OrderNo: "",
|
||||
Balance: req.Balance,
|
||||
Balance: *req.Balance,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
content, _ := balanceLog.Marshal()
|
||||
@@ -66,14 +66,14 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userInfo.Balance = req.Balance
|
||||
userInfo.Balance = *req.Balance
|
||||
}
|
||||
|
||||
if userInfo.GiftAmount != req.GiftAmount {
|
||||
change := req.GiftAmount - userInfo.GiftAmount
|
||||
if req.GiftAmount != nil && userInfo.GiftAmount != *req.GiftAmount {
|
||||
change := *req.GiftAmount - userInfo.GiftAmount
|
||||
if change != 0 {
|
||||
var changeType uint16
|
||||
if userInfo.GiftAmount < req.GiftAmount {
|
||||
if userInfo.GiftAmount < *req.GiftAmount {
|
||||
changeType = log.GiftTypeIncrease
|
||||
} else {
|
||||
changeType = log.GiftTypeReduce
|
||||
@@ -81,7 +81,7 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
giftLog := log.Gift{
|
||||
Type: changeType,
|
||||
Amount: change,
|
||||
Balance: req.GiftAmount,
|
||||
Balance: *req.GiftAmount,
|
||||
Remark: "Admin adjustment",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
@@ -96,23 +96,27 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userInfo.GiftAmount = req.GiftAmount
|
||||
userInfo.GiftAmount = *req.GiftAmount
|
||||
}
|
||||
}
|
||||
|
||||
if req.Commission != userInfo.Commission {
|
||||
if isWithdrawalScene(req.Remark) {
|
||||
if req.Commission != nil && *req.Commission != userInfo.Commission {
|
||||
remark := ""
|
||||
if req.Remark != nil {
|
||||
remark = *req.Remark
|
||||
}
|
||||
if isWithdrawalScene(remark) {
|
||||
logWithdrawalGuard(l.Logger, userInfo.Id)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
|
||||
}
|
||||
change := req.Commission - userInfo.Commission
|
||||
change := *req.Commission - userInfo.Commission
|
||||
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = logicCommon.WriteCommissionLog(tx, userInfo.Id, log.CommissionTypeAdjust, change, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
userInfo.Commission = req.Commission
|
||||
userInfo.Commission = *req.Commission
|
||||
}
|
||||
if req.Avatar != "" {
|
||||
userInfo.Avatar = req.Avatar
|
||||
@@ -120,8 +124,8 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
if req.ReferCode != "" {
|
||||
userInfo.ReferCode = req.ReferCode
|
||||
}
|
||||
if req.RefererId != 0 {
|
||||
userInfo.RefererId = req.RefererId
|
||||
if req.RefererId != nil {
|
||||
userInfo.RefererId = *req.RefererId
|
||||
}
|
||||
if req.Enable != nil {
|
||||
userInfo.Enable = req.Enable
|
||||
@@ -129,8 +133,8 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
if req.IsAdmin != nil {
|
||||
userInfo.IsAdmin = req.IsAdmin
|
||||
}
|
||||
if req.Remark != "" {
|
||||
userInfo.Remark = req.Remark
|
||||
if req.Remark != nil {
|
||||
userInfo.Remark = *req.Remark
|
||||
}
|
||||
if req.OnlyFirstPurchase != nil {
|
||||
userInfo.OnlyFirstPurchase = req.OnlyFirstPurchase
|
||||
|
||||
@@ -37,9 +37,9 @@ func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdraw
|
||||
}
|
||||
|
||||
if err := tx.Model(&usermodel.Withdrawal{}).
|
||||
Where("id = ? AND status = 0", withdrawalID).
|
||||
Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending).
|
||||
Updates(map[string]interface{}{
|
||||
"status": 1,
|
||||
"status": usermodel.WithdrawalStatusApproved,
|
||||
"reason": "",
|
||||
}).Error; err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "approve withdrawal failed: %v", err)
|
||||
@@ -80,9 +80,9 @@ func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawa
|
||||
// Commission was NOT deducted at application time under the new logic,
|
||||
// so rejection requires no refund — only a status update.
|
||||
return tx.Model(&usermodel.Withdrawal{}).
|
||||
Where("id = ? AND status = 0", withdrawalID).
|
||||
Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending).
|
||||
Updates(map[string]interface{}{
|
||||
"status": 2,
|
||||
"status": usermodel.WithdrawalStatusRejected,
|
||||
"reason": reason,
|
||||
}).Error
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
return nil, err
|
||||
}
|
||||
if withdrawal.Status != 0 {
|
||||
if withdrawal.Status != usermodel.WithdrawalStatusPending {
|
||||
return nil, errors.New("withdrawal status invalid")
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
// Validate payment method fields
|
||||
switch req.Method {
|
||||
case user.WithdrawalMethodAlipay, user.WithdrawalMethodWechat:
|
||||
if req.QrCodeUrl == "" {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "qr_code_url is required for method %d", req.Method)
|
||||
}
|
||||
case user.WithdrawalMethodBank:
|
||||
if req.Account == "" {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for bank transfer")
|
||||
}
|
||||
default: // WithdrawalMethodOther
|
||||
if req.Account == "" && req.Content == "" {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account or content is required for other methods")
|
||||
}
|
||||
}
|
||||
|
||||
// Sum all pending (status=0) withdrawals to compute available balance.
|
||||
// Available = commission - pendingTotal; commission is only deducted on approval.
|
||||
var pendingTotal int64
|
||||
if err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&user.Withdrawal{}).
|
||||
Where("user_id = ? AND status = 0", u.Id).
|
||||
Where("user_id = ? AND status = ?", u.Id, user.WithdrawalStatusPending).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&pendingTotal).Error; err != nil {
|
||||
l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err)
|
||||
@@ -56,11 +72,14 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
||||
var w user.Withdrawal
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
w = user.Withdrawal{
|
||||
UserId: u.Id,
|
||||
Amount: req.Amount,
|
||||
Content: req.Content,
|
||||
Status: 0,
|
||||
Reason: "",
|
||||
UserId: u.Id,
|
||||
Amount: req.Amount,
|
||||
Content: req.Content,
|
||||
Status: user.WithdrawalStatusPending,
|
||||
Reason: "",
|
||||
Method: req.Method,
|
||||
Account: req.Account,
|
||||
QrCodeUrl: req.QrCodeUrl,
|
||||
}
|
||||
return tx.Create(&w).Error
|
||||
})
|
||||
@@ -70,11 +89,15 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
||||
}
|
||||
|
||||
return &types.WithdrawalLog{
|
||||
Id: w.Id,
|
||||
UserId: u.Id,
|
||||
Amount: req.Amount,
|
||||
Content: req.Content,
|
||||
Status: 0,
|
||||
Status: user.WithdrawalStatusPending,
|
||||
Reason: "",
|
||||
Method: req.Method,
|
||||
Account: req.Account,
|
||||
QrCodeUrl: req.QrCodeUrl,
|
||||
CreatedAt: w.CreatedAt.UnixMilli(),
|
||||
UpdatedAt: w.UpdatedAt.UnixMilli(),
|
||||
}, nil
|
||||
|
||||
@@ -64,6 +64,9 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
|
||||
Content: row.Content,
|
||||
Status: row.Status,
|
||||
Reason: row.Reason,
|
||||
Method: row.Method,
|
||||
Account: row.Account,
|
||||
QrCodeUrl: row.QrCodeUrl,
|
||||
CreatedAt: row.CreatedAt.UnixMilli(),
|
||||
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ func (l *DeviceWsConnectLogic) DeviceWsConnect(c *gin.Context) error {
|
||||
_, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, identifier)
|
||||
if err != nil && !sysErr.Is(err, gorm.ErrRecordNotFound) {
|
||||
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)
|
||||
@@ -67,7 +67,7 @@ func (l *DeviceWsConnectLogic) DeviceWsConnect(c *gin.Context) error {
|
||||
err := l.svcCtx.UserModel.InsertDevice(l.ctx, &device)
|
||||
if err != nil {
|
||||
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
|
||||
|
||||
@@ -51,6 +51,7 @@ const (
|
||||
CommissionTypeAdjust uint16 = 335 // Admin Adjust
|
||||
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
|
||||
CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund
|
||||
CommissionTypeWithdrawCancel uint16 = 338 // 用户取消提现退佣金
|
||||
GiftTypeIncrease uint16 = 341 // Increase
|
||||
GiftTypeReduce uint16 = 342 // Reduce
|
||||
)
|
||||
|
||||
@@ -160,8 +160,8 @@ func (m *customOrderModel) QueryMonthlyOrders(ctx context.Context, date time.Tim
|
||||
Where("status IN ? AND created_at BETWEEN ? AND ? AND method != ?", []int64{2, 5}, firstDay, lastDay, "balance").
|
||||
Select(
|
||||
"SUM(amount) as amount_total, " +
|
||||
"SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
||||
"SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount",
|
||||
"SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
||||
"SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount",
|
||||
).
|
||||
Scan(v).Error
|
||||
})
|
||||
@@ -177,8 +177,8 @@ func (m *customOrderModel) QueryDateOrders(ctx context.Context, date time.Time)
|
||||
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance").
|
||||
Select(
|
||||
"SUM(amount) as amount_total, " +
|
||||
"SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
||||
"SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount",
|
||||
"SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
||||
"SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount",
|
||||
).
|
||||
Scan(v).Error
|
||||
})
|
||||
@@ -192,8 +192,8 @@ func (m *customOrderModel) QueryTotalOrders(ctx context.Context) (OrdersTotal, e
|
||||
return conn.Model(&Order{}).
|
||||
Select(`
|
||||
SUM(amount) AS amount_total,
|
||||
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
`).
|
||||
Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
|
||||
Scan(&result).Error
|
||||
@@ -214,8 +214,8 @@ func (m *customOrderModel) QueryMonthlyUserCounts(ctx context.Context, date time
|
||||
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
|
||||
return conn.Model(&Order{}).
|
||||
Select(`
|
||||
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
|
||||
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
|
||||
`).
|
||||
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
||||
[]int64{2, 5}, firstDay, nextMonth, "balance").
|
||||
@@ -232,8 +232,8 @@ func (m *customOrderModel) QueryDateUserCounts(ctx context.Context, date time.Ti
|
||||
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
|
||||
return conn.Model(&Order{}).
|
||||
Select(`
|
||||
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
|
||||
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
|
||||
`).
|
||||
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?",
|
||||
[]int64{2, 5}, dateStr, "balance").
|
||||
@@ -249,8 +249,8 @@ func (m *customOrderModel) QueryTotalUserCounts(ctx context.Context) (int64, int
|
||||
return conn.Model(&Order{}).
|
||||
Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
|
||||
Select(`
|
||||
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
|
||||
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
|
||||
`).
|
||||
Scan(&counts).Error
|
||||
})
|
||||
@@ -282,8 +282,8 @@ func (m *customOrderModel) QueryDailyOrdersList(ctx context.Context, date time.T
|
||||
Select(`
|
||||
DATE_FORMAT(created_at, '%Y-%m-%d') AS date,
|
||||
SUM(amount) AS amount_total,
|
||||
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
`).
|
||||
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
||||
[]int64{2, 5}, firstDay, nextDay, "balance").
|
||||
@@ -326,8 +326,8 @@ func (m *customOrderModel) QueryMonthlyOrdersList(ctx context.Context, date time
|
||||
Select(`
|
||||
DATE_FORMAT(created_at, '%Y-%m') AS date,
|
||||
SUM(amount) AS amount_total,
|
||||
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
`).
|
||||
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
||||
[]int64{2, 5}, start, end, "balance").
|
||||
|
||||
@@ -167,8 +167,11 @@ type Withdrawal struct {
|
||||
UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"`
|
||||
Amount int64 `gorm:"not null;comment:Withdrawal Amount"`
|
||||
Content string `gorm:"type:text;comment:Withdrawal Content"`
|
||||
Status uint8 `gorm:"type:tinyint(1);default:0;comment:Withdrawal Status: 0: Pending 1: Approved 2: Rejected"`
|
||||
Status uint8 `gorm:"type:tinyint(1);default:0;comment:Withdrawal Status: 0: Pending 1: Approved 2: Rejected 3: Cancelled"`
|
||||
Reason string `gorm:"type:varchar(500);default:'';comment:Rejection Reason"`
|
||||
Method uint8 `gorm:"type:tinyint(1);default:0;comment:收款方式 0:其他 1:支付宝 2:微信 3:银行卡"`
|
||||
Account string `gorm:"type:varchar(255);default:'';comment:收款账号"`
|
||||
QrCodeUrl string `gorm:"type:varchar(500);default:'';comment:收款码图片URL"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||
}
|
||||
|
||||
@@ -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 // 银行卡
|
||||
)
|
||||
+27
-16
@@ -296,8 +296,11 @@ type CommissionLog struct {
|
||||
}
|
||||
|
||||
type CommissionWithdrawRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
Method uint8 `json:"method" validate:"oneof=0 1 2 3"`
|
||||
Account string `json:"account,omitempty"`
|
||||
QrCodeUrl string `json:"qr_code_url,omitempty"`
|
||||
}
|
||||
|
||||
type ConnectionRecords struct {
|
||||
@@ -2315,6 +2318,10 @@ type QueryUserSubscribeNodeListResponse struct {
|
||||
List []UserSubscribeInfo `json:"list"`
|
||||
}
|
||||
|
||||
type CancelWithdrawalRequest struct {
|
||||
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||
}
|
||||
|
||||
type QueryWithdrawalLogListRequest struct {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
@@ -2330,6 +2337,7 @@ type GetWithdrawalListRequest struct {
|
||||
Size int `form:"size"`
|
||||
UserId *int64 `form:"user_id,omitempty"`
|
||||
Status *uint8 `form:"status,omitempty"`
|
||||
Method *uint8 `form:"method,omitempty"`
|
||||
}
|
||||
|
||||
type GetWithdrawalListResponse struct {
|
||||
@@ -3264,20 +3272,20 @@ type UpdateUserAuthMethodRequest struct {
|
||||
}
|
||||
|
||||
type UpdateUserBasiceInfoRequest struct {
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
Password string `json:"password"`
|
||||
Avatar string `json:"avatar"`
|
||||
Balance int64 `json:"balance"`
|
||||
Commission int64 `json:"commission"`
|
||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
ReferCode string `json:"refer_code"`
|
||||
RefererId int64 `json:"referer_id"`
|
||||
Enable *bool `json:"enable"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Remark string `json:"remark"`
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
Password string `json:"password"`
|
||||
Avatar string `json:"avatar"`
|
||||
Balance *int64 `json:"balance"`
|
||||
Commission *int64 `json:"commission"`
|
||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||
GiftAmount *int64 `json:"gift_amount"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
ReferCode string `json:"refer_code"`
|
||||
RefererId *int64 `json:"referer_id"`
|
||||
Enable *bool `json:"enable"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Remark *string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateUserNotifyRequest struct {
|
||||
@@ -3648,6 +3656,9 @@ type WithdrawalLog struct {
|
||||
Content string `json:"content"`
|
||||
Status uint8 `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Method uint8 `json:"method"`
|
||||
Account string `json:"account"`
|
||||
QrCodeUrl string `json:"qr_code_url"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build tools
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
Reference in New Issue
Block a user