Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18df7e4d6b |
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var c config.Config
|
||||
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||
ctx := svc.NewServiceContext(c)
|
||||
const key = "cache:auth:method:device"
|
||||
before, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result()
|
||||
fmt.Printf("cache before=%q\n", before)
|
||||
m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
|
||||
fmt.Printf("model err=%v enabled_nil=%v", err, m == nil || m.Enabled == nil)
|
||||
if m != nil && m.Enabled != nil { fmt.Printf(" enabled=%v", *m.Enabled) }
|
||||
if m != nil { fmt.Printf(" config=%s", m.Config) }
|
||||
fmt.Println()
|
||||
after, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result()
|
||||
fmt.Printf("cache after=%q\n", after)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
initpkg "github.com/perfect-panel/server/initialize"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var c config.Config
|
||||
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||
ctx := svc.NewServiceContext(c)
|
||||
method, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("db auth_method.enabled=%v config=%s\n", *method.Enabled, method.Config)
|
||||
initpkg.Device(ctx)
|
||||
fmt.Printf("ctx.Config.Device.Enable=%v SecuritySecret=%q EnableSecurity=%v\n", ctx.Config.Device.Enable, ctx.Config.Device.SecuritySecret, ctx.Config.Device.EnableSecurity)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
ID int64
|
||||
Method string
|
||||
Enabled int
|
||||
Config string
|
||||
}
|
||||
|
||||
func main() {
|
||||
var c config.Config
|
||||
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||
ctx := svc.NewServiceContext(c)
|
||||
|
||||
var rows []row
|
||||
if err := ctx.DB.Raw("SELECT id, method, enabled, config FROM auth_method WHERE method = ?", "device").Scan(&rows).Error; err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("raw rows: %+v\n", rows)
|
||||
|
||||
m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
|
||||
fmt.Printf("model err=%v\n", err)
|
||||
if err == nil && m != nil && m.Enabled != nil {
|
||||
fmt.Printf("model row: id=%d method=%s enabled=%v config=%s\n", m.Id, m.Method, *m.Enabled, m.Config)
|
||||
} else {
|
||||
fmt.Printf("model row nil or no enabled ptr: %#v\n", m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
authmodel "github.com/perfect-panel/server/internal/model/auth"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var c config.Config
|
||||
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||
ctx := svc.NewServiceContext(c)
|
||||
|
||||
var a1 authmodel.Auth
|
||||
err1 := ctx.DB.Model(&authmodel.Auth{}).Where("method = ?", "device").First(&a1).Error
|
||||
fmt.Printf("gorm direct err=%v enabled_nil=%v", err1, a1.Enabled == nil)
|
||||
if a1.Enabled != nil { fmt.Printf(" enabled=%v", *a1.Enabled) }
|
||||
fmt.Printf(" config=%s\n", a1.Config)
|
||||
|
||||
var a2 authmodel.Auth
|
||||
err2 := ctx.DB.Table("auth_method").Where("method = ?", "device").First(&a2).Error
|
||||
fmt.Printf("gorm table err=%v enabled_nil=%v", err2, a2.Enabled == nil)
|
||||
if a2.Enabled != nil { fmt.Printf(" enabled=%v", *a2.Enabled) }
|
||||
fmt.Printf(" config=%s\n", a2.Config)
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
# 复制此文件为 .env 并填写真实值
|
||||
# cp .env.example .env
|
||||
|
||||
# MySQL root 密码(同时需要在 configs/ppanel.yaml 的 MySQL.Password 中填写相同的值)
|
||||
MYSQL_ROOT_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||
|
||||
# Grafana 管理员密码
|
||||
GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||
|
||||
# PPanel Server 镜像标签(由 CI/CD 传入不可变 tag,如 git SHA)
|
||||
PPANEL_SERVER_TAG=CHANGE_ME_TO_GIT_SHA
|
||||
|
||||
# AWS 区域(香港)
|
||||
AWS_REGION=ap-east-1
|
||||
|
||||
# Grafana 公开域名(如需反代)
|
||||
GRAFANA_DOMAIN=logs-new.hifast.biz
|
||||
GRAFANA_ROOT_URL=https://logs-new.hifast.biz
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
name: Build docker and publish
|
||||
run-name: 简化的Docker构建和部署流程
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- internal
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- internal
|
||||
|
||||
env:
|
||||
# Docker镜像仓库
|
||||
REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }}
|
||||
# SSH连接信息 (根据分支自动选择服务器和用户)
|
||||
SSH_HOST: ${{ github.ref_name == 'main' && vars.SSH_HOST || vars.DEV_SSH_HOST }}
|
||||
SSH_PORT: ${{ vars.SSH_PORT }}
|
||||
SSH_USER: ${{ github.ref_name == 'main' && 'ubuntu' || 'root' }}
|
||||
# SSH私钥(Gitea Secret 名称:AWS)
|
||||
SSH_KEY: ${{ secrets.AWS }}
|
||||
# TG通知
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
# Go构建变量
|
||||
SERVICE: vpn
|
||||
SERVICE_STYLE: vpn
|
||||
VERSION: ${{ github.sha }}
|
||||
BUILDTIME: ${{ github.event.head_commit.timestamp }}
|
||||
GOARCH: amd64
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ario-server
|
||||
container:
|
||||
image: node:20
|
||||
strategy:
|
||||
matrix:
|
||||
# 只有node支持版本号别名
|
||||
node: ['20.15.1']
|
||||
steps:
|
||||
# 步骤1: 下载代码
|
||||
- name: 📥 下载代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 步骤2: 设置动态环境变量
|
||||
- name: ⚙️ 设置动态环境变量
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
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: |
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "等待 apt/dpkg 锁释放 (unattended-upgrades)..."
|
||||
end=$((SECONDS+300))
|
||||
while true; do
|
||||
LOCKS_BUSY=0
|
||||
if pgrep -x unattended-upgrades >/dev/null 2>&1; then LOCKS_BUSY=1; fi
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
if fuser /var/lib/dpkg/lock >/dev/null 2>&1 \
|
||||
|| fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
|
||||
|| fuser /var/lib/apt/lists/lock >/dev/null 2>&1; then
|
||||
LOCKS_BUSY=1
|
||||
fi
|
||||
fi
|
||||
if [ "$LOCKS_BUSY" -eq 0 ]; then break; fi
|
||||
if [ $SECONDS -ge $end ]; then
|
||||
echo "等待 apt/dpkg 锁超时,使用 Dpkg::Lock::Timeout 继续..."
|
||||
break
|
||||
fi
|
||||
echo "仍在等待锁释放..."; sleep 5
|
||||
done
|
||||
|
||||
# 基础工具
|
||||
apt-get update -y -o Dpkg::Lock::Timeout=600
|
||||
apt-get install -y -o Dpkg::Lock::Timeout=600 jq curl ca-certificates gnupg lsb-release
|
||||
|
||||
# 移除旧版 docker.io,避免客户端过旧 (API 1.41)
|
||||
if dpkg -s docker.io >/dev/null 2>&1; then
|
||||
apt-get remove -y docker.io || true
|
||||
fi
|
||||
|
||||
# 安装 Docker 官方仓库的 CLI (确保 API >= 1.44)
|
||||
distro_codename=$(. /etc/os-release && echo "$VERSION_CODENAME")
|
||||
install_repo="deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${distro_codename} stable"
|
||||
mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "$install_repo" > /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -y -o Dpkg::Lock::Timeout=600
|
||||
apt-get install -y -o Dpkg::Lock::Timeout=600 docker-ce-cli docker-buildx-plugin
|
||||
|
||||
# 版本检查
|
||||
docker --version || true
|
||||
docker version || true
|
||||
echo "客户端 API 版本:" $(docker version --format '{{.Client.APIVersion}}')
|
||||
|
||||
# 步骤4: 构建镜像
|
||||
- name: 🏗️ 构建镜像
|
||||
run: |
|
||||
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 }} \
|
||||
$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 }}"
|
||||
echo "SSH_HOST: ${{ env.SSH_HOST }}"
|
||||
echo "SSH_PORT: ${{ env.SSH_PORT }}"
|
||||
echo "SSH_USER: ${{ env.SSH_USER }}"
|
||||
echo "SSH认证方式: 私钥 (AWS)"
|
||||
echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}"
|
||||
echo "====================================="
|
||||
|
||||
# 步骤7: 传输配置文件
|
||||
- name: 📂 传输配置文件
|
||||
if: github.event_name == 'push'
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ env.SSH_HOST }}
|
||||
username: ${{ env.SSH_USER }}
|
||||
key: ${{ env.SSH_KEY }}
|
||||
port: ${{ env.SSH_PORT }}
|
||||
source: "docker-compose.cloud.yml"
|
||||
target: "/tmp/ppanel-deploy/"
|
||||
|
||||
# 步骤8: 连接服务器更新、健康检查并按需回滚
|
||||
- name: 🚀 连接服务器更新并启动
|
||||
if: github.event_name == 'push'
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ env.SSH_HOST }}
|
||||
username: ${{ env.SSH_USER }}
|
||||
key: ${{ env.SSH_KEY }}
|
||||
port: ${{ env.SSH_PORT }}
|
||||
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
|
||||
else
|
||||
mkdir -p ${{ env.DEPLOY_PATH }}
|
||||
cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
|
||||
fi
|
||||
|
||||
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() && 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
|
||||
|
||||
# 步骤10: TG通知 (失败)
|
||||
- name: 📱 发送失败通知到Telegram
|
||||
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
|
||||
@@ -1,24 +0,0 @@
|
||||
# Code owners — 自动 request review
|
||||
#
|
||||
# 仓库私有 + 当前 plan 不支持 branch protection(详见 doc/development-workflow-zh.md
|
||||
# 「平台层约束的现状」一节),CODEOWNERS 在此用作"自动 request review + 显性责任划分",
|
||||
# 而非强制门禁。
|
||||
#
|
||||
# 任何 PR 默认 request 给 @shanshanzhong147 (owner) review。若后续引入团队
|
||||
# handle(例如 @TawCorp/backend),把对应 path 改成 team handle 即可。
|
||||
|
||||
# 全部路径 — owner 默认 reviewer
|
||||
* @shanshanzhong147
|
||||
|
||||
# 部署/CI/Docker — 改这些要再确认一次(涉及生产部署链路)
|
||||
/.github/ @shanshanzhong147
|
||||
/Dockerfile @shanshanzhong147
|
||||
/docker-compose.*.yml @shanshanzhong147
|
||||
/scripts/ @shanshanzhong147
|
||||
/Makefile @shanshanzhong147
|
||||
|
||||
# 流程文档自身 — 改这里就是改流程
|
||||
/CONTRIBUTING.md @shanshanzhong147
|
||||
/CONTRIBUTING_ZH.md @shanshanzhong147
|
||||
/doc/development-workflow-zh.md @shanshanzhong147
|
||||
/.github/CODEOWNERS @shanshanzhong147
|
||||
@@ -1,51 +0,0 @@
|
||||
<!--
|
||||
完整流程见 doc/development-workflow-zh.md
|
||||
-->
|
||||
|
||||
## 关联 Issue
|
||||
|
||||
Closes HIF-XXX
|
||||
<!-- 如关联多个:Closes HIF-XXX, Closes HIF-YYY -->
|
||||
|
||||
## 改动摘要
|
||||
|
||||
<!-- 1-3 句话说清楚做了什么、为什么 -->
|
||||
|
||||
## 改动细节
|
||||
|
||||
<!-- 按文件/模块逐条列;引用代码用 `file.go:行号` 格式 -->
|
||||
|
||||
-
|
||||
-
|
||||
|
||||
## 测试计划
|
||||
|
||||
- [ ] `go build ./...` 通过
|
||||
- [ ] `go vet ./...` 通过
|
||||
- [ ] `go test -race ./... -count=1` 通过
|
||||
- [ ] golangci-lint 通过
|
||||
- [ ] 新增/修改的逻辑有对应单测覆盖
|
||||
- [ ] (如涉及 DB 变更)migration up/down 双向验证
|
||||
- [ ] (如涉及 API)curl / Postman 验证命令贴在下面
|
||||
|
||||
<!-- 贴 curl 或测试输出 -->
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
## 风险 / 回滚
|
||||
|
||||
<!-- 这次改动失败时怎么回滚;是否影响线上数据;是否需要 feature flag -->
|
||||
|
||||
-
|
||||
|
||||
## Reviewer 自检清单
|
||||
|
||||
- [ ] PR 标题符合 commitlint 规范(`修复/新功能/重构/文档/配置(#<num>): ...`)
|
||||
- [ ] 分支命名 `fix/<num>-…` / `feat/<num>-…` / `chore/…`
|
||||
- [ ] 目标分支 = `internal`
|
||||
- [ ] 改动 scope 与 Issue 描述一致,无 scope creep
|
||||
- [ ] **无无关代码改动**(架构师红线)
|
||||
- [ ] 无密钥/凭证泄露
|
||||
- [ ] CI 全绿
|
||||
- [ ] 测试工程师已验收(如涉及业务逻辑)
|
||||
@@ -0,0 +1,27 @@
|
||||
# Production Environment Configuration for GitHub Actions
|
||||
# This file defines production-specific deployment settings
|
||||
|
||||
environment:
|
||||
name: production
|
||||
url: https://api.ppanel.example.com
|
||||
protection_rules:
|
||||
- type: wait_timer
|
||||
minutes: 5
|
||||
- type: reviewers
|
||||
reviewers:
|
||||
- "@admin-team"
|
||||
- "@devops-team"
|
||||
variables:
|
||||
ENVIRONMENT: production
|
||||
LOG_LEVEL: info
|
||||
DEPLOY_TIMEOUT: 300
|
||||
|
||||
# Environment-specific secrets required:
|
||||
# PRODUCTION_HOST - Production server hostname/IP
|
||||
# PRODUCTION_USER - SSH username for production server
|
||||
# PRODUCTION_SSH_KEY - SSH private key for production server
|
||||
# PRODUCTION_PORT - SSH port (default: 22)
|
||||
# PRODUCTION_URL - Application URL for health checks
|
||||
# DATABASE_PASSWORD - Production database password
|
||||
# REDIS_PASSWORD - Production Redis password
|
||||
# JWT_SECRET - JWT secret key for production
|
||||
@@ -0,0 +1,23 @@
|
||||
# Staging Environment Configuration for GitHub Actions
|
||||
# This file defines staging-specific deployment settings
|
||||
|
||||
environment:
|
||||
name: staging
|
||||
url: https://staging-api.ppanel.example.com
|
||||
protection_rules:
|
||||
- type: wait_timer
|
||||
minutes: 2
|
||||
variables:
|
||||
ENVIRONMENT: staging
|
||||
LOG_LEVEL: debug
|
||||
DEPLOY_TIMEOUT: 180
|
||||
|
||||
# Environment-specific secrets required:
|
||||
# STAGING_HOST - Staging server hostname/IP
|
||||
# STAGING_USER - SSH username for staging server
|
||||
# STAGING_SSH_KEY - SSH private key for staging server
|
||||
# STAGING_PORT - SSH port (default: 22)
|
||||
# STAGING_URL - Application URL for health checks
|
||||
# DATABASE_PASSWORD - Staging database password
|
||||
# REDIS_PASSWORD - Staging Redis password
|
||||
# JWT_SECRET - JWT secret key for staging
|
||||
@@ -1,72 +0,0 @@
|
||||
name: 持续集成
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- internal
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: 构建/Vet/测试
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: 配置 Go 环境
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: 下载依赖模块
|
||||
run: go mod download
|
||||
|
||||
- name: 构建
|
||||
run: go build ./...
|
||||
|
||||
- name: 运行 go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: 运行测试
|
||||
run: go test -race -count=1 ./...
|
||||
|
||||
lint:
|
||||
name: golangci-lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Fetch base ref so golangci-lint can diff against it for only-new-issues.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 配置 Go 环境
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout=5m
|
||||
# Legacy codebase has ~47 pre-existing lint issues (errcheck / unused
|
||||
# carried over from upstream perfect-panel/server). Only flag NEW
|
||||
# issues introduced by this PR so CI stays useful without forcing a
|
||||
# mass cleanup. Backlog cleanup tracked separately.
|
||||
only-new-issues: true
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Build Linux Binary
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to build (leave empty for auto)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Linux Binary
|
||||
runs-on: ario-server
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23.3'
|
||||
cache: true
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: linux
|
||||
GOARCH: amd64
|
||||
run: |
|
||||
VERSION=${{ github.event.inputs.version }}
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
fi
|
||||
|
||||
echo "Building ppanel-server $VERSION"
|
||||
BUILD_TIME=$(date +"%Y-%m-%d_%H:%M:%S")
|
||||
go build -ldflags="-w -s -X github.com/perfect-panel/server/pkg/constant.Version=$VERSION -X github.com/perfect-panel/server/pkg/constant.BuildTime=$BUILD_TIME" -o ppanel-server ./ppanel.go
|
||||
tar -czf ppanel-server-${VERSION}-linux-amd64.tar.gz ppanel-server
|
||||
sha256sum ppanel-server ppanel-server-${VERSION}-linux-amd64.tar.gz > checksum.txt
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ppanel-server-linux-amd64
|
||||
path: |
|
||||
ppanel-server
|
||||
ppanel-server-*-linux-amd64.tar.gz
|
||||
checksum.txt
|
||||
|
||||
- name: Create Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
|
||||
# Check if release exists
|
||||
if gh release view $VERSION >/dev/null 2>&1; then
|
||||
echo "Release $VERSION already exists, deleting old assets..."
|
||||
# Delete existing assets if they exist
|
||||
gh release delete-asset $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz --yes 2>/dev/null || true
|
||||
gh release delete-asset $VERSION checksum.txt --yes 2>/dev/null || true
|
||||
else
|
||||
echo "Creating new release $VERSION..."
|
||||
gh release create $VERSION --title "PPanel Server $VERSION" --notes "Release $VERSION"
|
||||
fi
|
||||
|
||||
# Upload assets (will overwrite if --clobber is supported, otherwise will fail gracefully)
|
||||
echo "Uploading assets..."
|
||||
gh release upload $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz checksum.txt --clobber
|
||||
@@ -1,253 +0,0 @@
|
||||
name: 测试环境部署
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- internal
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-staging-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY_HOST: ${{ vars.REGISTRY_HOST || 'registry.kxsw.us' }}
|
||||
IMAGE_NAME: ${{ vars.REGISTRY_IMAGE || 'registry.kxsw.us/vpn-server' }}
|
||||
STAGING_HOST: ${{ vars.STAGING_HOST || '154.12.35.103' }}
|
||||
STAGING_DEPLOY_PATH: ${{ vars.STAGING_DEPLOY_PATH || '/opt/hifast-server' }}
|
||||
STAGING_HEALTHCHECK_URL: ${{ vars.STAGING_HEALTHCHECK_URL || 'http://127.0.0.1:8080/v1/common/heartbeat' }}
|
||||
# 关闭 docker/build-push-action 自动生成的英文 job summary
|
||||
# 我们用 Telegram 通知传递部署结果,不需要 GitHub Actions 页面上那段英文
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
name: 构建镜像并部署到测试环境
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 收集发布说明
|
||||
id: release-notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
|
||||
RANGE="${{ github.event.before }}..${{ github.sha }}"
|
||||
else
|
||||
RANGE="-5"
|
||||
fi
|
||||
|
||||
NOTES="$(git log "$RANGE" --pretty=format:'- %h %s' --no-merges | head -20)"
|
||||
if [ -z "$NOTES" ]; then
|
||||
NOTES="$(git log -1 --pretty=format:'- %h %s')"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "notes<<EOF"
|
||||
echo "$NOTES"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 配置 Go 环境
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: 运行测试
|
||||
run: go test ./...
|
||||
|
||||
- name: 配置 Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: 构建并推送镜像
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
build-args: |
|
||||
TARGETARCH=amd64
|
||||
VERSION=${{ github.sha }}
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
${{ env.IMAGE_NAME }}:staging
|
||||
|
||||
- name: 上传 compose 配置
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
host: ${{ env.STAGING_HOST }}
|
||||
username: ${{ secrets.STAGING_SSH_USER }}
|
||||
password: ${{ secrets.STAGING_SSH_PASSWORD }}
|
||||
port: ${{ secrets.STAGING_SSH_PORT || 22 }}
|
||||
source: docker-compose.cloud.yml
|
||||
target: /tmp/hifast-server-deploy/
|
||||
|
||||
- name: 在测试服务器上部署
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
with:
|
||||
host: ${{ env.STAGING_HOST }}
|
||||
username: ${{ secrets.STAGING_SSH_USER }}
|
||||
password: ${{ secrets.STAGING_SSH_PASSWORD }}
|
||||
port: ${{ secrets.STAGING_SSH_PORT || 22 }}
|
||||
timeout: 300s
|
||||
command_timeout: 600s
|
||||
script: |
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_NAME="${{ env.IMAGE_NAME }}"
|
||||
NEW_TAG="${{ github.sha }}"
|
||||
DEPLOY_PATH="${{ env.STAGING_DEPLOY_PATH }}"
|
||||
HEALTHCHECK_URL="${{ env.STAGING_HEALTHCHECK_URL }}"
|
||||
ROLLBACK_TAG="rollback-${NEW_TAG}"
|
||||
|
||||
if command -v sudo >/dev/null 2>&1 && ! docker ps >/dev/null 2>&1; then
|
||||
SUDO="sudo"
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
docker_cmd() {
|
||||
if [ -n "$SUDO" ]; then
|
||||
sudo docker "$@"
|
||||
else
|
||||
docker "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
compose_cmd() {
|
||||
tag="$1"
|
||||
shift
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
if [ -n "$SUDO" ]; then
|
||||
sudo env PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker compose -f docker-compose.cloud.yml "$@"
|
||||
else
|
||||
PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker compose -f docker-compose.cloud.yml "$@"
|
||||
fi
|
||||
else
|
||||
if [ -n "$SUDO" ]; then
|
||||
sudo env PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
|
||||
else
|
||||
PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
health_check() {
|
||||
attempt=1
|
||||
while [ "$attempt" -le 6 ]; do
|
||||
if curl -fsS "$HEALTHCHECK_URL"; then
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
echo "Health check ${attempt}/6 failed; retrying in 10s..."
|
||||
attempt=$((attempt + 1))
|
||||
sleep 10
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ -n "$SUDO" ]; then
|
||||
sudo mkdir -p "$DEPLOY_PATH"
|
||||
sudo cp /tmp/hifast-server-deploy/docker-compose.cloud.yml "$DEPLOY_PATH/docker-compose.cloud.yml"
|
||||
else
|
||||
mkdir -p "$DEPLOY_PATH"
|
||||
cp /tmp/hifast-server-deploy/docker-compose.cloud.yml "$DEPLOY_PATH/docker-compose.cloud.yml"
|
||||
fi
|
||||
|
||||
cd "$DEPLOY_PATH"
|
||||
|
||||
PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)"
|
||||
echo "Previous image ID: ${PREVIOUS_IMAGE_ID:-none}"
|
||||
|
||||
echo "Pulling ${IMAGE_NAME}:${NEW_TAG}"
|
||||
compose_cmd "$NEW_TAG" pull ppanel-server
|
||||
|
||||
echo "Starting ppanel-server"
|
||||
compose_cmd "$NEW_TAG" up -d ppanel-server
|
||||
|
||||
echo "Checking ${HEALTHCHECK_URL}"
|
||||
if health_check; then
|
||||
docker_cmd image prune -f || true
|
||||
echo "Staging deployment succeeded"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Health check failed; attempting rollback"
|
||||
if [ -n "$PREVIOUS_IMAGE_ID" ]; then
|
||||
docker_cmd tag "$PREVIOUS_IMAGE_ID" "${IMAGE_NAME}:${ROLLBACK_TAG}"
|
||||
compose_cmd "$ROLLBACK_TAG" up -d ppanel-server
|
||||
health_check || true
|
||||
else
|
||||
echo "No previous image found; rollback skipped"
|
||||
fi
|
||||
|
||||
docker_cmd image prune -f || true
|
||||
exit 1
|
||||
|
||||
- name: Telegram 成功通知
|
||||
if: success()
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram 密钥未配置,跳过通知。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MESSAGE="$(cat <<'EOF'
|
||||
✅ 测试环境发布成功
|
||||
仓库:${{ github.repository }}
|
||||
分支:${{ github.ref_name }}
|
||||
提交:${{ github.sha }}
|
||||
操作人:${{ github.actor }}
|
||||
镜像:${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
本次更新:
|
||||
${{ steps.release-notes.outputs.notes }}
|
||||
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EOF
|
||||
)"
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,但发布结果不受影响。"
|
||||
|
||||
- name: Telegram 失败通知
|
||||
if: failure()
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram 密钥未配置,跳过通知。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MESSAGE="$(cat <<'EOF'
|
||||
❌ 测试环境发布失败
|
||||
仓库:${{ github.repository }}
|
||||
分支:${{ github.ref_name }}
|
||||
提交:${{ github.sha }}
|
||||
操作人:${{ github.actor }}
|
||||
镜像:${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
本次更新:
|
||||
${{ steps.release-notes.outputs.notes }}
|
||||
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EOF
|
||||
)"
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,工作流失败状态已记录。"
|
||||
@@ -1,266 +0,0 @@
|
||||
name: 发布验收测试
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- 测试环境部署
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- internal
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: release-acceptance
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
ACCEPTANCE_ARTIFACT_NAME: release-acceptance-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
ACCEPTANCE_REPORT_DIR: ${{ github.workspace }}/acceptance-artifacts
|
||||
ACCEPTANCE_REPORT_PATH: ${{ github.workspace }}/acceptance-artifacts/acceptance-report.json
|
||||
ACCEPTANCE_TEST_JSON: ${{ github.workspace }}/acceptance-artifacts/go-test.json
|
||||
ACCEPTANCE_FAILURE_LOG: ${{ github.workspace }}/acceptance-artifacts/failure.log
|
||||
ACCEPTANCE_NODE_SERVER_ID: ${{ vars.ACCEPTANCE_NODE_SERVER_ID || '31' }}
|
||||
ACCEPTANCE_NODE_PROTOCOL: ${{ vars.ACCEPTANCE_NODE_PROTOCOL || 'trojan' }}
|
||||
|
||||
jobs:
|
||||
acceptance:
|
||||
name: Staging acceptance
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }}
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
)
|
||||
}}
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: 配置 Go 环境
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: 准备报告目录
|
||||
run: mkdir -p "$ACCEPTANCE_REPORT_DIR"
|
||||
|
||||
- name: 校验必需配置
|
||||
env:
|
||||
ACCEPTANCE_ADMIN_EMAIL: ${{ secrets.ACCEPTANCE_ADMIN_EMAIL }}
|
||||
ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }}
|
||||
ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }}
|
||||
ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }}
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }}
|
||||
STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }}
|
||||
STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
|
||||
STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
|
||||
STAGING_DB_NAME: ${{ secrets.STAGING_DB_NAME }}
|
||||
STAGING_REDIS_ADDR: ${{ secrets.STAGING_REDIS_ADDR }}
|
||||
STAGING_REDIS_PASSWORD: ${{ secrets.STAGING_REDIS_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${STAGING_BASE_URL:-}" ]; then
|
||||
echo "STAGING_BASE_URL 未配置且默认值不可用" | tee "$ACCEPTANCE_FAILURE_LOG"
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "状态:配置错误"
|
||||
echo
|
||||
echo "- `STAGING_BASE_URL` 不能为空。"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
missing_optional=()
|
||||
optional=(
|
||||
ACCEPTANCE_ADMIN_EMAIL
|
||||
ACCEPTANCE_ADMIN_PASSWORD
|
||||
ACCEPTANCE_USER_EMAIL
|
||||
ACCEPTANCE_USER_PASSWORD
|
||||
STAGING_DB_HOST
|
||||
STAGING_DB_USER
|
||||
STAGING_DB_PASSWORD
|
||||
STAGING_DB_NAME
|
||||
STAGING_REDIS_ADDR
|
||||
STAGING_REDIS_PASSWORD
|
||||
)
|
||||
|
||||
for key in "${optional[@]}"; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
missing_optional+=("$key")
|
||||
fi
|
||||
done
|
||||
|
||||
: > "$ACCEPTANCE_FAILURE_LOG"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
printf '缺少可选 GitHub Actions secrets/vars,部分验收用例将被跳过:\n' | tee -a "$ACCEPTANCE_FAILURE_LOG"
|
||||
printf -- '- %s\n' "${missing_optional[@]}" | tee -a "$ACCEPTANCE_FAILURE_LOG"
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "状态:部分配置缺失"
|
||||
echo
|
||||
echo "缺少以下可选 secrets/vars,对应验收用例会在测试阶段自动跳过:"
|
||||
printf -- '- `%s`\n' "${missing_optional[@]}"
|
||||
echo
|
||||
echo "- `STAGING_BASE_URL`:${STAGING_BASE_URL}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "状态:配置检查通过"
|
||||
echo
|
||||
echo "- `STAGING_BASE_URL`:${STAGING_BASE_URL}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: 下载依赖模块
|
||||
run: go mod download
|
||||
|
||||
- name: 运行 acceptance 测试
|
||||
env:
|
||||
ACCEPTANCE_ADMIN_EMAIL: ${{ secrets.ACCEPTANCE_ADMIN_EMAIL }}
|
||||
ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }}
|
||||
ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }}
|
||||
ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }}
|
||||
ACCEPTANCE_NODE_SECRET: ${{ secrets.ACCEPTANCE_NODE_SECRET }}
|
||||
ACCEPTANCE_RUN_ID: qa_${{ github.run_id }}_${{ github.run_attempt }}
|
||||
STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }}
|
||||
STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
|
||||
STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
|
||||
STAGING_DB_NAME: ${{ secrets.STAGING_DB_NAME }}
|
||||
STAGING_REDIS_ADDR: ${{ secrets.STAGING_REDIS_ADDR }}
|
||||
STAGING_REDIS_PASSWORD: ${{ secrets.STAGING_REDIS_PASSWORD }}
|
||||
STAGING_REDIS_DB: ${{ vars.STAGING_REDIS_DB || '0' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
|
||||
go test -json ./tests/acceptance/... \
|
||||
-staging-url="$STAGING_BASE_URL" \
|
||||
-report-path="$ACCEPTANCE_REPORT_PATH" \
|
||||
> >(tee "$ACCEPTANCE_TEST_JSON") \
|
||||
2> >(tee "$ACCEPTANCE_FAILURE_LOG" >&2)
|
||||
|
||||
- name: 生成测试摘要
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "- 状态:${{ job.status }}"
|
||||
echo "- Staging URL:${STAGING_BASE_URL}"
|
||||
echo "- Artifact:${ACCEPTANCE_ARTIFACT_NAME}"
|
||||
echo "- Run URL:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
if [ "${{ github.event_name }}" = "workflow_run" ]; then
|
||||
echo "- Deploy run:${{ github.event.workflow_run.html_url }}"
|
||||
echo "- Deploy SHA:${{ github.event.workflow_run.head_sha }}"
|
||||
else
|
||||
echo "- 手动触发 SHA:${{ github.sha }}"
|
||||
fi
|
||||
echo
|
||||
} | tee "$ACCEPTANCE_REPORT_DIR/summary.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "${{ job.status }}" = "failure" ]; then
|
||||
if [ -s "$ACCEPTANCE_FAILURE_LOG" ]; then
|
||||
{
|
||||
echo
|
||||
echo "Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "Artifact: ${ACCEPTANCE_ARTIFACT_NAME}"
|
||||
} >> "$ACCEPTANCE_FAILURE_LOG"
|
||||
else
|
||||
{
|
||||
echo "Acceptance 测试失败。"
|
||||
echo "Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "Artifact: ${ACCEPTANCE_ARTIFACT_NAME}"
|
||||
echo
|
||||
tail -100 "$ACCEPTANCE_TEST_JSON" 2>/dev/null || true
|
||||
} > "$ACCEPTANCE_FAILURE_LOG"
|
||||
fi
|
||||
elif [ ! -f "$ACCEPTANCE_FAILURE_LOG" ]; then
|
||||
: > "$ACCEPTANCE_FAILURE_LOG"
|
||||
fi
|
||||
|
||||
- name: 上传 acceptance artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ env.ACCEPTANCE_ARTIFACT_NAME }}
|
||||
path: |
|
||||
${{ env.ACCEPTANCE_REPORT_PATH }}
|
||||
${{ env.ACCEPTANCE_TEST_JSON }}
|
||||
${{ env.ACCEPTANCE_FAILURE_LOG }}
|
||||
${{ env.ACCEPTANCE_REPORT_DIR }}/summary.md
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Telegram 成功通知
|
||||
if: success()
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram 密钥未配置,跳过通知。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MESSAGE="$(cat <<'EOF'
|
||||
✅ 发布验收测试通过
|
||||
仓库:${{ github.repository }}
|
||||
分支:${{ github.event.workflow_run.head_branch || github.ref_name }}
|
||||
提交:${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
Staging:${{ env.STAGING_BASE_URL }}
|
||||
Artifact:${{ env.ACCEPTANCE_ARTIFACT_NAME }}
|
||||
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EOF
|
||||
)"
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,但验收结果不受影响。"
|
||||
|
||||
- name: Telegram 失败通知
|
||||
if: failure()
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram 密钥未配置,跳过通知。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MESSAGE="$(cat <<'EOF'
|
||||
❌ 发布验收测试失败
|
||||
仓库:${{ github.repository }}
|
||||
分支:${{ github.event.workflow_run.head_branch || github.ref_name }}
|
||||
提交:${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
Staging:${{ env.STAGING_BASE_URL }}
|
||||
Artifact:${{ env.ACCEPTANCE_ARTIFACT_NAME }}
|
||||
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EOF
|
||||
)"
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,工作流失败状态已记录。"
|
||||
@@ -26,13 +26,6 @@ Thumbs.db
|
||||
*.crt
|
||||
*.key
|
||||
*.pem
|
||||
*.pub
|
||||
*id_rsa*
|
||||
*id_ed25519*
|
||||
*_bak
|
||||
*.go_bak
|
||||
deploy/**/keys/
|
||||
deliverables/
|
||||
|
||||
# ==================== 日志 ====================
|
||||
*.log
|
||||
@@ -42,7 +35,6 @@ logs/
|
||||
# ==================== 测试 ====================
|
||||
/test/
|
||||
*_test.go
|
||||
!tests/acceptance/*_test.go
|
||||
*_test_config.go
|
||||
**/logtest/
|
||||
*_test.yaml
|
||||
@@ -78,7 +70,6 @@ script/*.sh
|
||||
|
||||
# Codex local configuration
|
||||
.codex/
|
||||
.codex-tmp/
|
||||
|
||||
# Claude Flow runtime data
|
||||
.claude-flow/data/
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
project_name: ppanel
|
||||
version: 1
|
||||
release:
|
||||
prerelease: auto
|
||||
builds:
|
||||
- # If true, skip the build.
|
||||
# Useful for library projects.
|
||||
# Default is false
|
||||
skip: true
|
||||
|
||||
changelog:
|
||||
# Set it to true if you wish to skip the changelog generation.
|
||||
# This may result in an empty release notes on GitHub/GitLab/Gitea.
|
||||
disable: false
|
||||
|
||||
# Changelog generation implementation to use.
|
||||
#
|
||||
# Valid options are:
|
||||
# - `git`: uses `git log`;
|
||||
# - `github`: uses the compare GitHub API, appending the author login to the changelog.
|
||||
# - `gitlab`: uses the compare GitLab API, appending the author name and email to the changelog.
|
||||
# - `github-native`: uses the GitHub release notes generation API, disables the groups feature.
|
||||
#
|
||||
# Defaults to `git`.
|
||||
use: github
|
||||
|
||||
# Sorts the changelog by the commit's messages.
|
||||
# Could either be asc, desc or empty
|
||||
# Default is empty
|
||||
sort: asc
|
||||
|
||||
# Format to use for commit formatting.
|
||||
# Only available when use is one of `github`, `gitea`, or `gitlab`.
|
||||
#
|
||||
# Default: '{{ .SHA }}: {{ .Message }} ({{ with .AuthorUsername }}@{{ . }}{{ else }}{{ .AuthorName }} <{{ .AuthorEmail }}>{{ end }})'.
|
||||
# Extra template fields: `SHA`, `Message`, `AuthorName`, `AuthorEmail`, and
|
||||
# `AuthorUsername`.
|
||||
format: "{{ .Message }}"
|
||||
|
||||
# Group commits messages by given regex and title.
|
||||
# Order value defines the order of the groups.
|
||||
# Proving no regex means all commits will be grouped under the default group.
|
||||
# Groups are disabled when using github-native, as it already groups things by itself.
|
||||
#
|
||||
# Default is no groups.
|
||||
groups:
|
||||
- title: "✨ Features"
|
||||
regexp: "^.*feat[(\\w)]*:+.*$"
|
||||
order: 0
|
||||
- title: "🐛 Bug Fixes"
|
||||
regexp: "^.*fix[(\\w)]*:+.*$"
|
||||
order: 1
|
||||
- title: "🎫 Chores"
|
||||
regexp: "^.*chore[(\\w)]*:+.*$"
|
||||
order: 2
|
||||
- title: "🔨 Refactor"
|
||||
regexp: "^.*refactor[(\\w)]*:+.*$"
|
||||
order: 3
|
||||
- title: "🔧 Build"
|
||||
regexp: "^.*?(ci)(\\(.+\\))??!?:.+$"
|
||||
order: 4
|
||||
- title: "📝 Documentation"
|
||||
regexp: "^.*?docs?(\\(.+\\))??!?:.+$"
|
||||
order: 5
|
||||
- title: "✨ Others"
|
||||
order: 999
|
||||
@@ -1,7 +1,5 @@
|
||||
# Pull Request Submission Guidelines
|
||||
|
||||
> **TawCorp internal contributors**: read [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md) first. It documents the canonical end-to-end flow (Multica issue → branch → PR → CI → review → squash merge via GitHub UI → Deploy Staging → QA), agent role boundaries, branch / commit conventions, and the soft-constraint model used in place of branch protection. The guidelines below apply to all contributors (internal and external) as the baseline.
|
||||
|
||||
To ensure the quality of the codebase and maintainability of the project, please follow these guidelines before submitting a Pull Request (PR):
|
||||
|
||||
## 1. PR Title and Description
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Pull Request 提交须知
|
||||
|
||||
> **TawCorp 内部协作者**:请先阅读 [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md)。该文档定义了从 Multica issue 到 PR 合并到 Deploy Staging 到 QA 验收的端到端流程,以及 agent 角色边界、分支/commit 约定、和不依赖 GitHub branch protection 的软约束模型。下面的通用指南仍然适用,但内部协作以 `doc/development-workflow-zh.md` 为准。
|
||||
|
||||
为了确保代码库的质量和项目的可维护性,在提交 Pull Request(PR)之前,请务必遵循以下准则:
|
||||
|
||||
## 1. PR 标题和描述
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
<!--
|
||||
TawCorp internal fork header. Upstream PPanel content begins below.
|
||||
Do not remove this header without updating Multica workspace context and doc/development-workflow-zh.md.
|
||||
-->
|
||||
|
||||
> ### TawCorp hifast-server (internal fork)
|
||||
>
|
||||
> This repository is the **canonical** TawCorp fork of [perfect-panel/server](https://github.com/perfect-panel/server). Migrated from `git.kxsw.us/HI-VPN/hi-server` on **2026-06-03**; the old Gitea remote is deprecated — do not push or pull from it.
|
||||
>
|
||||
> - **Development workflow (required reading for internal contributors)**: [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md)
|
||||
> - **Branch model**: `internal` (dev mainline, auto-deploys to staging) → `main` (release)
|
||||
> - **Merge policy**: PR + architect review + GitHub UI "Squash and merge"; direct push to `internal`/`main` is blocked by `lefthook` pre-push and forbidden by policy
|
||||
> - **Issue tracker**: Multica workspace `Hifast` (prefix `HIF-`)
|
||||
>
|
||||
> External contributors: read [`CONTRIBUTING.md`](CONTRIBUTING.md) for the upstream-compatible baseline.
|
||||
|
||||
---
|
||||
|
||||
# PPanel Server
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -21,10 +21,8 @@ type (
|
||||
InviteManageRecord {
|
||||
InviterId int64 `json:"inviter_id"`
|
||||
InviterIdentifier string `json:"inviter_identifier"`
|
||||
InviterDeviceNo string `json:"inviter_device_no"`
|
||||
InviteeId int64 `json:"invitee_id"`
|
||||
InviteeIdentifier string `json:"invitee_identifier"`
|
||||
InviteeDeviceNo string `json:"invitee_device_no"`
|
||||
InviteeAvatar string `json:"invitee_avatar"`
|
||||
InviteeEnable bool `json:"invitee_enable"`
|
||||
InvitedAt int64 `json:"invited_at"`
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ type (
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
|
||||
TrafficLimit []TrafficLimit `json:"traffic_limit,omitempty"`
|
||||
TrafficLimit *string `json:"traffic_limit,omitempty"`
|
||||
}
|
||||
GetUserLoginLogsRequest {
|
||||
Page int `form:"page"`
|
||||
|
||||
@@ -64,8 +64,6 @@ type (
|
||||
ServerUser {
|
||||
Id int64 `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
// SpeedLimit 单位为 Mbps,0 表示不限速。
|
||||
// 节点端 (V2bX/XrayR 等) 按 Mbps 解释该值,服务端透传不做单位换算。
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
}
|
||||
|
||||
+4
-37
@@ -117,7 +117,6 @@ type (
|
||||
}
|
||||
WithdrawalLog {
|
||||
Id int64 `json:"id"`
|
||||
BizType string `json:"biz_type"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
@@ -133,39 +132,12 @@ type (
|
||||
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||
}
|
||||
QueryWithdrawalLogListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
||||
}
|
||||
WithdrawalLogSummary {
|
||||
CommissionBalance int64 `json:"commission_balance"`
|
||||
LockedByPending int64 `json:"locked_by_pending"`
|
||||
AvailableToWithdraw int64 `json:"available_to_withdraw"`
|
||||
TotalHistoricalAmount int64 `json:"total_historical_amount"`
|
||||
TotalRefundedAmount int64 `json:"total_refunded_amount"`
|
||||
TotalIncomeAmount int64 `json:"total_income_amount"`
|
||||
}
|
||||
QueryWithdrawalLogListResponse {
|
||||
List []WithdrawalLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Summary *WithdrawalLogSummary `json:"summary,omitempty"`
|
||||
}
|
||||
QueryCommissionReturnLogRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
CommissionReturnLog {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
EventType uint16 `json:"event_type"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
QueryCommissionReturnLogResponse {
|
||||
List []CommissionReturnLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
QueryWithdrawalLogListResponse {
|
||||
List []WithdrawalLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
GetDeviceOnlineStatsResponse {
|
||||
WeeklyStats []WeeklyStat `json:"weekly_stats"`
|
||||
@@ -410,14 +382,10 @@ service ppanel {
|
||||
@handler CancelWithdrawal
|
||||
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
|
||||
|
||||
@doc "Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log)"
|
||||
@doc "Query Withdrawal Log"
|
||||
@handler QueryWithdrawalLog
|
||||
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
|
||||
|
||||
@doc "Query Commission Return Log"
|
||||
@handler QueryCommissionReturnLog
|
||||
get /commission_return_log (QueryCommissionReturnLogRequest) returns (QueryCommissionReturnLogResponse)
|
||||
|
||||
@doc "Device Online Statistics"
|
||||
@handler DeviceOnlineStatistics
|
||||
get /device_online_statistics returns (GetDeviceOnlineStatsResponse)
|
||||
@@ -477,4 +445,3 @@ service ppanel {
|
||||
@handler DeviceWsConnect
|
||||
get /device_ws_connect
|
||||
}
|
||||
|
||||
|
||||
@@ -575,7 +575,6 @@ type (
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
EntitlementSource string `json:"entitlement_source"`
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
EC2 SSH 连接资料
|
||||
|
||||
服务器名称: hifast-hk-app-01
|
||||
公网 IP: 43.198.248.161
|
||||
登录用户: ubuntu
|
||||
|
||||
私钥文件:
|
||||
- hifast-hk-app-01-reset
|
||||
|
||||
公钥文件:
|
||||
- hifast-hk-app-01-reset.pub
|
||||
|
||||
连接命令:
|
||||
ssh -i hifast-hk-app-01-reset ubuntu@43.198.248.161
|
||||
|
||||
如果在 Mac / Linux 上使用,先执行:
|
||||
chmod 600 hifast-hk-app-01-reset
|
||||
|
||||
如果要给别人使用,只需要把私钥文件 hifast-hk-app-01-reset 发给对方即可。
|
||||
出于安全考虑,建议通过安全渠道传输,并在后续需要时重新轮换密钥。
|
||||
@@ -0,0 +1,8 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCgAAAKjy5KDa8uSg
|
||||
2gAAAAtzc2gtZWQyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCg
|
||||
AAAEDwSb0b/0S6Tw8Od5hAtIKqt1JvomqQQS44Ty3xL+FjPtvqawOqZIcu/SZaAI/i9+ND
|
||||
cDnnaq/3m5B2wf3hGegKAAAAIWhpZmFzdC1oay1hcHAtMDEtcmVzZXQtMjAyNi0wNS0xMA
|
||||
ECAwQ=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvqawOqZIcu/SZaAI/i9+NDcDnnaq/3m5B2wf3hGegK hifast-hk-app-01-reset-2026-05-10
|
||||
@@ -0,0 +1,363 @@
|
||||
# PPanel 香港区新 AWS 账号部署说明
|
||||
|
||||
本目录用于在 **新 AWS 账号** 中按 **香港区 `ap-east-1`** 重建一套全新空环境。
|
||||
|
||||
目标架构:
|
||||
|
||||
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL`
|
||||
|
||||
## 1. 资源清单
|
||||
|
||||
按下面顺序创建资源:
|
||||
|
||||
1. VPC
|
||||
2. 2 个公有子网 + 2 个私有子网
|
||||
3. Internet Gateway
|
||||
4. 公有 / 私有路由表
|
||||
5. 安全组
|
||||
6. RDS MySQL
|
||||
7. EC2 本机 Redis Docker
|
||||
8. EC2
|
||||
9. ACM 证书
|
||||
10. ALB + Target Group
|
||||
11. WAF Web ACL
|
||||
12. 平行环境域名
|
||||
|
||||
建议命名:
|
||||
|
||||
- VPC: `ppanel-hk-prod`
|
||||
- EC2: `ppanel-app-hk-01`
|
||||
- RDS: `ppanel-mysql-hk`
|
||||
- Redis container: `hifast-redis`
|
||||
- ALB: `ppanel-alb-hk`
|
||||
- WAF: `ppanel-waf-hk`
|
||||
|
||||
## 2. 默认规格
|
||||
|
||||
### EC2
|
||||
|
||||
- Region: `ap-east-1`
|
||||
- OS: Ubuntu 24.04 LTS
|
||||
- Instance type: `t4g.large` 起步
|
||||
- Disk: `gp3 80GB`
|
||||
- Public subnet: 是
|
||||
- IAM Role: 允许读取 CloudWatch / SSM(如使用)
|
||||
- 如果要在 AWS EC2 本机执行 S3 备份:额外允许写入专用备份桶
|
||||
|
||||
### RDS MySQL
|
||||
|
||||
- Engine: MySQL 8.0
|
||||
- Class: `db.r7g.xlarge`
|
||||
- Storage: `gp3 100GB`
|
||||
- DB name: `hifast`
|
||||
- Username: `admin`
|
||||
- Public access: `No`
|
||||
- Charset: `utf8mb4`
|
||||
- Backup: `7-14 days`
|
||||
|
||||
### Redis
|
||||
|
||||
- 部署位置:业务 EC2 本机
|
||||
- 部署方式:Docker
|
||||
- 版本:`redis:8.2.1`
|
||||
- 监听:`0.0.0.0:6379`
|
||||
- 应用连接:`127.0.0.1:6379`
|
||||
- 安全组:仅对白名单备用节点或同机应用开放
|
||||
|
||||
## 3. 网络与安全组
|
||||
|
||||
### 子网布局
|
||||
|
||||
- `public-a`, `public-b`: ALB / EC2
|
||||
- `private-a`, `private-b`: RDS
|
||||
|
||||
### 安全组建议
|
||||
|
||||
#### `sg-alb`
|
||||
|
||||
- Inbound
|
||||
- `80/tcp` from `0.0.0.0/0`
|
||||
- `443/tcp` from `0.0.0.0/0`
|
||||
- Outbound
|
||||
- `80/tcp` to `sg-ec2`
|
||||
|
||||
#### `sg-ec2`
|
||||
|
||||
- Inbound
|
||||
- `80/tcp` from `sg-alb`
|
||||
- `22/tcp` from `你的固定运维 IP`
|
||||
- Outbound
|
||||
- all
|
||||
|
||||
说明:
|
||||
|
||||
- 应用容器监听 `127.0.0.1:8080`
|
||||
- EC2 对外只让 Nginx 监听 `80`
|
||||
- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1`
|
||||
|
||||
#### `sg-rds`
|
||||
|
||||
- Inbound
|
||||
- `3306/tcp` from `sg-ec2`
|
||||
|
||||
#### `sg-ec2` 额外说明
|
||||
|
||||
- 如果需要外部备用节点复制 Redis,再额外放行:
|
||||
- `6379/tcp` from `104.238.220.230/32`
|
||||
|
||||
## 4. ALB / Target Group / 健康检查
|
||||
|
||||
### Target Group
|
||||
|
||||
- Type: `Instance`
|
||||
- Protocol: `HTTP`
|
||||
- Port: `80`
|
||||
- Health check path: `/v1/common/heartbeat`
|
||||
- Success code: `200`
|
||||
|
||||
这个路径已由项目现有接口提供,无需额外改代码。
|
||||
|
||||
### ALB 监听器
|
||||
|
||||
- `80` -> redirect to `443`
|
||||
- `443` -> forward 到 target group
|
||||
|
||||
### ACM
|
||||
|
||||
- 在 `ap-east-1` 申请证书
|
||||
- 先给平行环境域名,例如:
|
||||
- `api-new.hifast.biz`
|
||||
- `logs-new.hifast.biz`
|
||||
|
||||
## 5. WAF 规则
|
||||
|
||||
首版至少启用:
|
||||
|
||||
1. `AWSManagedRulesCommonRuleSet`
|
||||
2. `AWSManagedRulesKnownBadInputsRuleSet`
|
||||
3. `AWSManagedRulesAmazonIpReputationList`
|
||||
4. 全站 rate-based rule
|
||||
5. 针对高风险路径的 rate-based rule
|
||||
|
||||
建议的第一版限流:
|
||||
|
||||
- 全站:每 IP `2000 / 5 分钟`
|
||||
- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟`
|
||||
- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟`
|
||||
|
||||
节点上报接口建议后续补:
|
||||
|
||||
- `/v1/server/status`
|
||||
- `/v1/server/online`
|
||||
- `/v1/server/traffic`
|
||||
|
||||
优先用节点出口 IP 白名单;没有固定出口 IP 的节点暂时保留 `secret_key`,但不要把它当成唯一防线。
|
||||
|
||||
## 6. EC2 文件落地
|
||||
|
||||
在 EC2 上建议使用:
|
||||
|
||||
- 应用目录:`/opt/ppanel`
|
||||
- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf`
|
||||
|
||||
需要上传这些文件 / 目录:
|
||||
|
||||
- `docker-compose.cloud.yml`
|
||||
- `deploy/aws/ap-east-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml`
|
||||
- `deploy/aws/ap-east-1/nginx/ppanel-api.conf`
|
||||
- `grafana/`
|
||||
- `loki/`
|
||||
- `prometheus/`
|
||||
- `tempo/`
|
||||
- `.env.example` -> 重命名为 `.env`
|
||||
|
||||
目标目录示例:
|
||||
|
||||
```text
|
||||
/opt/ppanel/
|
||||
docker-compose.cloud.yml
|
||||
.env
|
||||
configs/ppanel.yaml
|
||||
grafana/
|
||||
loki/
|
||||
prometheus/
|
||||
tempo/
|
||||
logs/
|
||||
cache/
|
||||
tempo_data/
|
||||
```
|
||||
|
||||
## 7. 应用配置
|
||||
|
||||
基线模板见:
|
||||
|
||||
- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example)
|
||||
- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf)
|
||||
|
||||
关键值必须替换:
|
||||
|
||||
- `MySQL.Addr`
|
||||
- `MySQL.Password`
|
||||
- `Redis.Host`
|
||||
- `Redis.Pass`
|
||||
- `JwtAuth.AccessSecret`
|
||||
- `Administrator.Email`
|
||||
- `Administrator.Password`
|
||||
- `AppSignature.AppSecrets.*`
|
||||
- `device.security_secret`
|
||||
- `Site.Host`
|
||||
- `Site.SiteName`
|
||||
|
||||
Redis 约定保持不变:
|
||||
|
||||
- 业务缓存:DB `0`
|
||||
- Asynq:DB `5`(代码内部已固定使用)
|
||||
|
||||
## 8. 部署步骤
|
||||
|
||||
### 8.1 初始化 EC2
|
||||
|
||||
把脚本上传到 EC2 后执行:
|
||||
|
||||
## 9. 104 灾备节点常用运维脚本
|
||||
|
||||
如果你要在 `104.238.220.230` 上执行数据迁移、主从重拉、主库提升,可以直接复用仓库里的这几份脚本:
|
||||
|
||||
- 数据导出 / 导入交互工具:
|
||||
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
|
||||
- MySQL 主从运维工具:
|
||||
- [`deploy/scripts/mysql_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/mysql_replica_ops.sh)
|
||||
- Redis 主从运维工具:
|
||||
- [`deploy/scripts/redis_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/redis_replica_ops.sh)
|
||||
- 统一总入口:
|
||||
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
|
||||
- 主从运维环境模板:
|
||||
- [`deploy/aws/ap-east-1/configs/replica-ops.env.example`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/aws/ap-east-1/configs/replica-ops.env.example)
|
||||
|
||||
### 9.1 数据迁移工具
|
||||
|
||||
支持:
|
||||
|
||||
- 备份 MySQL 到 S3
|
||||
- 备份 Redis 到 S3
|
||||
- 从正式库导出 MySQL `sql.gz`
|
||||
- 把 `sql.gz` 导入 AWS RDS
|
||||
- 从正式 Redis 导出 `RDB`
|
||||
- 将 `RDB` 导入 Docker Redis 或宿主机 Redis
|
||||
- 查看 MySQL / Redis 当前主从状态
|
||||
- 强制重拉 MySQL / Redis 主从
|
||||
- 把 MySQL / Redis 从库提升为可写主库
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
bash deploy/scripts/hifast_data_sync_tool.sh /root/replica-ops.env
|
||||
```
|
||||
|
||||
```bash
|
||||
chmod +x deploy/scripts/bootstrap_aws_ec2.sh
|
||||
sudo APP_DIR=/opt/ppanel deploy/scripts/bootstrap_aws_ec2.sh
|
||||
```
|
||||
|
||||
### 8.2 安装 Nginx 配置
|
||||
|
||||
```bash
|
||||
sudo cp deploy/aws/ap-east-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf
|
||||
sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 8.3 启动容器
|
||||
|
||||
```bash
|
||||
cd /opt/ppanel
|
||||
docker compose -f docker-compose.cloud.yml up -d
|
||||
```
|
||||
|
||||
### 8.4 预检
|
||||
|
||||
```bash
|
||||
chmod +x deploy/scripts/preflight_aws_hk.sh
|
||||
APP_DIR=/opt/ppanel \
|
||||
RDS_HOST=<new-rds-endpoint> \
|
||||
REDIS_HOST=127.0.0.1 \
|
||||
deploy/scripts/preflight_aws_hk.sh
|
||||
```
|
||||
|
||||
## 9. 平行环境验证
|
||||
|
||||
先验证 `api-new.hifast.biz`,不要直接切正式域名。
|
||||
|
||||
必测项:
|
||||
|
||||
1. `ALB target` 为 healthy
|
||||
2. `GET /v1/common/heartbeat` 返回 200
|
||||
3. 管理员登录
|
||||
4. 用户注册 / 登录
|
||||
5. 订阅查询
|
||||
6. 节点上报 `/v1/server/status`
|
||||
7. 本机 Redis 可写缓存
|
||||
8. Asynq 可入队并消费
|
||||
|
||||
## 10. 正式切换
|
||||
|
||||
切换前检查:
|
||||
|
||||
1. ALB 5xx 为 0
|
||||
2. EC2 CPU / Memory 正常
|
||||
3. RDS CPU / Connections 正常
|
||||
4. 本机 Redis CPU / Connections / Memory 正常
|
||||
5. WAF 已挂到 ALB
|
||||
6. EC2 安全组没有对公网放 `8080/3333/9090/4317`
|
||||
|
||||
切换方式:
|
||||
|
||||
1. 保持新环境先跑平行域名
|
||||
2. 正式域名切到新 ALB
|
||||
3. 观察至少 1 小时
|
||||
4. 确认无误后再处理旧环境
|
||||
|
||||
## 11. 监控建议
|
||||
|
||||
至少建这些 CloudWatch / Grafana 观测项:
|
||||
|
||||
- ALB `RequestCount`, `HTTPCode_ELB_5XX_Count`, `TargetResponseTime`
|
||||
- EC2 `CPUUtilization`, `NetworkIn`, `NetworkOut`, `StatusCheckFailed`
|
||||
- RDS `CPUUtilization`, `DatabaseConnections`, `ReadLatency`, `WriteLatency`
|
||||
- Redis 容器 CPU / Memory / restart count
|
||||
|
||||
## 12. 这次方案的边界
|
||||
|
||||
本目录交付的是:
|
||||
|
||||
- 香港区新账号的部署模板
|
||||
- 新空环境启动与验证流程
|
||||
- ALB / WAF / EC2 / RDS / 本机 Redis 的落地约定
|
||||
|
||||
不包含:
|
||||
|
||||
- 旧数据迁移
|
||||
- Terraform / CloudFormation 自动建资源
|
||||
- Redis 托管版改造
|
||||
- 多活 / 自动扩缩容
|
||||
|
||||
## 13. S3 备份补强
|
||||
|
||||
当前已落地的 S3 备份桶:
|
||||
|
||||
- `hifast-prod-backups-200810848252-ap-east-1`
|
||||
|
||||
建议与现网结合方式:
|
||||
|
||||
1. `RDS automated backup` 继续保留,作为第一层恢复能力
|
||||
2. `104` 外部 MySQL 从库执行逻辑备份并上传到 S3,作为第二层可下载备份
|
||||
3. `104` 外部 Redis 从库按需导出 `RDB` 到 S3,补齐缓存类灾备材料
|
||||
|
||||
仓库中已补充:
|
||||
|
||||
- 环境变量模板:[`configs/backup-to-s3.env.example`](./configs/backup-to-s3.env.example)
|
||||
- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh)
|
||||
- Redis 备份脚本:[`../../scripts/redis_rdb_backup_to_s3.sh`](../../scripts/redis_rdb_backup_to_s3.sh)
|
||||
|
||||
建议把 MySQL 备份脚本优先部署到 `104`,因为它直接连接本地只读从库,对 AWS 主库扰动最小。
|
||||
@@ -0,0 +1,18 @@
|
||||
AWS_REGION=ap-east-1
|
||||
S3_BUCKET=hifast-prod-backups-200810848252-ap-east-1
|
||||
S3_PREFIX=mysql
|
||||
BACKUP_DIR=/var/backups/hifast
|
||||
HOST_TAG=104-standby
|
||||
KEEP_LOCAL_DAYS=3
|
||||
CHECK_REPLICA=1
|
||||
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=backup_reader
|
||||
MYSQL_PASSWORD=CHANGE_ME
|
||||
MYSQL_SOCKET=
|
||||
MYSQL_DATABASE=hifast
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=CHANGE_ME
|
||||
@@ -0,0 +1,20 @@
|
||||
PRIMARY_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
|
||||
PRIMARY_PORT=3306
|
||||
PRIMARY_USER=admin
|
||||
PRIMARY_PASSWORD=CHANGE_ME
|
||||
PRIMARY_DB=hifast
|
||||
|
||||
PRIMARY_REPL_USER=repl
|
||||
PRIMARY_REPL_PASSWORD=CHANGE_ME
|
||||
PRIMARY_REPL_HOST=104.238.220.230
|
||||
PRIMARY_BINLOG_RETENTION_HOURS=24
|
||||
|
||||
REPLICA_HOST=127.0.0.1
|
||||
REPLICA_PORT=3306
|
||||
REPLICA_USER=root
|
||||
REPLICA_PASSWORD=
|
||||
REPLICA_SOCKET=/var/run/mysqld/mysqld.sock
|
||||
REPLICA_DB=hifast
|
||||
REPLICA_SOURCE_SSL=1
|
||||
|
||||
DUMP_FILE=
|
||||
@@ -0,0 +1,111 @@
|
||||
Host: 0.0.0.0
|
||||
Port: 8080
|
||||
Debug: false
|
||||
|
||||
JwtAuth:
|
||||
AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET
|
||||
AccessExpire: 604800
|
||||
|
||||
Logger:
|
||||
ServiceName: PPanel
|
||||
Mode: console
|
||||
Encoding: plain
|
||||
TimeFormat: "2006-01-02 15:04:05.000"
|
||||
Path: logs
|
||||
Level: info
|
||||
MaxContentLength: 0
|
||||
Compress: false
|
||||
Stat: true
|
||||
KeepDays: 7
|
||||
StackCooldownMillis: 100
|
||||
MaxBackups: 7
|
||||
MaxSize: 100
|
||||
Rotation: daily
|
||||
FileTimeFormat: "2006-01-02T15:04:05.000Z07:00"
|
||||
|
||||
MySQL:
|
||||
Addr: YOUR_RDS_ENDPOINT:3306
|
||||
Dbname: hifast
|
||||
Username: admin
|
||||
Password: CHANGE_ME_TO_RDS_PASSWORD
|
||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||
MaxIdleConns: 10
|
||||
MaxOpenConns: 100
|
||||
SlowThreshold: 1000
|
||||
|
||||
Redis:
|
||||
Host: 127.0.0.1:6379
|
||||
Pass: CHANGE_ME_TO_REDIS_PASSWORD
|
||||
DB: 0
|
||||
PoolSize: 100
|
||||
MinIdleConns: 10
|
||||
MaxRetries: 3
|
||||
PoolTimeout: 4
|
||||
IdleTimeout: 300
|
||||
MaxConnAge: 0
|
||||
DialTimeout: 5
|
||||
ReadTimeout: 3
|
||||
WriteTimeout: 3
|
||||
|
||||
Trace:
|
||||
Name: ppanel-server
|
||||
Endpoint: 127.0.0.1:4317
|
||||
Sampler: 0.1
|
||||
Batcher: otlpgrpc
|
||||
|
||||
Site:
|
||||
Host: api-new.hifast.biz
|
||||
SiteName: HiFastVPN
|
||||
|
||||
Administrator:
|
||||
Email: admin@example.com
|
||||
Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD
|
||||
|
||||
Telegram:
|
||||
Enable: false
|
||||
BotID: 0
|
||||
BotName: ""
|
||||
BotToken: ""
|
||||
GroupChatID: ""
|
||||
EnableNotify: false
|
||||
WebHookDomain: ""
|
||||
|
||||
Kutt:
|
||||
Enable: false
|
||||
ApiURL: ""
|
||||
ApiKey: ""
|
||||
TargetURL: ""
|
||||
Domain: ""
|
||||
|
||||
OpenInstall:
|
||||
Enable: false
|
||||
AppKey: ""
|
||||
ApiKey: ""
|
||||
|
||||
Loki:
|
||||
Enable: true
|
||||
URL: "http://localhost:3100"
|
||||
|
||||
AppSignature:
|
||||
AppSecrets:
|
||||
android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET
|
||||
ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET
|
||||
web-client: CHANGE_ME_WEB_SIGNATURE_SECRET
|
||||
ValidWindowSeconds: 300
|
||||
SkipPrefixes:
|
||||
- /v1/notify/
|
||||
- /v1/iap/notifications
|
||||
- /v1/telegram/webhook
|
||||
- /v1/subscribe/config
|
||||
|
||||
Signature:
|
||||
EnableSignature: false
|
||||
|
||||
device:
|
||||
enable: true
|
||||
security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET
|
||||
|
||||
Register:
|
||||
EnableTrial: true
|
||||
EnableTrialEmailWhitelist: true
|
||||
TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com"
|
||||
@@ -0,0 +1,23 @@
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=CHANGE_ME
|
||||
MYSQL_SOCKET=
|
||||
|
||||
REPL_SOURCE_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
|
||||
REPL_SOURCE_PORT=3306
|
||||
REPL_SOURCE_USER=repl
|
||||
REPL_SOURCE_PASSWORD=CHANGE_ME
|
||||
REPL_SOURCE_SSL=1
|
||||
REPL_SOURCE_LOG_FILE=
|
||||
REPL_SOURCE_LOG_POS=
|
||||
REPL_SOURCE_AUTO_POSITION=1
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=CHANGE_ME
|
||||
|
||||
REDIS_SOURCE_HOST=18.163.33.75
|
||||
REDIS_SOURCE_PORT=6379
|
||||
REDIS_SOURCE_USER=
|
||||
REDIS_SOURCE_PASSWORD=CHANGE_ME
|
||||
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
access_log /var/log/nginx/ppanel-access.log;
|
||||
error_log /var/log/nginx/ppanel-error.log warn;
|
||||
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location = /nginx_status {
|
||||
stub_status;
|
||||
access_log off;
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
# PPanel 日本东京区 AWS 部署说明
|
||||
|
||||
本目录用于在 **AWS 日本东京区 `ap-northeast-1`** 重建一套全新生产环境,并承接当前香港区 `ap-east-1` 的正式迁移。
|
||||
|
||||
如果你要看“当前已经真实跑起来的东京架构”,优先看:
|
||||
|
||||
- [`ops/hifast-current-architecture-zh.md`](/Users/Apple/code_vpn/vpn/ppanel-server/ops/hifast-current-architecture-zh.md)
|
||||
- [`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md)
|
||||
|
||||
这份 README 更偏向:
|
||||
|
||||
- 目标架构
|
||||
- 资源规划
|
||||
- 部署方法
|
||||
- 后续待完成项
|
||||
|
||||
目标架构:
|
||||
|
||||
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL`
|
||||
|
||||
灾备链路:
|
||||
|
||||
`RDS MySQL / EC2 Redis -> 104.238.220.230 外部灾备`
|
||||
|
||||
当前仓库内已补充:
|
||||
|
||||
- 东京基础设施参数模板:[`configs/aws-jp-infra.env.example`](./configs/aws-jp-infra.env.example)
|
||||
- 东京真实实施状态登记:[`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md)
|
||||
- 东京底座资源创建脚本:[`../../scripts/aws_jp_create_base_infra.sh`](../../scripts/aws_jp_create_base_infra.sh)
|
||||
- 东京资源状态检查脚本:[`../../scripts/aws_jp_describe_state.sh`](../../scripts/aws_jp_describe_state.sh)
|
||||
- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh)
|
||||
- 10 分钟 MySQL 备份定时器安装脚本:[`../../scripts/install_mysql_backup_timer.sh`](../../scripts/install_mysql_backup_timer.sh)
|
||||
|
||||
## 1. 资源清单
|
||||
|
||||
按下面顺序创建资源:
|
||||
|
||||
1. VPC
|
||||
2. 2 个公有子网 + 2 个私有子网
|
||||
3. Internet Gateway
|
||||
4. 公有 / 私有路由表
|
||||
5. 安全组
|
||||
6. RDS MySQL
|
||||
7. EC2 本机 Redis Docker
|
||||
8. EC2
|
||||
9. ACM 证书
|
||||
10. ALB + Target Group
|
||||
11. WAF Web ACL
|
||||
12. 东京平行环境域名
|
||||
13. 东京 S3 备份桶
|
||||
|
||||
建议命名:
|
||||
|
||||
- VPC: `ppanel-jp-prod`
|
||||
- EC2: `ppanel-app-jp-01`
|
||||
- RDS: `ppanel-mysql-jp`
|
||||
- Redis container: `hifast-redis`
|
||||
- ALB: `ppanel-alb-jp`
|
||||
- WAF: `ppanel-waf-jp`
|
||||
- S3: `hifast-prod-backups-200810848252-ap-northeast-1`
|
||||
|
||||
## 2. 默认规格
|
||||
|
||||
### EC2
|
||||
|
||||
- Region: `ap-northeast-1`
|
||||
- OS: Ubuntu 24.04 LTS
|
||||
- Instance type: `t4g.large`
|
||||
- Disk: `gp3 80GB`
|
||||
- Public subnet: 是
|
||||
- IAM Role:
|
||||
- 允许读取 CloudWatch / SSM(如使用)
|
||||
- 如果要在东京 EC2 上执行 S3 备份:额外允许写入东京备份桶
|
||||
|
||||
### RDS MySQL
|
||||
|
||||
- Engine: `MySQL 8.4`
|
||||
- Class: `db.r7g.xlarge`
|
||||
- Storage: `gp3 100GB`
|
||||
- DB name: `hifast`
|
||||
- Username: `admin`
|
||||
- Public access: `Yes`
|
||||
- Charset: `utf8mb4`
|
||||
- Backup retention: `7-14 days`
|
||||
- Deletion protection: `On`
|
||||
- Multi-AZ: `Yes`(当前按 2 实例 Multi-AZ 创建)
|
||||
|
||||
说明:
|
||||
|
||||
- 当前东京 RDS 需要允许 `104.238.220.230` 从公网直连 `3306`,用于外部 MySQL 从库复制
|
||||
- 因此本阶段 RDS 使用 `public subnet group + Publicly accessible = Yes`
|
||||
- 访问面只通过 `sg-rds` 严格限制到业务 EC2 安全组和 `104.238.220.230/32`
|
||||
|
||||
### Redis
|
||||
|
||||
- 部署位置:业务 EC2 本机
|
||||
- 部署方式:Docker
|
||||
- 版本:`redis:8.2.1`
|
||||
- 监听:`0.0.0.0:6379`
|
||||
- 应用连接:`127.0.0.1:6379`
|
||||
- 安全组:仅对白名单备用节点 `104.238.220.230/32` 或同机应用开放
|
||||
|
||||
## 3. 网络与安全组
|
||||
|
||||
### 子网布局
|
||||
|
||||
- `public-a`, `public-c`: ALB / EC2
|
||||
- `private-a`, `private-c`: RDS
|
||||
|
||||
说明:
|
||||
|
||||
- 东京优先使用 `ap-northeast-1a` 和 `ap-northeast-1c`
|
||||
- 如果账户映射不同,也可以用任意 2 个可用区,但公私网必须各 2 个子网
|
||||
|
||||
### 安全组建议
|
||||
|
||||
#### `sg-alb`
|
||||
|
||||
- Inbound
|
||||
- `80/tcp` from `0.0.0.0/0`
|
||||
- `443/tcp` from `0.0.0.0/0`
|
||||
- Outbound
|
||||
- `80/tcp` to `sg-ec2`
|
||||
|
||||
#### `sg-ec2`
|
||||
|
||||
- Inbound
|
||||
- `80/tcp` from `sg-alb`
|
||||
- `22/tcp` from `你的固定运维 IP`
|
||||
- `6379/tcp` from `104.238.220.230/32`
|
||||
- Outbound
|
||||
- all
|
||||
|
||||
说明:
|
||||
|
||||
- 应用容器监听 `127.0.0.1:8080`
|
||||
- EC2 对外只让 Nginx 监听 `80`
|
||||
- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1`
|
||||
|
||||
#### `sg-rds`
|
||||
|
||||
- Inbound
|
||||
- `3306/tcp` from `sg-ec2`
|
||||
- `3306/tcp` from `104.238.220.230/32`
|
||||
|
||||
## 4. ALB / Target Group / 健康检查
|
||||
|
||||
### Target Group
|
||||
|
||||
- Type: `Instance`
|
||||
- Protocol: `HTTP`
|
||||
- Port: `80`
|
||||
- Health check path: `/v1/common/heartbeat`
|
||||
- Success code: `200`
|
||||
|
||||
### ALB 监听器
|
||||
|
||||
- `80` -> redirect to `443`
|
||||
- `443` -> forward 到 target group
|
||||
|
||||
### ACM
|
||||
|
||||
- 在 `ap-northeast-1` 重新申请证书
|
||||
- 先给平行环境域名,例如:
|
||||
- `api-jp.hifast.biz`
|
||||
- `logs-jp.hifast.biz`
|
||||
|
||||
## 5. WAF 规则
|
||||
|
||||
首版至少启用:
|
||||
|
||||
1. `AWSManagedRulesCommonRuleSet`
|
||||
2. `AWSManagedRulesKnownBadInputsRuleSet`
|
||||
3. `AWSManagedRulesAmazonIpReputationList`
|
||||
4. 全站 rate-based rule
|
||||
5. 针对高风险路径的 rate-based rule
|
||||
|
||||
建议的第一版限流:
|
||||
|
||||
- 全站:每 IP `2000 / 5 分钟`
|
||||
- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟`
|
||||
- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟`
|
||||
|
||||
节点上报接口建议后续补:
|
||||
|
||||
- `/v1/server/status`
|
||||
- `/v1/server/online`
|
||||
- `/v1/server/traffic`
|
||||
|
||||
## 6. EC2 文件落地
|
||||
|
||||
在 EC2 上建议使用:
|
||||
|
||||
- 应用目录:`/opt/ppanel`
|
||||
- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf`
|
||||
|
||||
需要上传这些文件 / 目录:
|
||||
|
||||
- `docker-compose.cloud.yml`
|
||||
- `deploy/aws/ap-northeast-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml`
|
||||
- `deploy/aws/ap-northeast-1/nginx/ppanel-api.conf`
|
||||
- `grafana/`
|
||||
- `loki/`
|
||||
- `prometheus/`
|
||||
- `tempo/`
|
||||
- `.env.example` -> 重命名为 `.env`
|
||||
|
||||
目标目录示例:
|
||||
|
||||
```text
|
||||
/opt/ppanel/
|
||||
docker-compose.cloud.yml
|
||||
.env
|
||||
configs/ppanel.yaml
|
||||
grafana/
|
||||
loki/
|
||||
prometheus/
|
||||
tempo/
|
||||
logs/
|
||||
cache/
|
||||
tempo_data/
|
||||
```
|
||||
|
||||
## 7. 应用配置
|
||||
|
||||
基线模板见:
|
||||
|
||||
- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example)
|
||||
- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf)
|
||||
|
||||
关键值必须替换:
|
||||
|
||||
- `MySQL.Addr`
|
||||
- `MySQL.Password`
|
||||
- `Redis.Host`
|
||||
- `Redis.Pass`
|
||||
- `JwtAuth.AccessSecret`
|
||||
- `Administrator.Email`
|
||||
- `Administrator.Password`
|
||||
- `AppSignature.AppSecrets.*`
|
||||
- `device.security_secret`
|
||||
- `Site.Host`
|
||||
- `Site.SiteName`
|
||||
|
||||
Redis 约定保持不变:
|
||||
|
||||
- 业务缓存:DB `0`
|
||||
- Asynq:DB `5`
|
||||
|
||||
## 8. 部署步骤
|
||||
|
||||
### 8.0 创建东京基础设施
|
||||
|
||||
如果本机或跳板机已经配置好 AWS CLI 凭据,可以先直接执行:
|
||||
|
||||
```bash
|
||||
cp deploy/aws/ap-northeast-1/configs/aws-jp-infra.env.example /root/aws-jp-infra.env
|
||||
chmod 600 /root/aws-jp-infra.env
|
||||
vim /root/aws-jp-infra.env
|
||||
|
||||
chmod +x deploy/scripts/aws_jp_create_base_infra.sh
|
||||
bash deploy/scripts/aws_jp_create_base_infra.sh /root/aws-jp-infra.env
|
||||
```
|
||||
|
||||
执行后可用下面命令随时核对东京底座状态:
|
||||
|
||||
```bash
|
||||
chmod +x deploy/scripts/aws_jp_describe_state.sh
|
||||
bash deploy/scripts/aws_jp_describe_state.sh /root/aws-jp-infra.env
|
||||
```
|
||||
|
||||
### 8.1 初始化 EC2
|
||||
|
||||
把脚本上传到东京 EC2 后执行:
|
||||
|
||||
```bash
|
||||
chmod +x deploy/scripts/bootstrap_aws_ec2.sh
|
||||
sudo APP_DIR=/opt/ppanel APP_USER=ubuntu deploy/scripts/bootstrap_aws_ec2.sh
|
||||
```
|
||||
|
||||
### 8.2 安装 Nginx 配置
|
||||
|
||||
```bash
|
||||
sudo cp deploy/aws/ap-northeast-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf
|
||||
sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 8.3 启动容器
|
||||
|
||||
```bash
|
||||
cd /opt/ppanel
|
||||
docker compose -f docker-compose.cloud.yml up -d
|
||||
```
|
||||
|
||||
### 8.4 预检
|
||||
|
||||
```bash
|
||||
chmod +x deploy/scripts/preflight_aws_jp.sh
|
||||
APP_DIR=/opt/ppanel \
|
||||
RDS_HOST=<TOKYO_RDS_ENDPOINT> \
|
||||
REDIS_HOST=127.0.0.1 \
|
||||
deploy/scripts/preflight_aws_jp.sh
|
||||
```
|
||||
|
||||
## 9. 数据迁移与切换
|
||||
|
||||
正式迁移请按:
|
||||
|
||||
- [`ops/hifast-aws-jp-migration-runbook-zh.md`](/Users/Apple/code_vpn/vpn/ppanel-server/ops/hifast-aws-jp-migration-runbook-zh.md)
|
||||
|
||||
执行。
|
||||
|
||||
核心原则:
|
||||
|
||||
- 先搭平行环境
|
||||
- 停机后再导出香港主数据
|
||||
- 东京验收通过后再切正式域名
|
||||
- 切换后再重挂 `104` 灾备
|
||||
|
||||
## 10. 104 灾备节点常用模板
|
||||
|
||||
如果迁移完成后要把 `104.238.220.230` 重挂为东京主站从库,可复用:
|
||||
|
||||
- [`deploy/scripts/hifast_mysql_seed_primary_and_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_mysql_seed_primary_and_replica.sh)
|
||||
- [`deploy/scripts/hifast_mysql_attach_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_mysql_attach_replica.sh)
|
||||
- [`deploy/scripts/hifast_redis_attach_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_redis_attach_replica.sh)
|
||||
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
|
||||
- [`configs/replica-ops.env.example`](./configs/replica-ops.env.example)
|
||||
|
||||
## 11. 东京资源创建前置检查
|
||||
|
||||
在 AWS 控制台里至少先确认:
|
||||
|
||||
- 东京区已启用
|
||||
- `ap-northeast-1` 可创建 `t4g.large`
|
||||
- `ap-northeast-1` RDS 可创建 `db.r7g.xlarge`
|
||||
- ACM / ALB / WAF / S3 服务在东京区可正常使用
|
||||
- Tokyo 对应配额满足:
|
||||
- On-Demand Standard vCPU
|
||||
- ALB 数量
|
||||
- Elastic IP(如需)
|
||||
- RDS 实例数
|
||||
|
||||
## 12. 当前已知真实进度
|
||||
|
||||
截至 `2026-05-20`,已知状态如下:
|
||||
|
||||
- 东京 VPC `ppanel-jp-prod` 已创建
|
||||
- VPC ID: `vpc-0846b23b4a7d64eac`
|
||||
- VPC CIDR: `10.20.0.0/16`
|
||||
- 4 个子网在 AWS 控制台里曾填写完成,但提交时控制台 session 失效
|
||||
- 因此:
|
||||
- 子网是否真正创建成功,需要重新核实
|
||||
- IGW / 路由表 / 安全组 / RDS / EC2 / ALB / WAF 都应按“未完成”处理,重新复核
|
||||
|
||||
实时状态请以后续更新的 [`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md) 为准。
|
||||
@@ -0,0 +1,55 @@
|
||||
AWS_REGION=ap-northeast-1
|
||||
AWS_ACCOUNT_ID=200810848252
|
||||
|
||||
VPC_NAME=ppanel-jp-prod
|
||||
VPC_ID=
|
||||
VPC_CIDR=10.20.0.0/16
|
||||
|
||||
PUBLIC_SUBNET_A_NAME=ppanel-jp-public-a
|
||||
PUBLIC_SUBNET_A_AZ=ap-northeast-1a
|
||||
PUBLIC_SUBNET_A_CIDR=10.20.0.0/24
|
||||
|
||||
PUBLIC_SUBNET_C_NAME=ppanel-jp-public-c
|
||||
PUBLIC_SUBNET_C_AZ=ap-northeast-1c
|
||||
PUBLIC_SUBNET_C_CIDR=10.20.1.0/24
|
||||
|
||||
PRIVATE_SUBNET_A_NAME=ppanel-jp-private-a
|
||||
PRIVATE_SUBNET_A_AZ=ap-northeast-1a
|
||||
PRIVATE_SUBNET_A_CIDR=10.20.10.0/24
|
||||
|
||||
PRIVATE_SUBNET_C_NAME=ppanel-jp-private-c
|
||||
PRIVATE_SUBNET_C_AZ=ap-northeast-1c
|
||||
PRIVATE_SUBNET_C_CIDR=10.20.11.0/24
|
||||
|
||||
IGW_NAME=ppanel-jp-igw
|
||||
PUBLIC_ROUTE_TABLE_NAME=ppanel-jp-public-rt
|
||||
PRIVATE_ROUTE_TABLE_NAME=ppanel-jp-private-rt
|
||||
|
||||
SG_ALB_NAME=ppanel-jp-sg-alb
|
||||
SG_EC2_NAME=ppanel-jp-sg-ec2
|
||||
SG_RDS_NAME=ppanel-jp-sg-rds
|
||||
|
||||
OPS_SSH_CIDR=CHANGE_ME_TO_YOUR_FIXED_PUBLIC_IP_OR_CIDR
|
||||
DR_REPLICA_IP=104.238.220.230/32
|
||||
|
||||
EC2_NAME=ppanel-app-jp-01
|
||||
EC2_AMI_FAMILY=ubuntu-24.04
|
||||
EC2_INSTANCE_TYPE=t4g.large
|
||||
EC2_DISK_GB=80
|
||||
EC2_KEY_PAIR=CHANGE_ME
|
||||
|
||||
RDS_IDENTIFIER=ppanel-mysql-jp
|
||||
RDS_DB_NAME=hifast
|
||||
RDS_ADMIN_USER=admin
|
||||
RDS_INSTANCE_CLASS=db.r7g.xlarge
|
||||
RDS_STORAGE_GB=100
|
||||
|
||||
ALB_NAME=ppanel-alb-jp
|
||||
TARGET_GROUP_NAME=ppanel-tg-jp
|
||||
WAF_NAME=ppanel-waf-jp
|
||||
|
||||
PARALLEL_API_DOMAIN=api-jp.hifast.biz
|
||||
PARALLEL_LOGS_DOMAIN=logs-jp.hifast.biz
|
||||
PRODUCTION_API_DOMAIN=CHANGE_ME
|
||||
|
||||
S3_BACKUP_BUCKET=hifast-prod-backups-200810848252-ap-northeast-1
|
||||
@@ -0,0 +1,19 @@
|
||||
AWS_REGION=ap-northeast-1
|
||||
S3_BUCKET=hifast-prod-backups-200810848252-ap-northeast-1
|
||||
S3_PREFIX=mysql
|
||||
BACKUP_DIR=/var/backups/hifast
|
||||
HOST_TAG=104-standby-for-jp
|
||||
KEEP_LOCAL_DAYS=3
|
||||
CHECK_REPLICA=1
|
||||
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=backup_reader
|
||||
MYSQL_PASSWORD=CHANGE_ME
|
||||
MYSQL_SOCKET=
|
||||
MYSQL_DATABASE=hifast
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=CHANGE_ME
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
PRIMARY_HOST=ppanel-mysql-jp.<CHANGE_ME>.ap-northeast-1.rds.amazonaws.com
|
||||
PRIMARY_PORT=3306
|
||||
PRIMARY_USER=admin
|
||||
PRIMARY_PASSWORD=CHANGE_ME
|
||||
PRIMARY_DB=hifast
|
||||
|
||||
PRIMARY_REPL_USER=repl
|
||||
PRIMARY_REPL_PASSWORD=CHANGE_ME
|
||||
PRIMARY_REPL_HOST=104.238.220.230
|
||||
PRIMARY_BINLOG_RETENTION_HOURS=24
|
||||
|
||||
REPLICA_HOST=127.0.0.1
|
||||
REPLICA_PORT=3306
|
||||
REPLICA_USER=root
|
||||
REPLICA_PASSWORD=
|
||||
REPLICA_SOCKET=/var/run/mysqld/mysqld.sock
|
||||
REPLICA_DB=hifast
|
||||
REPLICA_SOURCE_SSL=1
|
||||
|
||||
DUMP_FILE=
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
Host: 0.0.0.0
|
||||
Port: 8080
|
||||
Debug: false
|
||||
|
||||
JwtAuth:
|
||||
AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET
|
||||
AccessExpire: 604800
|
||||
|
||||
Logger:
|
||||
ServiceName: PPanel
|
||||
Mode: console
|
||||
Encoding: plain
|
||||
TimeFormat: "2006-01-02 15:04:05.000"
|
||||
Path: logs
|
||||
Level: info
|
||||
MaxContentLength: 0
|
||||
Compress: false
|
||||
Stat: true
|
||||
KeepDays: 7
|
||||
StackCooldownMillis: 100
|
||||
MaxBackups: 7
|
||||
MaxSize: 100
|
||||
Rotation: daily
|
||||
FileTimeFormat: "2006-01-02T15:04:05.000Z07:00"
|
||||
|
||||
MySQL:
|
||||
Addr: YOUR_TOKYO_RDS_ENDPOINT:3306
|
||||
Dbname: hifast
|
||||
Username: admin
|
||||
Password: CHANGE_ME_TO_TOKYO_RDS_PASSWORD
|
||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FTokyo
|
||||
MaxIdleConns: 10
|
||||
MaxOpenConns: 100
|
||||
SlowThreshold: 1000
|
||||
|
||||
Redis:
|
||||
Host: 127.0.0.1:6379
|
||||
Pass: CHANGE_ME_TO_TOKYO_REDIS_PASSWORD
|
||||
DB: 0
|
||||
PoolSize: 100
|
||||
MinIdleConns: 10
|
||||
MaxRetries: 3
|
||||
PoolTimeout: 4
|
||||
IdleTimeout: 300
|
||||
MaxConnAge: 0
|
||||
DialTimeout: 5
|
||||
ReadTimeout: 3
|
||||
WriteTimeout: 3
|
||||
|
||||
Trace:
|
||||
Name: ppanel-server
|
||||
Endpoint: 127.0.0.1:4317
|
||||
Sampler: 0.1
|
||||
Batcher: otlpgrpc
|
||||
|
||||
Site:
|
||||
Host: api-jp.hifast.biz
|
||||
SiteName: HiFastVPN
|
||||
|
||||
Administrator:
|
||||
Email: admin@example.com
|
||||
Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD
|
||||
|
||||
Telegram:
|
||||
Enable: false
|
||||
BotID: 0
|
||||
BotName: ""
|
||||
BotToken: ""
|
||||
GroupChatID: ""
|
||||
EnableNotify: false
|
||||
WebHookDomain: ""
|
||||
|
||||
Kutt:
|
||||
Enable: false
|
||||
ApiURL: ""
|
||||
ApiKey: ""
|
||||
TargetURL: ""
|
||||
Domain: ""
|
||||
|
||||
OpenInstall:
|
||||
Enable: false
|
||||
AppKey: ""
|
||||
ApiKey: ""
|
||||
|
||||
Loki:
|
||||
Enable: true
|
||||
URL: "http://localhost:3100"
|
||||
|
||||
AppSignature:
|
||||
AppSecrets:
|
||||
android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET
|
||||
ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET
|
||||
web-client: CHANGE_ME_WEB_SIGNATURE_SECRET
|
||||
ValidWindowSeconds: 300
|
||||
SkipPrefixes:
|
||||
- /v1/notify/
|
||||
- /v1/iap/notifications
|
||||
- /v1/telegram/webhook
|
||||
- /v1/subscribe/config
|
||||
|
||||
Signature:
|
||||
EnableSignature: false
|
||||
|
||||
device:
|
||||
enable: true
|
||||
security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET
|
||||
|
||||
Register:
|
||||
EnableTrial: true
|
||||
EnableTrialEmailWhitelist: true
|
||||
TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=CHANGE_ME
|
||||
MYSQL_SOCKET=
|
||||
|
||||
REPL_SOURCE_HOST=ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com
|
||||
REPL_SOURCE_PORT=3306
|
||||
REPL_SOURCE_USER=repl
|
||||
REPL_SOURCE_PASSWORD=XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer
|
||||
REPL_SOURCE_SSL=1
|
||||
REPL_SOURCE_LOG_FILE=mysql-bin-changelog.000189
|
||||
REPL_SOURCE_LOG_POS=185053
|
||||
REPL_SOURCE_AUTO_POSITION=1
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=CHANGE_ME
|
||||
|
||||
REDIS_SOURCE_HOST=3.114.29.208
|
||||
REDIS_SOURCE_PORT=6379
|
||||
REDIS_SOURCE_USER=
|
||||
REDIS_SOURCE_PASSWORD=hifast67yj
|
||||
@@ -0,0 +1,187 @@
|
||||
# Tokyo Resource Inventory
|
||||
|
||||
最后更新:`2026-05-21`
|
||||
|
||||
这个文件记录当前东京迁移的真实实施状态,不是示例。
|
||||
|
||||
## Region
|
||||
|
||||
- AWS account: `hifastvpn (200810848252)`
|
||||
- Region: `ap-northeast-1`
|
||||
|
||||
## Current Status
|
||||
|
||||
- 东京迁移方案已在仓库内落地为执行资产
|
||||
- 东京 VPC 已创建
|
||||
- 东京子网、IGW、路由表已在 AWS 控制台创建并复核
|
||||
- 东京三层安全组已在 AWS 控制台创建并复核
|
||||
- 东京 VPC DNS 开关已开启,可支持公网可访问 RDS
|
||||
- 东京 S3 备份桶已在 AWS 控制台创建并复核
|
||||
- 东京 ACM 证书请求已创建,等待 DNS 验证
|
||||
- 东京 RDS MySQL 已创建完成并可用
|
||||
- 东京业务 EC2 已创建完成并绑定固定 EIP
|
||||
- 因当前本机没有可用 AWS CLI 凭据,云上资源状态仍需在 AWS 控制台或已登录环境中复查
|
||||
|
||||
## Networking
|
||||
|
||||
- VPC
|
||||
- Name: `ppanel-jp-prod`
|
||||
- VPC ID: `vpc-0846b23b4a7d64eac`
|
||||
- CIDR: `10.20.0.0/16`
|
||||
- Status: `created`
|
||||
- Public subnet A
|
||||
- Name: `ppanel-jp-public-a`
|
||||
- AZ: `ap-northeast-1a`
|
||||
- CIDR: `10.20.0.0/24`
|
||||
- Subnet ID: `subnet-091232bdb53e71490`
|
||||
- Status: `created`
|
||||
- Public subnet C
|
||||
- Name: `ppanel-jp-public-c`
|
||||
- AZ: `ap-northeast-1c`
|
||||
- CIDR: `10.20.1.0/24`
|
||||
- Subnet ID: `subnet-01ba0975c525ce8cf`
|
||||
- Status: `created`
|
||||
- Private subnet A
|
||||
- Name: `ppanel-jp-private-a`
|
||||
- AZ: `ap-northeast-1a`
|
||||
- CIDR: `10.20.10.0/24`
|
||||
- Subnet ID: `subnet-0bd13111c02f0edbe`
|
||||
- Status: `created`
|
||||
- Private subnet C
|
||||
- Name: `ppanel-jp-private-c`
|
||||
- AZ: `ap-northeast-1c`
|
||||
- CIDR: `10.20.11.0/24`
|
||||
- Subnet ID: `subnet-0d86c5c756dbc84b2`
|
||||
- Status: `created`
|
||||
- Internet Gateway
|
||||
- Name: `ppanel-jp-igw`
|
||||
- IGW ID: `igw-028041bcbf63b672c`
|
||||
- Status: `created`
|
||||
- Public route table
|
||||
- Name: `ppanel-jp-public-rt`
|
||||
- Route Table ID: `rtb-061b101080e4800e5`
|
||||
- Default route: `0.0.0.0/0 -> igw-028041bcbf63b672c`
|
||||
- Status: `created`
|
||||
- Private route table
|
||||
- Name: `ppanel-jp-private-rt`
|
||||
- Route Table ID: `rtb-0d7a191a515031c45`
|
||||
- Status: `created`
|
||||
|
||||
## Security
|
||||
|
||||
- `sg-alb`
|
||||
- Name: `ppanel-jp-sg-alb`
|
||||
- Security Group ID: `sg-0b3a23c31041a5a5a`
|
||||
- Inbound:
|
||||
- `80/tcp <- 0.0.0.0/0`
|
||||
- `443/tcp <- 0.0.0.0/0`
|
||||
- Status: `created`
|
||||
- `sg-ec2`
|
||||
- Name: `ppanel-jp-sg-ec2`
|
||||
- Security Group ID: `sg-01f2a5a81e7505c91`
|
||||
- Inbound:
|
||||
- `80/tcp <- sg-0b3a23c31041a5a5a`
|
||||
- `22/tcp <- 64.118.144.142/32`
|
||||
- `6379/tcp <- 104.238.220.230/32`
|
||||
- Status: `created`
|
||||
- `sg-rds`
|
||||
- Name: `ppanel-jp-sg-rds`
|
||||
- Security Group ID: `sg-0b71db1e2c18b57c0`
|
||||
- Inbound:
|
||||
- `3306/tcp <- sg-01f2a5a81e7505c91`
|
||||
- `3306/tcp <- 104.238.220.230/32`
|
||||
- Status: `created`
|
||||
|
||||
## Compute / Database / Edge
|
||||
|
||||
- EC2 `ppanel-app-jp-01`:
|
||||
- Instance ID: `i-07839130074cd7ed9`
|
||||
- Type: `c7i.xlarge`
|
||||
- Platform: `Ubuntu 26.04 / Linux`
|
||||
- AZ: `ap-northeast-1c`
|
||||
- VPC: `ppanel-jp-prod (vpc-0846b23b4a7d64eac)`
|
||||
- Subnet: `ppanel-jp-public-c (subnet-01ba0975c525ce8cf)`
|
||||
- Private IP: `10.20.1.168`
|
||||
- Public IP / Elastic IP: `3.114.29.208`
|
||||
- Public DNS: `ec2-3-114-29-208.ap-northeast-1.compute.amazonaws.com`
|
||||
- Security group: `ppanel-jp-sg-ec2 (sg-01f2a5a81e7505c91)`
|
||||
- Key pair: `ppanel-jp-key-20260521`
|
||||
- Root volume: `gp3 100GiB`
|
||||
- ENI: `eni-08accb427a470c9f7`
|
||||
- EIP allocation ID: `eipalloc-038b32d5c0119accf`
|
||||
- EIP association ID: `eipassoc-09184aa9161b6a4d9`
|
||||
- Status: `running`
|
||||
- SSH recovery private key: `deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery`
|
||||
- SSH recovery public key: `deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery.pub`
|
||||
- RDS subnet group:
|
||||
- Name: `ppanel-jp-rds-subnet-group`
|
||||
- VPC: `vpc-0846b23b4a7d64eac`
|
||||
- Subnets:
|
||||
- `subnet-0bd13111c02f0edbe` / `ppanel-jp-private-a`
|
||||
- `subnet-0d86c5c756dbc84b2` / `ppanel-jp-private-c`
|
||||
- Status: `created`
|
||||
- RDS public subnet group:
|
||||
- Name: `ppanel-jp-rds-public-subnet-group`
|
||||
- VPC: `vpc-0846b23b4a7d64eac`
|
||||
- Subnets:
|
||||
- `subnet-091232bdb53e71490` / `ppanel-jp-public-a`
|
||||
- `subnet-01ba0975c525ce8cf` / `ppanel-jp-public-c`
|
||||
- Status: `created`
|
||||
- RDS `ppanel-mysql-jp`:
|
||||
- Engine: `MySQL Community 8.4.8`
|
||||
- Class: `db.r7g.xlarge`
|
||||
- Storage: `gp3 100GiB`
|
||||
- Deployment: `Single instance (current actual state)`
|
||||
- VPC: `ppanel-jp-prod (vpc-0846b23b4a7d64eac)`
|
||||
- Subnet group: `ppanel-jp-rds-public-subnet-group`
|
||||
- Security group: `ppanel-jp-sg-rds (sg-0b71db1e2c18b57c0)`
|
||||
- Master username: `admin`
|
||||
- Credential management: `self-managed`
|
||||
- Secrets Manager managed password: `disabled`
|
||||
- Current master password visibility: `not retrievable from AWS console; reset only`
|
||||
- Public access: `enabled (set at creation time for external replication)`
|
||||
- Status: `available`
|
||||
- Endpoint: `ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com`
|
||||
- Public IP (resolved via public DNS): `52.196.204.186`
|
||||
- Connection test from Tokyo EC2:
|
||||
- `mysql -h ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com -u admin -e "select 1"`
|
||||
- Result: `ERROR 1045 (28000): Access denied for user 'admin'@'ip-10-20-1-168.ap-northeast-1.compute.internal' (using password: NO)`
|
||||
- Meaning: `network path and security group are working; only the password is missing`
|
||||
- Current admin password: `TkyRds20260521!N9mQ8sKe2vLp7Xa`
|
||||
- External replica prep for `104.238.220.230`:
|
||||
- binlog retention hours: `24`
|
||||
- replication user: `repl@104.238.220.230`
|
||||
- replication password: `XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer`
|
||||
- current binlog file: `mysql-bin-changelog.000189`
|
||||
- current binlog position: `185053`
|
||||
- ALB `ppanel-alb-jp`: `not created`
|
||||
- WAF `ppanel-waf-jp`: `not created`
|
||||
- ACM certificate in `ap-northeast-1`:
|
||||
- Certificate ID: `29d0b9b6-ab37-44d9-ad9e-18fa7e9aae3a`
|
||||
- Domains:
|
||||
- `api-jp.hifast.biz`
|
||||
- `logs-jp.hifast.biz`
|
||||
- Status: `pending_validation`
|
||||
- Route 53 hosted zone in current AWS account: `not found`
|
||||
- S3 backup bucket `hifast-prod-backups-200810848252-ap-northeast-1`: `created`
|
||||
|
||||
## Domains
|
||||
|
||||
- Parallel API domain: `api-jp.hifast.biz`
|
||||
- Parallel logs domain: `logs-jp.hifast.biz`
|
||||
- Production API domain: `pending user final confirmation`
|
||||
- ACM DNS validation records pending external DNS add:
|
||||
- `api-jp.hifast.biz`
|
||||
- Name: `_0de5970dfbadaf46759447b2ea627a10.api-jp.hifast.biz.`
|
||||
- Type: `CNAME`
|
||||
- Value: `_6a632a5b85c5b3f304cc492090b741b3.jkddzztszm.acm-validations.aws.`
|
||||
- `logs-jp.hifast.biz`
|
||||
- Name: `_349a2b2bc4678d76c3ab341ccf73db61.logs-jp.hifast.biz.`
|
||||
- Type: `CNAME`
|
||||
- Value: `_74ced20dc96aa39070188605cf0ced18.jkddzztszm.acm-validations.aws.`
|
||||
|
||||
## DR
|
||||
|
||||
- DR host: `104.238.220.230`
|
||||
- Planned MySQL upstream after cutover: `Tokyo RDS`
|
||||
- Planned Redis upstream after cutover: `Tokyo EC2 public IP`
|
||||
@@ -0,0 +1,69 @@
|
||||
# Tokyo Resource Inventory Example
|
||||
|
||||
Use this file as the single source of truth while building the Tokyo environment.
|
||||
|
||||
## Region
|
||||
|
||||
- AWS account: `hifastvpn (200810848252)`
|
||||
- Region: `ap-northeast-1`
|
||||
|
||||
## DNS
|
||||
|
||||
- Production API domain: `CHANGE_ME`
|
||||
- Parallel API domain: `api-jp.hifast.biz`
|
||||
- Parallel logs domain: `logs-jp.hifast.biz`
|
||||
|
||||
## Networking
|
||||
|
||||
- VPC name: `ppanel-jp-prod`
|
||||
- VPC CIDR: `10.20.0.0/16`
|
||||
- Public subnet A: `10.20.0.0/24`
|
||||
- Public subnet C: `10.20.1.0/24`
|
||||
- Private subnet A: `10.20.10.0/24`
|
||||
- Private subnet C: `10.20.11.0/24`
|
||||
- Ops CIDR for SSH: `CHANGE_ME`
|
||||
|
||||
## Compute
|
||||
|
||||
- EC2 name: `ppanel-app-jp-01`
|
||||
- EC2 type: `t4g.large`
|
||||
- EC2 disk: `gp3 80GB`
|
||||
- SSH key pair: `CHANGE_ME`
|
||||
|
||||
## Database
|
||||
|
||||
- RDS identifier: `ppanel-mysql-jp`
|
||||
- RDS engine: `MySQL 8.4`
|
||||
- RDS class: `db.r7g.xlarge`
|
||||
- RDS storage: `gp3 100GB`
|
||||
- DB name: `hifast`
|
||||
- DB admin user: `admin`
|
||||
|
||||
## Cache
|
||||
|
||||
- Redis container: `hifast-redis`
|
||||
- Redis port: `6379`
|
||||
- Redis password: `CHANGE_ME`
|
||||
|
||||
## Security / Secrets
|
||||
|
||||
- JWT secret: `CHANGE_ME`
|
||||
- Admin email: `CHANGE_ME`
|
||||
- Admin password: `CHANGE_ME`
|
||||
- Android app signature secret: `CHANGE_ME`
|
||||
- iOS app signature secret: `CHANGE_ME`
|
||||
- Web app signature secret: `CHANGE_ME`
|
||||
- Device security secret: `CHANGE_ME`
|
||||
|
||||
## Backup
|
||||
|
||||
- S3 backup bucket: `hifast-prod-backups-200810848252-ap-northeast-1`
|
||||
- Versioning: `Enabled`
|
||||
|
||||
## DR
|
||||
|
||||
- DR host: `104.238.220.230`
|
||||
- MySQL repl user: `repl`
|
||||
- MySQL repl password: `CHANGE_ME`
|
||||
- Redis source password: `CHANGE_ME`
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1YwAAAKBaQXT3WkF0
|
||||
9wAAAAtzc2gtZWQyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1Yw
|
||||
AAAEArWQWWwPoJp+za5JhSnrE0Pw1/TtqWRrdY5e8hULqYDGwnFkOMq9oh1WN6vdAwdFjD
|
||||
jfCRWfMe6sDZNCoeWvVjAAAAF0FwcGxlQE1hY0Jvb2stUHJvLmxvY2FsAQIDBAUG
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGwnFkOMq9oh1WN6vdAwdFjDjfCRWfMe6sDZNCoeWvVj Apple@MacBook-Pro.local
|
||||
@@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
access_log /var/log/nginx/ppanel-access.log;
|
||||
error_log /var/log/nginx/ppanel-error.log warn;
|
||||
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location = /nginx_status {
|
||||
stub_status;
|
||||
access_log off;
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Hifast MySQL backup to S3
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=root
|
||||
EnvironmentFile=/root/backup-to-s3.env
|
||||
ExecStart=/usr/bin/env bash -lc 'exec /opt/ppanel/deploy/scripts/mysql_backup_to_s3.sh'
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Run Hifast MySQL backup to S3 every 10 minutes
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/10
|
||||
Persistent=true
|
||||
RandomizedDelaySec=30
|
||||
Unit=hifast-mysql-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,254 +0,0 @@
|
||||
# 开发流程(迁移到 GitHub 后)
|
||||
|
||||
> 适用:`github.com/TawCorp/hifast-server`(canonical 仓库)及配套的 Multica agent 工作区。
|
||||
> 历史背景:本仓库于 2026-06-03 从 `git.kxsw.us/HI-VPN/hi-server` 迁移到 GitHub。**`git.kxsw.us` 已废弃**,所有新开发只走本仓库。
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
```
|
||||
Multica issue → 分支 fix/<num>-中文简述 → 推 github → 开 PR → CI 绿 → 架构师 approve
|
||||
→ GitHub UI Squash and merge 进 internal → 测试环境部署 (deploy-staging.yml) 自动跑
|
||||
→ QA 在 staging 验收 → Multica issue → done
|
||||
|
||||
发版时:架构师把 internal squash 进 main → 对外正式版本
|
||||
```
|
||||
|
||||
不要在 GitHub UI 之外 squash 后直接 push `internal` / `main`。不要往 `git.kxsw.us` 推。
|
||||
|
||||
---
|
||||
|
||||
## 1. 分支模型
|
||||
|
||||
| 分支 | 角色 | 写入方式 |
|
||||
|---|---|---|
|
||||
| `main` | 对外正式版本(生产) | **只**由架构师 squash merge `internal` → `main` |
|
||||
| `internal` | 日常开发主干,staging 触发分支 | **只**由架构师在 GitHub UI 上 Squash and merge PR |
|
||||
| `fix/<num>-…` | bug 修复分支 | 工程师自己开自己删(PR 合并后 GitHub 自动删) |
|
||||
| `feat/<num>-…` | 新功能分支 | 同上 |
|
||||
| `chore/…` `refactor/…` `hotfix/…` | 杂项 / 重构 / 紧急修复 | 同上 |
|
||||
|
||||
**分支命名约定**(严格遵守,便于 CI / CODEOWNERS / 历史追溯):
|
||||
|
||||
| 前缀 | 用途 | 示例 |
|
||||
|---|---|---|
|
||||
| `fix/<num>-` | 关联 Multica issue 编号的 bug 修复 | `fix/143-邀请权益单测flake` |
|
||||
| `feat/<num>-` | 关联 Multica issue 编号的新功能 | `feat/77-套餐列表促销信息` |
|
||||
| `chore/` | 配置 / 文档 / 工具链(无关联 issue 可不带编号) | `chore/dev-workflow-docs` |
|
||||
| `hotfix/<num>-` | 生产紧急修复 | `hotfix/151-payment-callback-503` |
|
||||
| `refactor/<num>-` | 重构(行为不变) | `refactor/138-promo-eligibility` |
|
||||
|
||||
**单分支存活上限:3 天**(架构师 CLAUDE.md 约定)。超时未合并的分支由架构师在 issue 上 ping 持有者收尾或重切。
|
||||
|
||||
---
|
||||
|
||||
## 2. 标准 PR 生命周期
|
||||
|
||||
### 2.1 立项
|
||||
|
||||
- Multica 上有对应 issue(HIF-XXX)。
|
||||
- issue 已 assigned,状态 `todo` 或 `in_progress`。
|
||||
|
||||
### 2.2 写代码
|
||||
|
||||
```bash
|
||||
# 在 worktree 里
|
||||
git fetch origin
|
||||
git checkout -B fix/<num>-中文简述 origin/internal
|
||||
|
||||
# 编码 + 写测试
|
||||
# lefthook pre-commit 会自动跑 fmt / imports / lint / vet / test
|
||||
git commit -m "修复(#<num>): 一句话说清楚做了什么"
|
||||
|
||||
# 推到 github(不再推 git.kxsw.us)
|
||||
git push origin fix/<num>-中文简述
|
||||
```
|
||||
|
||||
**commit message**(commitlint 强制):
|
||||
|
||||
```
|
||||
<类型>(#<num>): <一句话描述,<= 72 字符>
|
||||
|
||||
<可选正文,说明 why>
|
||||
```
|
||||
|
||||
类型只有这五个:**`修复` / `新功能` / `重构` / `文档` / `配置`**。其它一律不允许。
|
||||
|
||||
### 2.3 开 PR
|
||||
|
||||
```bash
|
||||
gh pr create --base internal --title '修复(#<num>): ...' --body-file <path>
|
||||
```
|
||||
|
||||
或 GitHub UI 开。PR 模板(`.github/PULL_REQUEST_TEMPLATE.md`)会自动填入,按指引补内容。
|
||||
|
||||
**PR 标题必须等于 squash 后的合入 commit 标题**(用 `<类型>(#<num>): ...` 形式)。
|
||||
|
||||
### 2.4 CI 自动跑
|
||||
|
||||
`.github/workflows/ci.yml` 在 PR 上触发:
|
||||
|
||||
| Job | 执行 |
|
||||
|---|---|
|
||||
| `build-and-test` | `go build ./...` + `go vet ./...` + `go test -race -count=1 ./...` |
|
||||
| `lint` | `golangci-lint run` |
|
||||
|
||||
红 → 工程师本地复现 + 修,反复直到全绿。
|
||||
|
||||
### 2.5 Code review
|
||||
|
||||
- `CODEOWNERS` 自动 request review。
|
||||
- 架构师(或被指派的 reviewer)逐 hunk 看,特别关注:
|
||||
- scope 与 issue 一致性(无 scope creep——架构师红线之一)
|
||||
- 错误处理 / SQL 注入 / N+1
|
||||
- 测试覆盖关键边界
|
||||
- 是否引入了无关的代码改动(架构师红线:"发现成员修改了无关代码 → 立即打回重做")
|
||||
- review 通过即在 PR 上点 Approve。
|
||||
|
||||
### 2.6 Merge to `internal`
|
||||
|
||||
- **必须用 GitHub UI 的 "Squash and merge"**。
|
||||
- squash 后 commit message 由架构师编辑确认:保持 `<类型>(#<num>): ...` 形式 + 必要正文。
|
||||
- 合并按钮按下后 PR 自动 close,分支由 GitHub 自动删("Automatically delete head branches" 应开启)。
|
||||
- 架构师在 Multica issue 上 `@运维工程师` 附 commit hash,通知可以部署(虽然测试环境部署 workflow 已自动跑,但运维需要确认部署状态)。
|
||||
|
||||
**禁止做的事**:
|
||||
- ❌ 在本地 squash 再 `git push` 到 `internal` / `main`
|
||||
- ❌ Rebase merge / Create a merge commit(保持 linear history)
|
||||
- ❌ Force push 到 `internal` / `main`
|
||||
- ❌ Bypass CI(CI 红时不要 merge)
|
||||
- ❌ 自己 approve 自己的 PR
|
||||
- ❌ 未经测试工程师验收的分支合并(架构师红线)
|
||||
|
||||
### 2.7 测试环境部署自动触发 (`.github/workflows/deploy-staging.yml`)
|
||||
|
||||
合并到 `internal` 后,`.github/workflows/deploy-staging.yml` 自动触发:
|
||||
|
||||
1. `go test ./...`(已被 CI 保证过,理论上不会再红)
|
||||
2. Build Docker image `registry.kxsw.us/vpn-server:<sha>` + `:staging`
|
||||
3. scp `docker-compose.cloud.yml` 到 staging host
|
||||
4. ssh 重启 `ppanel-server` 服务
|
||||
5. healthcheck + Telegram 通知
|
||||
|
||||
部署失败 → Telegram 告警 → 运维介入回滚。Deploy 不阻塞下一个 PR,但**修复 deploy 是当前 deploy 失败者的责任**。
|
||||
|
||||
### 2.8 QA 验收
|
||||
|
||||
- 测试工程师在 staging(`tapi.hifast.biz` / 配套前端)按 issue 描述的 acceptance criteria 验收。
|
||||
- 验收通过 → Multica issue 推 `done`。
|
||||
- 任何一项失败 → 单独开子 issue 指派对应工程师,**不要**回退 PR / revert(除非生产数据安全风险)。
|
||||
|
||||
### 2.9 发版到 `main`(对外正式版本)
|
||||
|
||||
由架构师在合适的时机(feature 集齐、staging 跑稳一段时间后)执行:
|
||||
|
||||
```bash
|
||||
gh pr create --base main --head internal --title '发版: <版本号>'
|
||||
```
|
||||
|
||||
走和普通 PR 一样的流程(CI 绿 + review + Squash and merge)。`main` 的 push 也可以触发后续生产部署 workflow(如未来增加 `deploy-production.yml`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. Multica issue 状态映射
|
||||
|
||||
| Multica 状态 | 对应阶段 |
|
||||
|---|---|
|
||||
| `backlog` | 还没排期 |
|
||||
| `todo` | 已排期,待 assigned agent 开始 |
|
||||
| `in_progress` | 分支已开始写,未 push |
|
||||
| `in_review` | PR 已开,等 review / CI / merge |
|
||||
| `done` | PR merged + 测试环境部署通过 + QA 验收通过 |
|
||||
|
||||
**三个条件没全满足就不要标 done**——否则验收链路看不到真问题。
|
||||
|
||||
Issue metadata 建议字段(与现有 agent CLAUDE.md 推荐一致):
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| `pr_url` | https://github.com/TawCorp/hifast-server/pull/XX |
|
||||
| `merge_commit` | merge 后的 squash commit SHA |
|
||||
| `deploy_url` | `tapi.hifast.biz` 或对应前端域名 |
|
||||
| `pipeline_status` | `coding` / `pr_open` / `merged` / `deployed` / `qa_passed` |
|
||||
| `waiting_on` | 当前阻塞点(如 `qa_e2e_access` / `architect_review`) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Agent 边界
|
||||
|
||||
| Agent | 可以做 | 不可以做 |
|
||||
|---|---|---|
|
||||
| 后端 / 前端 / 运维工程师 | push 自己的 fix/feat 分支;开 PR;回评 issue;本地 squash 自己分支 | 合 PR;push `internal` / `main`;改 CODEOWNERS;自己 approve 自己 PR |
|
||||
| 架构师(Squad Leader) | review;approve;GitHub UI squash merge;在 issue 上拆解需求 + 分派子任务;维护 doc/ 和 CLAUDE.md | 在本地 merge 后 push `internal`(绕过 CI gate);直接编写业务逻辑或 UI 代码 |
|
||||
| 测试工程师 | 在 staging 验收;回评 issue;开子 issue 报 bug | 改 prod 代码;改 CI workflow;自己合 PR |
|
||||
| 仓库 owner(人类) | 任何越界操作 + 流程治理决策(如 branch protection 升级) | — |
|
||||
|
||||
任何越界都需要在 issue 上声明 + 走另一个 PR。
|
||||
|
||||
---
|
||||
|
||||
## 5. 平台层约束的现状(重要)
|
||||
|
||||
> 私有仓库 + 当前 GitHub plan 不支持 branch protection / rulesets API(HTTP 403)。
|
||||
|
||||
这意味着 "禁止直接 push internal" / "require CI pass" / "require approval" 这些**在 GitHub 平台层面没法硬强制**。当前依赖三层软约束:
|
||||
|
||||
1. **客户端层**:`lefthook.yml` 的 `pre-push` 钩子会在尝试直接 push `internal` / `main` 时报错。绕过去要明确加 `--no-verify`,会留 git trailers。
|
||||
2. **流程层**:本文档 + `CODEOWNERS` 自动 request review + 架构师作为单一合并执行人。
|
||||
3. **审计层**:所有 merge 都对应 Multica issue,事后任何"非 PR 路径上去的 commit"都能在 git log + Multica 对照查出来。
|
||||
|
||||
如果未来组织规模扩大、人类协作者增多,建议升级 GitHub Team($4/user/月)拿到 branch protection 平台保障。**目前规模下软约束足够。**
|
||||
|
||||
---
|
||||
|
||||
## 6. 常见场景
|
||||
|
||||
### Hotfix(生产紧急修复)
|
||||
|
||||
走和 fix 同样的流程,只是分支前缀 `hotfix/`,PR 标题前加 `[HOTFIX]`。架构师可酌情把 review 等待时间压缩到 30 分钟以内。Hotfix 通常直接合 `internal` 后立即由架构师再开 `internal → main` 的 PR 一起发版。
|
||||
|
||||
### Revert
|
||||
|
||||
- 在 GitHub UI 上找到要 revert 的 PR,点 "Revert"。
|
||||
- GitHub 会生成 revert PR,按正常流程过 review + merge。
|
||||
- Revert PR 标题用 `修复(#<原 issue 编号>): revert <原 PR 标题>`。
|
||||
|
||||
### 文档 / 配置改动
|
||||
|
||||
走 `chore/` 分支。CI 一样跑(保险),review 可走 fast-track。
|
||||
|
||||
### 跨多个 issue 的大改动
|
||||
|
||||
- 优先拆成多个独立 PR,每个 PR 对应一个 issue。
|
||||
- 如果实在拆不开,PR title 用 `<类型>(#XXX/#YYY/#ZZZ): ...`,PR body 里 `Closes` 三个。
|
||||
|
||||
---
|
||||
|
||||
## 7. FAQ
|
||||
|
||||
**Q: 为什么不允许在本地 squash 再推 internal?**
|
||||
A: 绕过 GitHub PR review 流程 + CI gate + 审计记录。即使你确定改动 100% 正确,也走 PR——这是文化约束,避免架构师红线被逐渐侵蚀。
|
||||
|
||||
**Q: CI 跑得慢,PR 一直 yellow,可以先 merge 吗?**
|
||||
A: 不可以。CI 通常 5 分钟内出结果;如果超过 15 分钟仍 pending,检查是否有 stuck job,必要时 re-run。
|
||||
|
||||
**Q: 如果架构师不在,怎么办?**
|
||||
A: 备份 reviewer = 仓库 owner(@shanshanzhong147)。CODEOWNERS 已经把 owner 列为兜底 reviewer。
|
||||
|
||||
**Q: lefthook pre-push 不让我 push internal,但我确实需要紧急修一行小字?**
|
||||
A: 没有"紧急修一行小字"这种例外。开 hotfix 分支 + 开 PR + 走 fast-track review。
|
||||
|
||||
**Q: Multica issue 状态没流转到 done,是不是要手动推?**
|
||||
A: 不要直接推。先确认 PR merged + deploy 绿 + QA 通过三个条件全满足。任一项没满足就维持 `in_review` 并加评论说卡在哪。
|
||||
|
||||
**Q: 我能不能用 Rebase / Merge commit 模式合 PR?**
|
||||
A: 不行。必须用 Squash and merge。`internal` 必须保持 linear history(一个 PR = 一个 commit),方便回滚和审计。
|
||||
|
||||
---
|
||||
|
||||
## 8. 紧急联系
|
||||
|
||||
- Deploy 红 / staging 挂:运维工程师(Multica agent `071d94d9-38bb-43cc-8c58-29e3b52d7bd4`)
|
||||
- 流程问题 / branch protection 决策:仓库 owner @shanshanzhong147
|
||||
- 架构 / API 设计:架构师(Multica agent `0de10589-f101-49ef-8dfa-0e9e992fe27c`)
|
||||
+231
-2
@@ -1,12 +1,34 @@
|
||||
# PPanel 服务部署 (云端/无源码版)
|
||||
# 使用方法:
|
||||
# 1. 确保已将 docker-compose.cloud.yml, configs/, loki/, grafana/, prometheus/, tempo/ 目录上传到服务器同一目录
|
||||
# 2. 确保 configs/ 目录下有 ppanel.yaml 配置文件(参考 etc/ppanel.yaml)
|
||||
# 3. 确保 logs/ cache/ tempo_data/ 目录存在 (mkdir -p logs cache tempo_data)
|
||||
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
|
||||
#
|
||||
# 网络说明:
|
||||
# ppanel-server 使用 host 网络(可出外网,直接访问 AWS RDS / 本机 Redis)
|
||||
# 监控服务(Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中
|
||||
# Tempo(4317) 将端口映射到 127.0.0.1,ppanel-server 通过 host 网络访问
|
||||
# 监控端口绑定 127.0.0.1,需通过 SSH 隧道或 Nginx 反代访问
|
||||
#
|
||||
# 未来多开 ppanel-server 时:
|
||||
# 修复宿主机 iptables bridge 出网规则后,可将 ppanel-server 切回 bridge 网络
|
||||
# 多实例用不同端口: ports: ["8081:8080"] + container_name: ppanel-server-2
|
||||
|
||||
services:
|
||||
# ----------------------------------------------------
|
||||
# 1. 业务后端 (PPanel Server)
|
||||
# host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo
|
||||
# PPANEL_SERVER_TAG 由 CI/CD 传入不可变镜像标签(如 git SHA)
|
||||
# ----------------------------------------------------
|
||||
ppanel-server:
|
||||
image: ${PPANEL_SERVER_IMAGE:-registry.kxsw.us/vpn-server}:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag}
|
||||
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag}
|
||||
container_name: ppanel-server
|
||||
restart: always
|
||||
volumes:
|
||||
- ./configs:/app/etc
|
||||
- ./logs:/app/logs
|
||||
- ./cache:/app/cache
|
||||
- ./cache:/app/cache # GeoLite2-City.mmdb IP 地理位置数据库
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
network_mode: host
|
||||
@@ -15,8 +37,215 @@ services:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
depends_on:
|
||||
tempo:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 2. Tempo (链路追踪存储)
|
||||
# ----------------------------------------------------
|
||||
tempo:
|
||||
image: grafana/tempo:2.4.1
|
||||
container_name: ppanel-tempo
|
||||
user: root
|
||||
restart: always
|
||||
command:
|
||||
- "-config.file=/etc/tempo.yaml"
|
||||
- "-target=all"
|
||||
volumes:
|
||||
- ./tempo/tempo-config.yaml:/etc/tempo.yaml
|
||||
- ./tempo_data:/var/tempo
|
||||
ports:
|
||||
- "127.0.0.1:4317:4317" # OTLP gRPC,ppanel-server(host网络)通过127.0.0.1:4317发送trace
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 3. Loki (日志存储)
|
||||
# ----------------------------------------------------
|
||||
loki:
|
||||
image: grafana/loki:3.0.0
|
||||
container_name: ppanel-loki
|
||||
restart: always
|
||||
volumes:
|
||||
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
|
||||
- loki_data:/loki
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
# 不对外暴露端口,仅内网访问
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 4. Promtail (日志采集)
|
||||
# ----------------------------------------------------
|
||||
promtail:
|
||||
image: grafana/promtail:3.0.0
|
||||
container_name: ppanel-promtail
|
||||
restart: always
|
||||
volumes:
|
||||
- ./loki/promtail-config.yaml:/etc/promtail/config.yaml
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./logs:/var/log/ppanel-server:ro
|
||||
- /var/log/nginx:/var/log/nginx:ro
|
||||
command: -config.file=/etc/promtail/config.yaml
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- loki
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 5. Grafana (可观测面板)
|
||||
# 访问: ssh -L 3333:localhost:3333 your-server 后浏览器打开 http://localhost:3333
|
||||
# 或配置 Nginx 反代(建议加认证)
|
||||
# ----------------------------------------------------
|
||||
grafana:
|
||||
image: grafana/grafana:13.0.1
|
||||
container_name: ppanel-grafana
|
||||
restart: always
|
||||
ports:
|
||||
- "3333:3000" # 仅本机可访问,需 SSH 隧道或 Nginx 反代
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:?请在 .env 文件中设置 GRAFANA_PASSWORD}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_SERVER_DOMAIN=${GRAFANA_DOMAIN:-logsx.hifast.biz}
|
||||
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-https://logsx.hifast.biz}
|
||||
- GF_FEATURE_TOGGLES_ENABLE=appObservability
|
||||
- AWS_REGION=${AWS_REGION:-ap-east-1}
|
||||
- AWS_DEFAULT_REGION=${AWS_REGION:-ap-east-1}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
|
||||
- AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- loki
|
||||
- tempo
|
||||
- prometheus
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 6. Prometheus (指标采集)
|
||||
# ----------------------------------------------------
|
||||
prometheus:
|
||||
image: prom/prometheus:v3.11.3
|
||||
container_name: ppanel-prometheus
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:9090:9090" # 仅本机可访问
|
||||
volumes:
|
||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.enable-lifecycle'
|
||||
- '--web.enable-remote-write-receiver'
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 7. Nginx Exporter (监控宿主机 Nginx)
|
||||
# ----------------------------------------------------
|
||||
nginx-exporter:
|
||||
image: nginx/nginx-prometheus-exporter:1.5.0
|
||||
container_name: ppanel-nginx-exporter
|
||||
restart: always
|
||||
command:
|
||||
- -nginx.scrape-uri=http://host.docker.internal:8090/nginx_status
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 8. Node Exporter (宿主机监控)
|
||||
# ----------------------------------------------------
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.11.1
|
||||
container_name: ppanel-node-exporter
|
||||
restart: always
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.sysfs=/host/sys'
|
||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 9. cAdvisor (容器监控)
|
||||
# ----------------------------------------------------
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:v0.55.1
|
||||
container_name: ppanel-cadvisor
|
||||
restart: always
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk/:/dev/disk:ro
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
prometheus_data:
|
||||
tempo_data:
|
||||
|
||||
networks:
|
||||
ppanel_net:
|
||||
name: ppanel_net
|
||||
driver: bridge
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
ppanel:
|
||||
container_name: ppanel-server
|
||||
|
||||
+8
-8
@@ -15,10 +15,10 @@ Logger: # 日志配置
|
||||
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
||||
|
||||
MySQL:
|
||||
Addr: 127.0.0.1:3306 # 本地开发默认;Docker bridge 模式可改为 mysql:3306
|
||||
Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
||||
Username: root # MySQL用户名
|
||||
Password: CHANGE_ME_TO_DB_PASSWORD # MySQL密码
|
||||
Dbname: ppanel # MySQL数据库名
|
||||
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
||||
Dbname: hifast # MySQL数据库名
|
||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||
MaxIdleConns: 10
|
||||
MaxOpenConns: 100
|
||||
@@ -42,9 +42,9 @@ Redis:
|
||||
|
||||
AppSignature:
|
||||
AppSecrets:
|
||||
android-client: CHANGE_ME_ANDROID_APP_SECRET # Android 客户端签名密钥
|
||||
ios-client: CHANGE_ME_IOS_APP_SECRET # iOS 客户端签名密钥
|
||||
web-client: CHANGE_ME_WEB_APP_SECRET # Web 客户端签名密钥
|
||||
android-client: uB4G,XxL2{7b # Android 客户端签名密钥
|
||||
ios-client: uB4G,XxL2{7b # iOS 客户端签名密钥
|
||||
web-client: uB4G,XxL2{7b # Web 客户端签名密钥
|
||||
ValidWindowSeconds: 300 # 签名时间窗口(秒)
|
||||
SkipPrefixes:
|
||||
- /v1/notify/ # 支付回调不验签
|
||||
@@ -58,8 +58,8 @@ Signature:
|
||||
Trace: # 链路追踪配置 (OpenTelemetry)
|
||||
Name: ppanel # 服务名
|
||||
Sampler: 1.0 # 采样率 0.0-1.0,生产建议 0.1
|
||||
Batcher: "" # 本地开发留空;生产如需链路追踪再配置 exporter
|
||||
Endpoint: ""
|
||||
Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc
|
||||
Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317
|
||||
|
||||
S3:
|
||||
Enable: false
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
apiVersion: 1
|
||||
|
||||
groups:
|
||||
- orgId: 1
|
||||
name: ppanel-core
|
||||
folder: PPanel
|
||||
interval: 1m
|
||||
rules:
|
||||
- uid: ppanel-target-down
|
||||
title: PPanel monitoring target down
|
||||
condition: C
|
||||
for: 2m
|
||||
noDataState: Alerting
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Monitoring target is down"
|
||||
description: "{{ $labels.job }} on {{ $labels.instance }} has been down for more than 2 minutes."
|
||||
labels:
|
||||
severity: critical
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 300
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: 'up{job=~"grafana|prometheus|node-exporter|cadvisor|nginx-exporter|loki|tempo"}'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 1
|
||||
type: lt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-host-disk-high
|
||||
title: PPanel host disk usage high
|
||||
condition: C
|
||||
for: 10m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Host disk usage is high"
|
||||
description: "{{ $labels.instance }} {{ $labels.mountpoint }} disk usage is above 85% for 10 minutes."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 900
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: '100 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} * 100)'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 85
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-host-memory-high
|
||||
title: PPanel host memory usage high
|
||||
condition: C
|
||||
for: 10m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Host memory usage is high"
|
||||
description: "{{ $labels.instance }} memory usage is above 90% for 10 minutes."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 900
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 90
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-host-cpu-high
|
||||
title: PPanel host CPU usage high
|
||||
condition: C
|
||||
for: 10m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Host CPU usage is high"
|
||||
description: "{{ $labels.instance }} CPU usage is above 90% for 10 minutes."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 900
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 90
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-container-restarts
|
||||
title: PPanel container restarted
|
||||
condition: C
|
||||
for: 1m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Container restarted"
|
||||
description: "{{ $labels.name }} restarted or changed start time in the last hour."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 3600
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: 'sum by (name) (changes(container_start_time_seconds{name!=""}[1h]))'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: PPanel
|
||||
orgId: 1
|
||||
folder: PPanel
|
||||
folderUid: ppanel
|
||||
type: file
|
||||
disableDeletion: false
|
||||
allowUiUpdates: true
|
||||
updateIntervalSeconds: 30
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards/json
|
||||
foldersFromFilesStructure: false
|
||||
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 3,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"content": "<b>AWS CloudWatch overview</b><br/>Region: ap-east-1 (Hong Kong)<br/>RDS DBInstanceIdentifier: hifast-mysql-prod-v2<br/>Redis: current production uses a local Docker Redis container (<code>hifast-redis</code>) on EC2 rather than AWS ElastiCache.<br/><br/>This dashboard keeps the RDS CloudWatch panels. Redis should be observed from the local ops dashboard via Prometheus/cAdvisor instead of ElastiCache metrics.",
|
||||
"mode": "html"
|
||||
},
|
||||
"pluginVersion": "11.0.0",
|
||||
"title": "Read Me",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 3
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "CPUUtilization",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS CPU",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 3
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "DatabaseConnections",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS Connections",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "bytes"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 3
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "FreeStorageSpace",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Minimum"
|
||||
}
|
||||
],
|
||||
"title": "RDS Free Storage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 11
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "ReadLatency",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "WriteLatency",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "B",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS Read / Write Latency",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "iops"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 11
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "ReadIOPS",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "WriteIOPS",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "B",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS Read / Write IOPS",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 19
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "CPUUtilization",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Host CPU (Legacy ElastiCache)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 19
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "EngineCPUUtilization",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Engine CPU (Legacy ElastiCache)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 12,
|
||||
"y": 19
|
||||
},
|
||||
"id": 9,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "CurrConnections",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Connections (Legacy ElastiCache)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 19
|
||||
},
|
||||
"id": 10,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "DatabaseMemoryUsagePercentage",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Memory Usage % (Legacy ElastiCache)",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"style": "dark",
|
||||
"tags": [
|
||||
"aws",
|
||||
"cloudwatch",
|
||||
"rds",
|
||||
"redis"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "AWS RDS & Redis Overview",
|
||||
"uid": "aws-rds-redis-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"0": {
|
||||
"text": "DOWN"
|
||||
},
|
||||
"1": {
|
||||
"text": "UP"
|
||||
}
|
||||
},
|
||||
"type": "value"
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "13.0.1",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "up{job=~\"prometheus|grafana|node-exporter|cadvisor|nginx-exporter|loki|tempo\"}",
|
||||
"instant": true,
|
||||
"legendFormat": "{{job}}",
|
||||
"range": false,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Service Availability",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 75
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 4
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"legendFormat": "{{instance}} CPU",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Host CPU Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 80
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 4
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
|
||||
"legendFormat": "{{instance}} memory",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Host Memory Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 80
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 4
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "100 - (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} * 100)",
|
||||
"legendFormat": "{{mountpoint}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Host Disk Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 12
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name!=\"\"}[5m]))",
|
||||
"legendFormat": "{{name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Container CPU",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "bytes"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 12
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (name) (container_memory_working_set_bytes{name!=\"\"})",
|
||||
"legendFormat": "{{name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Container Memory",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 12
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (name) (changes(container_start_time_seconds{name!=\"\"}[1h]))",
|
||||
"legendFormat": "{{name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Container Restarts / Changes",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 20
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "rate(nginx_http_requests_total[5m])",
|
||||
"legendFormat": "requests",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Nginx Requests",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "loki"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 20
|
||||
},
|
||||
"id": 9,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "loki"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "{job=~\"ppanel-server|nginx|docker\"} |~ \"(?i)(error|panic|fatal|timeout|exception|failed)\"",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Recent Errors",
|
||||
"type": "logs"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 20
|
||||
},
|
||||
"id": 10,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (service_name) (rate(traces_spanmetrics_calls_total[5m]))",
|
||||
"legendFormat": "{{service_name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Trace Span Calls",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 42,
|
||||
"tags": [
|
||||
"ppanel",
|
||||
"ops",
|
||||
"prometheus",
|
||||
"loki",
|
||||
"tempo"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "PPanel Ops Overview",
|
||||
"uid": "ppanel-ops-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(count_over_time({compose_service=\"ppanel-server\"}[5m]))",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Matched Log Volume",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(count_over_time({compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\" [5m]))",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Matched Error Volume",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 12,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 7
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "{compose_service=\"ppanel-server\"}",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Filtered Server Logs",
|
||||
"type": "logs"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 19
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "{compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\"",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Filtered Server Errors",
|
||||
"type": "logs"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 42,
|
||||
"tags": [
|
||||
"ppanel",
|
||||
"logs",
|
||||
"server",
|
||||
"loki"
|
||||
],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "输入用户 ID;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "用户ID",
|
||||
"name": "user_id",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "输入邮箱或邮箱片段;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "邮箱",
|
||||
"name": "email",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "输入订单号/支付单号/交易号片段;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "订单",
|
||||
"name": "order",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": "All",
|
||||
"value": "."
|
||||
},
|
||||
"description": "日志等级",
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "等级",
|
||||
"multi": false,
|
||||
"name": "level",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "All",
|
||||
"value": "."
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "debug",
|
||||
"value": "debug"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "info",
|
||||
"value": "info"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "warn",
|
||||
"value": "warn"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "error",
|
||||
"value": "error"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "slow",
|
||||
"value": "slow"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "panic",
|
||||
"value": "panic"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "fatal",
|
||||
"value": "fatal"
|
||||
}
|
||||
],
|
||||
"query": "All : .,debug,info,warn,error,slow,panic,fatal",
|
||||
"queryValue": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "任意关键字;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "关键字",
|
||||
"name": "keyword",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "PPanel Server Logs",
|
||||
"uid": "ppanel-server-logs",
|
||||
"version": 5,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
uid: prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: true
|
||||
jsonData:
|
||||
httpMethod: POST
|
||||
manageAlerts: true
|
||||
prometheusType: Prometheus
|
||||
prometheusVersion: 2.50.0
|
||||
timeInterval: 15s
|
||||
|
||||
- name: Loki
|
||||
uid: loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
editable: true
|
||||
jsonData:
|
||||
derivedFields:
|
||||
- datasourceUid: tempo
|
||||
matcherRegex: '"(?:trace|traceID|trace_id)"\s*:\s*"([a-f0-9]{32})"'
|
||||
name: TraceID
|
||||
url: '$${__value.raw}'
|
||||
|
||||
- name: Tempo
|
||||
uid: tempo
|
||||
type: tempo
|
||||
access: proxy
|
||||
url: http://tempo:3200
|
||||
editable: true
|
||||
jsonData:
|
||||
tracesToLogsV2:
|
||||
datasourceUid: loki
|
||||
filterByTraceID: true
|
||||
filterBySpanID: false
|
||||
tags:
|
||||
- key: service.name
|
||||
value: service_name
|
||||
tracesToMetrics:
|
||||
datasourceUid: prometheus
|
||||
serviceMap:
|
||||
datasourceUid: prometheus
|
||||
|
||||
- name: CloudWatch
|
||||
uid: cloudwatch
|
||||
type: cloudwatch
|
||||
access: proxy
|
||||
editable: true
|
||||
jsonData:
|
||||
authType: default
|
||||
defaultRegion: ap-east-1
|
||||
@@ -1,6 +1,9 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@@ -12,18 +15,31 @@ import (
|
||||
func UpdateUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateUserSubscribeRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
if err := validateUpdateUserSubscribeTrafficLimit(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewUpdateUserSubscribeLogic(c.Request.Context(), svcCtx)
|
||||
err := l.UpdateUserSubscribe(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateUpdateUserSubscribeTrafficLimit(req *types.UpdateUserSubscribeRequest) error {
|
||||
if req.TrafficLimit == nil || *req.TrafficLimit == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rules []types.TrafficLimit
|
||||
if err := json.Unmarshal([]byte(*req.TrafficLimit), &rules); err != nil {
|
||||
return errors.New("traffic_limit must be a valid JSON array")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestUpdateUserSubscribeHandlerRejectsInvalidLimits(t *testing.T) {
|
||||
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"speed_limit":-1}`,
|
||||
},
|
||||
{
|
||||
name: "invalid traffic limit type",
|
||||
name: "invalid traffic limit json",
|
||||
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"traffic_limit":"not-json"}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// Query Commission Return Log
|
||||
func QueryCommissionReturnLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.QueryCommissionReturnLogRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewQueryCommissionReturnLogLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.QueryCommissionReturnLog(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCommissionReturnLogHandler_HTTPResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, cleanup := newCommissionReturnHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnHTTPQueries(mock, 42, 3, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, time.Unix(1700000000, 0)).
|
||||
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, time.Unix(1700000000, 0)).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0)))
|
||||
|
||||
router := gin.New()
|
||||
svcCtx := &svc.ServiceContext{DB: db}
|
||||
router.Use(injectTestUser(42))
|
||||
router.GET("/v1/public/user/commission_return_log", QueryCommissionReturnLogHandler(svcCtx))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/user/commission_return_log?page=1&size=10", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(rec.Body.String())
|
||||
t.Logf("commission_return_log response: %s", body)
|
||||
if !strings.Contains(body, `"event_type":338`) || !strings.Contains(body, `"event_type":337`) || !strings.Contains(body, `"event_type":333`) {
|
||||
t.Fatalf("response body = %s", body)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalLogHandler_CommissionRefundHTTPResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, cleanup := newCommissionReturnHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnHTTPQueries(mock, 42, 1, logmodel.CommissionTypeRefund)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0)))
|
||||
|
||||
router := gin.New()
|
||||
svcCtx := &svc.ServiceContext{DB: db}
|
||||
router.Use(injectTestUser(42))
|
||||
router.GET("/v1/public/user/withdrawal_log", QueryWithdrawalLogHandler(svcCtx))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/user/withdrawal_log?page=1&size=10&biz_type=commission_refund", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(rec.Body.String())
|
||||
t.Logf("withdrawal_log commission_refund response: %s", body)
|
||||
if !strings.Contains(body, `"biz_type":"commission_refund"`) || !strings.Contains(body, `"amount":2500`) || strings.Contains(body, `"amount":1500`) || strings.Contains(body, `"amount":2000`) {
|
||||
t.Fatalf("response body = %s", body)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newCommissionReturnHandlerTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||
if strings.Contains(actualSQL, expectedSQL) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
|
||||
return db, mock, func() {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func injectTestUser(userID int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), constant.CtxKeyUser, &usermodel.User{Id: userID})
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func expectCommissionReturnHTTPQueries(mock sqlmock.Sqlmock, userID int64, total int64, eventTypes ...uint16) {
|
||||
query := "SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ?"
|
||||
args := []driver.Value{logmodel.TypeCommission.Uint8(), userID}
|
||||
if len(eventTypes) > 0 {
|
||||
clauses := make([]string, 0, len(eventTypes))
|
||||
for _, eventType := range eventTypes {
|
||||
clauses = append(clauses, "`content` LIKE ?")
|
||||
args = append(args, fmt.Sprintf("%%\"type\":%d%%", eventType))
|
||||
}
|
||||
query += " AND (" + strings.Join(clauses, " OR ") + ")"
|
||||
}
|
||||
mock.ExpectQuery(query).
|
||||
WithArgs(args...).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total))
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log)
|
||||
// Query Withdrawal Log
|
||||
func QueryWithdrawalLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.QueryWithdrawalLogListRequest
|
||||
|
||||
@@ -1180,9 +1180,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// Verify Email
|
||||
publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx))
|
||||
|
||||
// Query Commission Return Log
|
||||
publicUserGroupRouter.GET("/commission_return_log", publicUser.QueryCommissionReturnLogHandler(serverCtx))
|
||||
|
||||
// Query Withdrawal Log
|
||||
publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/group"
|
||||
"github.com/perfect-panel/server/internal/model/node"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
@@ -49,33 +48,6 @@ func (l *DeleteNodeGroupLogic) DeleteNodeGroup(req *types.DeleteNodeGroupRequest
|
||||
return fmt.Errorf("cannot delete group with %d associated nodes, please migrate nodes first", nodeCount)
|
||||
}
|
||||
|
||||
var defaultSubscribeCount int64
|
||||
if err := l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("node_group_id = ?", nodeGroup.Id).Count(&defaultSubscribeCount).Error; err != nil {
|
||||
logger.Errorf("failed to count subscribes with default group: %v", err)
|
||||
return err
|
||||
}
|
||||
if defaultSubscribeCount > 0 {
|
||||
return fmt.Errorf("cannot delete group referenced by %d subscribes' default node group", defaultSubscribeCount)
|
||||
}
|
||||
|
||||
var subscribeGroupCount int64
|
||||
if err := l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroup.Id)).Count(&subscribeGroupCount).Error; err != nil {
|
||||
logger.Errorf("failed to count subscribes in group: %v", err)
|
||||
return err
|
||||
}
|
||||
if subscribeGroupCount > 0 {
|
||||
return fmt.Errorf("cannot delete group referenced by %d subscribes' node group list", subscribeGroupCount)
|
||||
}
|
||||
|
||||
var userSubscribeCount int64
|
||||
if err := l.svcCtx.DB.Table("user_subscribe").Where("node_group_id = ?", nodeGroup.Id).Count(&userSubscribeCount).Error; err != nil {
|
||||
logger.Errorf("failed to count user subscribes in group: %v", err)
|
||||
return err
|
||||
}
|
||||
if userSubscribeCount > 0 {
|
||||
return fmt.Errorf("cannot delete group referenced by %d user subscribes", userSubscribeCount)
|
||||
}
|
||||
|
||||
// 使用 GORM Transaction 删除节点组
|
||||
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 删除节点组
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/group"
|
||||
@@ -53,9 +52,10 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
|
||||
Email string `json:"email"`
|
||||
}
|
||||
var users []UserInfo
|
||||
if err := json.Unmarshal([]byte(detail.UserData), &users); err != nil {
|
||||
if err := l.svcCtx.DB.Raw("SELECT * FROM JSON_ARRAY(?)", detail.UserData).Scan(&users).Error; err != nil {
|
||||
// 如果解析失败,尝试用标准 JSON 解析
|
||||
logger.Errorf("failed to parse user data: %v", err)
|
||||
return nil, "", fmt.Errorf("parse group history user_data failed: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 查询节点组名称
|
||||
@@ -123,10 +123,7 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
|
||||
result = append(result, csvData...)
|
||||
|
||||
// 生成文件名
|
||||
filename := "group_result_current.csv"
|
||||
if req.HistoryId != nil {
|
||||
filename = fmt.Sprintf("group_result_%d.csv", *req.HistoryId)
|
||||
}
|
||||
filename := fmt.Sprintf("group_result_%d.csv", req.HistoryId)
|
||||
|
||||
return result, filename, nil
|
||||
}
|
||||
|
||||
@@ -76,16 +76,16 @@ func (l *GetGroupHistoryDetailLogic) GetGroupHistoryDetail(req *types.GetGroupHi
|
||||
configSnapshot := make(map[string]interface{})
|
||||
configSnapshot["group_details"] = details
|
||||
|
||||
// 获取配置快照(从 system 读取)
|
||||
// 获取配置快照(从 system_config 读取)
|
||||
var configValue string
|
||||
if history.GroupMode == "average" {
|
||||
l.svcCtx.DB.Table("system").
|
||||
Where("`category` = ? AND `key` = ?", "group", "average_config").
|
||||
l.svcCtx.DB.Table("system_config").
|
||||
Where("`key` = ?", "group.average_config").
|
||||
Select("value").
|
||||
Scan(&configValue)
|
||||
} else if history.GroupMode == "traffic" {
|
||||
l.svcCtx.DB.Table("system").
|
||||
Where("`category` = ? AND `key` = ?", "group", "traffic_config").
|
||||
l.svcCtx.DB.Table("system_config").
|
||||
Where("`key` = ?", "group.traffic_config").
|
||||
Select("value").
|
||||
Scan(&configValue)
|
||||
}
|
||||
|
||||
@@ -44,9 +44,9 @@ func (l *GetGroupHistoryLogic) GetGroupHistory(req *types.GetGroupHistoryRequest
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page, size := normalizePagination(req.Page, req.Size)
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&histories).Error; err != nil {
|
||||
// 分页查询
|
||||
offset := (req.Page - 1) * req.Size
|
||||
if err := query.Order("id DESC").Offset(offset).Limit(req.Size).Find(&histories).Error; err != nil {
|
||||
logger.Errorf("failed to find group histories: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -38,9 +38,9 @@ func (l *GetNodeGroupListLogic) GetNodeGroupList(req *types.GetNodeGroupListRequ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page, size := normalizePagination(req.Page, req.Size)
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("sort ASC").Offset(offset).Limit(size).Find(&nodeGroups).Error; err != nil {
|
||||
// 分页查询
|
||||
offset := (req.Page - 1) * req.Size
|
||||
if err := query.Order("sort ASC").Offset(offset).Limit(req.Size).Find(&nodeGroups).Error; err != nil {
|
||||
logger.Errorf("failed to find node groups: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
modelgroup "github.com/perfect-panel/server/internal/model/group"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestGetGroupHistoryDetailReadsConfigFromSystem(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
now := time.Unix(1710000000, 0)
|
||||
mock.ExpectQuery("FROM `group_history`").
|
||||
WithArgs(int64(9), 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "group_mode", "trigger_type", "state", "total_users", "success_count", "failed_count", "start_time", "end_time", "error_message", "created_at",
|
||||
}).AddRow(int64(9), "traffic", "manual", "completed", 1, 1, 0, now, now, "", now))
|
||||
mock.ExpectQuery("FROM `group_history_detail`").
|
||||
WithArgs(int64(9)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
|
||||
}).AddRow(int64(1), int64(9), int64(3), 1, 2, `[{"id":7,"email":"u@example.com"}]`, now))
|
||||
mock.ExpectQuery("FROM `system`").
|
||||
WithArgs("group", "traffic_config").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(`{"strategy":"closed"}`))
|
||||
|
||||
logic := newTestGroupHistoryDetailLogic(db)
|
||||
resp, err := logic.GetGroupHistoryDetail(&types.GetGroupHistoryDetailRequest{Id: 9})
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroupHistoryDetail error: %v", err)
|
||||
}
|
||||
if got := resp.ConfigSnapshot["config"].(map[string]interface{})["strategy"]; got != "closed" {
|
||||
t.Fatalf("config snapshot strategy = %v, want closed", got)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestExportGroupResultParsesHistoryUserDataJSON(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
historyID := int64(11)
|
||||
now := time.Unix(1710000000, 0)
|
||||
mock.ExpectQuery("FROM `group_history_detail`").
|
||||
WithArgs(historyID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
|
||||
}).AddRow(int64(1), historyID, int64(8), 1, 2, `[{"id":42,"email":"u@example.com"}]`, now))
|
||||
mock.ExpectQuery("FROM `node_group`").
|
||||
WithArgs(int64(8), 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(8), "VIP"))
|
||||
|
||||
logic := newTestExportGroupResultLogic(db)
|
||||
data, filename, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID})
|
||||
if err != nil {
|
||||
t.Fatalf("ExportGroupResult error: %v", err)
|
||||
}
|
||||
if filename != "group_result_11.csv" {
|
||||
t.Fatalf("filename = %q, want group_result_11.csv", filename)
|
||||
}
|
||||
|
||||
records, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(string(data), "\ufeff"))).ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("read csv: %v", err)
|
||||
}
|
||||
if len(records) != 2 || strings.Join(records[1], ",") != "42,8,VIP" {
|
||||
t.Fatalf("csv records = %#v, want user/group row", records)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestExportGroupResultRejectsInvalidHistoryUserDataJSON(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
historyID := int64(12)
|
||||
now := time.Unix(1710000000, 0)
|
||||
mock.ExpectQuery("FROM `group_history_detail`").
|
||||
WithArgs(historyID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
|
||||
}).AddRow(int64(1), historyID, int64(8), 1, 2, `{bad-json`, now))
|
||||
|
||||
logic := newTestExportGroupResultLogic(db)
|
||||
_, _, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID})
|
||||
if err == nil || !strings.Contains(err.Error(), "parse group history user_data failed") {
|
||||
t.Fatalf("ExportGroupResult error = %v, want parse error", err)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestDeleteNodeGroupRejectsSubscribeReferences(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultSubscribeCount int64
|
||||
subscribeGroupCount int64
|
||||
userSubscribeCount int64
|
||||
wantErr string
|
||||
}{
|
||||
{name: "subscribe default group", defaultSubscribeCount: 1, wantErr: "default node group"},
|
||||
{name: "subscribe group list", subscribeGroupCount: 1, wantErr: "node group list"},
|
||||
{name: "user subscribe group", userSubscribeCount: 1, wantErr: "user subscribes"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectDeleteNodeGroupBaseChecks(mock, 5)
|
||||
mock.ExpectQuery("FROM `subscribe`").
|
||||
WithArgs(int64(5)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.defaultSubscribeCount))
|
||||
if tt.defaultSubscribeCount == 0 {
|
||||
mock.ExpectQuery("FROM `subscribe`").
|
||||
WithArgs("[5]").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.subscribeGroupCount))
|
||||
}
|
||||
if tt.defaultSubscribeCount == 0 && tt.subscribeGroupCount == 0 {
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(int64(5)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.userSubscribeCount))
|
||||
}
|
||||
|
||||
logic := newTestDeleteNodeGroupLogic(db)
|
||||
err := logic.DeleteNodeGroup(&types.DeleteNodeGroupRequest{Id: 5})
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("DeleteNodeGroup error = %v, want contains %q", err, tt.wantErr)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetGroupsRollsBackOnFailure(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("DELETE FROM `node_group`").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE `subscribe`").
|
||||
WithArgs(int64(0), "[]").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE `nodes`").
|
||||
WithArgs("[]").
|
||||
WillReturnError(fmt.Errorf("node update failed"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
logic := newTestResetGroupsLogic(db)
|
||||
err := logic.ResetGroups()
|
||||
if err == nil || !strings.Contains(err.Error(), "node update failed") {
|
||||
t.Fatalf("ResetGroups error = %v, want node update failure", err)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestPreviewUserNodesIncludesPublicNodesInGroupMode(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
now := time.Unix(1710000000, 0)
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(int64(100), int8(0), int8(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subscribe_id", "node_group_id"}).
|
||||
AddRow(int64(1), int64(100), int64(10), int64(5)))
|
||||
mock.ExpectQuery("FROM `subscribe`").
|
||||
WithArgs(int64(10)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "node_group_id", "node_group_ids", "nodes", "node_tags"}).
|
||||
AddRow(int64(10), int64(0), "[]", "", ""))
|
||||
mock.ExpectQuery("FROM `system`").
|
||||
WithArgs("group", "enabled").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("true"))
|
||||
mock.ExpectQuery("FROM `nodes`").
|
||||
WithArgs(true).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "name", "tags", "port", "address", "server_id", "protocol", "enabled", "sort", "node_group_ids", "created_at", "updated_at",
|
||||
}).
|
||||
AddRow(int64(1), "group-node", "", uint16(443), "g.example.com", int64(1), "vless", true, 1, "[5]", now, now).
|
||||
AddRow(int64(2), "public-node", "", uint16(443), "p.example.com", int64(1), "vless", true, 2, "[]", now, now))
|
||||
mock.ExpectQuery("FROM `node_group`").
|
||||
WithArgs(int64(5)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(5), "Group A"))
|
||||
|
||||
logic := newTestPreviewUserNodesLogic(db)
|
||||
resp, err := logic.PreviewUserNodes(&types.PreviewUserNodesRequest{UserId: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("PreviewUserNodes error: %v", err)
|
||||
}
|
||||
if !hasNodeGroup(resp.NodeGroups, 0, "public-node") {
|
||||
t.Fatalf("PreviewUserNodes node groups = %#v, want public node group", resp.NodeGroups)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestTrafficRangeMatchingUsesClosedBounds(t *testing.T) {
|
||||
min0, max10 := int64(0), int64(10)
|
||||
min10, max20 := int64(10), int64(20)
|
||||
nodeGroups := []modelgroup.NodeGroup{
|
||||
{Id: 1, MinTrafficGB: &min0, MaxTrafficGB: &max10},
|
||||
{Id: 2, MinTrafficGB: &min10, MaxTrafficGB: &max20},
|
||||
}
|
||||
|
||||
tests := map[float64]int64{
|
||||
0: 1,
|
||||
10: 1,
|
||||
15: 2,
|
||||
21: 0,
|
||||
}
|
||||
for used, want := range tests {
|
||||
if got := matchTrafficNodeGroup(used, nodeGroups); got != want {
|
||||
t.Fatalf("matchTrafficNodeGroup(%v) = %d, want %d", used, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePaginationDefaultsAndMax(t *testing.T) {
|
||||
tests := []struct {
|
||||
page, size int
|
||||
wantPage int
|
||||
wantSize int
|
||||
}{
|
||||
{page: 0, size: 0, wantPage: 1, wantSize: defaultPageSize},
|
||||
{page: -1, size: -5, wantPage: 1, wantSize: defaultPageSize},
|
||||
{page: 2, size: maxPageSize + 1, wantPage: 2, wantSize: maxPageSize},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
gotPage, gotSize := normalizePagination(tt.page, tt.size)
|
||||
if gotPage != tt.wantPage || gotSize != tt.wantSize {
|
||||
t.Fatalf("normalizePagination(%d, %d) = (%d, %d), want (%d, %d)", tt.page, tt.size, gotPage, gotSize, tt.wantPage, tt.wantSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newGroupTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||
if strings.Contains(actualSQL, expectedSQL) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
|
||||
return db, mock, func() {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func newTestGroupHistoryDetailLogic(db *gorm.DB) *GetGroupHistoryDetailLogic {
|
||||
ctx := context.Background()
|
||||
return &GetGroupHistoryDetailLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestExportGroupResultLogic(db *gorm.DB) *ExportGroupResultLogic {
|
||||
ctx := context.Background()
|
||||
return &ExportGroupResultLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestDeleteNodeGroupLogic(db *gorm.DB) *DeleteNodeGroupLogic {
|
||||
ctx := context.Background()
|
||||
return &DeleteNodeGroupLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestResetGroupsLogic(db *gorm.DB) *ResetGroupsLogic {
|
||||
ctx := context.Background()
|
||||
return &ResetGroupsLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPreviewUserNodesLogic(db *gorm.DB) *PreviewUserNodesLogic {
|
||||
ctx := context.Background()
|
||||
return &PreviewUserNodesLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func expectDeleteNodeGroupBaseChecks(mock sqlmock.Sqlmock, nodeGroupId int64) {
|
||||
now := time.Unix(1710000000, 0)
|
||||
mock.ExpectQuery("FROM `node_group`").
|
||||
WithArgs(nodeGroupId, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at"}).
|
||||
AddRow(nodeGroupId, "Group", now, now))
|
||||
mock.ExpectQuery("FROM `nodes`").
|
||||
WithArgs(fmt.Sprintf("[%d]", nodeGroupId)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
}
|
||||
|
||||
func hasNodeGroup(items []types.NodeGroupItem, id int64, nodeName string) bool {
|
||||
for _, item := range items {
|
||||
if item.Id != id {
|
||||
continue
|
||||
}
|
||||
for _, n := range item.Nodes {
|
||||
if n.Name == nodeName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertGroupExpectations(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/node"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
)
|
||||
|
||||
func normalizePagination(page, size int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = defaultPageSize
|
||||
}
|
||||
if size > maxPageSize {
|
||||
size = maxPageSize
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
|
||||
func countNodesInGroup(db *gorm.DB, nodeGroupId int64) (int, error) {
|
||||
var count int64
|
||||
err := db.Model(&node.Node{}).
|
||||
Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroupId)).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(count), nil
|
||||
}
|
||||
@@ -196,10 +196,10 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. 过滤出公共节点和至少一个匹配节点组的节点,与真实订阅下发保持一致
|
||||
// 6. 过滤出包含至少一个匹配节点组的节点(仅显示用户真正所在分组的节点,不包含公共节点)
|
||||
for _, n := range dbNodes {
|
||||
// 节点未配置节点组(公共节点),预览时不显示
|
||||
if len(n.NodeGroupIds) == 0 {
|
||||
filteredNodes = append(filteredNodes, n)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -450,13 +450,9 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
|
||||
}
|
||||
}
|
||||
|
||||
// 预览模式不显示公共节点(node_group_ids 为空的节点),只展示用户真正所在分组的节点
|
||||
if len(publicNodes) > 0 {
|
||||
nodeGroupItems = append(nodeGroupItems, types.NodeGroupItem{
|
||||
Id: 0,
|
||||
Name: "公共节点",
|
||||
Nodes: publicNodes,
|
||||
})
|
||||
logger.Infof("[PreviewUserNodes] adding public nodes group: nodes=%d", len(publicNodes))
|
||||
logger.Infof("[PreviewUserNodes] skipping %d public nodes (not in user's assigned group)", len(publicNodes))
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
@@ -679,7 +679,21 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
||||
// 将字节转换为 GB
|
||||
usedTrafficGB := float64(us.UsedTraffic) / (1024 * 1024 * 1024)
|
||||
|
||||
targetNodeGroupId := matchTrafficNodeGroup(usedTrafficGB, nodeGroups)
|
||||
// 查找匹配的流量范围(使用左闭右开区间 [Min, Max))
|
||||
var targetNodeGroupId int64 = 0
|
||||
for _, ng := range nodeGroups {
|
||||
if ng.MinTrafficGB == nil || ng.MaxTrafficGB == nil {
|
||||
continue
|
||||
}
|
||||
minTraffic := float64(*ng.MinTrafficGB)
|
||||
maxTraffic := float64(*ng.MaxTrafficGB)
|
||||
|
||||
// 检查是否在区间内 [min, max)
|
||||
if usedTrafficGB >= minTraffic && usedTrafficGB < maxTraffic {
|
||||
targetNodeGroupId = ng.Id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配到任何范围,targetNodeGroupId 保持为 0(不分配节点组)
|
||||
|
||||
@@ -720,14 +734,7 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
||||
// 4. 创建分组历史详情记录(只统计有用户的节点组)
|
||||
nodeGroupCount := make(map[int64]int) // node_group_id -> node_count
|
||||
for _, ng := range nodeGroups {
|
||||
count, err := countNodesInGroup(tx, ng.Id)
|
||||
if err != nil {
|
||||
l.Errorw("failed to count nodes in group",
|
||||
logger.Field("node_group_id", ng.Id),
|
||||
logger.Field("error", err.Error()))
|
||||
return affectedCount, err
|
||||
}
|
||||
nodeGroupCount[ng.Id] = count
|
||||
nodeGroupCount[ng.Id] = 1 // 每个节点组计为1
|
||||
}
|
||||
|
||||
for nodeGroupId, userCount := range groupUserCount {
|
||||
@@ -757,20 +764,6 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
||||
return affectedCount, nil
|
||||
}
|
||||
|
||||
func matchTrafficNodeGroup(usedTrafficGB float64, nodeGroups []group.NodeGroup) int64 {
|
||||
for _, ng := range nodeGroups {
|
||||
if ng.MinTrafficGB == nil || ng.MaxTrafficGB == nil {
|
||||
continue
|
||||
}
|
||||
minTraffic := float64(*ng.MinTrafficGB)
|
||||
maxTraffic := float64(*ng.MaxTrafficGB)
|
||||
if usedTrafficGB >= minTraffic && usedTrafficGB <= maxTraffic {
|
||||
return ng.Id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// containsIgnoreCase checks if a string contains another substring (case-insensitive)
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
|
||||
@@ -7,10 +7,8 @@ import (
|
||||
"github.com/perfect-panel/server/internal/model/node"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/model/system"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ResetGroupsLogic struct {
|
||||
@@ -29,64 +27,55 @@ func NewResetGroupsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Reset
|
||||
}
|
||||
|
||||
func (l *ResetGroupsLogic) ResetGroups() error {
|
||||
err := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Delete all node groups
|
||||
if err := tx.Where("1 = 1").Delete(&group.NodeGroup{}).Error; err != nil {
|
||||
l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully deleted all node groups")
|
||||
|
||||
// 2. Clear node_group_id/node_group_ids for all subscribes (products)
|
||||
if err := tx.Table((&subscribe.Subscribe{}).TableName()).Where("1 = 1").Updates(map[string]interface{}{
|
||||
"node_group_id": 0,
|
||||
"node_group_ids": "[]",
|
||||
}).Error; err != nil {
|
||||
l.Errorw("Failed to clear subscribes' node groups", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared all subscribes' node groups")
|
||||
|
||||
// 3. Clear node_group_ids for all nodes
|
||||
if err := tx.Table((&node.Node{}).TableName()).Where("1 = 1").Update("node_group_ids", "[]").Error; err != nil {
|
||||
l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared all nodes' node_group_ids")
|
||||
|
||||
// 4. Clear user_subscribe node_group_id
|
||||
if err := tx.Table((&user.Subscribe{}).TableName()).Where("1 = 1").Update("node_group_id", 0).Error; err != nil {
|
||||
l.Errorw("Failed to clear user subscribes' node_group_id", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared all user subscribes' node_group_id")
|
||||
|
||||
// 5. Clear group history
|
||||
if err := tx.Where("1 = 1").Delete(&group.GroupHistory{}).Error; err != nil {
|
||||
l.Errorw("Failed to clear group history", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared group history")
|
||||
|
||||
// 6. Clear group history details
|
||||
if err := tx.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error; err != nil {
|
||||
l.Errorw("Failed to clear group history details", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared group history details")
|
||||
|
||||
// 7. Delete all group config settings
|
||||
if err := tx.Where("`category` = ?", "group").Delete(&system.System{}).Error; err != nil {
|
||||
l.Errorw("Failed to delete group config", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully deleted all group config settings")
|
||||
|
||||
return nil
|
||||
})
|
||||
// 1. Delete all node groups
|
||||
err := l.svcCtx.DB.Where("1 = 1").Delete(&group.NodeGroup{}).Error
|
||||
if err != nil {
|
||||
l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully deleted all node groups")
|
||||
|
||||
// 2. Clear node_group_ids for all subscribes (products)
|
||||
err = l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("1 = 1").Update("node_group_ids", "[]").Error
|
||||
if err != nil {
|
||||
l.Errorw("Failed to clear subscribes' node_group_ids", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared all subscribes' node_group_ids")
|
||||
|
||||
// 3. Clear node_group_ids for all nodes
|
||||
err = l.svcCtx.DB.Model(&node.Node{}).Where("1 = 1").Update("node_group_ids", "[]").Error
|
||||
if err != nil {
|
||||
l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared all nodes' node_group_ids")
|
||||
|
||||
// 4. Clear group history
|
||||
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistory{}).Error
|
||||
if err != nil {
|
||||
l.Errorw("Failed to clear group history", logger.Field("error", err.Error()))
|
||||
// Non-critical error, continue anyway
|
||||
} else {
|
||||
l.Infow("Successfully cleared group history")
|
||||
}
|
||||
|
||||
// 7. Clear group history details
|
||||
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error
|
||||
if err != nil {
|
||||
l.Errorw("Failed to clear group history details", logger.Field("error", err.Error()))
|
||||
// Non-critical error, continue anyway
|
||||
} else {
|
||||
l.Infow("Successfully cleared group history details")
|
||||
}
|
||||
|
||||
// 5. Delete all group config settings
|
||||
err = l.svcCtx.DB.Where("`category` = ?", "group").Delete(&system.System{}).Error
|
||||
if err != nil {
|
||||
l.Errorw("Failed to delete group config", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully deleted all group config settings")
|
||||
|
||||
l.Infow("Group reset completed successfully")
|
||||
return nil
|
||||
|
||||
@@ -2,10 +2,8 @@ package invite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
modellog "github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
@@ -115,7 +113,6 @@ func QueryBenefits(ctx context.Context, db *gorm.DB, relations []InviteRelation)
|
||||
for userId := range inviteeAndInviterSet {
|
||||
inviteeAndInviterIds = append(inviteeAndInviterIds, userId)
|
||||
}
|
||||
slices.Sort(inviteeAndInviterIds)
|
||||
|
||||
if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil {
|
||||
return nil, err
|
||||
@@ -189,35 +186,26 @@ func fillGiftBenefits(ctx context.Context, db *gorm.DB, benefits map[int64]Benef
|
||||
return nil
|
||||
}
|
||||
|
||||
type DeviceIdentifier struct {
|
||||
Identifier string
|
||||
DeviceNo string
|
||||
}
|
||||
|
||||
func QueryDeviceIdentifiers(ctx context.Context, db *gorm.DB, userIds []int64) (map[int64]DeviceIdentifier, error) {
|
||||
identifiers := make(map[int64]DeviceIdentifier, len(userIds))
|
||||
func QueryIdentifiers(ctx context.Context, db *gorm.DB, userIds []int64) (map[int64]string, error) {
|
||||
identifiers := make(map[int64]string, len(userIds))
|
||||
if len(userIds) == 0 {
|
||||
return identifiers, nil
|
||||
}
|
||||
|
||||
type identifierRow struct {
|
||||
UserId int64 `gorm:"column:user_id"`
|
||||
DeviceId int64 `gorm:"column:device_id"`
|
||||
Identifier string `gorm:"column:identifier"`
|
||||
}
|
||||
var rows []identifierRow
|
||||
if err := db.WithContext(ctx).
|
||||
Table("user_device ud").
|
||||
Select("ud.user_id, ud.id as device_id, ud.identifier as identifier").
|
||||
Joins("JOIN (SELECT user_id, MIN(id) AS id FROM user_device WHERE user_id IN ? GROUP BY user_id) first_ud ON first_ud.id = ud.id", userIds).
|
||||
Table("user_auth_methods uam").
|
||||
Select("uam.user_id, uam.auth_identifier as identifier").
|
||||
Joins("JOIN (SELECT user_id, MIN(id) AS id FROM user_auth_methods WHERE user_id IN ? GROUP BY user_id) first_uam ON first_uam.id = uam.id", userIds).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user device identifiers failed: %v", err)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user identifiers failed: %v", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
identifiers[row.UserId] = DeviceIdentifier{
|
||||
Identifier: row.Identifier,
|
||||
DeviceNo: tool.DeviceIdToHash(row.DeviceId),
|
||||
}
|
||||
identifiers[row.UserId] = row.Identifier
|
||||
}
|
||||
return identifiers, nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -26,7 +25,7 @@ func TestQueryBenefitsCountsFamilyOwnerGiftAsInviteeGift(t *testing.T) {
|
||||
WithArgs(33, int64(100), "family-order", 331, 332).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
||||
mock.ExpectQuery("object_id IN").
|
||||
WithArgs(34, int64(100), int64(200), int64(900), "family-order").
|
||||
WithArgs(34, int64(900), int64(200), int64(100), "family-order").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
||||
AddRow(900, `{"type":341,"order_no":"family-order","amount":7,"balance":7,"remark":"邀请赠送"}`))
|
||||
|
||||
@@ -59,7 +58,7 @@ func TestQueryBenefitsKeepsDirectInviteeGift(t *testing.T) {
|
||||
WithArgs(33, int64(100), "direct-order", 331, 332).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
||||
mock.ExpectQuery("object_id IN").
|
||||
WithArgs(34, int64(100), int64(200), "direct-order").
|
||||
WithArgs(34, int64(200), int64(100), "direct-order").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
||||
AddRow(200, `{"type":341,"order_no":"direct-order","amount":5,"balance":5,"remark":"邀请赠送"}`))
|
||||
|
||||
@@ -74,39 +73,6 @@ func TestQueryBenefitsKeepsDirectInviteeGift(t *testing.T) {
|
||||
assertBenefitsExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestQueryDeviceIdentifiersReturnsDeviceIdentifierAndNo(t *testing.T) {
|
||||
db, mock, cleanup := newBenefitsTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM user_device ud").
|
||||
WithArgs(int64(100), int64(200)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"user_id", "device_id", "identifier"}).
|
||||
AddRow(100, 11, "inviter-hash").
|
||||
AddRow(200, 22, "invitee-hash"))
|
||||
|
||||
identifiers, err := QueryDeviceIdentifiers(context.Background(), db, []int64{100, 200})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryDeviceIdentifiers returned error: %v", err)
|
||||
}
|
||||
|
||||
inviter := identifiers[100]
|
||||
if inviter.Identifier != "inviter-hash" {
|
||||
t.Fatalf("inviter identifier = %q, want inviter-hash", inviter.Identifier)
|
||||
}
|
||||
if inviter.DeviceNo != tool.DeviceIdToHash(11) {
|
||||
t.Fatalf("inviter device no = %q, want %q", inviter.DeviceNo, tool.DeviceIdToHash(11))
|
||||
}
|
||||
|
||||
invitee := identifiers[200]
|
||||
if invitee.Identifier != "invitee-hash" {
|
||||
t.Fatalf("invitee identifier = %q, want invitee-hash", invitee.Identifier)
|
||||
}
|
||||
if invitee.DeviceNo != tool.DeviceIdToHash(22) {
|
||||
t.Fatalf("invitee device no = %q, want %q", invitee.DeviceNo, tool.DeviceIdToHash(22))
|
||||
}
|
||||
assertBenefitsExpectations(t, mock)
|
||||
}
|
||||
|
||||
func newBenefitsTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ func (l *GetInviteManageListLogic) GetInviteManageList(req *types.GetInviteManag
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices, err := QueryDeviceIdentifiers(l.ctx, l.svcCtx.DB, userIds)
|
||||
identifiers, err := QueryIdentifiers(l.ctx, l.svcCtx.DB, userIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,11 +78,9 @@ func (l *GetInviteManageListLogic) GetInviteManageList(req *types.GetInviteManag
|
||||
benefit := benefits[row.InviteeId]
|
||||
list = append(list, types.InviteManageRecord{
|
||||
InviterId: row.InviterId,
|
||||
InviterIdentifier: devices[row.InviterId].Identifier,
|
||||
InviterDeviceNo: devices[row.InviterId].DeviceNo,
|
||||
InviterIdentifier: identifiers[row.InviterId],
|
||||
InviteeId: row.InviteeId,
|
||||
InviteeIdentifier: devices[row.InviteeId].Identifier,
|
||||
InviteeDeviceNo: devices[row.InviteeId].DeviceNo,
|
||||
InviteeIdentifier: identifiers[row.InviteeId],
|
||||
InviteeAvatar: row.InviteeAvatar,
|
||||
InviteeEnable: row.InviteeEnable,
|
||||
InvitedAt: row.InvitedAt,
|
||||
|
||||
@@ -55,8 +55,6 @@ func orderStatusName(status uint8) string {
|
||||
case 5:
|
||||
return "finished"
|
||||
case 6:
|
||||
return "claimed"
|
||||
case 7:
|
||||
return "refunded"
|
||||
default:
|
||||
return "unknown"
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
orderStatusRefunded = 7
|
||||
orderStatusRefunded = 6
|
||||
)
|
||||
|
||||
type RefundOrderLogic struct {
|
||||
@@ -67,12 +67,15 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "order %d status %d is not refundable", orderInfo.Id, orderInfo.Status)
|
||||
}
|
||||
|
||||
// 幂等校验:若该 order_no 已存在 333 退款日志,拒绝再次退款。
|
||||
// HIF-131 案例:订单状态被外部入口(stuckOrderRecovery 把 6 视为卡住的 claim)回退到 5,
|
||||
// 让 lockCommissionSource 误抓到原始 331/332 amount 再次扣减佣金。
|
||||
refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if refunded {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund log", orderInfo.Id)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund commission log", orderInfo.Id)
|
||||
}
|
||||
|
||||
userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo)
|
||||
@@ -170,9 +173,6 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
|
||||
orderUser := &modeluser.User{Id: orderInfo.UserId}
|
||||
userCacheTargets = append(userCacheTargets, orderUser)
|
||||
}
|
||||
if userSub.UserId > 0 && userSub.UserId != orderInfo.UserId {
|
||||
userCacheTargets = append(userCacheTargets, &modeluser.User{Id: userSub.UserId})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -180,19 +180,17 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(cachesToClear) > 0 && l.svcCtx.UserModel != nil {
|
||||
if len(cachesToClear) > 0 {
|
||||
if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, cachesToClear...); clearErr != nil {
|
||||
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("order_id", req.Id))
|
||||
}
|
||||
}
|
||||
for _, subscribeID := range planCacheIDs {
|
||||
if l.svcCtx.SubscribeModel != nil {
|
||||
if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != nil {
|
||||
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID))
|
||||
}
|
||||
if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != nil {
|
||||
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID))
|
||||
}
|
||||
}
|
||||
if len(userCacheTargets) > 0 && l.svcCtx.UserModel != nil {
|
||||
if len(userCacheTargets) > 0 {
|
||||
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userCacheTargets...); clearErr != nil {
|
||||
l.Errorw("[RefundOrder] clear user cache failed", logger.Field("error", clearErr.Error()))
|
||||
}
|
||||
@@ -201,16 +199,16 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
|
||||
}
|
||||
|
||||
func (l *RefundOrderLogic) lockRefundTargetSubscription(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) {
|
||||
if userSub, err := l.lockSubscriptionByOrderID(tx, orderInfo.Id); err != nil {
|
||||
return nil, err
|
||||
} else if userSub != nil {
|
||||
return userSub, nil
|
||||
var userSub modeluser.Subscribe
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&modeluser.Subscribe{}).
|
||||
Where("order_id = ?", orderInfo.Id).
|
||||
First(&userSub).Error
|
||||
if err == nil {
|
||||
return &userSub, nil
|
||||
}
|
||||
|
||||
if userSub, err := l.lockSubscriptionByEntitlement(tx, orderInfo); err != nil {
|
||||
return nil, err
|
||||
} else if userSub != nil {
|
||||
return userSub, nil
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query refund subscription failed: %v", err)
|
||||
}
|
||||
|
||||
if orderInfo.Type != 2 {
|
||||
@@ -221,122 +219,17 @@ func (l *RefundOrderLogic) lockRefundTargetSubscription(tx *gorm.DB, orderInfo *
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d has no parent order", orderInfo.Id)
|
||||
}
|
||||
|
||||
if userSub, err := l.lockSubscriptionByOrderID(tx, orderInfo.ParentId); err != nil {
|
||||
return nil, err
|
||||
} else if userSub != nil {
|
||||
return userSub, nil
|
||||
}
|
||||
|
||||
if userSub, err := l.lockRenewalParentEntitlementSubscription(tx, orderInfo); err != nil {
|
||||
return nil, err
|
||||
} else if userSub != nil {
|
||||
return userSub, nil
|
||||
}
|
||||
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d parent subscription not found", orderInfo.Id)
|
||||
}
|
||||
|
||||
func (l *RefundOrderLogic) lockSubscriptionByOrderID(tx *gorm.DB, orderID int64) (*modeluser.Subscribe, error) {
|
||||
if orderID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var userSub modeluser.Subscribe
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&modeluser.Subscribe{}).
|
||||
Where("order_id = ?", orderID).
|
||||
Where("order_id = ?", orderInfo.ParentId).
|
||||
First(&userSub).Error
|
||||
if err == nil {
|
||||
return &userSub, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query refund subscription failed: %v", err)
|
||||
}
|
||||
|
||||
func (l *RefundOrderLogic) lockSubscriptionByEntitlement(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) {
|
||||
entitlementUserID := orderInfo.SubscriptionUserId
|
||||
if entitlementUserID == 0 {
|
||||
entitlementUserID = orderInfo.UserId
|
||||
}
|
||||
if entitlementUserID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if userSub, err := l.lockExactEntitlementSubscription(tx, orderInfo, entitlementUserID); err != nil {
|
||||
return nil, err
|
||||
} else if userSub != nil {
|
||||
return userSub, nil
|
||||
}
|
||||
|
||||
var userSub modeluser.Subscribe
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&modeluser.Subscribe{}).
|
||||
Where("user_id = ? AND subscribe_id = ?", entitlementUserID, orderInfo.SubscribeId).
|
||||
Where("status IN ?", []int64{0, 1, 2, 3, 5}).
|
||||
Order("expire_time DESC").
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
First(&userSub).Error
|
||||
if err == nil {
|
||||
return &userSub, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query entitlement subscription failed: %v", err)
|
||||
}
|
||||
|
||||
func (l *RefundOrderLogic) lockExactEntitlementSubscription(tx *gorm.DB, orderInfo *modelorder.Order, entitlementUserID int64) (*modeluser.Subscribe, error) {
|
||||
if orderInfo.Id <= 0 && orderInfo.SubscribeToken == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&modeluser.Subscribe{}).
|
||||
Where("user_id = ? AND subscribe_id = ?", entitlementUserID, orderInfo.SubscribeId)
|
||||
if orderInfo.Id > 0 && orderInfo.SubscribeToken != "" {
|
||||
query = query.Where("(order_id = ? OR token = ?)", orderInfo.Id, orderInfo.SubscribeToken)
|
||||
} else if orderInfo.Id > 0 {
|
||||
query = query.Where("order_id = ?", orderInfo.Id)
|
||||
} else {
|
||||
query = query.Where("token = ?", orderInfo.SubscribeToken)
|
||||
}
|
||||
|
||||
var userSub modeluser.Subscribe
|
||||
err := query.Order("id DESC").First(&userSub).Error
|
||||
if err == nil {
|
||||
return &userSub, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query exact entitlement subscription failed: %v", err)
|
||||
}
|
||||
|
||||
func (l *RefundOrderLogic) lockRenewalParentEntitlementSubscription(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) {
|
||||
var parentOrder modelorder.Order
|
||||
err := tx.Model(&modelorder.Order{}).
|
||||
Where("id = ?", orderInfo.ParentId).
|
||||
First(&parentOrder).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d parent subscription not found", orderInfo.Id)
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query renewal parent order failed: %v", err)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query renewal subscription failed: %v", err)
|
||||
}
|
||||
|
||||
if parentOrder.SubscriptionUserId == 0 && orderInfo.SubscriptionUserId > 0 {
|
||||
parentOrder.SubscriptionUserId = orderInfo.SubscriptionUserId
|
||||
}
|
||||
if parentOrder.UserId == 0 {
|
||||
parentOrder.UserId = orderInfo.UserId
|
||||
}
|
||||
if parentOrder.SubscribeId == 0 {
|
||||
parentOrder.SubscribeId = orderInfo.SubscribeId
|
||||
}
|
||||
return l.lockSubscriptionByEntitlement(tx, &parentOrder)
|
||||
return &userSub, nil
|
||||
}
|
||||
|
||||
func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, orderCommission int64) (*modeluser.User, int64, error) {
|
||||
@@ -374,15 +267,10 @@ func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, ord
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
// hasRefundLog checks refund audit logs first, then falls back to legacy commission refund logs.
|
||||
// hasRefundLog 检查指定 order_no 是否已有 333 (CommissionTypeRefund) 退款佣金日志。
|
||||
// 仅扫 type=33 + 内容含 order_no 的命中项,再用 JSON 二次确认 content.type==333,
|
||||
// 防止 content.order_no 子串误判。
|
||||
func (l *RefundOrderLogic) hasRefundLog(tx *gorm.DB, orderNo string) (bool, error) {
|
||||
refunded, err := log.HasOrderRefundLog(tx, orderNo)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if refunded {
|
||||
return true, nil
|
||||
}
|
||||
return log.HasRefundCommissionLog(tx, orderNo)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
modelorder "github.com/perfect-panel/server/internal/model/order"
|
||||
modeluser "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"
|
||||
@@ -27,8 +27,8 @@ func TestOrderStatusName(t *testing.T) {
|
||||
3: "closed",
|
||||
4: "failed",
|
||||
5: "finished",
|
||||
6: "claimed",
|
||||
7: "refunded",
|
||||
6: "refunded",
|
||||
7: "unknown",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
@@ -96,64 +96,14 @@ func TestBuildRefundAuditLog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefundOrder_SetsStatusRefunded(t *testing.T) {
|
||||
const (
|
||||
orderID = int64(1000)
|
||||
orderNo = "ORD-REFUND-SUCCESS"
|
||||
operatorUID = int64(519)
|
||||
userID = int64(7000)
|
||||
subscribeID = int64(8000)
|
||||
userSubID = int64(9000)
|
||||
)
|
||||
|
||||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `order`").
|
||||
WithArgs(orderID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}).
|
||||
AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID))
|
||||
mock.ExpectQuery("FROM `system_logs`").
|
||||
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||
mock.ExpectQuery("FROM `system_logs`").
|
||||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(orderID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time"}).
|
||||
AddRow(userSubID, userID, orderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour)))
|
||||
mock.ExpectQuery("FROM `system_logs`").
|
||||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||
mock.ExpectExec("UPDATE `order`").
|
||||
WithArgs(orderStatusRefunded, sqlmock.AnyArg(), orderID, 2, 5).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE `user_subscribe`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO `system_logs`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||||
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "manual refund"})
|
||||
if err != nil {
|
||||
t.Fatalf("RefundOrder error: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-16 修复:
|
||||
// 当 system_logs 已存在该订单的 24 退款审计日志时,再次调用 RefundOrder 必须:
|
||||
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-131 / HIF-132 修复:
|
||||
// 当 system_logs 已存在该订单的 333 退款佣金日志时,再次调用 RefundOrder 必须:
|
||||
// 1. 返回 OrderAlreadyRefunded 错误码;
|
||||
// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发);
|
||||
// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。
|
||||
//
|
||||
// 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE /
|
||||
// SELECT system_logs(命中 24)/ ROLLBACK,不允许出现 commission 锁/更新/插入。
|
||||
// SELECT system_logs(命中 333)/ ROLLBACK,不允许出现 commission 锁/更新/插入。
|
||||
func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
||||
const (
|
||||
orderID = int64(53647)
|
||||
@@ -170,9 +120,9 @@ func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id"}).
|
||||
AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028)))
|
||||
mock.ExpectQuery("FROM `system_logs`").
|
||||
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
|
||||
AddRow(1, fmt.Sprintf(`{"order_id":%d,"order_no":"%s","order_status_before":5,"order_status_after":7}`, orderID, orderNo)))
|
||||
AddRow(1, fmt.Sprintf(`{"type":333,"order_no":"%s","amount":-2250,"timestamp":0}`, orderNo)))
|
||||
mock.ExpectRollback()
|
||||
|
||||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||||
@@ -188,12 +138,12 @@ func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==7 拒绝路径,
|
||||
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==6 拒绝路径,
|
||||
// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。
|
||||
func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) {
|
||||
const (
|
||||
orderID = int64(1001)
|
||||
orderNo = "ORD-STATUS-7"
|
||||
orderNo = "ORD-STATUS-6"
|
||||
operatorUID = int64(519)
|
||||
)
|
||||
|
||||
@@ -245,144 +195,6 @@ func TestRefundOrder_RejectsWhenStatusNotRefundable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockRefundTargetSubscription_FamilyMemberPurchaseLocksOwnerSubscription(t *testing.T) {
|
||||
const (
|
||||
orderID = int64(2001)
|
||||
memberUID = int64(101)
|
||||
ownerUID = int64(201)
|
||||
subscribeID = int64(301)
|
||||
userSubID = int64(401)
|
||||
)
|
||||
|
||||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(orderID, 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(ownerUID, subscribeID, orderID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time"}).
|
||||
AddRow(userSubID, ownerUID, orderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour)))
|
||||
|
||||
logic := &RefundOrderLogic{}
|
||||
got, err := logic.lockRefundTargetSubscription(db, &modelorder.Order{
|
||||
Id: orderID,
|
||||
UserId: memberUID,
|
||||
SubscriptionUserId: ownerUID,
|
||||
Type: 1,
|
||||
SubscribeId: subscribeID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("lockRefundTargetSubscription error: %v", err)
|
||||
}
|
||||
if got.Id != userSubID || got.UserId != ownerUID {
|
||||
t.Fatalf("locked subscription = %+v, want id=%d user_id=%d", got, userSubID, ownerUID)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockRefundTargetSubscription_RenewalFallsBackToParentOwnerEntitlement(t *testing.T) {
|
||||
const (
|
||||
orderID = int64(2101)
|
||||
parentOrderID = int64(2100)
|
||||
memberUID = int64(111)
|
||||
ownerUID = int64(211)
|
||||
subscribeID = int64(311)
|
||||
userSubID = int64(411)
|
||||
)
|
||||
|
||||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(orderID, 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(ownerUID, subscribeID, orderID, "renew-token", 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(ownerUID, subscribeID, int64(0), int64(1), int64(2), int64(3), int64(5), 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(parentOrderID, 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectQuery("FROM `order`").
|
||||
WithArgs(parentOrderID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subscription_user_id", "subscribe_id", "subscribe_token"}).
|
||||
AddRow(parentOrderID, memberUID, ownerUID, subscribeID, "owner-token"))
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(ownerUID, subscribeID, parentOrderID, "owner-token", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time", "token"}).
|
||||
AddRow(userSubID, ownerUID, parentOrderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour), "owner-token"))
|
||||
|
||||
logic := &RefundOrderLogic{}
|
||||
got, err := logic.lockRefundTargetSubscription(db, &modelorder.Order{
|
||||
Id: orderID,
|
||||
ParentId: parentOrderID,
|
||||
UserId: memberUID,
|
||||
SubscriptionUserId: ownerUID,
|
||||
Type: 2,
|
||||
SubscribeId: subscribeID,
|
||||
SubscribeToken: "renew-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("lockRefundTargetSubscription error: %v", err)
|
||||
}
|
||||
if got.Id != userSubID || got.UserId != ownerUID || got.OrderId != parentOrderID {
|
||||
t.Fatalf("locked subscription = %+v, want id=%d user_id=%d order_id=%d", got, userSubID, ownerUID, parentOrderID)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefundOrder_NoTargetSubscriptionDoesNotRefundOrder(t *testing.T) {
|
||||
const (
|
||||
orderID = int64(2201)
|
||||
orderNo = "ORD-NO-SUB"
|
||||
operatorUID = int64(519)
|
||||
userID = int64(7001)
|
||||
subscribeID = int64(8001)
|
||||
)
|
||||
|
||||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `order`").
|
||||
WithArgs(orderID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}).
|
||||
AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID))
|
||||
mock.ExpectQuery("FROM `system_logs`").
|
||||
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||
mock.ExpectQuery("FROM `system_logs`").
|
||||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(orderID, 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(userID, subscribeID, orderID, 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(userID, subscribeID, int64(0), int64(1), int64(2), int64(3), int64(5), 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectRollback()
|
||||
|
||||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||||
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "missing subscription"})
|
||||
if !isErrCode(err, xerr.OrderRefundNoSubscription) {
|
||||
t.Fatalf("RefundOrder error code = %v, want OrderRefundNoSubscription; raw=%v", errCodeOf(err), err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newRefundOrderTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -15,10 +15,6 @@ import (
|
||||
queue "github.com/perfect-panel/server/queue/types"
|
||||
)
|
||||
|
||||
const (
|
||||
orderStatusClaimed = 6
|
||||
)
|
||||
|
||||
type UpdateOrderStatusLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
@@ -35,10 +31,6 @@ func NewUpdateOrderStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
||||
}
|
||||
|
||||
func (l *UpdateOrderStatusLogic) UpdateOrderStatus(req *types.UpdateOrderStatusRequest) error {
|
||||
if req.Status == orderStatusClaimed || req.Status == orderStatusRefunded {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "claimed/refund statuses are reserved for internal refund and activation flows")
|
||||
}
|
||||
|
||||
info, err := l.svcCtx.OrderModel.FindOne(l.ctx, req.Id)
|
||||
if err != nil {
|
||||
l.Errorw("[UpdateOrderStatus] FindOne error", logger.Field("error", err.Error()))
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
)
|
||||
|
||||
func TestUpdateOrderStatus_RejectsRefundStatus(t *testing.T) {
|
||||
logic := &UpdateOrderStatusLogic{
|
||||
Logger: logger.WithContext(context.Background()),
|
||||
ctx: context.Background(),
|
||||
svcCtx: &svc.ServiceContext{},
|
||||
}
|
||||
|
||||
for _, status := range []uint8{orderStatusClaimed, orderStatusRefunded} {
|
||||
err := logic.UpdateOrderStatus(&types.UpdateOrderStatusRequest{
|
||||
Id: 1001,
|
||||
Status: status,
|
||||
})
|
||||
if !isErrCode(err, xerr.OrderStatusError) {
|
||||
t.Fatalf("status %d: UpdateOrderStatus error code = %v, want OrderStatusError; raw=%v", status, errCodeOf(err), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/group"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
@@ -63,24 +61,12 @@ func (l *GetUserSubscribeLogic) GetUserSubscribe(req *types.GetUserSubscribeList
|
||||
}
|
||||
|
||||
for _, item := range data {
|
||||
sub := buildUserSubscribeListItem(item, groupNames[item.NodeGroupId])
|
||||
var sub types.UserSubscribe
|
||||
tool.DeepCopy(&sub, item)
|
||||
sub.Short, _ = tool.FixedUniqueString(item.Token, 8, "")
|
||||
sub.NodeGroupId = item.NodeGroupId
|
||||
sub.NodeGroupName = groupNames[item.NodeGroupId]
|
||||
resp.List = append(resp.List, sub)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func buildUserSubscribeListItem(item *user.SubscribeDetails, groupName string) types.UserSubscribe {
|
||||
var sub types.UserSubscribe
|
||||
tool.DeepCopy(&sub, item)
|
||||
sub.Short, _ = tool.FixedUniqueString(item.Token, 8, "")
|
||||
sub.NodeGroupId = item.NodeGroupId
|
||||
sub.NodeGroupName = groupName
|
||||
sub.SpeedLimit = item.SpeedLimit
|
||||
if item.TrafficLimit != nil && *item.TrafficLimit != "" {
|
||||
_ = json.Unmarshal([]byte(*item.TrafficLimit), &sub.TrafficLimit)
|
||||
}
|
||||
if item.Subscribe != nil {
|
||||
sub.PlanSpeedLimit = item.Subscribe.SpeedLimit
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
modeluser "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
)
|
||||
|
||||
func TestBuildUserSubscribeListItem(t *testing.T) {
|
||||
trafficLimitJSON := `[{"stat_type":"day","stat_value":1,"traffic_usage":10,"speed_limit":5}]`
|
||||
now := time.Unix(1718000000, 0)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
item *modeluser.SubscribeDetails
|
||||
wantTrafficLimit []types.TrafficLimit
|
||||
}{
|
||||
{
|
||||
name: "non-empty user traffic limit",
|
||||
item: &modeluser.SubscribeDetails{
|
||||
Id: 1,
|
||||
UserId: 2,
|
||||
NodeGroupId: 3,
|
||||
Token: "token-12345678",
|
||||
TrafficLimit: &trafficLimitJSON,
|
||||
StartTime: now,
|
||||
ExpireTime: now,
|
||||
},
|
||||
wantTrafficLimit: []types.TrafficLimit{
|
||||
{
|
||||
StatType: "day",
|
||||
StatValue: 1,
|
||||
TrafficUsage: 10,
|
||||
SpeedLimit: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty user traffic limit",
|
||||
item: &modeluser.SubscribeDetails{
|
||||
Id: 4,
|
||||
UserId: 5,
|
||||
NodeGroupId: 6,
|
||||
Token: "token-empty",
|
||||
StartTime: now,
|
||||
ExpireTime: now,
|
||||
},
|
||||
wantTrafficLimit: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := buildUserSubscribeListItem(tc.item, "group-a")
|
||||
if got.NodeGroupName != "group-a" {
|
||||
t.Fatalf("NodeGroupName = %q, want %q", got.NodeGroupName, "group-a")
|
||||
}
|
||||
if len(got.Short) != 8 {
|
||||
t.Fatalf("Short length = %d, want 8", len(got.Short))
|
||||
}
|
||||
if len(got.TrafficLimit) != len(tc.wantTrafficLimit) {
|
||||
t.Fatalf("TrafficLimit length = %d, want %d", len(got.TrafficLimit), len(tc.wantTrafficLimit))
|
||||
}
|
||||
for i := range tc.wantTrafficLimit {
|
||||
if got.TrafficLimit[i] != tc.wantTrafficLimit[i] {
|
||||
t.Fatalf("TrafficLimit[%d] = %+v, want %+v", i, got.TrafficLimit[i], tc.wantTrafficLimit[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -110,16 +110,6 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
|
||||
}
|
||||
change := *req.Commission - userInfo.Commission
|
||||
if change < 0 {
|
||||
// 禁止直接扣减佣金:扣减必须走 approveWithdrawal 写 type=334 日志,
|
||||
// 否则 user.commission 和 system_logs 会再次失衡,破坏账目闭环。
|
||||
l.Logger.Errorw("blocked direct commission deduction via admin update",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("change", change),
|
||||
)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess),
|
||||
"commission deduction must go through withdrawal approval, not direct edit")
|
||||
}
|
||||
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
@@ -46,11 +45,7 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
|
||||
}
|
||||
trafficLimit := userSub.TrafficLimit
|
||||
if req.TrafficLimit != nil {
|
||||
trafficLimit, err = marshalUserSubscribeTrafficLimit(req.TrafficLimit)
|
||||
if err != nil {
|
||||
l.Errorw("marshal traffic_limit failed:", logger.Field("error", err.Error()))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal traffic_limit failed: %v", err.Error())
|
||||
}
|
||||
trafficLimit = req.TrafficLimit
|
||||
}
|
||||
|
||||
err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, &user.Subscribe{
|
||||
@@ -101,12 +96,3 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalUserSubscribeTrafficLimit(rules []types.TrafficLimit) (*string, error) {
|
||||
val, err := json.Marshal(rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trafficLimit := string(val)
|
||||
return &trafficLimit, nil
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
)
|
||||
|
||||
func TestMarshalUserSubscribeTrafficLimit(t *testing.T) {
|
||||
rules := []types.TrafficLimit{
|
||||
{
|
||||
StatType: "hour",
|
||||
StatValue: 1,
|
||||
TrafficUsage: 1,
|
||||
SpeedLimit: 1,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := marshalUserSubscribeTrafficLimit(rules)
|
||||
if err != nil {
|
||||
t.Fatalf("marshalUserSubscribeTrafficLimit() error = %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("marshalUserSubscribeTrafficLimit() returned nil")
|
||||
return
|
||||
}
|
||||
|
||||
want := `[{"stat_type":"hour","stat_value":1,"traffic_usage":1,"speed_limit":1}]`
|
||||
if *got != want {
|
||||
t.Fatalf("marshalUserSubscribeTrafficLimit() = %q, want %q", *got, want)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/pkg/payment/epay"
|
||||
"github.com/perfect-panel/server/pkg/payment/stripe"
|
||||
queueTypes "github.com/perfect-panel/server/queue/types"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
@@ -21,52 +18,19 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/payment/alipay"
|
||||
)
|
||||
|
||||
// GatewayPaymentStatus is the tri-state result of an external payment-gateway
|
||||
// query. The third state (Unknown) is the whole point of HIF-137: when the
|
||||
// gateway is unreachable or returns an inconclusive answer we must NOT collapse
|
||||
// it to "unpaid" — that's exactly the bug that closes already-paid orders.
|
||||
type GatewayPaymentStatus int
|
||||
|
||||
const (
|
||||
// GatewayStatusUnknown — the gateway query itself failed (timeout, 5xx,
|
||||
// network error, decode error) or the payment method has no gateway we can
|
||||
// query. The caller must treat this as "do not close, retry later".
|
||||
GatewayStatusUnknown GatewayPaymentStatus = iota
|
||||
// GatewayStatusPaid — the gateway confirms the user has paid.
|
||||
GatewayStatusPaid
|
||||
// GatewayStatusUnpaid — the gateway confirms the order is unpaid /
|
||||
// cancelled / closed on its side. Safe to close locally.
|
||||
GatewayStatusUnpaid
|
||||
)
|
||||
|
||||
type CloseOrderLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
|
||||
// Test seams (injected via overrides on the returned struct). Production
|
||||
// code never touches these — NewCloseOrderLogic wires the real impls.
|
||||
gatewayQuery func(*order.Order) GatewayPaymentStatus
|
||||
enqueueActivate func(context.Context, []byte) (string, error)
|
||||
}
|
||||
|
||||
// NewCloseOrderLogic Close order
|
||||
func NewCloseOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CloseOrderLogic {
|
||||
l := &CloseOrderLogic{
|
||||
return &CloseOrderLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
l.gatewayQuery = l.queryGatewayPaymentStatus
|
||||
l.enqueueActivate = func(c context.Context, payload []byte) (string, error) {
|
||||
task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload, asynq.MaxRetry(5))
|
||||
info, err := svcCtx.Queue.EnqueueContext(c, task)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return info.ID, nil
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
|
||||
@@ -88,31 +52,6 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HIF-137: before closing a pending order, ask the gateway whether it was
|
||||
// actually paid. Silent callback failures must not cause us to close a
|
||||
// paid order. Three branches:
|
||||
// - Paid → recover to status=2 + enqueue activation (do NOT close)
|
||||
// - Unpaid → fall through to the normal close flow
|
||||
// - Unknown → keep status=1 and let DeferCloseOrder retry on the next tick
|
||||
switch l.gatewayQuery(orderInfo) {
|
||||
case GatewayStatusPaid:
|
||||
return l.recoverPaidOrder(orderInfo)
|
||||
case GatewayStatusUnknown:
|
||||
l.Errorw("[CloseOrder] gateway query inconclusive — keeping order open (metric=close_gateway_error_kept_open)",
|
||||
logger.Field("metric", "close_gateway_error_kept_open"),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
logger.Field("method", orderInfo.Method),
|
||||
logger.Field("trade_no", orderInfo.TradeNo),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
l.Infow("[CloseOrder] gateway confirmed unpaid — proceeding with normal close (metric=close_normal)",
|
||||
logger.Field("metric", "close_normal"),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
logger.Field("method", orderInfo.Method),
|
||||
)
|
||||
|
||||
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, orderInfo.SubscribeId)
|
||||
if err != nil {
|
||||
l.Errorw("[CloseOrder] Find subscribe info failed",
|
||||
@@ -227,117 +166,42 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// recoverPaidOrder is the "gateway said paid" branch: flip the order to
|
||||
// status=2 (paid), persist the trade_no if we have one, and enqueue the same
|
||||
// activation task the notify handlers would have enqueued. We deliberately
|
||||
// reuse ForthwithActivateOrder so the rest of the activation pipeline
|
||||
// (idempotency, claim/release, commission, etc.) stays untouched.
|
||||
func (l *CloseOrderLogic) recoverPaidOrder(orderInfo *order.Order) error {
|
||||
updates := map[string]any{"status": 2}
|
||||
if orderInfo.TradeNo != "" {
|
||||
updates["trade_no"] = orderInfo.TradeNo
|
||||
}
|
||||
|
||||
// Race-safe: only flip 1→2. If another worker already moved the order
|
||||
// forward (e.g. a late notify finally landed), do nothing.
|
||||
result := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&order.Order{}).
|
||||
Where("order_no = ? AND status = ?", orderInfo.OrderNo, 1).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
l.Errorw("[CloseOrder] gateway-paid recovery update failed — keeping order open",
|
||||
logger.Field("metric", "close_gateway_error_kept_open"),
|
||||
logger.Field("error", result.Error.Error()),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
l.Infow("[CloseOrder] gateway-paid recovery skipped — order already moved past status=1",
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
payload := queueTypes.ForthwithActivateOrderPayload{OrderNo: orderInfo.OrderNo}
|
||||
bytes, err := json.Marshal(&payload)
|
||||
// confirmationPayment Determine whether the payment is successful
|
||||
//
|
||||
//nolint:unused
|
||||
func (l *CloseOrderLogic) confirmationPayment(order *order.Order) bool {
|
||||
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, order.PaymentId)
|
||||
if err != nil {
|
||||
l.Errorw("[CloseOrder] marshal activation payload failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
)
|
||||
return nil
|
||||
l.Errorw("[CloseOrder] Find payment config failed", logger.Field("error", err.Error()), logger.Field("paymentMark", order.Method))
|
||||
return false
|
||||
}
|
||||
taskID, err := l.enqueueActivate(l.ctx, bytes)
|
||||
if err != nil {
|
||||
// Order is already at status=2; if enqueue fails the stuck-order
|
||||
// recovery sweeper will pick it up. Log loudly but don't error out.
|
||||
l.Errorw("[CloseOrder] enqueue activation task failed after gateway-paid recovery",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
l.Infow("[CloseOrder] gateway-paid recovery succeeded — order promoted to paid + activation enqueued (metric=close_with_gateway_paid_recovered)",
|
||||
logger.Field("metric", "close_with_gateway_paid_recovered"),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
logger.Field("method", orderInfo.Method),
|
||||
logger.Field("trade_no", orderInfo.TradeNo),
|
||||
logger.Field("queue_task_id", taskID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryGatewayPaymentStatus dispatches to the right gateway client for the
|
||||
// order's payment method and returns a tri-state result. Methods without an
|
||||
// external gateway (Balance, unknown) return Unpaid — the historical close
|
||||
// path is preserved for them.
|
||||
func (l *CloseOrderLogic) queryGatewayPaymentStatus(orderInfo *order.Order) GatewayPaymentStatus {
|
||||
switch orderInfo.Method {
|
||||
switch order.Method {
|
||||
case AlipayF2f:
|
||||
return l.queryAlipay(orderInfo)
|
||||
case StripeAlipay, StripeWeChatPay:
|
||||
return l.queryStripe(orderInfo)
|
||||
case Epay:
|
||||
return l.queryEpay(orderInfo)
|
||||
case Balance:
|
||||
// Balance is settled in-process; no external gateway to ask. If status=1
|
||||
// here, the balance deduction simply never completed — safe to close.
|
||||
return GatewayStatusUnpaid
|
||||
if l.queryAlipay(paymentConfig, order.TradeNo) {
|
||||
return true
|
||||
}
|
||||
case StripeAlipay:
|
||||
if l.queryStripe(paymentConfig, order.TradeNo) {
|
||||
return true
|
||||
}
|
||||
case StripeWeChatPay:
|
||||
if l.queryStripe(paymentConfig, order.TradeNo) {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
l.Infow("[CloseOrder] no gateway query for method — falling back to close",
|
||||
logger.Field("method", orderInfo.Method),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
)
|
||||
return GatewayStatusUnpaid
|
||||
l.Infow("[CloseOrder] Unsupported payment method", logger.Field("paymentMethod", order.Method))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// queryAlipay queries Alipay F2F and maps the response to a tri-state.
|
||||
// "trade not exist" on Alipay's side is treated as Unpaid (the user never
|
||||
// completed the QR-code scan), not Unknown.
|
||||
func (l *CloseOrderLogic) queryAlipay(orderInfo *order.Order) GatewayPaymentStatus {
|
||||
if orderInfo.TradeNo == "" {
|
||||
// Alipay F2F creates the trade lazily — no trade_no means the user
|
||||
// never scanned. Treat as definitively unpaid.
|
||||
return GatewayStatusUnpaid
|
||||
}
|
||||
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId)
|
||||
if err != nil {
|
||||
l.Errorw("[CloseOrder] Find payment config failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("paymentMark", orderInfo.Method),
|
||||
)
|
||||
return GatewayStatusUnknown
|
||||
}
|
||||
// queryAlipay Query Alipay payment status
|
||||
//
|
||||
//nolint:unused
|
||||
func (l *CloseOrderLogic) queryAlipay(paymentConfig *payment.Payment, TradeNo string) bool {
|
||||
config := payment.AlipayF2FConfig{}
|
||||
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
|
||||
l.Errorw("[CloseOrder] Unmarshal Alipay config failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
)
|
||||
return GatewayStatusUnknown
|
||||
l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config))
|
||||
return false
|
||||
}
|
||||
client := alipay.NewClient(alipay.Config{
|
||||
AppId: config.AppId,
|
||||
@@ -345,112 +209,35 @@ func (l *CloseOrderLogic) queryAlipay(orderInfo *order.Order) GatewayPaymentStat
|
||||
PublicKey: config.PublicKey,
|
||||
InvoiceName: config.InvoiceName,
|
||||
})
|
||||
if client == nil {
|
||||
return GatewayStatusUnknown
|
||||
}
|
||||
status, err := client.QueryTrade(l.ctx, orderInfo.TradeNo)
|
||||
status, err := client.QueryTrade(l.ctx, TradeNo)
|
||||
if err != nil {
|
||||
l.Errorw("[CloseOrder] Alipay QueryTrade failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
logger.Field("tradeNo", orderInfo.TradeNo),
|
||||
)
|
||||
return GatewayStatusUnknown
|
||||
l.Errorw("[CloseOrder] Query trade failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo))
|
||||
return false
|
||||
}
|
||||
switch status {
|
||||
case alipay.Success, alipay.Finished:
|
||||
return GatewayStatusPaid
|
||||
case alipay.Pending, alipay.Closed:
|
||||
return GatewayStatusUnpaid
|
||||
default:
|
||||
// Unknown alipay status — be conservative.
|
||||
return GatewayStatusUnknown
|
||||
if status == alipay.Success || status == alipay.Finished {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// queryStripe queries Stripe PaymentIntent and maps to a tri-state.
|
||||
// Any Stripe-side error (network, 5xx, decode) is Unknown — Stripe is the
|
||||
// gateway most prone to silent webhook drops in this codebase, so we must not
|
||||
// downgrade query failures to "unpaid".
|
||||
func (l *CloseOrderLogic) queryStripe(orderInfo *order.Order) GatewayPaymentStatus {
|
||||
if orderInfo.TradeNo == "" {
|
||||
// Stripe's PaymentIntent ID is written into trade_no at create time.
|
||||
// Missing it means the intent was never persisted — treat as unpaid.
|
||||
return GatewayStatusUnpaid
|
||||
}
|
||||
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId)
|
||||
if err != nil {
|
||||
l.Errorw("[CloseOrder] Find payment config failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("paymentMark", orderInfo.Method),
|
||||
)
|
||||
return GatewayStatusUnknown
|
||||
}
|
||||
// queryStripe Query Stripe payment status
|
||||
//
|
||||
//nolint:unused
|
||||
func (l *CloseOrderLogic) queryStripe(paymentConfig *payment.Payment, TradeNo string) bool {
|
||||
config := payment.StripeConfig{}
|
||||
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
|
||||
l.Errorw("[CloseOrder] Unmarshal Stripe config failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
)
|
||||
return GatewayStatusUnknown
|
||||
l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config))
|
||||
return false
|
||||
}
|
||||
client := stripe.NewClient(stripe.Config{
|
||||
PublicKey: config.PublicKey,
|
||||
SecretKey: config.SecretKey,
|
||||
WebhookSecret: config.WebhookSecret,
|
||||
})
|
||||
paid, err := client.QueryOrderStatus(orderInfo.TradeNo)
|
||||
status, err := client.QueryOrderStatus(TradeNo)
|
||||
if err != nil {
|
||||
l.Errorw("[CloseOrder] Stripe QueryOrderStatus failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
logger.Field("tradeNo", orderInfo.TradeNo),
|
||||
)
|
||||
return GatewayStatusUnknown
|
||||
l.Errorw("[CloseOrder] Query order status failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo))
|
||||
return false
|
||||
}
|
||||
if paid {
|
||||
return GatewayStatusPaid
|
||||
}
|
||||
return GatewayStatusUnpaid
|
||||
}
|
||||
|
||||
// queryEpay queries EPay using out_trade_no (EPay's order endpoint accepts
|
||||
// out_trade_no even when we never recorded its internal trade_no, which is the
|
||||
// common case for orders that lost their notify callback).
|
||||
func (l *CloseOrderLogic) queryEpay(orderInfo *order.Order) GatewayPaymentStatus {
|
||||
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId)
|
||||
if err != nil {
|
||||
l.Errorw("[CloseOrder] Find payment config failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("paymentMark", orderInfo.Method),
|
||||
)
|
||||
return GatewayStatusUnknown
|
||||
}
|
||||
config := payment.EPayConfig{}
|
||||
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
|
||||
l.Errorw("[CloseOrder] Unmarshal EPay config failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
)
|
||||
return GatewayStatusUnknown
|
||||
}
|
||||
if config.Url == "" || config.Pid == "" || config.Key == "" {
|
||||
l.Errorw("[CloseOrder] EPay config incomplete",
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
)
|
||||
return GatewayStatusUnknown
|
||||
}
|
||||
client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type)
|
||||
paid, err := client.QueryOrderStatus(orderInfo.OrderNo)
|
||||
if err != nil {
|
||||
l.Errorw("[CloseOrder] EPay QueryOrderStatus failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("orderNo", orderInfo.OrderNo),
|
||||
)
|
||||
return GatewayStatusUnknown
|
||||
}
|
||||
if paid {
|
||||
return GatewayStatusPaid
|
||||
}
|
||||
return GatewayStatusUnpaid
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
modelorder "github.com/perfect-panel/server/internal/model/order"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// newCloseOrderTestDB wires gorm to go-sqlmock with a substring SQL matcher,
|
||||
// matching the convention used elsewhere in this package.
|
||||
func newCloseOrderTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||
if strings.Contains(actualSQL, expectedSQL) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
func newCloseOrderLogicForTest(db *gorm.DB) *CloseOrderLogic {
|
||||
ctx := context.Background()
|
||||
return &CloseOrderLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverPaidOrder_HappyPath covers HIF-137 P01 branch "gateway 已支付":
|
||||
// gateway said the order was paid → we must flip status 1→2, persist
|
||||
// trade_no, and enqueue exactly one ForthwithActivateOrder task. This is the
|
||||
// most important regression to keep — silently dropping the enqueue here would
|
||||
// re-create the bug we are fixing.
|
||||
func TestRecoverPaidOrder_HappyPath(t *testing.T) {
|
||||
db, mock, cleanup := newCloseOrderTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
const (
|
||||
orderNo = "ORD-PAID-1"
|
||||
tradeNo = "ALIPAY-TRADE-9999"
|
||||
)
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("UPDATE `order`").
|
||||
WithArgs(2, tradeNo, sqlmock.AnyArg(), orderNo, 1).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
var (
|
||||
enqueueCalls int32
|
||||
capturedPayload []byte
|
||||
)
|
||||
logic := newCloseOrderLogicForTest(db)
|
||||
logic.enqueueActivate = func(_ context.Context, payload []byte) (string, error) {
|
||||
atomic.AddInt32(&enqueueCalls, 1)
|
||||
capturedPayload = append([]byte(nil), payload...)
|
||||
return "task-123", nil
|
||||
}
|
||||
|
||||
err := logic.recoverPaidOrder(&modelorder.Order{
|
||||
OrderNo: orderNo,
|
||||
Method: AlipayF2f,
|
||||
TradeNo: tradeNo,
|
||||
Status: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("recoverPaidOrder error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&enqueueCalls); got != 1 {
|
||||
t.Fatalf("expected exactly one enqueue, got %d", got)
|
||||
}
|
||||
if !strings.Contains(string(capturedPayload), orderNo) {
|
||||
t.Fatalf("activation payload missing order_no: %q", capturedPayload)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverPaidOrder_AlreadyAdvanced covers the race: the late-arriving
|
||||
// gateway-notify already flipped the order past status=1 by the time the
|
||||
// deferred close ran. UPDATE returns 0 rows; we must NOT double-enqueue.
|
||||
func TestRecoverPaidOrder_AlreadyAdvanced(t *testing.T) {
|
||||
db, mock, cleanup := newCloseOrderTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
const orderNo = "ORD-RACE"
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("UPDATE `order`").
|
||||
WithArgs(2, sqlmock.AnyArg(), orderNo, 1).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectCommit()
|
||||
|
||||
var enqueueCalls int32
|
||||
logic := newCloseOrderLogicForTest(db)
|
||||
logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) {
|
||||
atomic.AddInt32(&enqueueCalls, 1)
|
||||
return "should-not-fire", nil
|
||||
}
|
||||
|
||||
err := logic.recoverPaidOrder(&modelorder.Order{
|
||||
OrderNo: orderNo,
|
||||
Method: Epay,
|
||||
Status: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("recoverPaidOrder error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&enqueueCalls); got != 0 {
|
||||
t.Fatalf("expected no enqueue when 0 rows updated, got %d", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoverPaidOrder_EnqueueFailureIsNotFatal covers the case where the DB
|
||||
// move succeeded but the activation enqueue failed (Redis blip, etc). The
|
||||
// order is already at status=2, so the stuck-order sweeper will pick it up —
|
||||
// recoverPaidOrder must not return an error or revert the status.
|
||||
func TestRecoverPaidOrder_EnqueueFailureIsNotFatal(t *testing.T) {
|
||||
db, mock, cleanup := newCloseOrderTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
const orderNo = "ORD-ENQ-FAIL"
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("UPDATE `order`").
|
||||
WithArgs(2, sqlmock.AnyArg(), orderNo, 1).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
logic := newCloseOrderLogicForTest(db)
|
||||
logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) {
|
||||
return "", fmt.Errorf("simulated redis outage")
|
||||
}
|
||||
|
||||
if err := logic.recoverPaidOrder(&modelorder.Order{OrderNo: orderNo, Method: Epay}); err != nil {
|
||||
t.Fatalf("recoverPaidOrder must swallow enqueue errors, got: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryGatewayPaymentStatus_NonGatewayMethods checks the "fall-through"
|
||||
// branches: methods that don't talk to an external gateway (Balance, empty
|
||||
// string, anything unrecognised) must report Unpaid so the legacy close path
|
||||
// is preserved. This is the regression hook for the issue's acceptance
|
||||
// criterion #5 ("user-initiated cancellation can still close normally").
|
||||
func TestQueryGatewayPaymentStatus_NonGatewayMethods(t *testing.T) {
|
||||
logic := newCloseOrderLogicForTest(nil) // no DB needed for these branches
|
||||
|
||||
cases := []struct {
|
||||
method string
|
||||
want GatewayPaymentStatus
|
||||
}{
|
||||
{Balance, GatewayStatusUnpaid},
|
||||
{"", GatewayStatusUnpaid},
|
||||
{"future-method-we-dont-know", GatewayStatusUnpaid},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: tc.method})
|
||||
if got != tc.want {
|
||||
t.Fatalf("method=%q: got %v want %v", tc.method, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid: Alipay F2F creates
|
||||
// the trade lazily — if we never recorded a trade_no, the user never scanned
|
||||
// the QR code. We must report Unpaid (not Unknown) so the close proceeds.
|
||||
// Without this short-circuit, every legitimately abandoned QR-code order
|
||||
// would be "kept open" forever by the inconclusive-query branch.
|
||||
func TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid(t *testing.T) {
|
||||
logic := newCloseOrderLogicForTest(nil)
|
||||
got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: AlipayF2f, TradeNo: ""})
|
||||
if got != GatewayStatusUnpaid {
|
||||
t.Fatalf("Alipay F2F with empty trade_no should be Unpaid, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid: same reasoning as
|
||||
// the Alipay case — Stripe's PaymentIntent ID lives in trade_no; if it's
|
||||
// missing the intent was never persisted.
|
||||
func TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid(t *testing.T) {
|
||||
logic := newCloseOrderLogicForTest(nil)
|
||||
for _, method := range []string{StripeAlipay, StripeWeChatPay} {
|
||||
got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: method, TradeNo: ""})
|
||||
if got != GatewayStatusUnpaid {
|
||||
t.Fatalf("%s with empty trade_no should be Unpaid, got %v", method, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring asserts that the
|
||||
// default gatewayQuery is wired to the real implementation (not nil) by
|
||||
// NewCloseOrderLogic. Without this guard a future refactor could silently
|
||||
// drop the wiring and re-create HIF-135.
|
||||
func TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring(t *testing.T) {
|
||||
svcCtx := &svc.ServiceContext{}
|
||||
l := NewCloseOrderLogic(context.Background(), svcCtx)
|
||||
if l.gatewayQuery == nil {
|
||||
t.Fatal("NewCloseOrderLogic must wire gatewayQuery; got nil")
|
||||
}
|
||||
if l.enqueueActivate == nil {
|
||||
t.Fatal("NewCloseOrderLogic must wire enqueueActivate; got nil")
|
||||
}
|
||||
}
|
||||
@@ -58,26 +58,3 @@ func calculatePurchasePrice(
|
||||
result.DiscountAmount = originalPrice - result.PayableBase
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func calculateRenewalPrice(
|
||||
ctx context.Context,
|
||||
svcCtx *svc.ServiceContext,
|
||||
userID int64,
|
||||
subscribeID int64,
|
||||
unitPrice int64,
|
||||
quantity int64,
|
||||
discounts []types.SubscribeDiscount,
|
||||
eligibleForDiscount bool,
|
||||
) (*orderPriceResult, error) {
|
||||
return calculatePurchasePrice(
|
||||
ctx,
|
||||
svcCtx,
|
||||
userID,
|
||||
subscribeID,
|
||||
unitPrice,
|
||||
quantity,
|
||||
discounts,
|
||||
eligibleForDiscount,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -297,78 +297,3 @@ func TestCalculatePurchasePriceNewUserGatedByFirstPurchase(t *testing.T) {
|
||||
t.Fatalf("PayableBase = %d, want 900 (regular 90%% discount)", result.PayableBase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateRenewalPriceCampaignAppliesToReturningUsers(t *testing.T) {
|
||||
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||
{
|
||||
Rule: promo.Rule{
|
||||
Id: 21,
|
||||
Name: "renewal campaign",
|
||||
Type: promo.RuleTypeCampaign,
|
||||
Enabled: true,
|
||||
},
|
||||
PromoPrice: 700,
|
||||
},
|
||||
}}
|
||||
svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}
|
||||
|
||||
result, err := calculateRenewalPrice(
|
||||
context.Background(),
|
||||
svcCtx,
|
||||
42,
|
||||
2,
|
||||
500,
|
||||
3,
|
||||
[]types.SubscribeDiscount{{Quantity: 3, Discount: 80}},
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calculateRenewalPrice returned error: %v", err)
|
||||
}
|
||||
if result.OriginalPrice != 1500 {
|
||||
t.Fatalf("OriginalPrice = %d, want 1500", result.OriginalPrice)
|
||||
}
|
||||
if result.PayableBase != 700 {
|
||||
t.Fatalf("PayableBase = %d, want 700", result.PayableBase)
|
||||
}
|
||||
if result.DiscountAmount != 0 {
|
||||
t.Fatalf("DiscountAmount = %d, want 0 when promo applies", result.DiscountAmount)
|
||||
}
|
||||
if result.PromoRuleId != 21 {
|
||||
t.Fatalf("PromoRuleId = %d, want 21", result.PromoRuleId)
|
||||
}
|
||||
if result.PromoDiscount != 800 {
|
||||
t.Fatalf("PromoDiscount = %d, want 800", result.PromoDiscount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateRenewalPriceFallsBackToRegularDiscountWhenPromoMisses(t *testing.T) {
|
||||
model := &fakePromoModel{rules: nil}
|
||||
svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}
|
||||
|
||||
result, err := calculateRenewalPrice(
|
||||
context.Background(),
|
||||
svcCtx,
|
||||
42,
|
||||
2,
|
||||
500,
|
||||
3,
|
||||
[]types.SubscribeDiscount{{Quantity: 3, Discount: 80}},
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calculateRenewalPrice returned error: %v", err)
|
||||
}
|
||||
if result.OriginalPrice != 1500 {
|
||||
t.Fatalf("OriginalPrice = %d, want 1500", result.OriginalPrice)
|
||||
}
|
||||
if result.PayableBase != 1200 {
|
||||
t.Fatalf("PayableBase = %d, want 1200", result.PayableBase)
|
||||
}
|
||||
if result.DiscountAmount != 300 {
|
||||
t.Fatalf("DiscountAmount = %d, want 300", result.DiscountAmount)
|
||||
}
|
||||
if result.PromoRuleId != 0 || result.PromoDiscount != 0 {
|
||||
t.Fatalf("promo fields = (%d, %d), want (0, 0)", result.PromoRuleId, result.PromoDiscount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package order
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -164,28 +165,13 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
priceResult, err := calculateRenewalPrice(
|
||||
l.ctx,
|
||||
l.svcCtx,
|
||||
entitlement.EffectiveUserID,
|
||||
userSubscribe.SubscribeId,
|
||||
sub.UnitPrice,
|
||||
req.Quantity,
|
||||
newUserDiscount.Discounts,
|
||||
newUserDiscount.EligibleForDiscount,
|
||||
)
|
||||
if err != nil {
|
||||
l.Errorw("[Renewal] Promo price calculation error",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", u.Id),
|
||||
logger.Field("effective_user_id", entitlement.EffectiveUserID),
|
||||
logger.Field("subscribe_id", userSubscribe.SubscribeId),
|
||||
)
|
||||
return nil, err
|
||||
var discount float64 = 1
|
||||
if len(newUserDiscount.Discounts) > 0 {
|
||||
discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount)
|
||||
}
|
||||
price := priceResult.OriginalPrice
|
||||
amount := priceResult.PayableBase
|
||||
discountAmount := priceResult.DiscountAmount
|
||||
price := sub.UnitPrice * req.Quantity
|
||||
amount := int64(math.Round(float64(price) * discount))
|
||||
discountAmount := price - amount
|
||||
|
||||
// Validate amount to prevent overflow
|
||||
if amount > MaxOrderAmount {
|
||||
@@ -283,8 +269,6 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
|
||||
Amount: amount,
|
||||
GiftAmount: deductionAmount,
|
||||
Discount: discountAmount,
|
||||
PromoRuleId: priceResult.PromoRuleId,
|
||||
PromoDiscount: priceResult.PromoDiscount,
|
||||
Coupon: req.Coupon,
|
||||
CouponDiscount: coupon,
|
||||
PaymentId: payment.Id,
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -56,10 +57,13 @@ func (l *CancelWithdrawalLogic) CancelWithdrawal(req *types.CancelWithdrawalRequ
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "cancel withdrawal failed: %v", txErr)
|
||||
}
|
||||
|
||||
// Commission was NOT deducted at application time under the HIF-22
|
||||
// approval flow, so cancellation requires no refund — only a status
|
||||
// update. Refunding here would mint phantom commission and pollute
|
||||
// reconciliation (mirrors rejectWithdrawal in admin/user/withdrawalCommon.go).
|
||||
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
|
||||
})
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestCancelWithdrawal_DoesNotRefundCommission 验证 HIF-140 修复:
|
||||
// 用户撤销 pending 提现的事务体必须只更新 withdrawal.status,
|
||||
// 严禁触发 UpdateCommission(commission 列读 / 写)或写 333/338 commission 日志。
|
||||
//
|
||||
// sqlmock 严格匹配期望 SQL:只允许出现 BEGIN / SELECT withdrawal FOR UPDATE /
|
||||
// UPDATE withdrawal SET status / COMMIT,不允许出现 SELECT/UPDATE `user` 或
|
||||
// INSERT system_logs。
|
||||
func TestCancelWithdrawal_DoesNotRefundCommission(t *testing.T) {
|
||||
const (
|
||||
withdrawalID = int64(987654)
|
||||
userID = int64(42)
|
||||
amount = int64(2500)
|
||||
)
|
||||
|
||||
db, mock, cleanup := newCancelWithdrawalTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `withdrawals`").
|
||||
WithArgs(withdrawalID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
|
||||
AddRow(withdrawalID, userID, amount, usermodel.WithdrawalStatusPending))
|
||||
mock.ExpectExec("UPDATE `withdrawals` SET").
|
||||
WithArgs(usermodel.WithdrawalStatusCancelled, sqlmock.AnyArg(), withdrawalID, usermodel.WithdrawalStatusPending).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
logic := newTestCancelWithdrawalLogic(t, db, userID)
|
||||
resp, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
|
||||
if err != nil {
|
||||
t.Fatalf("CancelWithdrawal unexpected error: %v", err)
|
||||
}
|
||||
if resp == nil || resp.Id != withdrawalID {
|
||||
t.Fatalf("CancelWithdrawal response = %+v, want id=%d", resp, withdrawalID)
|
||||
}
|
||||
if resp.Status != usermodel.WithdrawalStatusCancelled {
|
||||
t.Fatalf("CancelWithdrawal status = %d, want %d", resp.Status, usermodel.WithdrawalStatusCancelled)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelWithdrawal_RejectsNonPending 覆盖:状态非 pending(已批准/拒绝/已撤销)
|
||||
// 时撤销必须短路返回 WithdrawalStatusInvalid,且不得写任何状态或佣金。
|
||||
func TestCancelWithdrawal_RejectsNonPending(t *testing.T) {
|
||||
const (
|
||||
withdrawalID = int64(55555)
|
||||
userID = int64(42)
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status uint8
|
||||
}{
|
||||
{"already approved", usermodel.WithdrawalStatusApproved},
|
||||
{"already rejected", usermodel.WithdrawalStatusRejected},
|
||||
{"already cancelled", usermodel.WithdrawalStatusCancelled},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, mock, cleanup := newCancelWithdrawalTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `withdrawals`").
|
||||
WithArgs(withdrawalID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
|
||||
AddRow(withdrawalID, userID, int64(2500), tc.status))
|
||||
mock.ExpectRollback()
|
||||
|
||||
logic := newTestCancelWithdrawalLogic(t, db, userID)
|
||||
_, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
|
||||
if !isCancelErrCode(err, xerr.WithdrawalStatusInvalid) {
|
||||
t.Fatalf("CancelWithdrawal err = %v, want WithdrawalStatusInvalid", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelWithdrawal_RejectsOtherUserWithdrawal 覆盖:当前登录用户尝试撤销
|
||||
// 不属于自己的 pending 提现,必须返回 PermissionDenied 且不得写库。
|
||||
func TestCancelWithdrawal_RejectsOtherUserWithdrawal(t *testing.T) {
|
||||
const (
|
||||
withdrawalID = int64(33333)
|
||||
ownerUserID = int64(99)
|
||||
attackerID = int64(42)
|
||||
)
|
||||
|
||||
db, mock, cleanup := newCancelWithdrawalTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `withdrawals`").
|
||||
WithArgs(withdrawalID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
|
||||
AddRow(withdrawalID, ownerUserID, int64(2500), usermodel.WithdrawalStatusPending))
|
||||
mock.ExpectRollback()
|
||||
|
||||
logic := newTestCancelWithdrawalLogic(t, db, attackerID)
|
||||
_, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
|
||||
if !isCancelErrCode(err, xerr.PermissionDenied) {
|
||||
t.Fatalf("CancelWithdrawal err = %v, want PermissionDenied", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelWithdrawal_ConcurrentRaceLosesViaFORUPDATE 模拟撤销与审批并发的场景:
|
||||
// 第二个 cancel 在 LoadPendingWithdrawalForUpdate 取到行时,状态已被先到的 approve
|
||||
// 改成 1(FOR UPDATE 行锁让出后看到的最新状态),cancel 应当短路返回错误,
|
||||
// 严禁继续往下写 status 或动 commission。
|
||||
func TestCancelWithdrawal_ConcurrentRaceLosesViaFORUPDATE(t *testing.T) {
|
||||
const (
|
||||
withdrawalID = int64(77777)
|
||||
userID = int64(42)
|
||||
)
|
||||
|
||||
db, mock, cleanup := newCancelWithdrawalTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `withdrawals`").
|
||||
WithArgs(withdrawalID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
|
||||
AddRow(withdrawalID, userID, int64(2500), usermodel.WithdrawalStatusApproved))
|
||||
mock.ExpectRollback()
|
||||
|
||||
logic := newTestCancelWithdrawalLogic(t, db, userID)
|
||||
_, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
|
||||
if !isCancelErrCode(err, xerr.WithdrawalStatusInvalid) {
|
||||
t.Fatalf("CancelWithdrawal err = %v, want WithdrawalStatusInvalid (loser of race)", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newCancelWithdrawalTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||
if strings.Contains(actualSQL, expectedSQL) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
|
||||
return db, mock, func() {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func newTestCancelWithdrawalLogic(t *testing.T, db *gorm.DB, userID int64) *CancelWithdrawalLogic {
|
||||
t.Helper()
|
||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID})
|
||||
return &CancelWithdrawalLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{
|
||||
DB: db,
|
||||
UserModel: stubUserModelForCancel{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// stubUserModelForCancel 是 user.Model 的零依赖替身,仅覆盖 CancelWithdrawal 必须
|
||||
// 调用的 ClearUserCache。其它方法被调用会导致 nil-interface panic,正好可以暴露
|
||||
// 测试边界外的意外依赖。
|
||||
type stubUserModelForCancel struct {
|
||||
usermodel.Model
|
||||
}
|
||||
|
||||
func (stubUserModelForCancel) ClearUserCache(_ context.Context, _ ...*usermodel.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cancelErrCodeOf(err error) uint32 {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
type coder interface {
|
||||
GetErrCode() uint32
|
||||
}
|
||||
cause := errors.Cause(err)
|
||||
if c, ok := cause.(coder); ok {
|
||||
return c.GetErrCode()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isCancelErrCode(err error, code uint32) bool {
|
||||
return cancelErrCodeOf(err) == code
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type CommissionWithdrawLogic struct {
|
||||
@@ -29,7 +30,7 @@ func NewCommissionWithdrawLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
||||
}
|
||||
|
||||
func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdrawRequest) (resp *types.WithdrawalLog, err error) {
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
ctxUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
logger.Error("current user is not found in context")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
@@ -51,28 +52,38 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = ?", 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)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to query pending withdrawals for user %d", u.Id)
|
||||
}
|
||||
|
||||
if u.Commission < req.Amount+pendingTotal {
|
||||
logger.Errorf("User %d insufficient available commission: total=%d pending=%d requested=%d",
|
||||
u.Id, u.Commission, pendingTotal, req.Amount)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", u.Id)
|
||||
}
|
||||
|
||||
// HIF-139: read commission and pending total directly from DB inside a
|
||||
// transaction, FOR UPDATE on the user row. The ctxUser snapshot may be
|
||||
// served from cache and can be stale (the original bug allowed a user
|
||||
// with cached commission=996900 to submit a withdrawal while DB said 0,
|
||||
// which then failed admin approval with 20010). Approve already locks
|
||||
// the user row this way; aligning submission closes the gap.
|
||||
var w user.Withdrawal
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var dbUser user.User
|
||||
if txErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", ctxUser.Id).First(&dbUser).Error; txErr != nil {
|
||||
l.Errorf("Failed to lock user %d for withdrawal: %v", ctxUser.Id, txErr)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to lock user %d: %v", ctxUser.Id, txErr)
|
||||
}
|
||||
|
||||
var pendingTotal int64
|
||||
if txErr := tx.Model(&user.Withdrawal{}).
|
||||
Where("user_id = ? AND status = ?", ctxUser.Id, user.WithdrawalStatusPending).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&pendingTotal).Error; txErr != nil {
|
||||
l.Errorf("Failed to query pending withdrawals for user %d: %v", ctxUser.Id, txErr)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to query pending withdrawals for user %d", ctxUser.Id)
|
||||
}
|
||||
|
||||
if dbUser.Commission < req.Amount+pendingTotal {
|
||||
logger.Errorf("User %d insufficient available commission: db_commission=%d pending=%d requested=%d",
|
||||
ctxUser.Id, dbUser.Commission, pendingTotal, req.Amount)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", ctxUser.Id)
|
||||
}
|
||||
|
||||
w = user.Withdrawal{
|
||||
UserId: u.Id,
|
||||
UserId: ctxUser.Id,
|
||||
Amount: req.Amount,
|
||||
Content: req.Content,
|
||||
Status: user.WithdrawalStatusPending,
|
||||
@@ -81,16 +92,19 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
||||
Account: req.Account,
|
||||
QrCodeUrl: req.QrCodeUrl,
|
||||
}
|
||||
return tx.Create(&w).Error
|
||||
if txErr := tx.Create(&w).Error; txErr != nil {
|
||||
l.Errorf("Failed to create withdrawal for user %d: %v", ctxUser.Id, txErr)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", ctxUser.Id, txErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorf("Failed to create withdrawal for user %d: %v", u.Id, err)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", u.Id, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.WithdrawalLog{
|
||||
Id: w.Id,
|
||||
UserId: u.Id,
|
||||
UserId: ctxUser.Id,
|
||||
Amount: req.Amount,
|
||||
Content: req.Content,
|
||||
Status: user.WithdrawalStatusPending,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
modeluser "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/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestCommissionWithdraw_StaleCacheRejected 覆盖 HIF-139 修复:
|
||||
// ctxUser(来自 auth middleware 的 cache-aside FindOne)即使 Commission=996900,
|
||||
// 只要事务内 FOR UPDATE 读出的 DB 真值 Commission < 申请金额 + pendingTotal,
|
||||
// 申请就必须被拒(UserCommissionNotEnough),且不得 INSERT 任何 withdrawal。
|
||||
func TestCommissionWithdraw_StaleCacheRejected(t *testing.T) {
|
||||
const userID = int64(510)
|
||||
|
||||
db, mock, cleanup := newWithdrawTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
// FOR UPDATE 锁 user 行 → DB 真值 commission=0
|
||||
mock.ExpectQuery("FROM `user`").
|
||||
WithArgs(userID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(0)))
|
||||
// pendingTotal=0
|
||||
mock.ExpectQuery("FROM `withdrawals`").
|
||||
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(0)))
|
||||
// 校验失败 → ROLLBACK,不得 INSERT
|
||||
mock.ExpectRollback()
|
||||
|
||||
// ctxUser 故意带一个虚高 commission,模拟陈旧 cache
|
||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 996900})
|
||||
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
||||
Amount: 3000,
|
||||
Method: modeluser.WithdrawalMethodBank,
|
||||
Account: "222",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("CommissionWithdraw expected error, got nil")
|
||||
}
|
||||
if !isWithdrawErrCode(err, xerr.UserCommissionNotEnough) {
|
||||
t.Fatalf("CommissionWithdraw error code = %v, want UserCommissionNotEnough; raw=%v", withdrawErrCodeOf(err), err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommissionWithdraw_HappyPath 覆盖申请成功路径:DB 真值充足 → INSERT withdrawal。
|
||||
func TestCommissionWithdraw_HappyPath(t *testing.T) {
|
||||
const userID = int64(72)
|
||||
|
||||
db, mock, cleanup := newWithdrawTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `user`").
|
||||
WithArgs(userID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(10000)))
|
||||
mock.ExpectQuery("FROM `withdrawals`").
|
||||
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(2000)))
|
||||
// 10000 >= 3000 + 2000 → INSERT
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `withdrawals`")).
|
||||
WillReturnResult(sqlmock.NewResult(99, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 10000})
|
||||
resp, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
||||
Amount: 3000,
|
||||
Method: modeluser.WithdrawalMethodBank,
|
||||
Account: "acc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CommissionWithdraw unexpected error: %v", err)
|
||||
}
|
||||
if resp == nil || resp.Amount != 3000 || resp.Status != modeluser.WithdrawalStatusPending {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommissionWithdraw_PendingTotalExhausts 覆盖 pendingTotal 把可用额度吃光的场景:
|
||||
// DB commission=5000、pending=4000、申请=2000 → 5000 < 6000 → 20010,不得 INSERT。
|
||||
func TestCommissionWithdraw_PendingTotalExhausts(t *testing.T) {
|
||||
const userID = int64(88)
|
||||
|
||||
db, mock, cleanup := newWithdrawTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `user`").
|
||||
WithArgs(userID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(5000)))
|
||||
mock.ExpectQuery("FROM `withdrawals`").
|
||||
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(4000)))
|
||||
mock.ExpectRollback()
|
||||
|
||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 5000})
|
||||
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
||||
Amount: 2000,
|
||||
Method: modeluser.WithdrawalMethodBank,
|
||||
Account: "acc",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("CommissionWithdraw expected error, got nil")
|
||||
}
|
||||
if !isWithdrawErrCode(err, xerr.UserCommissionNotEnough) {
|
||||
t.Fatalf("error code = %v, want UserCommissionNotEnough; raw=%v", withdrawErrCodeOf(err), err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommissionWithdraw_LockUserMissing 覆盖事务内 FOR UPDATE 找不到 user 的场景(user 已删/不存在)→ DatabaseQueryError。
|
||||
func TestCommissionWithdraw_LockUserMissing(t *testing.T) {
|
||||
const userID = int64(999999)
|
||||
|
||||
db, mock, cleanup := newWithdrawTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `user`").
|
||||
WithArgs(userID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}))
|
||||
mock.ExpectRollback()
|
||||
|
||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 999})
|
||||
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
||||
Amount: 100,
|
||||
Method: modeluser.WithdrawalMethodBank,
|
||||
Account: "acc",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("CommissionWithdraw expected error, got nil")
|
||||
}
|
||||
if !isWithdrawErrCode(err, xerr.DatabaseQueryError) {
|
||||
t.Fatalf("error code = %v, want DatabaseQueryError; raw=%v", withdrawErrCodeOf(err), err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newWithdrawTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||
if strings.Contains(actualSQL, expectedSQL) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
|
||||
return db, mock, func() {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func newTestCommissionWithdrawLogic(t *testing.T, db *gorm.DB, ctxUser *modeluser.User) *CommissionWithdrawLogic {
|
||||
t.Helper()
|
||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, ctxUser)
|
||||
return &CommissionWithdrawLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawErrCodeOf(err error) uint32 {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
type coder interface {
|
||||
GetErrCode() uint32
|
||||
}
|
||||
cause := errors.Cause(err)
|
||||
if c, ok := cause.(coder); ok {
|
||||
return c.GetErrCode()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isWithdrawErrCode(err error, code uint32) bool {
|
||||
return withdrawErrCodeOf(err) == code
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type commissionReturnLogRecord struct {
|
||||
LogID int64
|
||||
UserID int64
|
||||
Amount int64
|
||||
EventType uint16
|
||||
Content string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
type QueryCommissionReturnLogLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// NewQueryCommissionReturnLogLogic Query Commission Return Log
|
||||
func NewQueryCommissionReturnLogLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryCommissionReturnLogLogic {
|
||||
return &QueryCommissionReturnLogLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *QueryCommissionReturnLogLogic) QueryCommissionReturnLog(req *types.QueryCommissionReturnLogRequest) (*types.QueryCommissionReturnLogResponse, 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")
|
||||
}
|
||||
|
||||
page, size := normalizePagination(req.Page, req.Size)
|
||||
list, total, err := l.queryCommissionReturnLogRecords(u.Id, page, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
respList := make([]types.CommissionReturnLog, 0, len(list))
|
||||
for _, item := range list {
|
||||
respList = append(respList, types.CommissionReturnLog{
|
||||
Id: item.LogID,
|
||||
UserId: item.UserID,
|
||||
Amount: item.Amount,
|
||||
EventType: item.EventType,
|
||||
Content: item.Content,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return &types.QueryCommissionReturnLogResponse{
|
||||
List: respList,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizePagination(page, size int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 10
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
|
||||
func (l *QueryCommissionReturnLogLogic) queryCommissionReturnLogRecords(userID int64, page, size int) ([]commissionReturnLogRecord, int64, error) {
|
||||
return l.queryCommissionReturnLogRecordsByEventTypes(userID, page, size,
|
||||
log.CommissionTypeRefund,
|
||||
log.CommissionTypeWithdrawReject,
|
||||
log.CommissionTypeWithdrawCancel,
|
||||
)
|
||||
}
|
||||
|
||||
func (l *QueryCommissionReturnLogLogic) queryCommissionReturnLogRecordsByEventTypes(userID int64, page, size int, eventTypes ...uint16) ([]commissionReturnLogRecord, int64, error) {
|
||||
if len(eventTypes) == 0 {
|
||||
return []commissionReturnLogRecord{}, 0, nil
|
||||
}
|
||||
|
||||
likeClauses := make([]string, 0, len(eventTypes))
|
||||
args := make([]interface{}, 0, len(eventTypes)+2)
|
||||
args = append(args, log.TypeCommission.Uint8(), userID)
|
||||
for _, eventType := range eventTypes {
|
||||
likeClauses = append(likeClauses, "`content` LIKE ?")
|
||||
args = append(args, commissionReturnLogEventTypePattern(eventType))
|
||||
}
|
||||
query := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&log.SystemLog{}).
|
||||
Where("`type` = ? AND object_id = ? AND ("+strings.Join(likeClauses, " OR ")+")", args...)
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count commission return logs failed: %v", err)
|
||||
}
|
||||
|
||||
var rows []log.SystemLog
|
||||
if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query commission return logs failed: %v", err)
|
||||
}
|
||||
|
||||
list := make([]commissionReturnLogRecord, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
var content log.Commission
|
||||
if err := content.Unmarshal([]byte(row.Content)); err != nil {
|
||||
l.Errorw("unmarshal commission return log content failed",
|
||||
logger.Field("log_id", row.Id),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if !isCommissionReturnEventType(content.Type) {
|
||||
continue
|
||||
}
|
||||
|
||||
// content.Timestamp 历史数据为毫秒,按项目约定(统一秒级)转换;为 0 时回退到 row.CreatedAt 秒值
|
||||
var timestamp int64
|
||||
if content.Timestamp > 0 {
|
||||
timestamp = content.Timestamp / 1000
|
||||
} else {
|
||||
timestamp = row.CreatedAt.Unix()
|
||||
}
|
||||
list = append(list, commissionReturnLogRecord{
|
||||
LogID: row.Id,
|
||||
UserID: row.ObjectID,
|
||||
Amount: content.Amount,
|
||||
EventType: content.Type,
|
||||
Content: commissionReturnLogContentText(content.Type, content.OrderNo),
|
||||
CreatedAt: timestamp,
|
||||
UpdatedAt: timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
func commissionReturnLogEventTypePattern(eventType uint16) string {
|
||||
return "%\"type\":" + strconv.FormatUint(uint64(eventType), 10) + "%"
|
||||
}
|
||||
|
||||
func isCommissionReturnEventType(eventType uint16) bool {
|
||||
switch eventType {
|
||||
case log.CommissionTypeRefund, log.CommissionTypeWithdrawReject, log.CommissionTypeWithdrawCancel:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// commissionReturnLogContentText 把佣金回退事件的原始 JSON 转成前端友好文字。
|
||||
// 333 订单退款回佣 - 附订单号便于追溯;337/338 提现相关 - 跟订单无关。
|
||||
func commissionReturnLogContentText(eventType uint16, orderNo string) string {
|
||||
switch eventType {
|
||||
case log.CommissionTypeRefund:
|
||||
if strings.TrimSpace(orderNo) != "" {
|
||||
return "订单退款回佣(订单号 " + orderNo + ")"
|
||||
}
|
||||
return "订单退款回佣"
|
||||
case log.CommissionTypeWithdrawReject:
|
||||
return "提现申请被驳回,佣金已退回"
|
||||
case log.CommissionTypeWithdrawCancel:
|
||||
return "已取消提现,佣金已退回"
|
||||
default:
|
||||
return "佣金调整"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package user
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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"
|
||||
@@ -13,11 +12,6 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
withdrawalLogBizTypeWithdrawal = "withdrawal"
|
||||
withdrawalLogBizTypeCommissionRefund = "commission_refund"
|
||||
)
|
||||
|
||||
type QueryWithdrawalLogLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
@@ -40,28 +34,24 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
page, size := normalizePagination(req.Page, req.Size)
|
||||
|
||||
switch req.BizType {
|
||||
case "", withdrawalLogBizTypeWithdrawal:
|
||||
return l.queryWithdrawalLogs(u.Id, page, size)
|
||||
case withdrawalLogBizTypeCommissionRefund:
|
||||
return l.queryCommissionRefundLogs(u.Id, page, size)
|
||||
default:
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "invalid biz_type: %s", req.BizType)
|
||||
page := req.Page
|
||||
size := req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 10
|
||||
}
|
||||
}
|
||||
|
||||
func (l *QueryWithdrawalLogLogic) queryWithdrawalLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) {
|
||||
query := l.svcCtx.DB.WithContext(l.ctx).Model(&user.Withdrawal{}).Where("user_id = ?", userID)
|
||||
query := l.svcCtx.DB.WithContext(l.ctx).Model(&user.Withdrawal{}).Where("user_id = ?", u.Id)
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
if err = query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count withdrawal logs failed: %v", err)
|
||||
}
|
||||
|
||||
var rows []user.Withdrawal
|
||||
if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
if err = query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query withdrawal logs failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -69,7 +59,6 @@ func (l *QueryWithdrawalLogLogic) queryWithdrawalLogs(userID int64, page, size i
|
||||
for _, row := range rows {
|
||||
list = append(list, types.WithdrawalLog{
|
||||
Id: row.Id,
|
||||
BizType: withdrawalLogBizTypeWithdrawal,
|
||||
UserId: row.UserId,
|
||||
Amount: row.Amount,
|
||||
Content: row.Content,
|
||||
@@ -78,89 +67,8 @@ func (l *QueryWithdrawalLogLogic) queryWithdrawalLogs(userID int64, page, size i
|
||||
Method: row.Method,
|
||||
Account: row.Account,
|
||||
QrCodeUrl: row.QrCodeUrl,
|
||||
CreatedAt: row.CreatedAt.Unix(),
|
||||
UpdatedAt: row.UpdatedAt.Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
summary, err := l.buildSummary(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.QueryWithdrawalLogListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Summary: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildSummary 聚合用户佣金账目快照,供前端展示闭环对账
|
||||
//
|
||||
// commission_balance = user.commission(当前余额,由 system_logs 累计而来)
|
||||
// locked_by_pending = 待审批提现占用
|
||||
// available_to_withdraw = balance - locked
|
||||
// total_historical_amount = 已通过提现累计(status=1)
|
||||
// total_refunded_amount = 佣金回扣累计(type=333/337/338)
|
||||
// total_income_amount = 佣金收入累计(type=331/332)
|
||||
func (l *QueryWithdrawalLogLogic) buildSummary(userID int64) (*types.WithdrawalLogSummary, error) {
|
||||
db := l.svcCtx.DB.WithContext(l.ctx)
|
||||
var summary types.WithdrawalLogSummary
|
||||
|
||||
if err := db.Model(&user.User{}).Where("id = ?", userID).
|
||||
Select("COALESCE(commission, 0)").Scan(&summary.CommissionBalance).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load commission balance failed: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Model(&user.Withdrawal{}).
|
||||
Where("user_id = ? AND status = ?", userID, user.WithdrawalStatusPending).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&summary.LockedByPending).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "sum pending withdrawals failed: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Model(&user.Withdrawal{}).
|
||||
Where("user_id = ? AND status = ?", userID, user.WithdrawalStatusApproved).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&summary.TotalHistoricalAmount).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "sum approved withdrawals failed: %v", err)
|
||||
}
|
||||
|
||||
row := db.Raw(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN CAST(JSON_EXTRACT(content,'$.type') AS UNSIGNED) IN (?,?)
|
||||
THEN CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED) ELSE 0 END), 0) AS income,
|
||||
COALESCE(SUM(CASE WHEN CAST(JSON_EXTRACT(content,'$.type') AS UNSIGNED) IN (?,?,?)
|
||||
THEN CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED) ELSE 0 END), 0) AS refund
|
||||
FROM system_logs
|
||||
WHERE type = ? AND object_id = ?`,
|
||||
log.CommissionTypePurchase, log.CommissionTypeRenewal,
|
||||
log.CommissionTypeRefund, log.CommissionTypeWithdrawReject, log.CommissionTypeWithdrawCancel,
|
||||
log.TypeCommission.Uint8(), userID,
|
||||
).Row()
|
||||
if err := row.Scan(&summary.TotalIncomeAmount, &summary.TotalRefundedAmount); err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "aggregate commission logs failed: %v", err)
|
||||
}
|
||||
|
||||
summary.AvailableToWithdraw = summary.CommissionBalance - summary.LockedByPending
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (l *QueryWithdrawalLogLogic) queryCommissionRefundLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) {
|
||||
queryLogic := NewQueryCommissionReturnLogLogic(l.ctx, l.svcCtx)
|
||||
rows, total, err := queryLogic.queryCommissionReturnLogRecordsByEventTypes(userID, page, size, log.CommissionTypeRefund)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := make([]types.WithdrawalLog, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
list = append(list, types.WithdrawalLog{
|
||||
Id: row.LogID,
|
||||
BizType: withdrawalLogBizTypeCommissionRefund,
|
||||
UserId: row.UserID,
|
||||
Amount: row.Amount,
|
||||
Content: row.Content,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
CreatedAt: row.CreatedAt.UnixMilli(),
|
||||
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) {
|
||||
const userID = int64(42)
|
||||
createdAt := time.Unix(1700000000, 0)
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
|
||||
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("SELECT count(*) FROM `withdrawals` WHERE user_id = ?").
|
||||
WithArgs(userID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery("SELECT * FROM `withdrawals` WHERE user_id = ? ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(userID, 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "user_id", "amount", "content", "status", "reason", "method", "account", "qr_code_url", "created_at", "updated_at",
|
||||
}).AddRow(
|
||||
int64(1001), userID, int64(3000), "bank withdrawal", usermodel.WithdrawalStatusPending, "", uint8(3), "acct-001", "", createdAt, updatedAt,
|
||||
))
|
||||
// buildSummary 触发的 4 个聚合查询
|
||||
mock.ExpectQuery("SELECT COALESCE(commission, 0) FROM `user`").
|
||||
WithArgs(userID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"commission"}).AddRow(int64(5000)))
|
||||
mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`").
|
||||
WithArgs(userID, usermodel.WithdrawalStatusPending).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(3000)))
|
||||
mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`").
|
||||
WithArgs(userID, usermodel.WithdrawalStatusApproved).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(0)))
|
||||
mock.ExpectQuery("FROM system_logs").
|
||||
WithArgs(
|
||||
logmodel.CommissionTypePurchase, logmodel.CommissionTypeRenewal,
|
||||
logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel,
|
||||
logmodel.TypeCommission.Uint8(), userID,
|
||||
).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"income", "refund"}).AddRow(int64(8000), int64(0)))
|
||||
|
||||
logic := newTestQueryWithdrawalLogLogic(t, db, userID)
|
||||
resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{
|
||||
BizType: withdrawalLogBizTypeWithdrawal,
|
||||
Page: 1,
|
||||
Size: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryWithdrawalLog unexpected error: %v", err)
|
||||
}
|
||||
if resp.Total != 1 || len(resp.List) != 1 {
|
||||
t.Fatalf("QueryWithdrawalLog response = %+v, want one withdrawal", resp)
|
||||
}
|
||||
got := resp.List[0]
|
||||
if got.BizType != withdrawalLogBizTypeWithdrawal {
|
||||
t.Fatalf("BizType = %q, want %q", got.BizType, withdrawalLogBizTypeWithdrawal)
|
||||
}
|
||||
if got.Id != 1001 || got.UserId != userID || got.Amount != 3000 || got.CreatedAt != createdAt.Unix() || got.UpdatedAt != updatedAt.Unix() {
|
||||
t.Fatalf("withdrawal item = %+v", got)
|
||||
}
|
||||
if resp.Summary == nil {
|
||||
t.Fatalf("expected summary, got nil")
|
||||
}
|
||||
if resp.Summary.CommissionBalance != 5000 || resp.Summary.LockedByPending != 3000 ||
|
||||
resp.Summary.AvailableToWithdraw != 2000 || resp.Summary.TotalIncomeAmount != 8000 {
|
||||
t.Fatalf("summary = %+v, want balance=5000 locked=3000 avail=2000 income=8000", resp.Summary)
|
||||
}
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestQueryCommissionReturnLog_HappyPathIncludes333337338(t *testing.T) {
|
||||
const userID = int64(42)
|
||||
createdAt := time.Unix(1700000000, 0)
|
||||
|
||||
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnQueries(mock, userID, 3, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, createdAt).
|
||||
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt))
|
||||
|
||||
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
|
||||
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
|
||||
}
|
||||
if resp.Total != 3 || len(resp.List) != 3 {
|
||||
t.Fatalf("QueryCommissionReturnLog response = %+v, want three logs", resp)
|
||||
}
|
||||
|
||||
eventTypes := []uint16{resp.List[0].EventType, resp.List[1].EventType, resp.List[2].EventType}
|
||||
wantTypes := []uint16{logmodel.CommissionTypeWithdrawCancel, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeRefund}
|
||||
if fmt.Sprint(eventTypes) != fmt.Sprint(wantTypes) {
|
||||
t.Fatalf("event types = %v, want %v", eventTypes, wantTypes)
|
||||
}
|
||||
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestQueryWithdrawalLog_WithCommissionRefundBizTypeOnlyIncludes333(t *testing.T) {
|
||||
const userID = int64(42)
|
||||
createdAt := time.Unix(1700000000, 0)
|
||||
|
||||
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnQueries(mock, userID, 1, logmodel.CommissionTypeRefund)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt))
|
||||
|
||||
logic := newTestQueryWithdrawalLogLogic(t, db, userID)
|
||||
resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{
|
||||
BizType: withdrawalLogBizTypeCommissionRefund,
|
||||
Page: 1,
|
||||
Size: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryWithdrawalLog unexpected error: %v", err)
|
||||
}
|
||||
if resp.Total != 1 || len(resp.List) != 1 {
|
||||
t.Fatalf("QueryWithdrawalLog response = %+v, want one commission refund", resp)
|
||||
}
|
||||
for _, item := range resp.List {
|
||||
if item.BizType != withdrawalLogBizTypeCommissionRefund {
|
||||
t.Fatalf("BizType = %q, want %q", item.BizType, withdrawalLogBizTypeCommissionRefund)
|
||||
}
|
||||
if item.Status != 0 || item.Reason != "" || item.Method != 0 || item.Account != "" || item.QrCodeUrl != "" {
|
||||
t.Fatalf("withdrawal-only fields should keep zero values, got %+v", item)
|
||||
}
|
||||
}
|
||||
if resp.List[0].Id != 2001 {
|
||||
t.Fatalf("commission refund item id = %d, want 2001", resp.List[0].Id)
|
||||
}
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestQueryCommissionReturnLog_SkipsInvalidJSONAndLogsWarn(t *testing.T) {
|
||||
const userID = int64(42)
|
||||
createdAt := time.Unix(1700000000, 0)
|
||||
|
||||
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnQueries(mock, userID, 2, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333`, createdAt))
|
||||
|
||||
var buf bytes.Buffer
|
||||
restoreLogger := captureTestLogs(&buf)
|
||||
defer restoreLogger()
|
||||
|
||||
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
|
||||
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
|
||||
}
|
||||
if resp.Total != 2 || len(resp.List) != 1 {
|
||||
t.Fatalf("QueryCommissionReturnLog response = %+v, want total=2 and one valid row", resp)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "unmarshal commission return log content failed") {
|
||||
t.Fatalf("expected warn log, got %q", buf.String())
|
||||
}
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestQueryCommissionReturnLog_FiltersOtherUsersByObjectID(t *testing.T) {
|
||||
const userID = int64(42)
|
||||
|
||||
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnQueries(mock, userID, 0, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}))
|
||||
|
||||
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
|
||||
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
|
||||
}
|
||||
if resp.Total != 0 || len(resp.List) != 0 {
|
||||
t.Fatalf("QueryCommissionReturnLog response = %+v, want no rows for filtered user", resp)
|
||||
}
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestQueryWithdrawalLog_RejectsInvalidBizType(t *testing.T) {
|
||||
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
logic := newTestQueryWithdrawalLogLogic(t, db, 42)
|
||||
_, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{
|
||||
BizType: "all",
|
||||
Page: 1,
|
||||
Size: 10,
|
||||
})
|
||||
if !isQueryWithdrawalLogErrCode(err, xerr.InvalidParams) {
|
||||
t.Fatalf("QueryWithdrawalLog err = %v, want InvalidParams", err)
|
||||
}
|
||||
assertQueryWithdrawalLogExpectations(t, mock)
|
||||
}
|
||||
|
||||
func newQueryWithdrawalLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||
if strings.Contains(actualSQL, expectedSQL) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
|
||||
return db, mock, func() {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func newTestQueryWithdrawalLogLogic(t *testing.T, db *gorm.DB, userID int64) *QueryWithdrawalLogLogic {
|
||||
t.Helper()
|
||||
ctx := newTestQueryCtx(userID)
|
||||
return &QueryWithdrawalLogLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{
|
||||
DB: db,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestQueryCtx(userID int64) context.Context {
|
||||
return context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID})
|
||||
}
|
||||
|
||||
func expectCommissionReturnQueries(mock sqlmock.Sqlmock, userID int64, total int64, eventTypes ...uint16) {
|
||||
query := "SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ?"
|
||||
args := []driver.Value{logmodel.TypeCommission.Uint8(), userID}
|
||||
if len(eventTypes) > 0 {
|
||||
clauses := make([]string, 0, len(eventTypes))
|
||||
for _, eventType := range eventTypes {
|
||||
clauses = append(clauses, "`content` LIKE ?")
|
||||
args = append(args, fmt.Sprintf("%%\"type\":%d%%", eventType))
|
||||
}
|
||||
query += " AND (" + strings.Join(clauses, " OR ") + ")"
|
||||
}
|
||||
mock.ExpectQuery(query).
|
||||
WithArgs(args...).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total))
|
||||
}
|
||||
|
||||
func assertQueryWithdrawalLogExpectations(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func captureTestLogs(buf *bytes.Buffer) func() {
|
||||
prevWriter := logger.Reset()
|
||||
prevLevel := logger.InfoLevel
|
||||
logger.SetLevel(logger.DebugLevel)
|
||||
logger.SetWriter(logger.NewWriter(buf))
|
||||
return func() {
|
||||
logger.Reset()
|
||||
logger.SetLevel(prevLevel)
|
||||
if prevWriter != nil {
|
||||
logger.SetWriter(prevWriter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func queryWithdrawalLogErrCodeOf(err error) uint32 {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
type coder interface {
|
||||
GetErrCode() uint32
|
||||
}
|
||||
cause := errors.Cause(err)
|
||||
if c, ok := cause.(coder); ok {
|
||||
return c.GetErrCode()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isQueryWithdrawalLogErrCode(err error, code uint32) bool {
|
||||
return queryWithdrawalLogErrCodeOf(err) == code
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type GetServerUserListLogic struct {
|
||||
@@ -28,46 +27,6 @@ type GetServerUserListLogic struct {
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
type serverUserListPerfStats struct {
|
||||
serverID int64
|
||||
protocol string
|
||||
cacheHit bool
|
||||
nodesCount int
|
||||
subsCount int
|
||||
usersCount int
|
||||
speedLimitZeroCount int
|
||||
speedLimitPositiveCount int
|
||||
trafficCalcMS int64
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
type serverUserSpeedLimitCandidate struct {
|
||||
userID int64
|
||||
userSubscribeID int64
|
||||
baseSpeed int64
|
||||
trafficLimit string
|
||||
}
|
||||
|
||||
type serverUserTrafficWindow struct {
|
||||
statType string
|
||||
statValue int64
|
||||
start time.Time
|
||||
end time.Time
|
||||
}
|
||||
|
||||
type serverUserTrafficUsageKey struct {
|
||||
userID int64
|
||||
userSubscribeID int64
|
||||
statType string
|
||||
statValue int64
|
||||
}
|
||||
|
||||
type serverUserTrafficUsage struct {
|
||||
userID int64
|
||||
userSubscribeID int64
|
||||
usedGB float64
|
||||
}
|
||||
|
||||
// NewGetServerUserListLogic Get user list
|
||||
func NewGetServerUserListLogic(ctx *gin.Context, svcCtx *svc.ServiceContext) *GetServerUserListLogic {
|
||||
return &GetServerUserListLogic{
|
||||
@@ -78,27 +37,13 @@ func NewGetServerUserListLogic(ctx *gin.Context, svcCtx *svc.ServiceContext) *Ge
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListRequest) (resp *types.GetServerUserListResponse, err error) {
|
||||
startedAt := time.Now()
|
||||
protocolRequest := normalizeServerUserListProtocol(req.Protocol)
|
||||
stats := serverUserListPerfStats{
|
||||
serverID: req.ServerId,
|
||||
protocol: protocolRequest,
|
||||
startedAt: startedAt,
|
||||
cacheHit: false,
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s%d:%s", node.ServerUserListCacheKey, req.ServerId, protocolRequest)
|
||||
cacheKey := fmt.Sprintf("%s%d", node.ServerUserListCacheKey, req.ServerId)
|
||||
cache, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
l.Errorw("[ServerUserListCacheKey] redis get error", logger.Field("error", err.Error()))
|
||||
}
|
||||
if cache != "" {
|
||||
stats.cacheHit = true
|
||||
etag := tool.GenerateETag([]byte(cache))
|
||||
resp = &types.GetServerUserListResponse{}
|
||||
// Check If-None-Match header
|
||||
if match := l.ctx.GetHeader("If-None-Match"); match == etag {
|
||||
l.logServerUserListPerf(stats)
|
||||
return nil, xerr.StatusNotModified
|
||||
}
|
||||
l.ctx.Header("ETag", etag)
|
||||
@@ -107,9 +52,6 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
l.Errorw("[ServerUserListCacheKey] json unmarshal error", logger.Field("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
stats.usersCount = len(resp.Users)
|
||||
stats.recordSpeedLimits(resp.Users)
|
||||
l.logServerUserListPerf(stats)
|
||||
return resp, nil
|
||||
}
|
||||
server, err := l.svcCtx.NodeModel.FindOneServer(l.ctx, req.ServerId)
|
||||
@@ -122,22 +64,14 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
Page: 1,
|
||||
Size: 1000,
|
||||
ServerId: []int64{server.Id},
|
||||
Protocol: protocolRequest,
|
||||
Protocol: req.Protocol,
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorw("FilterNodeList error", logger.Field("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
stats.nodesCount = len(nodes)
|
||||
|
||||
if len(nodes) == 0 {
|
||||
l.Errorw("[ServerUserList] fallback: no nodes matched server+protocol, returning placeholder without cache",
|
||||
logger.Field("server_id", req.ServerId),
|
||||
logger.Field("protocol", req.Protocol),
|
||||
)
|
||||
stats.usersCount = 1
|
||||
stats.speedLimitZeroCount = 1
|
||||
l.logServerUserListPerf(stats)
|
||||
return &types.GetServerUserListResponse{
|
||||
Users: []types.ServerUser{
|
||||
{
|
||||
@@ -205,13 +139,6 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
}
|
||||
|
||||
if len(subs) == 0 {
|
||||
l.Errorw("[ServerUserList] fallback: no subscriptions matched node group/tags, returning placeholder without cache",
|
||||
logger.Field("server_id", req.ServerId),
|
||||
logger.Field("protocol", req.Protocol),
|
||||
)
|
||||
stats.usersCount = 1
|
||||
stats.speedLimitZeroCount = 1
|
||||
l.logServerUserListPerf(stats)
|
||||
return &types.GetServerUserListResponse{
|
||||
Users: []types.ServerUser{
|
||||
{
|
||||
@@ -221,9 +148,7 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
stats.subsCount = len(subs)
|
||||
users := make([]types.ServerUser, 0)
|
||||
speedCandidates := make(map[int64]serverUserSpeedLimitCandidate)
|
||||
for _, sub := range subs {
|
||||
data, err := l.svcCtx.UserModel.FindUsersSubscribeBySubscribeId(l.ctx, sub.Id)
|
||||
if err != nil {
|
||||
@@ -234,63 +159,38 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
continue
|
||||
}
|
||||
|
||||
baseSpeed, trafficLimit := serverUserSpeedLimitInputs(sub, datum)
|
||||
speedCandidates[datum.Id] = serverUserSpeedLimitCandidate{
|
||||
userID: datum.UserId,
|
||||
userSubscribeID: datum.Id,
|
||||
baseSpeed: baseSpeed,
|
||||
trafficLimit: trafficLimit,
|
||||
}
|
||||
// 计算该用户的实际限速值(考虑按量限速规则)
|
||||
effectiveSpeedLimit := l.calculateEffectiveSpeedLimit(sub, datum)
|
||||
|
||||
users = append(users, types.ServerUser{
|
||||
Id: datum.Id,
|
||||
UUID: datum.UUID,
|
||||
SpeedLimit: baseSpeed,
|
||||
SpeedLimit: effectiveSpeedLimit,
|
||||
DeviceLimit: sub.DeviceLimit,
|
||||
})
|
||||
}
|
||||
}
|
||||
trafficCalcStartedAt := time.Now()
|
||||
speedLimits := l.calculateServerUserSpeedLimits(speedCandidates)
|
||||
stats.trafficCalcMS = time.Since(trafficCalcStartedAt).Milliseconds()
|
||||
for i := range users {
|
||||
if speedLimit, ok := speedLimits[users[i].Id]; ok {
|
||||
users[i].SpeedLimit = speedLimit
|
||||
}
|
||||
}
|
||||
|
||||
// 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户。
|
||||
// 用户级 speed_limit (user_subscribe.speed_limit) 与过期节点组 speed_limit
|
||||
// 取更严格的一个 — 0 视为"无限制",正值优先于 0。
|
||||
// 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户
|
||||
if len(nodeGroupIds) > 0 {
|
||||
expiredUsers, expiredSpeedLimit := l.getExpiredUsers(nodeGroupIds)
|
||||
for i := range expiredUsers {
|
||||
expiredUsers[i].SpeedLimit = mergeSpeedLimit(expiredUsers[i].SpeedLimit, expiredSpeedLimit)
|
||||
if expiredSpeedLimit > 0 {
|
||||
expiredUsers[i].SpeedLimit = expiredSpeedLimit
|
||||
}
|
||||
}
|
||||
users = append(users, expiredUsers...)
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
l.Errorw("[ServerUserList] fallback: matched subs returned zero eligible users, returning placeholder without cache",
|
||||
logger.Field("server_id", req.ServerId),
|
||||
logger.Field("protocol", req.Protocol),
|
||||
)
|
||||
stats.usersCount = 1
|
||||
stats.speedLimitZeroCount = 1
|
||||
l.logServerUserListPerf(stats)
|
||||
return &types.GetServerUserListResponse{
|
||||
Users: []types.ServerUser{
|
||||
{
|
||||
Id: 1,
|
||||
UUID: uuidx.NewUUID().String(),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
users = append(users, types.ServerUser{
|
||||
Id: 1,
|
||||
UUID: uuidx.NewUUID().String(),
|
||||
})
|
||||
}
|
||||
resp = &types.GetServerUserListResponse{
|
||||
Users: users,
|
||||
}
|
||||
stats.usersCount = len(users)
|
||||
stats.recordSpeedLimits(users)
|
||||
val, _ := json.Marshal(resp)
|
||||
etag := tool.GenerateETag(val)
|
||||
l.ctx.Header("ETag", etag)
|
||||
@@ -300,24 +200,11 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
}
|
||||
// Check If-None-Match header
|
||||
if match := l.ctx.GetHeader("If-None-Match"); match == etag {
|
||||
l.logServerUserListPerf(stats)
|
||||
return nil, xerr.StatusNotModified
|
||||
}
|
||||
l.logServerUserListPerf(stats)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// normalizeServerUserListProtocol 将客户端可能携带的 hysteria2 兼容字段映射回
|
||||
// DB 中存储的规范名 "hysteria"。其它协议原样返回。
|
||||
// 缓存 key 与 FilterNodeList 查询都必须用归一化后的值,
|
||||
// 否则 hysteria2 永远查不到节点,永远走兜底。
|
||||
func normalizeServerUserListProtocol(protocol string) string {
|
||||
if protocol == Hysteria2 {
|
||||
return Hysteria
|
||||
}
|
||||
return protocol
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) serverUserListCacheTTL() time.Duration {
|
||||
pullInterval := l.svcCtx.Config.Node.NodePullInterval
|
||||
if pullInterval <= 0 {
|
||||
@@ -369,34 +256,14 @@ func (l *GetServerUserListLogic) getExpiredUsers(serverNodeGroupIds []int64) ([]
|
||||
}
|
||||
seen[userSub.Id] = true
|
||||
users = append(users, types.ServerUser{
|
||||
Id: userSub.Id,
|
||||
UUID: userSub.UUID,
|
||||
SpeedLimit: userSub.SpeedLimit,
|
||||
Id: userSub.Id,
|
||||
UUID: userSub.UUID,
|
||||
})
|
||||
}
|
||||
|
||||
return users, int64(expiredGroup.SpeedLimit)
|
||||
}
|
||||
|
||||
// mergeSpeedLimit 返回两个速度限制(Mbps)中更严格的一个。
|
||||
// 0 视为"无限制",因此会被任意正值覆盖;都为 0 时返回 0。
|
||||
// 用于用户级 speed_limit 与节点组级 speed_limit 的合并:
|
||||
// - both 0 → 0 (不限速)
|
||||
// - 仅一个 > 0 → 取该值
|
||||
// - both > 0 → 取较小者(更严格)
|
||||
func mergeSpeedLimit(a, b int64) int64 {
|
||||
if a <= 0 {
|
||||
return b
|
||||
}
|
||||
if b <= 0 {
|
||||
return a
|
||||
}
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) checkExpiredUserEligibility(userSub *user.Subscribe, expiredGroup *group.NodeGroup) bool {
|
||||
expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24)
|
||||
if expiredDays > expiredGroup.ExpiredDaysLimit {
|
||||
@@ -438,7 +305,8 @@ func (l *GetServerUserListLogic) canUseExpiredNodeGroup(userSub *user.Subscribe,
|
||||
return true
|
||||
}
|
||||
|
||||
func serverUserSpeedLimitInputs(sub *subscribe.Subscribe, userSub *user.Subscribe) (int64, string) {
|
||||
// calculateEffectiveSpeedLimit 计算用户的实际限速值(考虑按量限速规则)
|
||||
func (l *GetServerUserListLogic) calculateEffectiveSpeedLimit(sub *subscribe.Subscribe, userSub *user.Subscribe) int64 {
|
||||
baseSpeed := sub.SpeedLimit
|
||||
if userSub.SpeedLimit > 0 {
|
||||
baseSpeed = userSub.SpeedLimit
|
||||
@@ -449,170 +317,15 @@ func serverUserSpeedLimitInputs(sub *subscribe.Subscribe, userSub *user.Subscrib
|
||||
trafficLimit = *userSub.TrafficLimit
|
||||
}
|
||||
|
||||
return baseSpeed, trafficLimit
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) calculateServerUserSpeedLimits(candidates map[int64]serverUserSpeedLimitCandidate) map[int64]int64 {
|
||||
speedLimits := make(map[int64]int64, len(candidates))
|
||||
rulesByUserSubscribeID := make(map[int64][]speedlimit.TrafficLimitRule)
|
||||
windowByKey := make(map[string]serverUserTrafficWindow)
|
||||
now := time.Now()
|
||||
|
||||
for userSubscribeID, candidate := range candidates {
|
||||
speedLimits[userSubscribeID] = candidate.baseSpeed
|
||||
if candidate.trafficLimit == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var rules []speedlimit.TrafficLimitRule
|
||||
if err := json.Unmarshal([]byte(candidate.trafficLimit), &rules); err != nil || len(rules) == 0 {
|
||||
continue
|
||||
}
|
||||
rulesByUserSubscribeID[userSubscribeID] = rules
|
||||
for _, rule := range rules {
|
||||
window, ok := serverUserTrafficRuleWindow(rule, now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
windowByKey[serverUserTrafficWindowKey(rule.StatType, rule.StatValue)] = window
|
||||
}
|
||||
}
|
||||
|
||||
if len(rulesByUserSubscribeID) == 0 || len(windowByKey) == 0 {
|
||||
return speedLimits
|
||||
}
|
||||
|
||||
usageByKey := make(map[serverUserTrafficUsageKey]float64)
|
||||
for _, window := range windowByKey {
|
||||
trafficUsage, err := l.queryServerUserTrafficUsage(candidates, window)
|
||||
if err != nil {
|
||||
l.Errorw("[ServerUserList] batch traffic usage query failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("stat_type", window.statType),
|
||||
logger.Field("stat_value", window.statValue),
|
||||
)
|
||||
continue
|
||||
}
|
||||
for _, usage := range trafficUsage {
|
||||
usageByKey[serverUserTrafficUsageKey{
|
||||
userID: usage.userID,
|
||||
userSubscribeID: usage.userSubscribeID,
|
||||
statType: window.statType,
|
||||
statValue: window.statValue,
|
||||
}] = usage.usedGB
|
||||
}
|
||||
}
|
||||
|
||||
for userSubscribeID, rules := range rulesByUserSubscribeID {
|
||||
candidate := candidates[userSubscribeID]
|
||||
for _, rule := range rules {
|
||||
if rule.SpeedLimit <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := serverUserTrafficRuleWindow(rule, now); !ok {
|
||||
continue
|
||||
}
|
||||
usedGB := usageByKey[serverUserTrafficUsageKey{
|
||||
userID: candidate.userID,
|
||||
userSubscribeID: candidate.userSubscribeID,
|
||||
statType: rule.StatType,
|
||||
statValue: rule.StatValue,
|
||||
}]
|
||||
if usedGB < float64(rule.TrafficUsage) {
|
||||
continue
|
||||
}
|
||||
current := speedLimits[userSubscribeID]
|
||||
if current == 0 || rule.SpeedLimit < current {
|
||||
speedLimits[userSubscribeID] = rule.SpeedLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return speedLimits
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) queryServerUserTrafficUsage(
|
||||
candidates map[int64]serverUserSpeedLimitCandidate,
|
||||
window serverUserTrafficWindow,
|
||||
) ([]serverUserTrafficUsage, error) {
|
||||
userSubscribeIDs := make([]int64, 0, len(candidates))
|
||||
for userSubscribeID := range candidates {
|
||||
userSubscribeIDs = append(userSubscribeIDs, userSubscribeID)
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
UserID int64
|
||||
UserSubscribeID int64
|
||||
Upload int64
|
||||
Download int64
|
||||
}
|
||||
err := l.svcCtx.DB.WithContext(l.ctx.Request.Context()).
|
||||
Table("traffic_log").
|
||||
Select("user_id, subscribe_id AS user_subscribe_id, COALESCE(SUM(upload), 0) AS upload, COALESCE(SUM(download), 0) AS download").
|
||||
Where("subscribe_id IN ? AND timestamp >= ? AND timestamp < ?", userSubscribeIDs, window.start, window.end).
|
||||
Group("user_id, subscribe_id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trafficUsage := make([]serverUserTrafficUsage, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
trafficUsage = append(trafficUsage, serverUserTrafficUsage{
|
||||
userID: row.UserID,
|
||||
userSubscribeID: row.UserSubscribeID,
|
||||
usedGB: float64(row.Upload+row.Download) / (1024 * 1024 * 1024),
|
||||
})
|
||||
}
|
||||
return trafficUsage, nil
|
||||
}
|
||||
|
||||
func serverUserTrafficRuleWindow(rule speedlimit.TrafficLimitRule, now time.Time) (serverUserTrafficWindow, bool) {
|
||||
if rule.StatValue <= 0 {
|
||||
return serverUserTrafficWindow{}, false
|
||||
}
|
||||
|
||||
window := serverUserTrafficWindow{
|
||||
statType: rule.StatType,
|
||||
statValue: rule.StatValue,
|
||||
end: now,
|
||||
}
|
||||
switch rule.StatType {
|
||||
case "hour":
|
||||
window.start = now.Add(-time.Duration(rule.StatValue) * time.Hour)
|
||||
case "day":
|
||||
window.start = now.AddDate(0, 0, -int(rule.StatValue))
|
||||
default:
|
||||
return serverUserTrafficWindow{}, false
|
||||
}
|
||||
return window, true
|
||||
}
|
||||
|
||||
func serverUserTrafficWindowKey(statType string, statValue int64) string {
|
||||
return fmt.Sprintf("%s:%d", statType, statValue)
|
||||
}
|
||||
|
||||
func (s *serverUserListPerfStats) recordSpeedLimits(users []types.ServerUser) {
|
||||
for _, user := range users {
|
||||
if user.SpeedLimit > 0 {
|
||||
s.speedLimitPositiveCount++
|
||||
continue
|
||||
}
|
||||
s.speedLimitZeroCount++
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) logServerUserListPerf(stats serverUserListPerfStats) {
|
||||
l.Infow("[ServerUserList] performance",
|
||||
logger.Field("server_id", stats.serverID),
|
||||
logger.Field("protocol", stats.protocol),
|
||||
logger.Field("cache_hit", stats.cacheHit),
|
||||
logger.Field("nodes_count", stats.nodesCount),
|
||||
logger.Field("subs_count", stats.subsCount),
|
||||
logger.Field("users_count", stats.usersCount),
|
||||
logger.Field("speed_limit_0_count", stats.speedLimitZeroCount),
|
||||
logger.Field("speed_limit_positive_count", stats.speedLimitPositiveCount),
|
||||
logger.Field("traffic_calc_ms", stats.trafficCalcMS),
|
||||
logger.Field("total_ms", time.Since(stats.startedAt).Milliseconds()),
|
||||
result := speedlimit.CalculateWithCache(
|
||||
l.ctx.Request.Context(),
|
||||
l.svcCtx.Redis,
|
||||
l.svcCtx.DB,
|
||||
userSub.UserId,
|
||||
userSub.Id,
|
||||
baseSpeed,
|
||||
trafficLimit,
|
||||
30*time.Second,
|
||||
)
|
||||
return result.EffectiveSpeed
|
||||
}
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/model/node"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/speedlimit"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestNormalizeServerUserListProtocol 验证客户端 hysteria2 兼容字段被映射回
|
||||
// DB 规范名 "hysteria";其他协议必须原样返回,不可被误改。
|
||||
// 缺这一步会导致 hysteria2 永远查不到节点、永远走兜底,HIF-1 表象。
|
||||
func TestNormalizeServerUserListProtocol(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"hysteria2 maps to hysteria", "hysteria2", "hysteria"},
|
||||
{"hysteria unchanged", "hysteria", "hysteria"},
|
||||
{"trojan unchanged", "trojan", "trojan"},
|
||||
{"vless unchanged", "vless", "vless"},
|
||||
{"vmess unchanged", "vmess", "vmess"},
|
||||
{"tuic unchanged", "tuic", "tuic"},
|
||||
{"shadowsocks unchanged", "shadowsocks", "shadowsocks"},
|
||||
{"anytls unchanged", "anytls", "anytls"},
|
||||
{"empty unchanged", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := normalizeServerUserListProtocol(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("normalizeServerUserListProtocol(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerUserListCacheKey_ProtocolIsolation 验证缓存 key 在 server_id 相同、
|
||||
// protocol 不同(如 trojan vs tuic)时一定不同——这是 HIF-1 的根因防回归。
|
||||
// 若 key 重叠,tuic 的兜底假用户会污染 trojan 的真实用户列表。
|
||||
func TestServerUserListCacheKey_ProtocolIsolation(t *testing.T) {
|
||||
const serverId int64 = 42
|
||||
build := func(protocol string) string {
|
||||
return fmt.Sprintf("%s%d:%s", node.ServerUserListCacheKey, serverId, normalizeServerUserListProtocol(protocol))
|
||||
}
|
||||
|
||||
protocols := []string{"trojan", "vless", "vmess", "tuic", "shadowsocks", "anytls"}
|
||||
seen := make(map[string]string, len(protocols))
|
||||
for _, p := range protocols {
|
||||
key := build(p)
|
||||
if other, exists := seen[key]; exists {
|
||||
t.Fatalf("cache key collision: %q (%q) == %q (%q)", p, key, other, key)
|
||||
}
|
||||
seen[key] = p
|
||||
}
|
||||
|
||||
// hysteria 与 hysteria2 必须落到同一 key,否则 hysteria2 客户端拿不到 hysteria 节点列表
|
||||
hysteriaKey := build("hysteria")
|
||||
hysteria2Key := build("hysteria2")
|
||||
if hysteriaKey != hysteria2Key {
|
||||
t.Errorf("hysteria/hysteria2 expected to share cache key, got %q vs %q", hysteriaKey, hysteria2Key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerUserListCacheKey_ShapeMatchesDelPath 验证 GetServerUserList 写入的
|
||||
// cache key 形态与 ServerUserListCacheKeysForServer(Del 路径)枚举出的 key
|
||||
// 形态完全一致。形态一旦漂移,Del 会清不到刚写入的 key,HIF-1 重现。
|
||||
func TestServerUserListCacheKey_ShapeMatchesDelPath(t *testing.T) {
|
||||
const serverId int64 = 7
|
||||
writeShape := fmt.Sprintf("%s%d:%s", node.ServerUserListCacheKey, serverId, normalizeServerUserListProtocol("trojan"))
|
||||
|
||||
delKeys := node.ServerUserListCacheKeysForServer(serverId)
|
||||
if !slices.Contains(delKeys, writeShape) {
|
||||
t.Fatalf("write-path key %q not present in Del-path enumeration %v", writeShape, delKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerUserSpeedLimitInputs(t *testing.T) {
|
||||
planTrafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":10,"speed_limit":5}]`
|
||||
userTrafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":1,"speed_limit":3}]`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sub *subscribe.Subscribe
|
||||
userSub *user.Subscribe
|
||||
wantSpeedLimit int64
|
||||
wantTrafficLimit string
|
||||
}{
|
||||
{
|
||||
name: "未设置用户覆盖时返回套餐基础限速",
|
||||
sub: &subscribe.Subscribe{
|
||||
SpeedLimit: 100,
|
||||
TrafficLimit: planTrafficLimit,
|
||||
},
|
||||
userSub: &user.Subscribe{},
|
||||
wantSpeedLimit: 100,
|
||||
wantTrafficLimit: planTrafficLimit,
|
||||
},
|
||||
{
|
||||
name: "用户 speed_limit 覆盖套餐基础限速",
|
||||
sub: &subscribe.Subscribe{
|
||||
SpeedLimit: 100,
|
||||
TrafficLimit: planTrafficLimit,
|
||||
},
|
||||
userSub: &user.Subscribe{
|
||||
SpeedLimit: 30,
|
||||
},
|
||||
wantSpeedLimit: 30,
|
||||
wantTrafficLimit: planTrafficLimit,
|
||||
},
|
||||
{
|
||||
name: "用户 traffic_limit 覆盖套餐规则",
|
||||
sub: &subscribe.Subscribe{
|
||||
SpeedLimit: 100,
|
||||
TrafficLimit: planTrafficLimit,
|
||||
},
|
||||
userSub: &user.Subscribe{
|
||||
TrafficLimit: &userTrafficLimit,
|
||||
},
|
||||
wantSpeedLimit: 100,
|
||||
wantTrafficLimit: userTrafficLimit,
|
||||
},
|
||||
{
|
||||
name: "用户 traffic_limit 为空时保留套餐规则",
|
||||
sub: &subscribe.Subscribe{
|
||||
SpeedLimit: 100,
|
||||
TrafficLimit: planTrafficLimit,
|
||||
},
|
||||
userSub: &user.Subscribe{
|
||||
TrafficLimit: ptrString(""),
|
||||
},
|
||||
wantSpeedLimit: 100,
|
||||
wantTrafficLimit: planTrafficLimit,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotSpeedLimit, gotTrafficLimit := serverUserSpeedLimitInputs(tc.sub, tc.userSub)
|
||||
if gotSpeedLimit != tc.wantSpeedLimit {
|
||||
t.Fatalf("speedLimit = %d, want %d", gotSpeedLimit, tc.wantSpeedLimit)
|
||||
}
|
||||
if gotTrafficLimit != tc.wantTrafficLimit {
|
||||
t.Fatalf("trafficLimit = %q, want %q", gotTrafficLimit, tc.wantTrafficLimit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateServerUserSpeedLimits_BatchTrafficRules(t *testing.T) {
|
||||
db, mock, cleanup := newServerUserListTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx, _ := gin.CreateTestContext(nil)
|
||||
ctx.Request = httptestNewRequest()
|
||||
logic := &GetServerUserListLogic{
|
||||
Logger: logger.WithContext(ctx.Request.Context()),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
|
||||
trafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":10,"speed_limit":5}]`
|
||||
candidates := map[int64]serverUserSpeedLimitCandidate{
|
||||
101: {userID: 1001, userSubscribeID: 101, baseSpeed: 0, trafficLimit: trafficLimit},
|
||||
102: {userID: 1002, userSubscribeID: 102, baseSpeed: 0, trafficLimit: trafficLimit},
|
||||
103: {userID: 1003, userSubscribeID: 103, baseSpeed: 20, trafficLimit: trafficLimit},
|
||||
}
|
||||
|
||||
mock.ExpectQuery("FROM `traffic_log`").
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"user_id", "user_subscribe_id", "upload", "download"}).
|
||||
AddRow(1001, 101, gb(4), gb(5)).
|
||||
AddRow(1002, 102, gb(4), gb(6)).
|
||||
AddRow(1003, 103, gb(12), int64(0)))
|
||||
|
||||
got := logic.calculateServerUserSpeedLimits(candidates)
|
||||
assertSpeedLimit(t, got, 101, 0)
|
||||
assertSpeedLimit(t, got, 102, 5)
|
||||
assertSpeedLimit(t, got, 103, 5)
|
||||
assertServerUserListExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestCalculateServerUserSpeedLimits_UserTrafficLimitOverride(t *testing.T) {
|
||||
db, mock, cleanup := newServerUserListTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx, _ := gin.CreateTestContext(nil)
|
||||
ctx.Request = httptestNewRequest()
|
||||
logic := &GetServerUserListLogic{
|
||||
Logger: logger.WithContext(ctx.Request.Context()),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
|
||||
candidates := map[int64]serverUserSpeedLimitCandidate{
|
||||
201: {
|
||||
userID: 2001,
|
||||
userSubscribeID: 201,
|
||||
baseSpeed: 50,
|
||||
trafficLimit: `[{"stat_type":"day","stat_value":1,"traffic_usage":2,"speed_limit":3}]`,
|
||||
},
|
||||
202: {
|
||||
userID: 2002,
|
||||
userSubscribeID: 202,
|
||||
baseSpeed: 50,
|
||||
trafficLimit: "",
|
||||
},
|
||||
}
|
||||
|
||||
mock.ExpectQuery("FROM `traffic_log`").
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"user_id", "user_subscribe_id", "upload", "download"}).
|
||||
AddRow(2001, 201, gb(2), int64(0)))
|
||||
|
||||
got := logic.calculateServerUserSpeedLimits(candidates)
|
||||
assertSpeedLimit(t, got, 201, 3)
|
||||
assertSpeedLimit(t, got, 202, 50)
|
||||
assertServerUserListExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestServerUserTrafficRuleWindow(t *testing.T) {
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rule speedlimit.TrafficLimitRule
|
||||
want time.Time
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "hour window",
|
||||
rule: speedlimit.TrafficLimitRule{StatType: "hour", StatValue: 2},
|
||||
want: now.Add(-2 * time.Hour),
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "day window",
|
||||
rule: speedlimit.TrafficLimitRule{StatType: "day", StatValue: 1},
|
||||
want: now.AddDate(0, 0, -1),
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "invalid stat type",
|
||||
rule: speedlimit.TrafficLimitRule{StatType: "week", StatValue: 1},
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "invalid stat value",
|
||||
rule: speedlimit.TrafficLimitRule{StatType: "day", StatValue: 0},
|
||||
ok: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := serverUserTrafficRuleWindow(tc.rule, now)
|
||||
if ok != tc.ok {
|
||||
t.Fatalf("ok = %v, want %v", ok, tc.ok)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !got.start.Equal(tc.want) || !got.end.Equal(now) {
|
||||
t.Fatalf("window = [%s, %s), want [%s, %s)", got.start, got.end, tc.want, now)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newServerUserListTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||
if matched, _ := regexp.MatchString(expectedSQL, actualSQL); matched {
|
||||
return nil
|
||||
}
|
||||
t.Fatalf("unexpected SQL\nexpected pattern: %s\nactual: %s", expectedSQL, actualSQL)
|
||||
return nil
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{
|
||||
Conn: sqlDB,
|
||||
SkipInitializeWithVersion: true,
|
||||
}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
|
||||
return db, mock, func() {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func assertServerUserListExpectations(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSpeedLimit(t *testing.T, got map[int64]int64, userSubscribeID int64, want int64) {
|
||||
t.Helper()
|
||||
|
||||
if got[userSubscribeID] != want {
|
||||
t.Fatalf("speed limit for user_subscribe_id=%d = %d, want %d", userSubscribeID, got[userSubscribeID], want)
|
||||
}
|
||||
}
|
||||
|
||||
func gb(value int64) int64 {
|
||||
return value * 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func httptestNewRequest() *http.Request {
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/v1/server/user", nil)
|
||||
return req
|
||||
}
|
||||
|
||||
// TestMergeSpeedLimit 验证用户级 speed_limit 与过期节点组 speed_limit 合并规则:
|
||||
// 0 = 不限制 (loses),正值优先;都为正取较小者(更严格)。
|
||||
// 这是 user-dimension 限速在过期节点组分支下能生效的关键。
|
||||
func TestMergeSpeedLimit(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a int64
|
||||
b int64
|
||||
want int64
|
||||
}{
|
||||
{"both zero stays zero", 0, 0, 0},
|
||||
{"a positive b zero takes a", 30, 0, 30},
|
||||
{"a zero b positive takes b", 0, 50, 50},
|
||||
{"a negative treated as zero takes b", -1, 50, 50},
|
||||
{"b negative treated as zero takes a", 50, -1, 50},
|
||||
{"both positive takes smaller (a<b)", 20, 50, 20},
|
||||
{"both positive takes smaller (b<a)", 80, 30, 30},
|
||||
{"equal positive returns same value", 25, 25, 25},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := mergeSpeedLimit(tc.a, tc.b); got != tc.want {
|
||||
t.Fatalf("mergeSpeedLimit(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,7 @@ type NodeGroup struct {
|
||||
IsExpiredGroup *bool `gorm:"default:false;not null;index:idx_is_expired_group;comment:Is Expired Group"`
|
||||
ExpiredDaysLimit int `gorm:"default:7;not null;comment:Expired days limit (days)"`
|
||||
MaxTrafficGBExpired *int64 `gorm:"default:0;comment:Max traffic for expired users (GB)"`
|
||||
// SpeedLimit: 过期节点组对其内用户施加的速度上限。
|
||||
// 实际下发节点的字段为 ServerUser.SpeedLimit (Mbps),二者直接透传,无单位换算。
|
||||
// 注:早期 schema 注释写作 KB/s 系笔误,真实语义与 user_subscribe.speed_limit / subscribe.speed_limit 一致,均为 Mbps。
|
||||
SpeedLimit int `gorm:"default:0;not null;comment:Speed limit (Mbps); 0 means no limit"`
|
||||
SpeedLimit int `gorm:"default:0;not null;comment:Speed limit (KB/s)"`
|
||||
MinTrafficGB *int64 `gorm:"default:0;comment:Minimum Traffic (GB) for this node group"`
|
||||
MaxTrafficGB *int64 `gorm:"default:0;comment:Maximum Traffic (GB) for this node group"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user