Compare commits
29 Commits
87ebfa1fac
...
de388ac1ef
| Author | SHA1 | Date | |
|---|---|---|---|
| de388ac1ef | |||
| 6157b2c571 | |||
| 268ae1ebf8 | |||
| f5ad26b943 | |||
| 9b5ff89ee8 | |||
| e74958e17f | |||
| 21811f4d63 | |||
| 24caf58987 | |||
| b98d718f3c | |||
| bcb8cd222c | |||
| 34cd1c524e | |||
| 377f13da48 | |||
| 5e4cc33ff6 | |||
| 53fb541846 | |||
| f6f2ca9a29 | |||
| 19d28a8f89 | |||
| 8ff992e74c | |||
| 84d222576a | |||
| cfc9cf790b | |||
| c540091ef9 | |||
| c837999573 | |||
| e33af1450b | |||
| e567821e07 | |||
| 5e30794db1 | |||
| ccbdab55aa | |||
| ed181886dd | |||
| 9ddd8257c5 | |||
| fc6f193479 | |||
| 3df59ef345 |
@@ -1,25 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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,18 +1,5 @@
|
|||||||
# 复制此文件为 .env 并填写真实值
|
# 复制此文件为 .env 并填写真实值
|
||||||
# cp .env.example .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 镜像标签(由 CI/CD 传入不可变 tag,如 git SHA)
|
||||||
PPANEL_SERVER_TAG=CHANGE_ME_TO_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
|
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!--
|
||||||
|
完整流程见 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 全绿
|
||||||
|
- [ ] 测试工程师已验收(如涉及业务逻辑)
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
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 通知发送失败,工作流失败状态已记录。"
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
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: acceptance-artifacts
|
||||||
|
ACCEPTANCE_REPORT_PATH: acceptance-artifacts/acceptance-report.json
|
||||||
|
ACCEPTANCE_TEST_JSON: acceptance-artifacts/go-test.json
|
||||||
|
ACCEPTANCE_FAILURE_LOG: 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 }}
|
||||||
|
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 }}
|
||||||
|
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
|
||||||
|
|
||||||
|
missing=()
|
||||||
|
required=(
|
||||||
|
ACCEPTANCE_ADMIN_EMAIL
|
||||||
|
ACCEPTANCE_ADMIN_PASSWORD
|
||||||
|
ACCEPTANCE_USER_EMAIL
|
||||||
|
ACCEPTANCE_USER_PASSWORD
|
||||||
|
STAGING_BASE_URL
|
||||||
|
STAGING_DB_HOST
|
||||||
|
STAGING_DB_USER
|
||||||
|
STAGING_DB_PASSWORD
|
||||||
|
STAGING_DB_NAME
|
||||||
|
STAGING_REDIS_ADDR
|
||||||
|
STAGING_REDIS_PASSWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in "${required[@]}"; do
|
||||||
|
if [ -z "${!key:-}" ]; then
|
||||||
|
missing+=("$key")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${#missing[@]}" -gt 0 ]; then
|
||||||
|
printf '缺少必需 GitHub Actions secrets/vars:\n' | tee "$ACCEPTANCE_FAILURE_LOG"
|
||||||
|
printf -- '- %s\n' "${missing[@]}" | tee -a "$ACCEPTANCE_FAILURE_LOG"
|
||||||
|
{
|
||||||
|
echo "## 发布验收测试"
|
||||||
|
echo
|
||||||
|
echo "状态:配置缺失"
|
||||||
|
echo
|
||||||
|
echo "缺少以下 secrets/vars:"
|
||||||
|
printf -- '- `%s`\n' "${missing[@]}"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
exit 1
|
||||||
|
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,6 +26,13 @@ Thumbs.db
|
|||||||
*.crt
|
*.crt
|
||||||
*.key
|
*.key
|
||||||
*.pem
|
*.pem
|
||||||
|
*.pub
|
||||||
|
*id_rsa*
|
||||||
|
*id_ed25519*
|
||||||
|
*_bak
|
||||||
|
*.go_bak
|
||||||
|
deploy/**/keys/
|
||||||
|
deliverables/
|
||||||
|
|
||||||
# ==================== 日志 ====================
|
# ==================== 日志 ====================
|
||||||
*.log
|
*.log
|
||||||
@@ -35,6 +42,7 @@ logs/
|
|||||||
# ==================== 测试 ====================
|
# ==================== 测试 ====================
|
||||||
/test/
|
/test/
|
||||||
*_test.go
|
*_test.go
|
||||||
|
!tests/acceptance/*_test.go
|
||||||
*_test_config.go
|
*_test_config.go
|
||||||
**/logtest/
|
**/logtest/
|
||||||
*_test.yaml
|
*_test.yaml
|
||||||
@@ -70,6 +78,7 @@ script/*.sh
|
|||||||
|
|
||||||
# Codex local configuration
|
# Codex local configuration
|
||||||
.codex/
|
.codex/
|
||||||
|
.codex-tmp/
|
||||||
|
|
||||||
# Claude Flow runtime data
|
# Claude Flow runtime data
|
||||||
.claude-flow/data/
|
.claude-flow/data/
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
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,5 +1,7 @@
|
|||||||
# Pull Request Submission Guidelines
|
# 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):
|
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. PR Title and Description
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Pull Request 提交须知
|
# 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)之前,请务必遵循以下准则:
|
为了确保代码库的质量和项目的可维护性,在提交 Pull Request(PR)之前,请务必遵循以下准则:
|
||||||
|
|
||||||
## 1. PR 标题和描述
|
## 1. PR 标题和描述
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
<!--
|
||||||
|
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
|
# PPanel Server
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ type (
|
|||||||
InviteManageRecord {
|
InviteManageRecord {
|
||||||
InviterId int64 `json:"inviter_id"`
|
InviterId int64 `json:"inviter_id"`
|
||||||
InviterIdentifier string `json:"inviter_identifier"`
|
InviterIdentifier string `json:"inviter_identifier"`
|
||||||
|
InviterDeviceNo string `json:"inviter_device_no"`
|
||||||
InviteeId int64 `json:"invitee_id"`
|
InviteeId int64 `json:"invitee_id"`
|
||||||
InviteeIdentifier string `json:"invitee_identifier"`
|
InviteeIdentifier string `json:"invitee_identifier"`
|
||||||
|
InviteeDeviceNo string `json:"invitee_device_no"`
|
||||||
InviteeAvatar string `json:"invitee_avatar"`
|
InviteeAvatar string `json:"invitee_avatar"`
|
||||||
InviteeEnable bool `json:"invitee_enable"`
|
InviteeEnable bool `json:"invitee_enable"`
|
||||||
InvitedAt int64 `json:"invited_at"`
|
InvitedAt int64 `json:"invited_at"`
|
||||||
|
|||||||
+1
-1
@@ -152,7 +152,7 @@ type (
|
|||||||
Upload int64 `json:"upload"`
|
Upload int64 `json:"upload"`
|
||||||
Download int64 `json:"download"`
|
Download int64 `json:"download"`
|
||||||
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
|
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
|
||||||
TrafficLimit *string `json:"traffic_limit,omitempty"`
|
TrafficLimit []TrafficLimit `json:"traffic_limit,omitempty"`
|
||||||
}
|
}
|
||||||
GetUserLoginLogsRequest {
|
GetUserLoginLogsRequest {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ type (
|
|||||||
}
|
}
|
||||||
WithdrawalLog {
|
WithdrawalLog {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
|
BizType string `json:"biz_type"`
|
||||||
UserId int64 `json:"user_id"`
|
UserId int64 `json:"user_id"`
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
@@ -134,6 +135,7 @@ type (
|
|||||||
QueryWithdrawalLogListRequest {
|
QueryWithdrawalLogListRequest {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
|
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
||||||
}
|
}
|
||||||
QueryWithdrawalLogListResponse {
|
QueryWithdrawalLogListResponse {
|
||||||
List []WithdrawalLog `json:"list"`
|
List []WithdrawalLog `json:"list"`
|
||||||
|
|||||||
@@ -575,6 +575,7 @@ type (
|
|||||||
Traffic int64 `json:"traffic"`
|
Traffic int64 `json:"traffic"`
|
||||||
Download int64 `json:"download"`
|
Download int64 `json:"download"`
|
||||||
Upload int64 `json:"upload"`
|
Upload int64 `json:"upload"`
|
||||||
|
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Status uint8 `json:"status"`
|
Status uint8 `json:"status"`
|
||||||
EntitlementSource string `json:"entitlement_source"`
|
EntitlementSource string `json:"entitlement_source"`
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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 发给对方即可。
|
|
||||||
出于安全考虑,建议通过安全渠道传输,并在后续需要时重新轮换密钥。
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
|
||||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
|
||||||
QyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCgAAAKjy5KDa8uSg
|
|
||||||
2gAAAAtzc2gtZWQyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCg
|
|
||||||
AAAEDwSb0b/0S6Tw8Od5hAtIKqt1JvomqQQS44Ty3xL+FjPtvqawOqZIcu/SZaAI/i9+ND
|
|
||||||
cDnnaq/3m5B2wf3hGegKAAAAIWhpZmFzdC1oay1hcHAtMDEtcmVzZXQtMjAyNi0wNS0xMA
|
|
||||||
ECAwQ=
|
|
||||||
-----END OPENSSH PRIVATE KEY-----
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvqawOqZIcu/SZaAI/i9+NDcDnnaq/3m5B2wf3hGegK hifast-hk-app-01-reset-2026-05-10
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
# 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 主库扰动最小。
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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=
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
# 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) 为准。
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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=
|
|
||||||
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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"
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
# 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`
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# 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`
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
|
||||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
|
||||||
QyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1YwAAAKBaQXT3WkF0
|
|
||||||
9wAAAAtzc2gtZWQyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1Yw
|
|
||||||
AAAEArWQWWwPoJp+za5JhSnrE0Pw1/TtqWRrdY5e8hULqYDGwnFkOMq9oh1WN6vdAwdFjD
|
|
||||||
jfCRWfMe6sDZNCoeWvVjAAAAF0FwcGxlQE1hY0Jvb2stUHJvLmxvY2FsAQIDBAUG
|
|
||||||
-----END OPENSSH PRIVATE KEY-----
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGwnFkOMq9oh1WN6vdAwdFjDjfCRWfMe6sDZNCoeWvVj Apple@MacBook-Pro.local
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# 开发流程(迁移到 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`)
|
||||||
+2
-231
@@ -1,34 +1,12 @@
|
|||||||
# 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:
|
services:
|
||||||
# ----------------------------------------------------
|
|
||||||
# 1. 业务后端 (PPanel Server)
|
|
||||||
# host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo
|
|
||||||
# PPANEL_SERVER_TAG 由 CI/CD 传入不可变镜像标签(如 git SHA)
|
|
||||||
# ----------------------------------------------------
|
|
||||||
ppanel-server:
|
ppanel-server:
|
||||||
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag}
|
image: ${PPANEL_SERVER_IMAGE:-registry.kxsw.us/vpn-server}:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag}
|
||||||
container_name: ppanel-server
|
container_name: ppanel-server
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./configs:/app/etc
|
- ./configs:/app/etc
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
- ./cache:/app/cache # GeoLite2-City.mmdb IP 地理位置数据库
|
- ./cache:/app/cache
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -37,215 +15,8 @@ services:
|
|||||||
nofile:
|
nofile:
|
||||||
soft: 65535
|
soft: 65535
|
||||||
hard: 65535
|
hard: 65535
|
||||||
depends_on:
|
|
||||||
tempo:
|
|
||||||
condition: service_started
|
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
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,5 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ppanel:
|
ppanel:
|
||||||
container_name: ppanel-server
|
container_name: ppanel-server
|
||||||
|
|||||||
+8
-8
@@ -15,10 +15,10 @@ Logger: # 日志配置
|
|||||||
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
||||||
|
|
||||||
MySQL:
|
MySQL:
|
||||||
Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
Addr: 127.0.0.1:3306 # 本地开发默认;Docker bridge 模式可改为 mysql:3306
|
||||||
Username: root # MySQL用户名
|
Username: root # MySQL用户名
|
||||||
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
Password: CHANGE_ME_TO_DB_PASSWORD # MySQL密码
|
||||||
Dbname: hifast # MySQL数据库名
|
Dbname: ppanel # MySQL数据库名
|
||||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||||
MaxIdleConns: 10
|
MaxIdleConns: 10
|
||||||
MaxOpenConns: 100
|
MaxOpenConns: 100
|
||||||
@@ -42,9 +42,9 @@ Redis:
|
|||||||
|
|
||||||
AppSignature:
|
AppSignature:
|
||||||
AppSecrets:
|
AppSecrets:
|
||||||
android-client: uB4G,XxL2{7b # Android 客户端签名密钥
|
android-client: CHANGE_ME_ANDROID_APP_SECRET # Android 客户端签名密钥
|
||||||
ios-client: uB4G,XxL2{7b # iOS 客户端签名密钥
|
ios-client: CHANGE_ME_IOS_APP_SECRET # iOS 客户端签名密钥
|
||||||
web-client: uB4G,XxL2{7b # Web 客户端签名密钥
|
web-client: CHANGE_ME_WEB_APP_SECRET # Web 客户端签名密钥
|
||||||
ValidWindowSeconds: 300 # 签名时间窗口(秒)
|
ValidWindowSeconds: 300 # 签名时间窗口(秒)
|
||||||
SkipPrefixes:
|
SkipPrefixes:
|
||||||
- /v1/notify/ # 支付回调不验签
|
- /v1/notify/ # 支付回调不验签
|
||||||
@@ -58,8 +58,8 @@ Signature:
|
|||||||
Trace: # 链路追踪配置 (OpenTelemetry)
|
Trace: # 链路追踪配置 (OpenTelemetry)
|
||||||
Name: ppanel # 服务名
|
Name: ppanel # 服务名
|
||||||
Sampler: 1.0 # 采样率 0.0-1.0,生产建议 0.1
|
Sampler: 1.0 # 采样率 0.0-1.0,生产建议 0.1
|
||||||
Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc
|
Batcher: "" # 本地开发留空;生产如需链路追踪再配置 exporter
|
||||||
Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317
|
Endpoint: ""
|
||||||
|
|
||||||
S3:
|
S3:
|
||||||
Enable: false
|
Enable: false
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
{
|
|
||||||
"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": ""
|
|
||||||
}
|
|
||||||
@@ -1,565 +0,0 @@
|
|||||||
{
|
|
||||||
"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": ""
|
|
||||||
}
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
{
|
|
||||||
"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": ""
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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,9 +1,6 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/perfect-panel/server/internal/logic/admin/user"
|
"github.com/perfect-panel/server/internal/logic/admin/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
@@ -15,31 +12,18 @@ import (
|
|||||||
func UpdateUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
func UpdateUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req types.UpdateUserSubscribeRequest
|
var req types.UpdateUserSubscribeRequest
|
||||||
_ = c.ShouldBind(&req)
|
if err := c.ShouldBind(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
validateErr := svcCtx.Validate(&req)
|
validateErr := svcCtx.Validate(&req)
|
||||||
if validateErr != nil {
|
if validateErr != nil {
|
||||||
result.ParamErrorResult(c, validateErr)
|
result.ParamErrorResult(c, validateErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateUpdateUserSubscribeTrafficLimit(&req); err != nil {
|
|
||||||
result.ParamErrorResult(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := user.NewUpdateUserSubscribeLogic(c.Request.Context(), svcCtx)
|
l := user.NewUpdateUserSubscribeLogic(c.Request.Context(), svcCtx)
|
||||||
err := l.UpdateUserSubscribe(&req)
|
err := l.UpdateUserSubscribe(&req)
|
||||||
result.HttpResult(c, nil, err)
|
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}`,
|
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"speed_limit":-1}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid traffic limit json",
|
name: "invalid traffic limit type",
|
||||||
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"traffic_limit":"not-json"}`,
|
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"traffic_limit":"not-json"}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/group"
|
"github.com/perfect-panel/server/internal/model/group"
|
||||||
"github.com/perfect-panel/server/internal/model/node"
|
"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/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@@ -48,6 +49,33 @@ func (l *DeleteNodeGroupLogic) DeleteNodeGroup(req *types.DeleteNodeGroupRequest
|
|||||||
return fmt.Errorf("cannot delete group with %d associated nodes, please migrate nodes first", nodeCount)
|
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 删除节点组
|
// 使用 GORM Transaction 删除节点组
|
||||||
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
// 删除节点组
|
// 删除节点组
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/group"
|
"github.com/perfect-panel/server/internal/model/group"
|
||||||
@@ -52,10 +53,9 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
var users []UserInfo
|
var users []UserInfo
|
||||||
if err := l.svcCtx.DB.Raw("SELECT * FROM JSON_ARRAY(?)", detail.UserData).Scan(&users).Error; err != nil {
|
if err := json.Unmarshal([]byte(detail.UserData), &users); err != nil {
|
||||||
// 如果解析失败,尝试用标准 JSON 解析
|
|
||||||
logger.Errorf("failed to parse user data: %v", err)
|
logger.Errorf("failed to parse user data: %v", err)
|
||||||
continue
|
return nil, "", fmt.Errorf("parse group history user_data failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询节点组名称
|
// 查询节点组名称
|
||||||
@@ -123,7 +123,10 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
|
|||||||
result = append(result, csvData...)
|
result = append(result, csvData...)
|
||||||
|
|
||||||
// 生成文件名
|
// 生成文件名
|
||||||
filename := fmt.Sprintf("group_result_%d.csv", req.HistoryId)
|
filename := "group_result_current.csv"
|
||||||
|
if req.HistoryId != nil {
|
||||||
|
filename = fmt.Sprintf("group_result_%d.csv", *req.HistoryId)
|
||||||
|
}
|
||||||
|
|
||||||
return result, filename, nil
|
return result, filename, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,16 +76,16 @@ func (l *GetGroupHistoryDetailLogic) GetGroupHistoryDetail(req *types.GetGroupHi
|
|||||||
configSnapshot := make(map[string]interface{})
|
configSnapshot := make(map[string]interface{})
|
||||||
configSnapshot["group_details"] = details
|
configSnapshot["group_details"] = details
|
||||||
|
|
||||||
// 获取配置快照(从 system_config 读取)
|
// 获取配置快照(从 system 读取)
|
||||||
var configValue string
|
var configValue string
|
||||||
if history.GroupMode == "average" {
|
if history.GroupMode == "average" {
|
||||||
l.svcCtx.DB.Table("system_config").
|
l.svcCtx.DB.Table("system").
|
||||||
Where("`key` = ?", "group.average_config").
|
Where("`category` = ? AND `key` = ?", "group", "average_config").
|
||||||
Select("value").
|
Select("value").
|
||||||
Scan(&configValue)
|
Scan(&configValue)
|
||||||
} else if history.GroupMode == "traffic" {
|
} else if history.GroupMode == "traffic" {
|
||||||
l.svcCtx.DB.Table("system_config").
|
l.svcCtx.DB.Table("system").
|
||||||
Where("`key` = ?", "group.traffic_config").
|
Where("`category` = ? AND `key` = ?", "group", "traffic_config").
|
||||||
Select("value").
|
Select("value").
|
||||||
Scan(&configValue)
|
Scan(&configValue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ func (l *GetGroupHistoryLogic) GetGroupHistory(req *types.GetGroupHistoryRequest
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页查询
|
page, size := normalizePagination(req.Page, req.Size)
|
||||||
offset := (req.Page - 1) * req.Size
|
offset := (page - 1) * size
|
||||||
if err := query.Order("id DESC").Offset(offset).Limit(req.Size).Find(&histories).Error; err != nil {
|
if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&histories).Error; err != nil {
|
||||||
logger.Errorf("failed to find group histories: %v", err)
|
logger.Errorf("failed to find group histories: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ func (l *GetNodeGroupListLogic) GetNodeGroupList(req *types.GetNodeGroupListRequ
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页查询
|
page, size := normalizePagination(req.Page, req.Size)
|
||||||
offset := (req.Page - 1) * req.Size
|
offset := (page - 1) * size
|
||||||
if err := query.Order("sort ASC").Offset(offset).Limit(req.Size).Find(&nodeGroups).Error; err != nil {
|
if err := query.Order("sort ASC").Offset(offset).Limit(size).Find(&nodeGroups).Error; err != nil {
|
||||||
logger.Errorf("failed to find node groups: %v", err)
|
logger.Errorf("failed to find node groups: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 过滤出包含至少一个匹配节点组的节点(仅显示用户真正所在分组的节点,不包含公共节点)
|
// 6. 过滤出公共节点和至少一个匹配节点组的节点,与真实订阅下发保持一致
|
||||||
for _, n := range dbNodes {
|
for _, n := range dbNodes {
|
||||||
// 节点未配置节点组(公共节点),预览时不显示
|
|
||||||
if len(n.NodeGroupIds) == 0 {
|
if len(n.NodeGroupIds) == 0 {
|
||||||
|
filteredNodes = append(filteredNodes, n)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,9 +450,13 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预览模式不显示公共节点(node_group_ids 为空的节点),只展示用户真正所在分组的节点
|
|
||||||
if len(publicNodes) > 0 {
|
if len(publicNodes) > 0 {
|
||||||
logger.Infof("[PreviewUserNodes] skipping %d public nodes (not in user's assigned group)", len(publicNodes))
|
nodeGroupItems = append(nodeGroupItems, types.NodeGroupItem{
|
||||||
|
Id: 0,
|
||||||
|
Name: "公共节点",
|
||||||
|
Nodes: publicNodes,
|
||||||
|
})
|
||||||
|
logger.Infof("[PreviewUserNodes] adding public nodes group: nodes=%d", len(publicNodes))
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -679,21 +679,7 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
|||||||
// 将字节转换为 GB
|
// 将字节转换为 GB
|
||||||
usedTrafficGB := float64(us.UsedTraffic) / (1024 * 1024 * 1024)
|
usedTrafficGB := float64(us.UsedTraffic) / (1024 * 1024 * 1024)
|
||||||
|
|
||||||
// 查找匹配的流量范围(使用左闭右开区间 [Min, Max))
|
targetNodeGroupId := matchTrafficNodeGroup(usedTrafficGB, nodeGroups)
|
||||||
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(不分配节点组)
|
// 如果没有匹配到任何范围,targetNodeGroupId 保持为 0(不分配节点组)
|
||||||
|
|
||||||
@@ -734,7 +720,14 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
|||||||
// 4. 创建分组历史详情记录(只统计有用户的节点组)
|
// 4. 创建分组历史详情记录(只统计有用户的节点组)
|
||||||
nodeGroupCount := make(map[int64]int) // node_group_id -> node_count
|
nodeGroupCount := make(map[int64]int) // node_group_id -> node_count
|
||||||
for _, ng := range nodeGroups {
|
for _, ng := range nodeGroups {
|
||||||
nodeGroupCount[ng.Id] = 1 // 每个节点组计为1
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
for nodeGroupId, userCount := range groupUserCount {
|
for nodeGroupId, userCount := range groupUserCount {
|
||||||
@@ -764,6 +757,20 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
|||||||
return affectedCount, nil
|
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)
|
// containsIgnoreCase checks if a string contains another substring (case-insensitive)
|
||||||
func containsIgnoreCase(s, substr string) bool {
|
func containsIgnoreCase(s, substr string) bool {
|
||||||
if len(substr) == 0 {
|
if len(substr) == 0 {
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"github.com/perfect-panel/server/internal/model/node"
|
"github.com/perfect-panel/server/internal/model/node"
|
||||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||||
"github.com/perfect-panel/server/internal/model/system"
|
"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/internal/svc"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ResetGroupsLogic struct {
|
type ResetGroupsLogic struct {
|
||||||
@@ -27,56 +29,65 @@ func NewResetGroupsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Reset
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *ResetGroupsLogic) ResetGroups() error {
|
func (l *ResetGroupsLogic) ResetGroups() error {
|
||||||
|
err := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
// 1. Delete all node groups
|
// 1. Delete all node groups
|
||||||
err := l.svcCtx.DB.Where("1 = 1").Delete(&group.NodeGroup{}).Error
|
if err := tx.Where("1 = 1").Delete(&group.NodeGroup{}).Error; err != nil {
|
||||||
if err != nil {
|
|
||||||
l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error()))
|
l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
l.Infow("Successfully deleted all node groups")
|
l.Infow("Successfully deleted all node groups")
|
||||||
|
|
||||||
// 2. Clear node_group_ids for all subscribes (products)
|
// 2. Clear node_group_id/node_group_ids for all subscribes (products)
|
||||||
err = l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("1 = 1").Update("node_group_ids", "[]").Error
|
if err := tx.Table((&subscribe.Subscribe{}).TableName()).Where("1 = 1").Updates(map[string]interface{}{
|
||||||
if err != nil {
|
"node_group_id": 0,
|
||||||
l.Errorw("Failed to clear subscribes' node_group_ids", logger.Field("error", err.Error()))
|
"node_group_ids": "[]",
|
||||||
|
}).Error; err != nil {
|
||||||
|
l.Errorw("Failed to clear subscribes' node groups", logger.Field("error", err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
l.Infow("Successfully cleared all subscribes' node_group_ids")
|
l.Infow("Successfully cleared all subscribes' node groups")
|
||||||
|
|
||||||
// 3. Clear node_group_ids for all nodes
|
// 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 := tx.Table((&node.Node{}).TableName()).Where("1 = 1").Update("node_group_ids", "[]").Error; err != nil {
|
||||||
if err != nil {
|
|
||||||
l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error()))
|
l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
l.Infow("Successfully cleared all nodes' node_group_ids")
|
l.Infow("Successfully cleared all nodes' node_group_ids")
|
||||||
|
|
||||||
// 4. Clear group history
|
// 4. Clear user_subscribe node_group_id
|
||||||
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistory{}).Error
|
if err := tx.Table((&user.Subscribe{}).TableName()).Where("1 = 1").Update("node_group_id", 0).Error; err != nil {
|
||||||
if 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()))
|
l.Errorw("Failed to clear group history", logger.Field("error", err.Error()))
|
||||||
// Non-critical error, continue anyway
|
return err
|
||||||
} else {
|
}
|
||||||
l.Infow("Successfully cleared group history")
|
l.Infow("Successfully cleared group history")
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Clear group history details
|
// 6. Clear group history details
|
||||||
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error
|
if err := tx.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error; err != nil {
|
||||||
if err != nil {
|
|
||||||
l.Errorw("Failed to clear group history details", logger.Field("error", err.Error()))
|
l.Errorw("Failed to clear group history details", logger.Field("error", err.Error()))
|
||||||
// Non-critical error, continue anyway
|
return err
|
||||||
} else {
|
|
||||||
l.Infow("Successfully cleared group history details")
|
|
||||||
}
|
}
|
||||||
|
l.Infow("Successfully cleared group history details")
|
||||||
|
|
||||||
// 5. Delete all group config settings
|
// 7. Delete all group config settings
|
||||||
err = l.svcCtx.DB.Where("`category` = ?", "group").Delete(&system.System{}).Error
|
if err := tx.Where("`category` = ?", "group").Delete(&system.System{}).Error; err != nil {
|
||||||
if err != nil {
|
|
||||||
l.Errorw("Failed to delete group config", logger.Field("error", err.Error()))
|
l.Errorw("Failed to delete group config", logger.Field("error", err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
l.Infow("Successfully deleted all group config settings")
|
l.Infow("Successfully deleted all group config settings")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
l.Infow("Group reset completed successfully")
|
l.Infow("Group reset completed successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package invite
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
|
|
||||||
modellog "github.com/perfect-panel/server/internal/model/log"
|
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/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -113,6 +115,7 @@ func QueryBenefits(ctx context.Context, db *gorm.DB, relations []InviteRelation)
|
|||||||
for userId := range inviteeAndInviterSet {
|
for userId := range inviteeAndInviterSet {
|
||||||
inviteeAndInviterIds = append(inviteeAndInviterIds, userId)
|
inviteeAndInviterIds = append(inviteeAndInviterIds, userId)
|
||||||
}
|
}
|
||||||
|
slices.Sort(inviteeAndInviterIds)
|
||||||
|
|
||||||
if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil {
|
if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -186,26 +189,35 @@ func fillGiftBenefits(ctx context.Context, db *gorm.DB, benefits map[int64]Benef
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func QueryIdentifiers(ctx context.Context, db *gorm.DB, userIds []int64) (map[int64]string, error) {
|
type DeviceIdentifier struct {
|
||||||
identifiers := make(map[int64]string, len(userIds))
|
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))
|
||||||
if len(userIds) == 0 {
|
if len(userIds) == 0 {
|
||||||
return identifiers, nil
|
return identifiers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type identifierRow struct {
|
type identifierRow struct {
|
||||||
UserId int64 `gorm:"column:user_id"`
|
UserId int64 `gorm:"column:user_id"`
|
||||||
|
DeviceId int64 `gorm:"column:device_id"`
|
||||||
Identifier string `gorm:"column:identifier"`
|
Identifier string `gorm:"column:identifier"`
|
||||||
}
|
}
|
||||||
var rows []identifierRow
|
var rows []identifierRow
|
||||||
if err := db.WithContext(ctx).
|
if err := db.WithContext(ctx).
|
||||||
Table("user_auth_methods uam").
|
Table("user_device ud").
|
||||||
Select("uam.user_id, uam.auth_identifier as identifier").
|
Select("ud.user_id, ud.id as device_id, ud.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).
|
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).
|
||||||
Scan(&rows).Error; err != nil {
|
Scan(&rows).Error; err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user identifiers failed: %v", err)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user device identifiers failed: %v", err)
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
identifiers[row.UserId] = row.Identifier
|
identifiers[row.UserId] = DeviceIdentifier{
|
||||||
|
Identifier: row.Identifier,
|
||||||
|
DeviceNo: tool.DeviceIdToHash(row.DeviceId),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return identifiers, nil
|
return identifiers, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/DATA-DOG/go-sqlmock"
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -25,7 +26,7 @@ func TestQueryBenefitsCountsFamilyOwnerGiftAsInviteeGift(t *testing.T) {
|
|||||||
WithArgs(33, int64(100), "family-order", 331, 332).
|
WithArgs(33, int64(100), "family-order", 331, 332).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
||||||
mock.ExpectQuery("object_id IN").
|
mock.ExpectQuery("object_id IN").
|
||||||
WithArgs(34, int64(900), int64(200), int64(100), "family-order").
|
WithArgs(34, int64(100), int64(200), int64(900), "family-order").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
||||||
AddRow(900, `{"type":341,"order_no":"family-order","amount":7,"balance":7,"remark":"邀请赠送"}`))
|
AddRow(900, `{"type":341,"order_no":"family-order","amount":7,"balance":7,"remark":"邀请赠送"}`))
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ func TestQueryBenefitsKeepsDirectInviteeGift(t *testing.T) {
|
|||||||
WithArgs(33, int64(100), "direct-order", 331, 332).
|
WithArgs(33, int64(100), "direct-order", 331, 332).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
||||||
mock.ExpectQuery("object_id IN").
|
mock.ExpectQuery("object_id IN").
|
||||||
WithArgs(34, int64(200), int64(100), "direct-order").
|
WithArgs(34, int64(100), int64(200), "direct-order").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
||||||
AddRow(200, `{"type":341,"order_no":"direct-order","amount":5,"balance":5,"remark":"邀请赠送"}`))
|
AddRow(200, `{"type":341,"order_no":"direct-order","amount":5,"balance":5,"remark":"邀请赠送"}`))
|
||||||
|
|
||||||
@@ -73,6 +74,39 @@ func TestQueryBenefitsKeepsDirectInviteeGift(t *testing.T) {
|
|||||||
assertBenefitsExpectations(t, mock)
|
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()) {
|
func newBenefitsTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (l *GetInviteManageListLogic) GetInviteManageList(req *types.GetInviteManag
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
identifiers, err := QueryIdentifiers(l.ctx, l.svcCtx.DB, userIds)
|
devices, err := QueryDeviceIdentifiers(l.ctx, l.svcCtx.DB, userIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -78,9 +78,11 @@ func (l *GetInviteManageListLogic) GetInviteManageList(req *types.GetInviteManag
|
|||||||
benefit := benefits[row.InviteeId]
|
benefit := benefits[row.InviteeId]
|
||||||
list = append(list, types.InviteManageRecord{
|
list = append(list, types.InviteManageRecord{
|
||||||
InviterId: row.InviterId,
|
InviterId: row.InviterId,
|
||||||
InviterIdentifier: identifiers[row.InviterId],
|
InviterIdentifier: devices[row.InviterId].Identifier,
|
||||||
|
InviterDeviceNo: devices[row.InviterId].DeviceNo,
|
||||||
InviteeId: row.InviteeId,
|
InviteeId: row.InviteeId,
|
||||||
InviteeIdentifier: identifiers[row.InviteeId],
|
InviteeIdentifier: devices[row.InviteeId].Identifier,
|
||||||
|
InviteeDeviceNo: devices[row.InviteeId].DeviceNo,
|
||||||
InviteeAvatar: row.InviteeAvatar,
|
InviteeAvatar: row.InviteeAvatar,
|
||||||
InviteeEnable: row.InviteeEnable,
|
InviteeEnable: row.InviteeEnable,
|
||||||
InvitedAt: row.InvitedAt,
|
InvitedAt: row.InvitedAt,
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ func orderStatusName(status uint8) string {
|
|||||||
case 5:
|
case 5:
|
||||||
return "finished"
|
return "finished"
|
||||||
case 6:
|
case 6:
|
||||||
|
return "claimed"
|
||||||
|
case 7:
|
||||||
return "refunded"
|
return "refunded"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
orderStatusRefunded = 6
|
orderStatusRefunded = 7
|
||||||
)
|
)
|
||||||
|
|
||||||
type RefundOrderLogic struct {
|
type RefundOrderLogic struct {
|
||||||
@@ -67,15 +67,12 @@ 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)
|
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)
|
refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if refunded {
|
if refunded {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund commission log", orderInfo.Id)
|
return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund log", orderInfo.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo)
|
userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo)
|
||||||
@@ -173,6 +170,9 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
|
|||||||
orderUser := &modeluser.User{Id: orderInfo.UserId}
|
orderUser := &modeluser.User{Id: orderInfo.UserId}
|
||||||
userCacheTargets = append(userCacheTargets, orderUser)
|
userCacheTargets = append(userCacheTargets, orderUser)
|
||||||
}
|
}
|
||||||
|
if userSub.UserId > 0 && userSub.UserId != orderInfo.UserId {
|
||||||
|
userCacheTargets = append(userCacheTargets, &modeluser.User{Id: userSub.UserId})
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -180,17 +180,19 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cachesToClear) > 0 {
|
if len(cachesToClear) > 0 && l.svcCtx.UserModel != nil {
|
||||||
if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, cachesToClear...); clearErr != nil {
|
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))
|
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("order_id", req.Id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, subscribeID := range planCacheIDs {
|
for _, subscribeID := range planCacheIDs {
|
||||||
|
if l.svcCtx.SubscribeModel != nil {
|
||||||
if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != 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))
|
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(userCacheTargets) > 0 {
|
}
|
||||||
|
if len(userCacheTargets) > 0 && l.svcCtx.UserModel != nil {
|
||||||
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userCacheTargets...); clearErr != nil {
|
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userCacheTargets...); clearErr != nil {
|
||||||
l.Errorw("[RefundOrder] clear user cache failed", logger.Field("error", clearErr.Error()))
|
l.Errorw("[RefundOrder] clear user cache failed", logger.Field("error", clearErr.Error()))
|
||||||
}
|
}
|
||||||
@@ -199,16 +201,16 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *RefundOrderLogic) lockRefundTargetSubscription(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) {
|
func (l *RefundOrderLogic) lockRefundTargetSubscription(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) {
|
||||||
var userSub modeluser.Subscribe
|
if userSub, err := l.lockSubscriptionByOrderID(tx, orderInfo.Id); err != nil {
|
||||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
return nil, err
|
||||||
Model(&modeluser.Subscribe{}).
|
} else if userSub != nil {
|
||||||
Where("order_id = ?", orderInfo.Id).
|
return userSub, nil
|
||||||
First(&userSub).Error
|
|
||||||
if err == 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 userSub, err := l.lockSubscriptionByEntitlement(tx, orderInfo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if userSub != nil {
|
||||||
|
return userSub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if orderInfo.Type != 2 {
|
if orderInfo.Type != 2 {
|
||||||
@@ -219,18 +221,123 @@ 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)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d has no parent order", orderInfo.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
if userSub, err := l.lockSubscriptionByOrderID(tx, orderInfo.ParentId); err != nil {
|
||||||
Model(&modeluser.Subscribe{}).
|
return nil, err
|
||||||
Where("order_id = ?", orderInfo.ParentId).
|
} else if userSub != nil {
|
||||||
First(&userSub).Error
|
return userSub, nil
|
||||||
if err != nil {
|
}
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
|
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)
|
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 subscription failed: %v", err)
|
|
||||||
|
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"}).
|
||||||
|
Model(&modeluser.Subscribe{}).
|
||||||
|
Where("order_id = ?", orderID).
|
||||||
|
First(&userSub).Error
|
||||||
|
if err == nil {
|
||||||
return &userSub, 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.DatabaseQueryError), "query renewal parent order 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)
|
||||||
|
}
|
||||||
|
|
||||||
func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, orderCommission int64) (*modeluser.User, int64, error) {
|
func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, orderCommission int64) (*modeluser.User, int64, error) {
|
||||||
var commissionLogs []log.SystemLog
|
var commissionLogs []log.SystemLog
|
||||||
@@ -267,10 +374,15 @@ func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, ord
|
|||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasRefundLog 检查指定 order_no 是否已有 333 (CommissionTypeRefund) 退款佣金日志。
|
// hasRefundLog checks refund audit logs first, then falls back to legacy commission refund logs.
|
||||||
// 仅扫 type=33 + 内容含 order_no 的命中项,再用 JSON 二次确认 content.type==333,
|
|
||||||
// 防止 content.order_no 子串误判。
|
|
||||||
func (l *RefundOrderLogic) hasRefundLog(tx *gorm.DB, orderNo string) (bool, error) {
|
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)
|
return log.HasRefundCommissionLog(tx, orderNo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/DATA-DOG/go-sqlmock"
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
|
||||||
modelorder "github.com/perfect-panel/server/internal/model/order"
|
modelorder "github.com/perfect-panel/server/internal/model/order"
|
||||||
modeluser "github.com/perfect-panel/server/internal/model/user"
|
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/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@@ -27,8 +27,8 @@ func TestOrderStatusName(t *testing.T) {
|
|||||||
3: "closed",
|
3: "closed",
|
||||||
4: "failed",
|
4: "failed",
|
||||||
5: "finished",
|
5: "finished",
|
||||||
6: "refunded",
|
6: "claimed",
|
||||||
7: "unknown",
|
7: "refunded",
|
||||||
}
|
}
|
||||||
|
|
||||||
for input, want := range tests {
|
for input, want := range tests {
|
||||||
@@ -96,14 +96,64 @@ func TestBuildRefundAuditLog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-131 / HIF-132 修复:
|
func TestRefundOrder_SetsStatusRefunded(t *testing.T) {
|
||||||
// 当 system_logs 已存在该订单的 333 退款佣金日志时,再次调用 RefundOrder 必须:
|
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 必须:
|
||||||
// 1. 返回 OrderAlreadyRefunded 错误码;
|
// 1. 返回 OrderAlreadyRefunded 错误码;
|
||||||
// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发);
|
// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发);
|
||||||
// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。
|
// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。
|
||||||
//
|
//
|
||||||
// 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE /
|
// 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE /
|
||||||
// SELECT system_logs(命中 333)/ ROLLBACK,不允许出现 commission 锁/更新/插入。
|
// SELECT system_logs(命中 24)/ ROLLBACK,不允许出现 commission 锁/更新/插入。
|
||||||
func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
orderID = int64(53647)
|
orderID = int64(53647)
|
||||||
@@ -120,9 +170,9 @@ func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
|||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id"}).
|
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id"}).
|
||||||
AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028)))
|
AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028)))
|
||||||
mock.ExpectQuery("FROM `system_logs`").
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
|
||||||
AddRow(1, fmt.Sprintf(`{"type":333,"order_no":"%s","amount":-2250,"timestamp":0}`, orderNo)))
|
AddRow(1, fmt.Sprintf(`{"order_id":%d,"order_no":"%s","order_status_before":5,"order_status_after":7}`, orderID, orderNo)))
|
||||||
mock.ExpectRollback()
|
mock.ExpectRollback()
|
||||||
|
|
||||||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||||||
@@ -138,12 +188,12 @@ func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==6 拒绝路径,
|
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==7 拒绝路径,
|
||||||
// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。
|
// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。
|
||||||
func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) {
|
func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
orderID = int64(1001)
|
orderID = int64(1001)
|
||||||
orderNo = "ORD-STATUS-6"
|
orderNo = "ORD-STATUS-7"
|
||||||
operatorUID = int64(519)
|
operatorUID = int64(519)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -195,6 +245,144 @@ 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()) {
|
func newRefundOrderTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ func NewUpdateOrderStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *UpdateOrderStatusLogic) UpdateOrderStatus(req *types.UpdateOrderStatusRequest) error {
|
func (l *UpdateOrderStatusLogic) UpdateOrderStatus(req *types.UpdateOrderStatusRequest) error {
|
||||||
|
if req.Status == orderStatusRefunded {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "refund status must use refund order endpoint")
|
||||||
|
}
|
||||||
|
|
||||||
info, err := l.svcCtx.OrderModel.FindOne(l.ctx, req.Id)
|
info, err := l.svcCtx.OrderModel.FindOne(l.ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[UpdateOrderStatus] FindOne error", logger.Field("error", err.Error()))
|
l.Errorw("[UpdateOrderStatus] FindOne error", logger.Field("error", err.Error()))
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := logic.UpdateOrderStatus(&types.UpdateOrderStatusRequest{
|
||||||
|
Id: 1001,
|
||||||
|
Status: orderStatusRefunded,
|
||||||
|
})
|
||||||
|
if !isErrCode(err, xerr.OrderStatusError) {
|
||||||
|
t.Fatalf("UpdateOrderStatus error code = %v, want OrderStatusError; raw=%v", errCodeOf(err), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/group"
|
"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/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@@ -61,12 +63,24 @@ func (l *GetUserSubscribeLogic) GetUserSubscribe(req *types.GetUserSubscribeList
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range data {
|
for _, item := range data {
|
||||||
var sub types.UserSubscribe
|
sub := buildUserSubscribeListItem(item, groupNames[item.NodeGroupId])
|
||||||
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)
|
resp.List = append(resp.List, sub)
|
||||||
}
|
}
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
@@ -45,7 +46,11 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
|
|||||||
}
|
}
|
||||||
trafficLimit := userSub.TrafficLimit
|
trafficLimit := userSub.TrafficLimit
|
||||||
if req.TrafficLimit != nil {
|
if req.TrafficLimit != nil {
|
||||||
trafficLimit = req.TrafficLimit
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, &user.Subscribe{
|
err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, &user.Subscribe{
|
||||||
@@ -96,3 +101,12 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,3 +58,26 @@ func calculatePurchasePrice(
|
|||||||
result.DiscountAmount = originalPrice - result.PayableBase
|
result.DiscountAmount = originalPrice - result.PayableBase
|
||||||
return result, nil
|
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,3 +297,78 @@ func TestCalculatePurchasePriceNewUserGatedByFirstPurchase(t *testing.T) {
|
|||||||
t.Fatalf("PayableBase = %d, want 900 (regular 90%% discount)", result.PayableBase)
|
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,7 +3,6 @@ package order
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -165,13 +164,28 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
|
|||||||
)
|
)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var discount float64 = 1
|
priceResult, err := calculateRenewalPrice(
|
||||||
if len(newUserDiscount.Discounts) > 0 {
|
l.ctx,
|
||||||
discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount)
|
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
|
||||||
}
|
}
|
||||||
price := sub.UnitPrice * req.Quantity
|
price := priceResult.OriginalPrice
|
||||||
amount := int64(math.Round(float64(price) * discount))
|
amount := priceResult.PayableBase
|
||||||
discountAmount := price - amount
|
discountAmount := priceResult.DiscountAmount
|
||||||
|
|
||||||
// Validate amount to prevent overflow
|
// Validate amount to prevent overflow
|
||||||
if amount > MaxOrderAmount {
|
if amount > MaxOrderAmount {
|
||||||
@@ -269,6 +283,8 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
|
|||||||
Amount: amount,
|
Amount: amount,
|
||||||
GiftAmount: deductionAmount,
|
GiftAmount: deductionAmount,
|
||||||
Discount: discountAmount,
|
Discount: discountAmount,
|
||||||
|
PromoRuleId: priceResult.PromoRuleId,
|
||||||
|
PromoDiscount: priceResult.PromoDiscount,
|
||||||
Coupon: req.Coupon,
|
Coupon: req.Coupon,
|
||||||
CouponDiscount: coupon,
|
CouponDiscount: coupon,
|
||||||
PaymentId: payment.Id,
|
PaymentId: payment.Id,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package user
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
@@ -12,6 +13,11 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
withdrawalLogBizTypeWithdrawal = "withdrawal"
|
||||||
|
withdrawalLogBizTypeCommissionRefund = "commission_refund"
|
||||||
|
)
|
||||||
|
|
||||||
type QueryWithdrawalLogLogic struct {
|
type QueryWithdrawalLogLogic struct {
|
||||||
logger.Logger
|
logger.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -43,15 +49,26 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
|
|||||||
size = 10
|
size = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
query := l.svcCtx.DB.WithContext(l.ctx).Model(&user.Withdrawal{}).Where("user_id = ?", u.Id)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
var total int64
|
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)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count withdrawal logs failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []user.Withdrawal
|
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)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query withdrawal logs failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +76,7 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
list = append(list, types.WithdrawalLog{
|
list = append(list, types.WithdrawalLog{
|
||||||
Id: row.Id,
|
Id: row.Id,
|
||||||
|
BizType: withdrawalLogBizTypeWithdrawal,
|
||||||
UserId: row.UserId,
|
UserId: row.UserId,
|
||||||
Amount: row.Amount,
|
Amount: row.Amount,
|
||||||
Content: row.Content,
|
Content: row.Content,
|
||||||
@@ -77,3 +95,53 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
|
|||||||
Total: total,
|
Total: total,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *QueryWithdrawalLogLogic) queryCommissionRefundLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) {
|
||||||
|
query := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&log.SystemLog{}).
|
||||||
|
Where("`type` = ? AND object_id = ? AND `content` LIKE ?", log.TypeCommission.Uint8(), userID, "%\"type\":333%")
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count commission refund 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, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query commission refund logs failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]types.WithdrawalLog, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
var content log.Commission
|
||||||
|
if err := content.Unmarshal([]byte(row.Content)); err != nil {
|
||||||
|
l.Errorw("unmarshal commission refund log content failed",
|
||||||
|
logger.Field("log_id", row.Id),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if content.Type != log.CommissionTypeRefund {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := content.Timestamp
|
||||||
|
if timestamp == 0 {
|
||||||
|
timestamp = row.CreatedAt.UnixMilli()
|
||||||
|
}
|
||||||
|
list = append(list, types.WithdrawalLog{
|
||||||
|
Id: row.Id,
|
||||||
|
BizType: withdrawalLogBizTypeCommissionRefund,
|
||||||
|
UserId: row.ObjectID,
|
||||||
|
Amount: content.Amount,
|
||||||
|
Content: row.Content,
|
||||||
|
CreatedAt: timestamp,
|
||||||
|
UpdatedAt: timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.QueryWithdrawalLogListResponse{
|
||||||
|
List: list,
|
||||||
|
Total: total,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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,
|
||||||
|
))
|
||||||
|
|
||||||
|
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.UnixMilli() || got.UpdatedAt != updatedAt.UnixMilli() {
|
||||||
|
t.Fatalf("withdrawal item = %+v", got)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWithdrawalLog_WithCommissionRefundBizType(t *testing.T) {
|
||||||
|
const userID = int64(42)
|
||||||
|
content := `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000000123}`
|
||||||
|
createdAt := time.Unix(1700000000, 0)
|
||||||
|
|
||||||
|
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ? AND `content` LIKE ?").
|
||||||
|
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||||
|
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, content, 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)
|
||||||
|
}
|
||||||
|
got := resp.List[0]
|
||||||
|
if got.BizType != withdrawalLogBizTypeCommissionRefund {
|
||||||
|
t.Fatalf("BizType = %q, want %q", got.BizType, withdrawalLogBizTypeCommissionRefund)
|
||||||
|
}
|
||||||
|
if got.Id != 2001 || got.UserId != userID || got.Amount != 2500 || got.Content != content || got.CreatedAt != 1700000000123 || got.UpdatedAt != 1700000000123 {
|
||||||
|
t.Fatalf("commission refund item = %+v", got)
|
||||||
|
}
|
||||||
|
if got.Status != 0 || got.Method != 0 || got.Account != "" || got.QrCodeUrl != "" {
|
||||||
|
t.Fatalf("commission refund withdrawal-only fields = %+v, want zero values", got)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID})
|
||||||
|
return &QueryWithdrawalLogLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: &svc.ServiceContext{
|
||||||
|
DB: db,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +19,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/tool"
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
"github.com/perfect-panel/server/pkg/uuidx"
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GetServerUserListLogic struct {
|
type GetServerUserListLogic struct {
|
||||||
@@ -27,6 +28,46 @@ type GetServerUserListLogic struct {
|
|||||||
svcCtx *svc.ServiceContext
|
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
|
// NewGetServerUserListLogic Get user list
|
||||||
func NewGetServerUserListLogic(ctx *gin.Context, svcCtx *svc.ServiceContext) *GetServerUserListLogic {
|
func NewGetServerUserListLogic(ctx *gin.Context, svcCtx *svc.ServiceContext) *GetServerUserListLogic {
|
||||||
return &GetServerUserListLogic{
|
return &GetServerUserListLogic{
|
||||||
@@ -37,13 +78,27 @@ func NewGetServerUserListLogic(ctx *gin.Context, svcCtx *svc.ServiceContext) *Ge
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListRequest) (resp *types.GetServerUserListResponse, err error) {
|
func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListRequest) (resp *types.GetServerUserListResponse, err error) {
|
||||||
cacheKey := fmt.Sprintf("%s%d", node.ServerUserListCacheKey, req.ServerId)
|
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)
|
||||||
cache, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
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 != "" {
|
if cache != "" {
|
||||||
|
stats.cacheHit = true
|
||||||
etag := tool.GenerateETag([]byte(cache))
|
etag := tool.GenerateETag([]byte(cache))
|
||||||
resp = &types.GetServerUserListResponse{}
|
resp = &types.GetServerUserListResponse{}
|
||||||
// Check If-None-Match header
|
// Check If-None-Match header
|
||||||
if match := l.ctx.GetHeader("If-None-Match"); match == etag {
|
if match := l.ctx.GetHeader("If-None-Match"); match == etag {
|
||||||
|
l.logServerUserListPerf(stats)
|
||||||
return nil, xerr.StatusNotModified
|
return nil, xerr.StatusNotModified
|
||||||
}
|
}
|
||||||
l.ctx.Header("ETag", etag)
|
l.ctx.Header("ETag", etag)
|
||||||
@@ -52,6 +107,9 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
|||||||
l.Errorw("[ServerUserListCacheKey] json unmarshal error", logger.Field("error", err.Error()))
|
l.Errorw("[ServerUserListCacheKey] json unmarshal error", logger.Field("error", err.Error()))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
stats.usersCount = len(resp.Users)
|
||||||
|
stats.recordSpeedLimits(resp.Users)
|
||||||
|
l.logServerUserListPerf(stats)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
server, err := l.svcCtx.NodeModel.FindOneServer(l.ctx, req.ServerId)
|
server, err := l.svcCtx.NodeModel.FindOneServer(l.ctx, req.ServerId)
|
||||||
@@ -64,14 +122,22 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
|||||||
Page: 1,
|
Page: 1,
|
||||||
Size: 1000,
|
Size: 1000,
|
||||||
ServerId: []int64{server.Id},
|
ServerId: []int64{server.Id},
|
||||||
Protocol: req.Protocol,
|
Protocol: protocolRequest,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("FilterNodeList error", logger.Field("error", err.Error()))
|
l.Errorw("FilterNodeList error", logger.Field("error", err.Error()))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
stats.nodesCount = len(nodes)
|
||||||
|
|
||||||
if len(nodes) == 0 {
|
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{
|
return &types.GetServerUserListResponse{
|
||||||
Users: []types.ServerUser{
|
Users: []types.ServerUser{
|
||||||
{
|
{
|
||||||
@@ -139,6 +205,13 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(subs) == 0 {
|
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{
|
return &types.GetServerUserListResponse{
|
||||||
Users: []types.ServerUser{
|
Users: []types.ServerUser{
|
||||||
{
|
{
|
||||||
@@ -148,7 +221,9 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
|||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
stats.subsCount = len(subs)
|
||||||
users := make([]types.ServerUser, 0)
|
users := make([]types.ServerUser, 0)
|
||||||
|
speedCandidates := make(map[int64]serverUserSpeedLimitCandidate)
|
||||||
for _, sub := range subs {
|
for _, sub := range subs {
|
||||||
data, err := l.svcCtx.UserModel.FindUsersSubscribeBySubscribeId(l.ctx, sub.Id)
|
data, err := l.svcCtx.UserModel.FindUsersSubscribeBySubscribeId(l.ctx, sub.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -159,17 +234,29 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算该用户的实际限速值(考虑按量限速规则)
|
baseSpeed, trafficLimit := serverUserSpeedLimitInputs(sub, datum)
|
||||||
effectiveSpeedLimit := l.calculateEffectiveSpeedLimit(sub, datum)
|
speedCandidates[datum.Id] = serverUserSpeedLimitCandidate{
|
||||||
|
userID: datum.UserId,
|
||||||
|
userSubscribeID: datum.Id,
|
||||||
|
baseSpeed: baseSpeed,
|
||||||
|
trafficLimit: trafficLimit,
|
||||||
|
}
|
||||||
users = append(users, types.ServerUser{
|
users = append(users, types.ServerUser{
|
||||||
Id: datum.Id,
|
Id: datum.Id,
|
||||||
UUID: datum.UUID,
|
UUID: datum.UUID,
|
||||||
SpeedLimit: effectiveSpeedLimit,
|
SpeedLimit: baseSpeed,
|
||||||
DeviceLimit: sub.DeviceLimit,
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户
|
// 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户
|
||||||
if len(nodeGroupIds) > 0 {
|
if len(nodeGroupIds) > 0 {
|
||||||
@@ -183,14 +270,27 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(users) == 0 {
|
if len(users) == 0 {
|
||||||
users = append(users, types.ServerUser{
|
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,
|
Id: 1,
|
||||||
UUID: uuidx.NewUUID().String(),
|
UUID: uuidx.NewUUID().String(),
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
resp = &types.GetServerUserListResponse{
|
resp = &types.GetServerUserListResponse{
|
||||||
Users: users,
|
Users: users,
|
||||||
}
|
}
|
||||||
|
stats.usersCount = len(users)
|
||||||
|
stats.recordSpeedLimits(users)
|
||||||
val, _ := json.Marshal(resp)
|
val, _ := json.Marshal(resp)
|
||||||
etag := tool.GenerateETag(val)
|
etag := tool.GenerateETag(val)
|
||||||
l.ctx.Header("ETag", etag)
|
l.ctx.Header("ETag", etag)
|
||||||
@@ -200,11 +300,24 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
|||||||
}
|
}
|
||||||
// Check If-None-Match header
|
// Check If-None-Match header
|
||||||
if match := l.ctx.GetHeader("If-None-Match"); match == etag {
|
if match := l.ctx.GetHeader("If-None-Match"); match == etag {
|
||||||
|
l.logServerUserListPerf(stats)
|
||||||
return nil, xerr.StatusNotModified
|
return nil, xerr.StatusNotModified
|
||||||
}
|
}
|
||||||
|
l.logServerUserListPerf(stats)
|
||||||
return resp, nil
|
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 {
|
func (l *GetServerUserListLogic) serverUserListCacheTTL() time.Duration {
|
||||||
pullInterval := l.svcCtx.Config.Node.NodePullInterval
|
pullInterval := l.svcCtx.Config.Node.NodePullInterval
|
||||||
if pullInterval <= 0 {
|
if pullInterval <= 0 {
|
||||||
@@ -305,8 +418,7 @@ func (l *GetServerUserListLogic) canUseExpiredNodeGroup(userSub *user.Subscribe,
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateEffectiveSpeedLimit 计算用户的实际限速值(考虑按量限速规则)
|
func serverUserSpeedLimitInputs(sub *subscribe.Subscribe, userSub *user.Subscribe) (int64, string) {
|
||||||
func (l *GetServerUserListLogic) calculateEffectiveSpeedLimit(sub *subscribe.Subscribe, userSub *user.Subscribe) int64 {
|
|
||||||
baseSpeed := sub.SpeedLimit
|
baseSpeed := sub.SpeedLimit
|
||||||
if userSub.SpeedLimit > 0 {
|
if userSub.SpeedLimit > 0 {
|
||||||
baseSpeed = userSub.SpeedLimit
|
baseSpeed = userSub.SpeedLimit
|
||||||
@@ -317,15 +429,170 @@ func (l *GetServerUserListLogic) calculateEffectiveSpeedLimit(sub *subscribe.Sub
|
|||||||
trafficLimit = *userSub.TrafficLimit
|
trafficLimit = *userSub.TrafficLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
result := speedlimit.CalculateWithCache(
|
return baseSpeed, trafficLimit
|
||||||
l.ctx.Request.Context(),
|
}
|
||||||
l.svcCtx.Redis,
|
|
||||||
l.svcCtx.DB,
|
func (l *GetServerUserListLogic) calculateServerUserSpeedLimits(candidates map[int64]serverUserSpeedLimitCandidate) map[int64]int64 {
|
||||||
userSub.UserId,
|
speedLimits := make(map[int64]int64, len(candidates))
|
||||||
userSub.Id,
|
rulesByUserSubscribeID := make(map[int64][]speedlimit.TrafficLimitRule)
|
||||||
baseSpeed,
|
windowByKey := make(map[string]serverUserTrafficWindow)
|
||||||
trafficLimit,
|
now := time.Now()
|
||||||
30*time.Second,
|
|
||||||
)
|
for userSubscribeID, candidate := range candidates {
|
||||||
return result.EffectiveSpeed
|
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()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -7,9 +7,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HasRefundCommissionLog 判断指定订单号是否已写入 333 退款佣金日志。
|
// HasRefundCommissionLog 判断指定订单号是否已写入 333 退款佣金日志。
|
||||||
// 用于 refund 主流程做幂等校验,以及 stuck-order recovery / activate worker
|
|
||||||
// 区分「已退款」(terminal)与「短暂 claimed」(transient)这两种共用 status=6
|
|
||||||
// 的语义。
|
|
||||||
//
|
//
|
||||||
// 实现细节:
|
// 实现细节:
|
||||||
// 1. type=33 + content LIKE '%"order_no":"<orderNo>"%' 先走索引粗筛;
|
// 1. type=33 + content LIKE '%"order_no":"<orderNo>"%' 先走索引粗筛;
|
||||||
@@ -36,3 +33,26 @@ func HasRefundCommissionLog(tx *gorm.DB, orderNo string) (bool, error) {
|
|||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasOrderRefundLog 判断指定订单号是否已写入 24 订单退款审计日志。
|
||||||
|
func HasOrderRefundLog(tx *gorm.DB, orderNo string) (bool, error) {
|
||||||
|
if orderNo == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var logs []SystemLog
|
||||||
|
if err := tx.Model(&SystemLog{}).
|
||||||
|
Where("type = ? AND content LIKE ?", TypeOrderRefund.Uint8(), fmt.Sprintf("%%\"order_no\":\"%s\"%%", orderNo)).
|
||||||
|
Find(&logs).Error; err != nil {
|
||||||
|
return false, fmt.Errorf("query order refund log failed: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range logs {
|
||||||
|
var content OrderRefund
|
||||||
|
if err := content.Unmarshal([]byte(item.Content)); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if content.OrderNo == orderNo {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,6 +140,62 @@ func TestHasRefundCommissionLog(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHasOrderRefundLog(t *testing.T) {
|
||||||
|
const orderNo = "ORD-REFUND-AUDIT-1"
|
||||||
|
|
||||||
|
t.Run("returns true when order refund audit log exists", func(t *testing.T) {
|
||||||
|
db, mock, cleanup := newRefundLogTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
|
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
|
||||||
|
AddRow(1, fmt.Sprintf(`{"order_id":100,"order_no":"%s","order_status_after":7}`, orderNo)))
|
||||||
|
|
||||||
|
got, err := HasOrderRefundLog(db, orderNo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HasOrderRefundLog error: %v", err)
|
||||||
|
}
|
||||||
|
if !got {
|
||||||
|
t.Fatalf("HasOrderRefundLog = false, want true")
|
||||||
|
}
|
||||||
|
assertRefundLogExpectations(t, mock)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns false when audit log order_no does not match", func(t *testing.T) {
|
||||||
|
db, mock, cleanup := newRefundLogTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("FROM `system_logs`").
|
||||||
|
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}).
|
||||||
|
AddRow(1, `{"order_id":100,"order_no":"OTHER","order_status_after":7}`))
|
||||||
|
|
||||||
|
got, err := HasOrderRefundLog(db, orderNo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HasOrderRefundLog error: %v", err)
|
||||||
|
}
|
||||||
|
if got {
|
||||||
|
t.Fatalf("HasOrderRefundLog = true, want false")
|
||||||
|
}
|
||||||
|
assertRefundLogExpectations(t, mock)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns false for empty order_no without querying", func(t *testing.T) {
|
||||||
|
db, mock, cleanup := newRefundLogTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
got, err := HasOrderRefundLog(db, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HasOrderRefundLog error: %v", err)
|
||||||
|
}
|
||||||
|
if got {
|
||||||
|
t.Fatalf("HasOrderRefundLog = true, want false")
|
||||||
|
}
|
||||||
|
assertRefundLogExpectations(t, mock)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func newRefundLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
func newRefundLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,31 @@ const (
|
|||||||
ServerConfigCacheKey = "server:config:"
|
ServerConfigCacheKey = "server:config:"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AllProtocols 枚举所有客户端可能携带的 protocol。
|
||||||
|
// 用户列表缓存 key 形态为 `server:user:{server_id}:{protocol}`,按 server_id
|
||||||
|
// 失效时需要按协议精确删除。SCAN 在增量 rehash 期间可能漏 key,因此采用显式枚举。
|
||||||
|
// 包含 hysteria2(兼容字段,与 hysteria 同语义),多余的 Del 是 no-op,over-deletion 安全。
|
||||||
|
var AllProtocols = []string{
|
||||||
|
"shadowsocks",
|
||||||
|
"vmess",
|
||||||
|
"vless",
|
||||||
|
"trojan",
|
||||||
|
"anytls",
|
||||||
|
"tuic",
|
||||||
|
"hysteria",
|
||||||
|
"hysteria2",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。
|
||||||
|
// 用于 Del 路径——节点 / 订阅 / 流量统计触发缓存失效时一次性清掉该 server 下所有协议条目。
|
||||||
|
func ServerUserListCacheKeysForServer(serverId int64) []string {
|
||||||
|
keys := make([]string, 0, len(AllProtocols))
|
||||||
|
for _, protocol := range AllProtocols {
|
||||||
|
keys = append(keys, fmt.Sprintf("%s%d:%s", ServerUserListCacheKey, serverId, protocol))
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
// FilterParams Filter Server Params
|
// FilterParams Filter Server Params
|
||||||
type FilterParams struct {
|
type FilterParams struct {
|
||||||
Page int
|
Page int
|
||||||
@@ -134,7 +159,7 @@ func (m *customServerModel) ClearNodeCache(ctx context.Context, params *FilterNo
|
|||||||
}
|
}
|
||||||
var cacheKeys []string
|
var cacheKeys []string
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
cacheKeys = append(cacheKeys, fmt.Sprintf("%s%d", ServerUserListCacheKey, node.ServerId))
|
cacheKeys = append(cacheKeys, ServerUserListCacheKeysForServer(node.ServerId)...)
|
||||||
if node.Protocol != "" {
|
if node.Protocol != "" {
|
||||||
var cursor uint64
|
var cursor uint64
|
||||||
for {
|
for {
|
||||||
@@ -162,8 +187,7 @@ func (m *customServerModel) ClearNodeCache(ctx context.Context, params *FilterNo
|
|||||||
|
|
||||||
// ClearServerCache Clear Server Cache
|
// ClearServerCache Clear Server Cache
|
||||||
func (m *customServerModel) ClearServerCache(ctx context.Context, serverId int64) error {
|
func (m *customServerModel) ClearServerCache(ctx context.Context, serverId int64) error {
|
||||||
var cacheKeys []string
|
cacheKeys := ServerUserListCacheKeysForServer(serverId)
|
||||||
cacheKeys = append(cacheKeys, fmt.Sprintf("%s%d", ServerUserListCacheKey, serverId))
|
|
||||||
var cursor uint64
|
var cursor uint64
|
||||||
for {
|
for {
|
||||||
keys, newCursor, err := m.Cache.Scan(ctx, cursor, fmt.Sprintf("%s%d*", ServerConfigCacheKey, serverId), 100).Result()
|
keys, newCursor, err := m.Cache.Scan(ctx, cursor, fmt.Sprintf("%s%d*", ServerConfigCacheKey, serverId), 100).Result()
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package node
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestServerUserListCacheKeysForServer 验证按 server 枚举出的协议缓存 key 覆盖
|
||||||
|
// 所有客户端可能携带的 protocol(包括 hysteria2 兼容字段),形态为
|
||||||
|
// `server:user:{server_id}:{protocol}`。HIF-1: Del 路径必须用枚举而非 SCAN。
|
||||||
|
func TestServerUserListCacheKeysForServer(t *testing.T) {
|
||||||
|
const serverId int64 = 42
|
||||||
|
|
||||||
|
keys := ServerUserListCacheKeysForServer(serverId)
|
||||||
|
if len(keys) != len(AllProtocols) {
|
||||||
|
t.Fatalf("expected %d keys, got %d: %v", len(AllProtocols), len(keys), keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, len(keys))
|
||||||
|
copy(got, keys)
|
||||||
|
sort.Strings(got)
|
||||||
|
|
||||||
|
want := make([]string, 0, len(AllProtocols))
|
||||||
|
for _, p := range AllProtocols {
|
||||||
|
want = append(want, fmt.Sprintf("%s%d:%s", ServerUserListCacheKey, serverId, p))
|
||||||
|
}
|
||||||
|
sort.Strings(want)
|
||||||
|
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("key[%d] = %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServerUserListCacheKeysForServer_NoLegacyFlatKey 验证清缓存路径不再使用
|
||||||
|
// 旧的扁平 key `server:user:{server_id}`(无 protocol 维度)——旧 key 一旦再出现,
|
||||||
|
// 与 P0-1 写入的带 protocol key 形态不一致,Del 会失效,回归 HIF-1 缺陷。
|
||||||
|
func TestServerUserListCacheKeysForServer_NoLegacyFlatKey(t *testing.T) {
|
||||||
|
const serverId int64 = 7
|
||||||
|
|
||||||
|
legacy := fmt.Sprintf("%s%d", ServerUserListCacheKey, serverId)
|
||||||
|
for _, key := range ServerUserListCacheKeysForServer(serverId) {
|
||||||
|
if key == legacy {
|
||||||
|
t.Fatalf("Del path still emits legacy flat key %q without protocol", legacy)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(key, legacy+":") {
|
||||||
|
t.Errorf("key %q does not match `<flat>:<protocol>` shape", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAllProtocolsContainsKnownProtocols 兜底保证 AllProtocols 至少覆盖关键协议;
|
||||||
|
// 缺失任一会让对应协议的用户列表缓存清不掉。
|
||||||
|
func TestAllProtocolsContainsKnownProtocols(t *testing.T) {
|
||||||
|
required := []string{
|
||||||
|
"shadowsocks",
|
||||||
|
"vmess",
|
||||||
|
"vless",
|
||||||
|
"trojan",
|
||||||
|
"tuic",
|
||||||
|
"hysteria",
|
||||||
|
"hysteria2",
|
||||||
|
}
|
||||||
|
set := make(map[string]struct{}, len(AllProtocols))
|
||||||
|
for _, p := range AllProtocols {
|
||||||
|
set[p] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, r := range required {
|
||||||
|
if _, ok := set[r]; !ok {
|
||||||
|
t.Errorf("AllProtocols missing %q", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ func (m *defaultSubscribeModel) getCacheKeys(data *Subscribe) []string {
|
|||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
keys = append(keys, fmt.Sprintf("%s%d", node.ServerUserListCacheKey, n.ServerId))
|
keys = append(keys, node.ServerUserListCacheKeysForServer(n.ServerId)...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ func (m *defaultSubscribeModel) getCacheKeys(data *Subscribe) []string {
|
|||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
keys = append(keys, fmt.Sprintf("%s%d", node.ServerUserListCacheKey, n.ServerId))
|
keys = append(keys, node.ServerUserListCacheKeysForServer(n.ServerId)...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1845,8 +1845,10 @@ type InviteConfig struct {
|
|||||||
type InviteManageRecord struct {
|
type InviteManageRecord struct {
|
||||||
InviterId int64 `json:"inviter_id"`
|
InviterId int64 `json:"inviter_id"`
|
||||||
InviterIdentifier string `json:"inviter_identifier"`
|
InviterIdentifier string `json:"inviter_identifier"`
|
||||||
|
InviterDeviceNo string `json:"inviter_device_no"`
|
||||||
InviteeId int64 `json:"invitee_id"`
|
InviteeId int64 `json:"invitee_id"`
|
||||||
InviteeIdentifier string `json:"invitee_identifier"`
|
InviteeIdentifier string `json:"invitee_identifier"`
|
||||||
|
InviteeDeviceNo string `json:"invitee_device_no"`
|
||||||
InviteeAvatar string `json:"invitee_avatar"`
|
InviteeAvatar string `json:"invitee_avatar"`
|
||||||
InviteeEnable bool `json:"invitee_enable"`
|
InviteeEnable bool `json:"invitee_enable"`
|
||||||
InvitedAt int64 `json:"invited_at"`
|
InvitedAt int64 `json:"invited_at"`
|
||||||
@@ -2540,6 +2542,7 @@ type QueryUserSubscribeNodeListResponse struct {
|
|||||||
type QueryWithdrawalLogListRequest struct {
|
type QueryWithdrawalLogListRequest struct {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
|
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueryWithdrawalLogListResponse struct {
|
type QueryWithdrawalLogListResponse struct {
|
||||||
@@ -3551,7 +3554,7 @@ type UpdateUserSubscribeRequest struct {
|
|||||||
Upload int64 `json:"upload"`
|
Upload int64 `json:"upload"`
|
||||||
Download int64 `json:"download"`
|
Download int64 `json:"download"`
|
||||||
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
|
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
|
||||||
TrafficLimit *string `json:"traffic_limit,omitempty"`
|
TrafficLimit []TrafficLimit `json:"traffic_limit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserTicketStatusRequest struct {
|
type UpdateUserTicketStatusRequest struct {
|
||||||
@@ -3691,6 +3694,9 @@ type UserSubscribe struct {
|
|||||||
Traffic int64 `json:"traffic"`
|
Traffic int64 `json:"traffic"`
|
||||||
Download int64 `json:"download"`
|
Download int64 `json:"download"`
|
||||||
Upload int64 `json:"upload"`
|
Upload int64 `json:"upload"`
|
||||||
|
SpeedLimit int64 `json:"speed_limit"`
|
||||||
|
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
|
||||||
|
PlanSpeedLimit int64 `json:"plan_speed_limit"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Status uint8 `json:"status"`
|
Status uint8 `json:"status"`
|
||||||
EntitlementSource string `json:"entitlement_source"`
|
EntitlementSource string `json:"entitlement_source"`
|
||||||
@@ -3883,6 +3889,7 @@ type WeeklyStat struct {
|
|||||||
|
|
||||||
type WithdrawalLog struct {
|
type WithdrawalLog struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
|
BizType string `json:"biz_type"`
|
||||||
UserId int64 `json:"user_id"`
|
UserId int64 `json:"user_id"`
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
|||||||
@@ -16,3 +16,28 @@ commit-msg:
|
|||||||
commands:
|
commands:
|
||||||
commitlint:
|
commitlint:
|
||||||
run: npx --no -- commitlint --edit $1
|
run: npx --no -- commitlint --edit $1
|
||||||
|
|
||||||
|
# Soft constraint: the GitHub plan tier blocks branch protection on this
|
||||||
|
# private repo (HTTP 403). Catch direct pushes to internal / main here so the
|
||||||
|
# normal flow stays "open a PR + GitHub UI Squash and merge".
|
||||||
|
# See doc/development-workflow-zh.md § 5 for the full soft-constraint model.
|
||||||
|
#
|
||||||
|
# Emergency bypass: `git push --no-verify`. The reason must be logged in the
|
||||||
|
# corresponding Multica issue per the development workflow doc.
|
||||||
|
pre-push:
|
||||||
|
commands:
|
||||||
|
block-direct-push-to-protected:
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
while read local_ref local_sha remote_ref remote_sha; do
|
||||||
|
case "$remote_ref" in
|
||||||
|
refs/heads/internal|refs/heads/main)
|
||||||
|
echo "❌ Direct push to ${remote_ref##refs/heads/} is forbidden." >&2
|
||||||
|
echo " Open a PR and use GitHub UI 'Squash and merge' instead." >&2
|
||||||
|
echo " See doc/development-workflow-zh.md § 2." >&2
|
||||||
|
echo " Emergency bypass: git push --no-verify (must be logged in Multica)." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
auth_enabled: false
|
|
||||||
|
|
||||||
server:
|
|
||||||
http_listen_port: 3100
|
|
||||||
grpc_listen_port: 9096
|
|
||||||
|
|
||||||
common:
|
|
||||||
path_prefix: /loki
|
|
||||||
replication_factor: 1
|
|
||||||
ring:
|
|
||||||
instance_addr: 127.0.0.1
|
|
||||||
kvstore:
|
|
||||||
store: inmemory
|
|
||||||
|
|
||||||
schema_config:
|
|
||||||
configs:
|
|
||||||
- from: 2024-01-01
|
|
||||||
store: tsdb
|
|
||||||
object_store: filesystem
|
|
||||||
schema: v13
|
|
||||||
index:
|
|
||||||
prefix: index_
|
|
||||||
period: 24h
|
|
||||||
|
|
||||||
storage_config:
|
|
||||||
filesystem:
|
|
||||||
directory: /loki/chunks
|
|
||||||
|
|
||||||
limits_config:
|
|
||||||
allow_structured_metadata: true
|
|
||||||
retention_period: 168h
|
|
||||||
|
|
||||||
compactor:
|
|
||||||
working_directory: /loki/compactor
|
|
||||||
retention_enabled: true
|
|
||||||
delete_request_store: filesystem
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
server:
|
|
||||||
http_listen_port: 9080
|
|
||||||
grpc_listen_port: 0
|
|
||||||
|
|
||||||
positions:
|
|
||||||
filename: /tmp/positions.yaml
|
|
||||||
|
|
||||||
clients:
|
|
||||||
- url: http://loki:3100/loki/api/v1/push
|
|
||||||
|
|
||||||
scrape_configs:
|
|
||||||
- job_name: ppanel-file
|
|
||||||
static_configs:
|
|
||||||
- targets:
|
|
||||||
- localhost
|
|
||||||
labels:
|
|
||||||
job: ppanel-server
|
|
||||||
service_name: ppanel
|
|
||||||
__path__: /var/log/ppanel-server/*.log
|
|
||||||
pipeline_stages:
|
|
||||||
- json:
|
|
||||||
expressions:
|
|
||||||
caller: caller
|
|
||||||
content: content
|
|
||||||
level: level
|
|
||||||
timestamp: timestamp
|
|
||||||
- timestamp:
|
|
||||||
source: timestamp
|
|
||||||
format: "2006-01-02 15:04:05.000"
|
|
||||||
location: Asia/Shanghai
|
|
||||||
- labels:
|
|
||||||
caller:
|
|
||||||
level:
|
|
||||||
|
|
||||||
- job_name: nginx-file
|
|
||||||
static_configs:
|
|
||||||
- targets:
|
|
||||||
- localhost
|
|
||||||
labels:
|
|
||||||
job: nginx
|
|
||||||
service_name: nginx
|
|
||||||
__path__: /var/log/nginx/*.log
|
|
||||||
|
|
||||||
- job_name: docker-containers
|
|
||||||
docker_sd_configs:
|
|
||||||
- host: unix:///var/run/docker.sock
|
|
||||||
refresh_interval: 15s
|
|
||||||
relabel_configs:
|
|
||||||
- source_labels: [__meta_docker_container_name]
|
|
||||||
regex: '/(.*)'
|
|
||||||
target_label: container
|
|
||||||
- source_labels: [__meta_docker_container_label_com_docker_compose_service]
|
|
||||||
target_label: compose_service
|
|
||||||
- source_labels: [__meta_docker_container_log_stream]
|
|
||||||
target_label: stream
|
|
||||||
- source_labels: [__meta_docker_container_name]
|
|
||||||
regex: '/(.*)'
|
|
||||||
target_label: service_name
|
|
||||||
- source_labels: [__meta_docker_container_id]
|
|
||||||
target_label: __path__
|
|
||||||
replacement: /var/lib/docker/containers/$1/$1-json.log
|
|
||||||
@@ -1,652 +0,0 @@
|
|||||||
# AWS RDS + EC2 Redis 到外部备用服务器 Runbook
|
|
||||||
|
|
||||||
目标:让 `104.238.220.230` 持续作为 AWS 主生产环境的异地备用节点,承接:
|
|
||||||
|
|
||||||
- MySQL 外部只读从库
|
|
||||||
- Redis 外部从库
|
|
||||||
- 故障时的快速提升与业务切换
|
|
||||||
|
|
||||||
本文档以 `2026-05-13` 的真实现网状态为准,覆盖:
|
|
||||||
|
|
||||||
- 当前已经落地的主从架构
|
|
||||||
- 日常验收命令
|
|
||||||
- 主从故障排查
|
|
||||||
- 全量重建从库
|
|
||||||
- “新建规范 RDS 再切换”的生产级收敛路线
|
|
||||||
- 故障切换与回滚
|
|
||||||
|
|
||||||
## 1. 当前已确认资源
|
|
||||||
|
|
||||||
### 1.1 AWS 主生产
|
|
||||||
|
|
||||||
- Region: `ap-east-1`
|
|
||||||
- AWS app EC2:
|
|
||||||
- Name: `hifast-hk-app-01`
|
|
||||||
- Public IP: `18.163.33.75`
|
|
||||||
- Private IP: `10.0.1.201`
|
|
||||||
- AWS MySQL:
|
|
||||||
- Type: `RDS MySQL`
|
|
||||||
- Instance: `hifast-mysql-prod-v2`
|
|
||||||
- Endpoint: `hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
|
|
||||||
- Version: `8.4.8`
|
|
||||||
- DB Name: `hifast`
|
|
||||||
- Admin user: `admin`
|
|
||||||
- Admin password: keep it in a secret store, do not write plaintext into repo docs
|
|
||||||
- Replication user: `repl`
|
|
||||||
- Replication password: keep it in a secret store, do not write plaintext into repo docs
|
|
||||||
- AWS Redis:
|
|
||||||
- Location: `hifast-hk-app-01`
|
|
||||||
- Deployment: `Docker`
|
|
||||||
- Container: `hifast-redis`
|
|
||||||
- Version: `redis:8.2.1`
|
|
||||||
- Listen: `0.0.0.0:6379`
|
|
||||||
- Password: keep it in a secret store, do not write plaintext into repo docs
|
|
||||||
|
|
||||||
### 1.2 外部备用服务器
|
|
||||||
|
|
||||||
- Host: `104.238.220.230`
|
|
||||||
- OS: `Ubuntu 24.04 LTS`
|
|
||||||
- SSH user: `root`
|
|
||||||
- MySQL version: `8.4.9`
|
|
||||||
- Redis version: `8.6.3`
|
|
||||||
- 当前角色:
|
|
||||||
- MySQL external replica
|
|
||||||
- Redis replica
|
|
||||||
- 备用应用节点
|
|
||||||
|
|
||||||
### 1.3 当前应用真实运行方式
|
|
||||||
|
|
||||||
AWS 应用机上的 `ppanel-server` 当前不是 systemd 托管,而是 Docker Compose 服务:
|
|
||||||
|
|
||||||
- Compose file: `/opt/ppanel/docker-compose.cloud.yml`
|
|
||||||
- Config file: `/opt/ppanel/configs/ppanel.yaml`
|
|
||||||
- Container name: `ppanel-server`
|
|
||||||
- Current MySQL target: `hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306`
|
|
||||||
- Current Redis target: `127.0.0.1:6379`
|
|
||||||
|
|
||||||
也就是说,后续所有应用侧切换步骤,都应该以修改:
|
|
||||||
|
|
||||||
- `/opt/ppanel/configs/ppanel.yaml`
|
|
||||||
|
|
||||||
并执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/ppanel
|
|
||||||
docker compose -f docker-compose.cloud.yml up -d ppanel-server
|
|
||||||
```
|
|
||||||
|
|
||||||
作为准。
|
|
||||||
|
|
||||||
## 2. 当前健康基线
|
|
||||||
|
|
||||||
截至 `2026-05-13`,已确认以下状态成立:
|
|
||||||
|
|
||||||
- `104` MySQL:
|
|
||||||
- `Source_Host = hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
|
|
||||||
- `Replica_IO_Running: Yes`
|
|
||||||
- `Replica_SQL_Running: Yes`
|
|
||||||
- `Seconds_Behind_Source: 0`
|
|
||||||
- `read_only = ON`
|
|
||||||
- `super_read_only = ON`
|
|
||||||
- AWS Redis 主库:
|
|
||||||
- `role:master`
|
|
||||||
- `connected_slaves:1`
|
|
||||||
- `104` Redis:
|
|
||||||
- `role:slave`
|
|
||||||
- `master_link_status:up`
|
|
||||||
- AWS 应用:
|
|
||||||
- `curl http://127.0.0.1:8080/v1/common/heartbeat` 返回 `code=200`
|
|
||||||
|
|
||||||
这说明当前状态已经达到:
|
|
||||||
|
|
||||||
- 主生产可用
|
|
||||||
- 外部备用持续同步
|
|
||||||
- Redis 已回到真实本机链路
|
|
||||||
|
|
||||||
## 3. 当前网络前提
|
|
||||||
|
|
||||||
### 3.1 安全组
|
|
||||||
|
|
||||||
当前实际生效的安全组如下:
|
|
||||||
|
|
||||||
- `hifast-hk-app-core-sg`
|
|
||||||
- `22/tcp <- 0.0.0.0/0`
|
|
||||||
- `6379/tcp <- 104.238.220.230/32`
|
|
||||||
- `8080/tcp <- hifast-hk-web-sg`
|
|
||||||
- `hifast-hk-rds-core-sg`
|
|
||||||
- `3306/tcp <- 104.238.220.230/32`
|
|
||||||
- `3306/tcp <- hifast-hk-app-core-sg`
|
|
||||||
- `hifast-hk-web-sg`
|
|
||||||
- `22/80/443 <- 0.0.0.0/0`
|
|
||||||
|
|
||||||
### 3.2 当前结构性限制
|
|
||||||
|
|
||||||
当前 `104 -> RDS` 公网复制链路虽然可用,但依赖的是:
|
|
||||||
|
|
||||||
- `hifast-mysql-prod-v2` 为 `Publicly accessible = Yes`
|
|
||||||
- RDS ENI 仍位于 `hifast-hk-private-1b`
|
|
||||||
- `private-1b` 被临时挂到了公网路由表
|
|
||||||
|
|
||||||
这不是最终规范的生产形态。
|
|
||||||
|
|
||||||
当前这条路线已经完成,后续更推荐的动作是:
|
|
||||||
|
|
||||||
1. 持续确认应用主库目标保持在 `hifast-mysql-prod-v2`
|
|
||||||
2. 持续确认 `104` 复制目标保持在 `hifast-mysql-prod-v2`
|
|
||||||
3. 根据回收窗口安排旧 RDS 下线
|
|
||||||
4. 按网络收敛计划处理 `private-1b` 路由语义
|
|
||||||
|
|
||||||
## 4. 日常验收命令
|
|
||||||
|
|
||||||
### 4.1 验收 MySQL 主从
|
|
||||||
|
|
||||||
在 `104` 执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysql -e "SHOW REPLICA STATUS\G"
|
|
||||||
```
|
|
||||||
|
|
||||||
重点看:
|
|
||||||
|
|
||||||
- `Source_Host`
|
|
||||||
- `Replica_IO_Running`
|
|
||||||
- `Replica_SQL_Running`
|
|
||||||
- `Seconds_Behind_Source`
|
|
||||||
- `Last_IO_Error`
|
|
||||||
- `Last_SQL_Error`
|
|
||||||
|
|
||||||
成功标准:
|
|
||||||
|
|
||||||
- `Replica_IO_Running: Yes`
|
|
||||||
- `Replica_SQL_Running: Yes`
|
|
||||||
- `Seconds_Behind_Source: 0` 或较小
|
|
||||||
|
|
||||||
### 4.2 验收 Redis 主从
|
|
||||||
|
|
||||||
在 AWS app EC2 执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec hifast-redis redis-cli -a '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr' INFO replication
|
|
||||||
```
|
|
||||||
|
|
||||||
重点看:
|
|
||||||
|
|
||||||
- `role:master`
|
|
||||||
- `connected_slaves:1`
|
|
||||||
|
|
||||||
在 `104` 执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redis-cli INFO replication
|
|
||||||
```
|
|
||||||
|
|
||||||
重点看:
|
|
||||||
|
|
||||||
- `role:slave`
|
|
||||||
- `master_host:18.163.33.75`
|
|
||||||
- `master_port:6379`
|
|
||||||
- `master_link_status:up`
|
|
||||||
|
|
||||||
### 4.3 验收应用
|
|
||||||
|
|
||||||
在 AWS app EC2 执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sf http://127.0.0.1:8080/v1/common/heartbeat
|
|
||||||
```
|
|
||||||
|
|
||||||
期望返回:
|
|
||||||
|
|
||||||
- `{"code":200,...}`
|
|
||||||
|
|
||||||
查看容器:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/ppanel
|
|
||||||
docker compose -f docker-compose.cloud.yml ps
|
|
||||||
```
|
|
||||||
|
|
||||||
期望:
|
|
||||||
|
|
||||||
- `ppanel-server` 为 `Up`
|
|
||||||
- `hifast-redis` 为 `Up`
|
|
||||||
|
|
||||||
## 5. MySQL 复制故障排查
|
|
||||||
|
|
||||||
### 5.1 先看复制状态
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysql -e "SHOW REPLICA STATUS\G"
|
|
||||||
```
|
|
||||||
|
|
||||||
重点判断:
|
|
||||||
|
|
||||||
- `Replica_IO_Running = No`
|
|
||||||
- `Replica_SQL_Running = No`
|
|
||||||
- `Last_IO_Error`
|
|
||||||
- `Last_SQL_Error`
|
|
||||||
|
|
||||||
### 5.2 常见场景
|
|
||||||
|
|
||||||
#### 场景 A:网络或白名单断开
|
|
||||||
|
|
||||||
表现:
|
|
||||||
|
|
||||||
- `Replica_IO_Running: No`
|
|
||||||
- `Last_IO_Error` 出现连接失败、超时、拒绝访问
|
|
||||||
|
|
||||||
排查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysql -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u repl -p -e "SELECT 1;"
|
|
||||||
```
|
|
||||||
|
|
||||||
处理:
|
|
||||||
|
|
||||||
- 检查 RDS SG 是否仍保留 `104.238.220.230/32 -> 3306`
|
|
||||||
- 检查 RDS 是否仍为 `Publicly accessible = Yes`
|
|
||||||
- 如果后续已迁到新规范 RDS,则检查新实例的 SG 和公网可达性
|
|
||||||
|
|
||||||
#### 场景 B:主库 binlog 位点丢失
|
|
||||||
|
|
||||||
表现:
|
|
||||||
|
|
||||||
- `Last_IO_Error` 或 `Last_SQL_Error` 指向缺失 binlog
|
|
||||||
|
|
||||||
处理:
|
|
||||||
|
|
||||||
- 不要硬跳过
|
|
||||||
- 直接执行全量重建从库
|
|
||||||
|
|
||||||
#### 场景 C:SQL 执行报错
|
|
||||||
|
|
||||||
表现:
|
|
||||||
|
|
||||||
- `Replica_SQL_Running: No`
|
|
||||||
- `Last_SQL_Error` 有实际 SQL 冲突信息
|
|
||||||
|
|
||||||
处理建议:
|
|
||||||
|
|
||||||
- 如果只是临时演练环境,可重建从库
|
|
||||||
- 如果已经进入生产切换阶段,不建议盲目 `sql_slave_skip_counter`
|
|
||||||
- 优先保守做法仍是重新全量初始化
|
|
||||||
|
|
||||||
## 6. Redis 复制故障排查
|
|
||||||
|
|
||||||
### 6.1 看 `104` 从库状态
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redis-cli INFO replication
|
|
||||||
```
|
|
||||||
|
|
||||||
重点:
|
|
||||||
|
|
||||||
- `role`
|
|
||||||
- `master_host`
|
|
||||||
- `master_link_status`
|
|
||||||
|
|
||||||
### 6.2 看 AWS 主库状态
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec hifast-redis redis-cli -a '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr' INFO replication
|
|
||||||
```
|
|
||||||
|
|
||||||
重点:
|
|
||||||
|
|
||||||
- `role:master`
|
|
||||||
- `connected_slaves`
|
|
||||||
|
|
||||||
### 6.3 常见问题
|
|
||||||
|
|
||||||
#### 场景 A:安全组断开
|
|
||||||
|
|
||||||
表现:
|
|
||||||
|
|
||||||
- `master_link_status:down`
|
|
||||||
|
|
||||||
处理:
|
|
||||||
|
|
||||||
- 确认 `hifast-hk-app-core-sg` 仍保留:
|
|
||||||
- `6379/tcp <- 104.238.220.230/32`
|
|
||||||
|
|
||||||
#### 场景 B:主库密码漂移
|
|
||||||
|
|
||||||
表现:
|
|
||||||
|
|
||||||
- 从库重连失败
|
|
||||||
- 日志出现 `NOAUTH`
|
|
||||||
|
|
||||||
处理:
|
|
||||||
|
|
||||||
- 统一更新 `/etc/redis/redis.conf` 中的:
|
|
||||||
- `masterauth`
|
|
||||||
- 然后:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl restart redis-server
|
|
||||||
redis-cli INFO replication
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 全量重建 MySQL 从库
|
|
||||||
|
|
||||||
适用场景:
|
|
||||||
|
|
||||||
- 主从中断且无法安全追平
|
|
||||||
- `6666@qq.com` 这类写入在主库存在、从库未同步
|
|
||||||
- binlog 不连续
|
|
||||||
- 需要回到最稳妥状态
|
|
||||||
|
|
||||||
### 7.1 在主库导出
|
|
||||||
|
|
||||||
在一台可连 RDS 的机器上执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysqldump \
|
|
||||||
-h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com \
|
|
||||||
-u admin \
|
|
||||||
-p \
|
|
||||||
--single-transaction \
|
|
||||||
--routines \
|
|
||||||
--triggers \
|
|
||||||
--events \
|
|
||||||
--set-gtid-purged=OFF \
|
|
||||||
hifast > hifast-full.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
如果需要同步账号权限,也可以额外单独导出授权对象;但当前业务库恢复重点是 `hifast` 数据库本身。
|
|
||||||
|
|
||||||
### 7.2 清理 `104` 当前复制
|
|
||||||
|
|
||||||
在 `104` 执行:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
STOP REPLICA;
|
|
||||||
RESET REPLICA ALL;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 重新导入业务库
|
|
||||||
|
|
||||||
在 `104` 执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysql -e "DROP DATABASE IF EXISTS hifast; CREATE DATABASE hifast CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;"
|
|
||||||
mysql hifast < hifast-full.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 重新挂复制
|
|
||||||
|
|
||||||
当前现网是非 GTID 自动定位,使用 file/position 模式。
|
|
||||||
|
|
||||||
先在主库取位点:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SHOW MASTER STATUS;
|
|
||||||
```
|
|
||||||
|
|
||||||
然后在 `104` 执行:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CHANGE REPLICATION SOURCE TO
|
|
||||||
SOURCE_HOST='hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com',
|
|
||||||
SOURCE_PORT=3306,
|
|
||||||
SOURCE_USER='repl',
|
|
||||||
SOURCE_PASSWORD='<REPL_PASSWORD>',
|
|
||||||
SOURCE_LOG_FILE='<MASTER_LOG_FILE>',
|
|
||||||
SOURCE_LOG_POS=<MASTER_LOG_POS>,
|
|
||||||
SOURCE_SSL=1;
|
|
||||||
START REPLICA;
|
|
||||||
SHOW REPLICA STATUS\G
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.5 验收
|
|
||||||
|
|
||||||
确认:
|
|
||||||
|
|
||||||
- `Replica_IO_Running: Yes`
|
|
||||||
- `Replica_SQL_Running: Yes`
|
|
||||||
- `Seconds_Behind_Source: 0`
|
|
||||||
- `read_only = ON`
|
|
||||||
- `super_read_only = ON`
|
|
||||||
|
|
||||||
## 8. 重新配置 Redis 从库
|
|
||||||
|
|
||||||
当前 `104` Redis 为原生安装,不使用 Docker。
|
|
||||||
|
|
||||||
### 8.1 临时切回从库
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redis-cli CONFIG SET masterauth '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr'
|
|
||||||
redis-cli REPLICAOF 18.163.33.75 6379
|
|
||||||
redis-cli CONFIG SET replica-read-only yes
|
|
||||||
redis-cli INFO replication
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 持久化配置
|
|
||||||
|
|
||||||
检查 `/etc/redis/redis.conf` 至少包含:
|
|
||||||
|
|
||||||
```conf
|
|
||||||
replicaof 18.163.33.75 6379
|
|
||||||
masterauth 0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr
|
|
||||||
replica-read-only yes
|
|
||||||
```
|
|
||||||
|
|
||||||
然后:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl restart redis-server
|
|
||||||
redis-cli INFO replication
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. 生产级收敛路线:新建规范 RDS 再切换
|
|
||||||
|
|
||||||
这条路线已经完成,当前现网主库已经切到 `hifast-mysql-prod-v2`。下面内容保留为迁移归档参考,不再表示待执行。
|
|
||||||
|
|
||||||
### 9.1 目标
|
|
||||||
|
|
||||||
把当前临时方案:
|
|
||||||
|
|
||||||
- `private-1b` 挂公网路由
|
|
||||||
|
|
||||||
收敛成:
|
|
||||||
|
|
||||||
- RDS 使用纯公网 DB subnet group
|
|
||||||
- 安全组仍只对白名单和应用组开放
|
|
||||||
|
|
||||||
### 9.2 已准备好的资源
|
|
||||||
|
|
||||||
- 已建 DB subnet group:
|
|
||||||
- `hifast-hk-rds-public-only-sgprep`
|
|
||||||
- 子网:
|
|
||||||
- `hifast-hk-public-1a`
|
|
||||||
- `hifast-hk-public-1b`
|
|
||||||
|
|
||||||
### 9.3 新 RDS 建议参数
|
|
||||||
|
|
||||||
新实例建议名:
|
|
||||||
|
|
||||||
- `hifast-mysql-prod-v2`
|
|
||||||
|
|
||||||
建议保持与旧主库一致:
|
|
||||||
|
|
||||||
- Engine: `mysql`
|
|
||||||
- Version: `8.4.8`
|
|
||||||
- Class: `db.r7g.xlarge`
|
|
||||||
- Storage: `gp3`
|
|
||||||
- Size: `200 GB`
|
|
||||||
- IOPS: `3000`
|
|
||||||
- Throughput: `125`
|
|
||||||
- Publicly accessible: `Yes`
|
|
||||||
- Multi-AZ: `No` 或按预算单独评估
|
|
||||||
- Deletion protection: `On`
|
|
||||||
- Performance Insights: `On`
|
|
||||||
- Backup retention: `7`
|
|
||||||
- DB subnet group: `hifast-hk-rds-public-only-sgprep`
|
|
||||||
- VPC SG: `hifast-hk-rds-core-sg`
|
|
||||||
|
|
||||||
### 9.4 迁移步骤
|
|
||||||
|
|
||||||
1. 已创建新 RDS `hifast-mysql-prod-v2`
|
|
||||||
2. 在旧主库导出 `hifast`
|
|
||||||
3. 导入新库
|
|
||||||
4. 在新库创建 `repl` 用户并配置 binlog retention
|
|
||||||
5. 修改 AWS app EC2 上 `/opt/ppanel/configs/ppanel.yaml` 的 `MySQL.Addr`
|
|
||||||
6. 重启 `ppanel-server` 容器
|
|
||||||
7. 在 `104` 上 `STOP REPLICA; RESET REPLICA ALL;`
|
|
||||||
8. 指向新 RDS endpoint 重新挂复制
|
|
||||||
9. 验收应用与主从
|
|
||||||
10. 验收通过后,安排旧 RDS 下线,并推进 `private-1b` 恢复私网路由
|
|
||||||
|
|
||||||
### 9.5 应用切换命令
|
|
||||||
|
|
||||||
在 AWS app EC2:
|
|
||||||
|
|
||||||
1. 备份配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp /opt/ppanel/configs/ppanel.yaml /opt/ppanel/configs/ppanel.yaml.bak.$(date +%Y%m%d%H%M%S)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 编辑:
|
|
||||||
|
|
||||||
- `/opt/ppanel/configs/ppanel.yaml`
|
|
||||||
|
|
||||||
把:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
MySQL:
|
|
||||||
Addr: hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306
|
|
||||||
```
|
|
||||||
|
|
||||||
改成:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
MySQL:
|
|
||||||
Addr: hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 重启应用容器:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/ppanel
|
|
||||||
docker compose -f docker-compose.cloud.yml up -d ppanel-server
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 验证:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sf http://127.0.0.1:8080/v1/common/heartbeat
|
|
||||||
docker compose -f docker-compose.cloud.yml logs --tail=100 ppanel-server
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.6 `104` 改挂新 RDS
|
|
||||||
|
|
||||||
```sql
|
|
||||||
STOP REPLICA;
|
|
||||||
RESET REPLICA ALL;
|
|
||||||
CHANGE REPLICATION SOURCE TO
|
|
||||||
SOURCE_HOST='<NEW_RDS_ENDPOINT>',
|
|
||||||
SOURCE_PORT=3306,
|
|
||||||
SOURCE_USER='repl',
|
|
||||||
SOURCE_PASSWORD='<REPL_PASSWORD>',
|
|
||||||
SOURCE_LOG_FILE='<MASTER_LOG_FILE>',
|
|
||||||
SOURCE_LOG_POS=<MASTER_LOG_POS>,
|
|
||||||
SOURCE_SSL=1;
|
|
||||||
START REPLICA;
|
|
||||||
SHOW REPLICA STATUS\G
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.7 切换验收
|
|
||||||
|
|
||||||
至少确认:
|
|
||||||
|
|
||||||
- AWS 应用心跳正常
|
|
||||||
- 新 RDS 可正常读写
|
|
||||||
- `104` 复制恢复为 `Yes/Yes`
|
|
||||||
- `Seconds_Behind_Source` 追到 `0`
|
|
||||||
- Navicat 可从允许的白名单来源连接新 RDS
|
|
||||||
|
|
||||||
### 9.8 回滚
|
|
||||||
|
|
||||||
如果新 RDS 切换后应用异常:
|
|
||||||
|
|
||||||
1. 立即把 `/opt/ppanel/configs/ppanel.yaml` 中 `MySQL.Addr` 改回旧 endpoint
|
|
||||||
2. 重启 `ppanel-server`
|
|
||||||
3. 暂不处理 `104`,先恢复主生产
|
|
||||||
4. 复盘新库数据、权限、参数、网络
|
|
||||||
|
|
||||||
## 10. AWS 故障时的备用接管
|
|
||||||
|
|
||||||
### 10.1 Redis 提升为主库
|
|
||||||
|
|
||||||
在 `104`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redis-cli REPLICAOF NO ONE
|
|
||||||
redis-cli INFO replication
|
|
||||||
```
|
|
||||||
|
|
||||||
期望:
|
|
||||||
|
|
||||||
- `role:master`
|
|
||||||
|
|
||||||
### 10.2 MySQL 提升为可写主库
|
|
||||||
|
|
||||||
在 `104`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
STOP REPLICA;
|
|
||||||
RESET REPLICA ALL;
|
|
||||||
SET GLOBAL super_read_only=OFF;
|
|
||||||
SET GLOBAL read_only=OFF;
|
|
||||||
```
|
|
||||||
|
|
||||||
再确认:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SHOW VARIABLES LIKE 'read_only';
|
|
||||||
SHOW VARIABLES LIKE 'super_read_only';
|
|
||||||
```
|
|
||||||
|
|
||||||
期望:
|
|
||||||
|
|
||||||
- `OFF`
|
|
||||||
- `OFF`
|
|
||||||
|
|
||||||
### 10.3 应用切到 `104` 本机数据层
|
|
||||||
|
|
||||||
如果 `104` 上也部署同样的 PPanel 服务,则应用配置应改为:
|
|
||||||
|
|
||||||
- MySQL 指向 `127.0.0.1:3306` 或本机 socket
|
|
||||||
- Redis 指向 `127.0.0.1:6379`
|
|
||||||
|
|
||||||
### 10.4 最后切入口
|
|
||||||
|
|
||||||
数据层和应用层都确认可写后,再做:
|
|
||||||
|
|
||||||
- DNS 切换
|
|
||||||
- 或 Nginx / 上游切流量
|
|
||||||
|
|
||||||
原则:
|
|
||||||
|
|
||||||
- 先数据接管
|
|
||||||
- 再应用确认
|
|
||||||
- 最后入口切换
|
|
||||||
|
|
||||||
## 11. 日常巡检建议
|
|
||||||
|
|
||||||
建议至少每天巡检一次:
|
|
||||||
|
|
||||||
1. `104` MySQL `SHOW REPLICA STATUS\G`
|
|
||||||
2. `104` Redis `INFO replication`
|
|
||||||
3. AWS Redis 主库 `INFO replication`
|
|
||||||
4. AWS app 心跳 `/v1/common/heartbeat`
|
|
||||||
5. `docker compose -f /opt/ppanel/docker-compose.cloud.yml ps`
|
|
||||||
|
|
||||||
如果后续要做真正的生产级自动化,再补:
|
|
||||||
|
|
||||||
- MySQL 复制延迟告警
|
|
||||||
- Redis 主从断链告警
|
|
||||||
- 应用心跳失败告警
|
|
||||||
- RDS 连接失败告警
|
|
||||||
- 定期灾备切换演练
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Docker Image Version Pins
|
|
||||||
|
|
||||||
This file records the infrastructure image versions pinned in `docker-compose.cloud.yml`.
|
|
||||||
The versions below match the images observed on the test deployment on 2026-05-26.
|
|
||||||
|
|
||||||
| Service | Image | Running version source |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| grafana | `grafana/grafana:13.0.1` | `grafana version 13.0.1` |
|
|
||||||
| prometheus | `prom/prometheus:v3.11.3` | `prometheus, version 3.11.3` |
|
|
||||||
| nginx-exporter | `nginx/nginx-prometheus-exporter:1.5.0` | image label `org.opencontainers.image.version=1.5.0` |
|
|
||||||
| node-exporter | `prom/node-exporter:v1.11.1` | `node_exporter, version 1.11.1` |
|
|
||||||
| cadvisor | `gcr.io/cadvisor/cadvisor:v0.55.1` | `cAdvisor version v0.55.1` |
|
|
||||||
|
|
||||||
The test deployment in `/root/bindbox/docker-compose.cloud.yml` also contains
|
|
||||||
live-only exporter services that are not present in this repository's
|
|
||||||
`docker-compose.cloud.yml`. They were pinned during staging validation:
|
|
||||||
|
|
||||||
| Test-only service | Image | Running version source |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| mysql-exporter | `prom/mysqld-exporter:v0.19.0` | `mysqld_exporter, version 0.19.0` |
|
|
||||||
| redis-exporter | `oliver006/redis_exporter:v1.82.0` | image label `org.opencontainers.image.version=v1.82.0` |
|
|
||||||
|
|
||||||
`ppanel-server` intentionally remains variable and requires `PPANEL_SERVER_TAG`
|
|
||||||
from CI/CD so deployments use an immutable application image tag.
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
Restore the previous compose file from git and redeploy:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git checkout HEAD~1 -- docker-compose.cloud.yml .env.example ops/docker-image-version-pins.md
|
|
||||||
docker compose -f docker-compose.cloud.yml up -d
|
|
||||||
```
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# Hifast 东京切换执行清单
|
|
||||||
|
|
||||||
本文档用于正式执行香港 `ap-east-1` -> 东京 `ap-northeast-1` 迁移时,逐项勾选和留痕。
|
|
||||||
|
|
||||||
## 0. 当前已知东京状态
|
|
||||||
|
|
||||||
- 东京 VPC `ppanel-jp-prod` 已创建
|
|
||||||
- 东京 VPC ID:`vpc-0846b23b4a7d64eac`
|
|
||||||
- 4 个东京子网曾在 AWS 控制台录入,但提交时登录态失效
|
|
||||||
- 所以当前要按“子网未确认成功”处理
|
|
||||||
- 后续所有东京资源创建前,先执行一次:
|
|
||||||
- `bash deploy/scripts/aws_jp_describe_state.sh /root/aws-jp-infra.env`
|
|
||||||
- 真实状态登记文件:
|
|
||||||
- `deploy/aws/ap-northeast-1/configs/resource-inventory.current.md`
|
|
||||||
|
|
||||||
## 1. 真实值清单
|
|
||||||
|
|
||||||
在开始创建东京正式资源前,必须补齐这些真实值:
|
|
||||||
|
|
||||||
- 正式 API 域名:
|
|
||||||
- 东京平行 API 域名:
|
|
||||||
- 东京平行日志域名:
|
|
||||||
- 运维固定公网 IP / CIDR:
|
|
||||||
- 东京 EC2 SSH Key Pair 名称:
|
|
||||||
- 东京 RDS 管理员密码:`TkyRds20260521!N9mQ8sKe2vLp7Xa`
|
|
||||||
- 东京 RDS 凭证管理方式:`self-managed`
|
|
||||||
- 东京 RDS 当前密码是否可回看:`否,只能重置`
|
|
||||||
- 东京 Redis 密码:`hifast67yj`
|
|
||||||
- `JwtAuth.AccessSecret`:
|
|
||||||
- `Administrator.Email`:`admin@ppanel.dev`
|
|
||||||
- `Administrator.Password`:`PpanelAdmin!20260521Temp`
|
|
||||||
- `AppSignature.AppSecrets.android-client`:`uB4G,XxL2{7b`
|
|
||||||
- `AppSignature.AppSecrets.ios-client`:`uB4G,XxL2{7b`
|
|
||||||
- `AppSignature.AppSecrets.web-client`:`uB4G,XxL2{7b`
|
|
||||||
- `device.security_secret`:`uB4G,XxL2{7b`
|
|
||||||
- 东京备份桶最终名称:
|
|
||||||
- `104.238.220.230` MySQL 复制密码:`XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer`
|
|
||||||
- `104.238.220.230` Redis 主从认证密码:`hifast67yj`
|
|
||||||
|
|
||||||
## 2. 东京资源创建勾选
|
|
||||||
|
|
||||||
- 已切换 AWS 控制台到 `ap-northeast-1`
|
|
||||||
- 已确认东京区可用
|
|
||||||
- 已确认 EC2 配额满足 `t4g.large`
|
|
||||||
- 已确认 RDS 配额满足 `db.r7g.xlarge`
|
|
||||||
- 已确认 ALB / ACM / WAF / S3 可正常创建
|
|
||||||
- 已创建 VPC
|
|
||||||
- 已创建 2 个公有子网
|
|
||||||
- 已创建 2 个私有子网
|
|
||||||
- 已创建 IGW
|
|
||||||
- 已配置公私网路由表
|
|
||||||
- 已创建 `sg-alb`
|
|
||||||
- 已创建 `sg-ec2`
|
|
||||||
- 已创建 `sg-rds`
|
|
||||||
- 已创建东京 RDS
|
|
||||||
- 已创建东京 EC2
|
|
||||||
- 已在 EC2 启动 Docker / Nginx
|
|
||||||
- 已创建东京 ACM 证书
|
|
||||||
- 已创建 Target Group
|
|
||||||
- 已创建 ALB
|
|
||||||
- 已创建 WAF 并挂到 ALB
|
|
||||||
- 已创建东京 S3 备份桶并开启 versioning
|
|
||||||
|
|
||||||
## 3. 东京应用部署勾选
|
|
||||||
|
|
||||||
- 已上传 `docker-compose.cloud.yml`
|
|
||||||
- 已上传 `configs/ppanel.yaml`
|
|
||||||
- 已上传 `.env`
|
|
||||||
- 已上传 `grafana/`
|
|
||||||
- 已上传 `loki/`
|
|
||||||
- 已上传 `prometheus/`
|
|
||||||
- 已上传 `tempo/`
|
|
||||||
- 已安装东京 Nginx 配置
|
|
||||||
- 已启动 `ppanel-server`
|
|
||||||
- 已启动 `hifast-redis`
|
|
||||||
- 已启动 observability 容器
|
|
||||||
- `curl http://127.0.0.1:8080/v1/common/heartbeat` 正常
|
|
||||||
- `curl http://127.0.0.1/v1/common/heartbeat` 正常
|
|
||||||
- ALB 健康检查正常
|
|
||||||
|
|
||||||
## 4. 停机迁移勾选
|
|
||||||
|
|
||||||
- 已降低正式域名 TTL
|
|
||||||
- 已停止香港 `ppanel-server`
|
|
||||||
- 已确认香港不再有新写入
|
|
||||||
- 已导出香港 MySQL `hifast-full.sql.gz`
|
|
||||||
- 已导出香港 Redis `dump.rdb`
|
|
||||||
- 已导入东京 RDS
|
|
||||||
- 已导入东京 Redis
|
|
||||||
- 东京应用已改为连接东京 MySQL / Redis
|
|
||||||
- 平行域名验收通过
|
|
||||||
|
|
||||||
## 5. 104 灾备重挂勾选
|
|
||||||
|
|
||||||
- 东京 RDS 已设置 `binlog retention hours`
|
|
||||||
- 东京 RDS 已创建 `repl@104.238.220.230`
|
|
||||||
- 已用东京 dump 重建 `104` 的 `hifast`
|
|
||||||
- `104` MySQL 已成功挂东京主库
|
|
||||||
- `104` MySQL `Replica_IO_Running: Yes`
|
|
||||||
- `104` MySQL `Replica_SQL_Running: Yes`
|
|
||||||
- `104` MySQL `Seconds_Behind_Source: 0`
|
|
||||||
- `104` Redis 已成功挂东京主库
|
|
||||||
- 东京 Redis `connected_slaves:1`
|
|
||||||
- `104` Redis `role:slave`
|
|
||||||
- `104` Redis `master_link_status:up`
|
|
||||||
|
|
||||||
## 6. 正式切换与回滚勾选
|
|
||||||
|
|
||||||
- 已切正式域名到东京 ALB
|
|
||||||
- 外网请求正常
|
|
||||||
- 核心业务接口无 `5xx`
|
|
||||||
- 应用日志无 MySQL / Redis 连接错误
|
|
||||||
- WAF 无误伤
|
|
||||||
- 已验证 DNS 可回切
|
|
||||||
- 香港环境已保留为只读回滚基线
|
|
||||||
- 香港环境计划保留 `7` 天观察期
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
# Hifast AWS 香港到日本东京迁移 Runbook
|
|
||||||
|
|
||||||
本文档用于把当前香港区 `ap-east-1` 主生产,迁移到日本东京 `ap-northeast-1`。
|
|
||||||
|
|
||||||
适用目标:
|
|
||||||
|
|
||||||
- 东京成为新主站
|
|
||||||
- 架构升级为 `ALB + WAF + EC2 + RDS`
|
|
||||||
- `104.238.220.230` 继续作为东京主站的 MySQL / Redis 外部灾备
|
|
||||||
|
|
||||||
## 0. 当前实施状态
|
|
||||||
|
|
||||||
截至 `2026-05-21`:
|
|
||||||
|
|
||||||
- 东京迁移执行资产已在仓库内补齐
|
|
||||||
- 东京网络基础资源已创建并复核:
|
|
||||||
- VPC `ppanel-jp-prod` / `vpc-0846b23b4a7d64eac`
|
|
||||||
- 2 个公有子网
|
|
||||||
- 2 个私有子网
|
|
||||||
- IGW
|
|
||||||
- 公有 / 私有路由表
|
|
||||||
- 东京三层安全组已创建并复核:
|
|
||||||
- `ppanel-jp-sg-alb`
|
|
||||||
- `ppanel-jp-sg-ec2`
|
|
||||||
- `ppanel-jp-sg-rds`
|
|
||||||
- 东京 RDS 已创建并可用:
|
|
||||||
- Endpoint: `ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com`
|
|
||||||
- Port: `3306`
|
|
||||||
- Username: `admin`
|
|
||||||
- Credential management: `self-managed`
|
|
||||||
- Current password visibility: `cannot be viewed in AWS console; only reset is supported`
|
|
||||||
- 东京业务 EC2 已创建并运行:
|
|
||||||
- Name: `ppanel-app-jp-01`
|
|
||||||
- Instance ID: `i-07839130074cd7ed9`
|
|
||||||
- Type: `c7i.xlarge`
|
|
||||||
- Private IP: `10.20.1.168`
|
|
||||||
- Elastic IP: `3.114.29.208`
|
|
||||||
- 东京 S3 备份桶已创建
|
|
||||||
- 东京 ACM 证书已请求,仍等待 DNS 验证
|
|
||||||
- 当前尚未完成的核心资源:
|
|
||||||
- ALB
|
|
||||||
- WAF
|
|
||||||
- ACM DNS 验证
|
|
||||||
- 东京应用目录部署与数据导入
|
|
||||||
|
|
||||||
建议先执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp deploy/aws/ap-northeast-1/configs/aws-jp-infra.env.example /root/aws-jp-infra.env
|
|
||||||
vim /root/aws-jp-infra.env
|
|
||||||
bash deploy/scripts/aws_jp_create_base_infra.sh /root/aws-jp-infra.env
|
|
||||||
bash deploy/scripts/aws_jp_describe_state.sh /root/aws-jp-infra.env
|
|
||||||
```
|
|
||||||
|
|
||||||
然后把结果回填到:
|
|
||||||
|
|
||||||
- `deploy/aws/ap-northeast-1/configs/resource-inventory.current.md`
|
|
||||||
- `ops/hifast-aws-jp-cutover-checklist-zh.md`
|
|
||||||
|
|
||||||
## 1. 当前基线
|
|
||||||
|
|
||||||
迁移前默认当前现网状态为:
|
|
||||||
|
|
||||||
- 香港应用 EC2:`hifast-hk-app-01`
|
|
||||||
- 香港 RDS:`hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
|
|
||||||
- 香港 Redis 主库:`18.163.33.75:6379`
|
|
||||||
- 外部灾备:`104.238.220.230`
|
|
||||||
- 当前应用部署目录:`/opt/ppanel`
|
|
||||||
- 当前应用配置文件:`/opt/ppanel/configs/ppanel.yaml`
|
|
||||||
- 当前业务容器:`ppanel-server`
|
|
||||||
|
|
||||||
## 2. 迁移前准备
|
|
||||||
|
|
||||||
正式迁移前必须完成:
|
|
||||||
|
|
||||||
1. 东京基础设施已创建
|
|
||||||
2. 东京 EC2 已部署应用目录,但暂未导入正式数据
|
|
||||||
3. 东京 RDS 可连通
|
|
||||||
- 已验证从东京 EC2 到 `ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com:3306` 网络畅通
|
|
||||||
- 当前若无密码只能得到 `ERROR 1045 ... using password: NO`,这表示链路正常,不表示实例异常
|
|
||||||
4. 东京 Redis 已启动并可认证
|
|
||||||
5. 平行域名已准备:
|
|
||||||
- `api-jp.hifast.biz`
|
|
||||||
- `logs-jp.hifast.biz`
|
|
||||||
6. 东京 ALB 健康检查已通过
|
|
||||||
7. 东京 ACM 证书已签发
|
|
||||||
8. 东京 WAF 已挂到 ALB
|
|
||||||
9. 已准备正式回滚入口
|
|
||||||
10. 已把正式域名 TTL 降低
|
|
||||||
|
|
||||||
## 3. 香港停机冻结
|
|
||||||
|
|
||||||
在香港主环境执行:
|
|
||||||
|
|
||||||
1. 停止业务写入
|
|
||||||
2. 停 `ppanel-server`
|
|
||||||
3. 保留 Nginx 维护页或直接下线入口
|
|
||||||
|
|
||||||
建议命令:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/ppanel
|
|
||||||
docker compose -f docker-compose.cloud.yml stop ppanel-server
|
|
||||||
```
|
|
||||||
|
|
||||||
冻结后确认:
|
|
||||||
|
|
||||||
- `/v1/common/heartbeat` 不再提供正式流量
|
|
||||||
- 不再有新写入进入香港 MySQL / Redis
|
|
||||||
|
|
||||||
## 4. 导出香港数据
|
|
||||||
|
|
||||||
### 4.1 MySQL
|
|
||||||
|
|
||||||
从香港主库导出:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysqldump \
|
|
||||||
-h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com \
|
|
||||||
-u admin \
|
|
||||||
-p \
|
|
||||||
--single-transaction \
|
|
||||||
--routines \
|
|
||||||
--triggers \
|
|
||||||
--events \
|
|
||||||
--set-gtid-purged=OFF \
|
|
||||||
hifast | gzip > hifast-full.sql.gz
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Redis
|
|
||||||
|
|
||||||
在香港 Redis 主库导出:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec hifast-redis redis-cli -a '<REDIS_PASSWORD>' BGSAVE
|
|
||||||
docker cp hifast-redis:/data/dump.rdb ./dump.rdb
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 导入东京
|
|
||||||
|
|
||||||
### 5.1 MySQL 导入东京 RDS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gunzip -c hifast-full.sql.gz | mysql -h <TOKYO_RDS_ENDPOINT> -u admin -p hifast
|
|
||||||
```
|
|
||||||
|
|
||||||
导入后确认:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysql -h <TOKYO_RDS_ENDPOINT> -u admin -p -e "USE hifast; SHOW TABLES;"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 Redis 导入东京
|
|
||||||
|
|
||||||
1. 停止东京 Redis 容器
|
|
||||||
2. 替换 `/data/dump.rdb`
|
|
||||||
3. 启动东京 Redis 容器
|
|
||||||
|
|
||||||
导入后确认:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec hifast-redis redis-cli -a '<TOKYO_REDIS_PASSWORD>' PING
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 启动东京应用
|
|
||||||
|
|
||||||
更新东京:
|
|
||||||
|
|
||||||
- `/opt/ppanel/configs/ppanel.yaml`
|
|
||||||
- `/opt/ppanel/.env`
|
|
||||||
|
|
||||||
关键值:
|
|
||||||
|
|
||||||
- `MySQL.Addr=<TOKYO_RDS_ENDPOINT>:3306`
|
|
||||||
- `MySQL.Username=admin`
|
|
||||||
- `MySQL.Password=<TOKYO_RDS_PASSWORD>`
|
|
||||||
- `Redis.Host=127.0.0.1:6379`
|
|
||||||
- `Redis.Pass=<TOKYO_REDIS_PASSWORD>`
|
|
||||||
- `Site.Host=api-jp.hifast.biz`
|
|
||||||
- `.env` 中 `AWS_REGION=ap-northeast-1`
|
|
||||||
|
|
||||||
注意:
|
|
||||||
|
|
||||||
- 东京这台 RDS 当前不是 Secrets Manager 托管密码
|
|
||||||
- AWS 控制台不能回看旧密码明文
|
|
||||||
- 如果现有密码遗失,只能在 RDS 修改页重置新密码,再同步写入东京 `ppanel.yaml`
|
|
||||||
|
|
||||||
当前东京已实际生效:
|
|
||||||
|
|
||||||
- `MySQL.Addr=ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com:3306`
|
|
||||||
- `MySQL.Username=admin`
|
|
||||||
- `MySQL.Password=TkyRds20260521!N9mQ8sKe2vLp7Xa`
|
|
||||||
- `Redis.Host=127.0.0.1:6379`
|
|
||||||
- `Redis.Pass=hifast67yj`
|
|
||||||
|
|
||||||
启动:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/ppanel
|
|
||||||
docker compose -f docker-compose.cloud.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 东京平行环境验收
|
|
||||||
|
|
||||||
至少执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sf http://127.0.0.1:8080/v1/common/heartbeat
|
|
||||||
curl -sf http://127.0.0.1/v1/common/heartbeat
|
|
||||||
docker compose -f /opt/ppanel/docker-compose.cloud.yml ps
|
|
||||||
docker compose -f /opt/ppanel/docker-compose.cloud.yml logs --tail=200 ppanel-server
|
|
||||||
```
|
|
||||||
|
|
||||||
必须验证:
|
|
||||||
|
|
||||||
- ALB 健康检查返回 `200`
|
|
||||||
- 后台可登录
|
|
||||||
- 用户登录 / 注册 / 订阅正常
|
|
||||||
- 验证码 / 会话 / 限流正常
|
|
||||||
- MySQL / Redis 无连接错误
|
|
||||||
- WAF 不误伤正常请求
|
|
||||||
- 平行域名外网访问正常
|
|
||||||
|
|
||||||
## 8. 正式域名切换
|
|
||||||
|
|
||||||
仅在东京平行环境验收全部通过后执行:
|
|
||||||
|
|
||||||
1. 将正式域名切到东京 `ALB`
|
|
||||||
2. 观察 5xx、延迟、容器日志、RDS、Redis
|
|
||||||
3. 保持香港不删,只做回滚保留
|
|
||||||
|
|
||||||
## 9. 104 灾备重挂
|
|
||||||
|
|
||||||
### 9.1 MySQL
|
|
||||||
|
|
||||||
在东京 RDS 上:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CALL mysql.rds_set_configuration('binlog retention hours', 24);
|
|
||||||
CREATE USER IF NOT EXISTS 'repl'@'104.238.220.230' IDENTIFIED BY '<REPL_PASSWORD>';
|
|
||||||
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl'@'104.238.220.230';
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
SHOW BINARY LOG STATUS;
|
|
||||||
```
|
|
||||||
|
|
||||||
当前东京已准备完成:
|
|
||||||
|
|
||||||
- `repl@104.238.220.230`
|
|
||||||
- 复制密码:`XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer`
|
|
||||||
- binlog file:`mysql-bin-changelog.000189`
|
|
||||||
- binlog pos:`185053`
|
|
||||||
|
|
||||||
然后在 `104` 重建并挂从。
|
|
||||||
|
|
||||||
### 9.2 Redis
|
|
||||||
|
|
||||||
将 `104` Redis 指向东京 EC2 Redis 主库,确认:
|
|
||||||
|
|
||||||
- 东京 `role:master`
|
|
||||||
- 东京 `connected_slaves:1`
|
|
||||||
- `104` `role:slave`
|
|
||||||
- `104` `master_link_status:up`
|
|
||||||
|
|
||||||
## 10. 回滚
|
|
||||||
|
|
||||||
如果东京验收失败:
|
|
||||||
|
|
||||||
- 不切正式域名
|
|
||||||
- 继续保留香港主环境
|
|
||||||
|
|
||||||
如果正式切换后发现严重问题:
|
|
||||||
|
|
||||||
1. 立刻把 DNS 切回香港
|
|
||||||
2. 恢复香港 `ppanel-server`
|
|
||||||
3. 放弃本次东京接管
|
|
||||||
|
|
||||||
## 11. 切换后观察期
|
|
||||||
|
|
||||||
切换后至少保留香港环境 `7` 天:
|
|
||||||
|
|
||||||
- 香港 RDS 快照
|
|
||||||
- 香港 Redis RDB
|
|
||||||
- 香港 EC2 配置
|
|
||||||
- 香港 `ppanel.yaml` 备份
|
|
||||||
|
|
||||||
观察期内不删除香港资源。
|
|
||||||
@@ -1,696 +0,0 @@
|
|||||||
# Hifast AWS 主生产 + 外部备用 完整部署方案与访问架构
|
|
||||||
|
|
||||||
本文档整理当前已经实际落地的生产架构、访问链路、数据库与缓存主从关系、网络边界、故障切换方案,以及后续扩展建议。
|
|
||||||
|
|
||||||
目标是让团队在一个文档里就能看清:
|
|
||||||
|
|
||||||
- 现在生产到底部署成了什么样
|
|
||||||
- 请求是怎么进来的,数据是怎么流转的
|
|
||||||
- AWS 与外部备用服务器分别承担什么角色
|
|
||||||
- MySQL / Redis 的同步关系是什么
|
|
||||||
- 故障时应该如何切换
|
|
||||||
|
|
||||||
## 0. 2026-05-13 验收摘要
|
|
||||||
|
|
||||||
- 已确认 `104.238.220.230 -> AWS RDS MySQL` 连通,MySQL 外部从库健康:
|
|
||||||
- 当前复制上游:`hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
|
|
||||||
- `Replica_IO_Running: Yes`
|
|
||||||
- `Replica_SQL_Running: Yes`
|
|
||||||
- `Seconds_Behind_Source: 0`
|
|
||||||
- 已确认 `104.238.220.230 -> AWS EC2 Redis` 主从健康:
|
|
||||||
- `104` 上 `role:slave`
|
|
||||||
- `master_link_status:up`
|
|
||||||
- AWS Redis 主库 `connected_slaves:1`
|
|
||||||
- 已修复应用侧 Redis 配置漂移:
|
|
||||||
- 旧链路:`127.0.0.1:6380 -> stunnel4 -> 旧 ElastiCache`
|
|
||||||
- 新链路:`127.0.0.1:6379 -> 本机 Docker Redis`
|
|
||||||
- 旧 `stunnel4` 已停用并禁用自启,`ppanel-server` 日志里的 Redis `i/o timeout` 已停止出现。
|
|
||||||
- 已确认 `ppanel-server` 当前运行方式为 Docker Compose 容器,而不是 systemd 服务:
|
|
||||||
- Compose 文件:`/opt/ppanel/docker-compose.cloud.yml`
|
|
||||||
- 配置文件:`/opt/ppanel/configs/ppanel.yaml`
|
|
||||||
- 容器名:`ppanel-server`
|
|
||||||
- 健康检查:`curl http://127.0.0.1:8080/v1/common/heartbeat` 返回正常
|
|
||||||
- 旧自建安全组 `hifast-hk-app-sg`、`hifast-hk-nginx-sg` 已删除;当前 VPC 内仅保留:
|
|
||||||
- `hifast-hk-app-core-sg`
|
|
||||||
- `hifast-hk-rds-core-sg`
|
|
||||||
- `hifast-hk-web-sg`
|
|
||||||
- `default`
|
|
||||||
- 目前已经达到“可用且关键链路已恢复”的状态。
|
|
||||||
- 当前入口层属于已确认的过渡方案:
|
|
||||||
- 现阶段公网入口仍在 `hifast-hk-app-01`
|
|
||||||
- 独立 `nginx` 服务器已预留,待 AWS 后续资源到位后再迁移承接
|
|
||||||
- 当前仍需要继续收敛的核心生产项只剩 1 个:
|
|
||||||
- RDS 为了让 `104` 走公网白名单复制,当前仍依赖 `hifast-hk-private-1b` 子网临时挂到公网路由表,这不是最终规范形态。
|
|
||||||
- 已完成的下一步准备:
|
|
||||||
- 已创建纯公网子网专用的 `DB subnet group`:`hifast-hk-rds-public-only-sgprep`
|
|
||||||
- 子网包含:
|
|
||||||
- `subnet-070e3f264a9c79f32` `hifast-hk-public-1a`
|
|
||||||
- `subnet-00cb5add705c447c8` `hifast-hk-public-1b`
|
|
||||||
- 已创建专用 S3 备份桶:
|
|
||||||
- `hifast-prod-backups-200810848252-ap-east-1`
|
|
||||||
- 当前状态:private
|
|
||||||
- 当前状态:versioning enabled
|
|
||||||
|
|
||||||
## 1. 当前实际环境
|
|
||||||
|
|
||||||
### 1.1 AWS 区域
|
|
||||||
|
|
||||||
- Region: `ap-east-1`
|
|
||||||
- 说明:香港区
|
|
||||||
|
|
||||||
### 1.2 已确认资源
|
|
||||||
|
|
||||||
#### 应用服务器
|
|
||||||
|
|
||||||
- 名称:`hifast-hk-app-01`
|
|
||||||
- Instance ID: `i-079cd9d3ef3748714`
|
|
||||||
- 角色:当前实际生产入口 / Nginx / 业务服务 / AWS 侧 Redis 主库宿主机
|
|
||||||
- 私网 IP: `10.0.1.201`
|
|
||||||
- 公网 IP: `18.163.33.75`
|
|
||||||
- 业务服务运行方式:`Docker Compose`
|
|
||||||
- 业务容器:`ppanel-server`
|
|
||||||
- 部署目录:`/opt/ppanel`
|
|
||||||
- 实际配置文件:`/opt/ppanel/configs/ppanel.yaml`
|
|
||||||
|
|
||||||
#### 独立 Nginx 服务器
|
|
||||||
|
|
||||||
- 名称:`hifast-hk-nginx-01`
|
|
||||||
- Instance ID: `i-0701d54bf2c6bf594`
|
|
||||||
- 角色:计划中的独立入口机
|
|
||||||
- 私网 IP: `10.0.1.175`
|
|
||||||
- 当前状态:`stopped`
|
|
||||||
- 当前说明:已经只绑定 `hifast-hk-web-sg`,但目前并未承接正式流量
|
|
||||||
|
|
||||||
#### MySQL 主库
|
|
||||||
|
|
||||||
- 类型:`AWS RDS MySQL`
|
|
||||||
- 实例名:`hifast-mysql-prod-v2`
|
|
||||||
- Endpoint: `hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
|
|
||||||
- 角色:生产主库
|
|
||||||
- Engine: `MySQL 8.4.8`
|
|
||||||
- Publicly accessible: `Yes`
|
|
||||||
- Multi-AZ: `No`
|
|
||||||
- Backup retention: `7 days`
|
|
||||||
- Deletion protection: `On`
|
|
||||||
- 当前数据库实际体量:约 `187.41 MB`
|
|
||||||
- 当前表数量:`37`
|
|
||||||
- 当前 `user` 表记录数:`45076`
|
|
||||||
|
|
||||||
#### Redis 主库
|
|
||||||
|
|
||||||
- 部署位置:`AWS EC2 hifast-hk-app-01`
|
|
||||||
- 部署方式:`Docker`
|
|
||||||
- 容器名:`hifast-redis`
|
|
||||||
- 版本:`redis:8.2.1`
|
|
||||||
- 访问端口:`6379`
|
|
||||||
- 主库出口地址:`18.163.33.75:6379`
|
|
||||||
- 应用当前实际连接:`127.0.0.1:6379`
|
|
||||||
- 认证方式:已启用密码认证
|
|
||||||
|
|
||||||
#### 外部备用服务器
|
|
||||||
|
|
||||||
- IP: `104.238.220.230`
|
|
||||||
- OS: `Ubuntu 24.04 LTS`
|
|
||||||
- 角色:异地备用节点
|
|
||||||
- 当前状态:已部署与 AWS 相同的业务服务
|
|
||||||
- 当前 MySQL 状态:外部只读从库
|
|
||||||
- 当前 Redis 部署方式:`宿主机原生安装`
|
|
||||||
- 当前 Redis 版本:`8.6.3`
|
|
||||||
- 当前 Redis 角色:`AWS Redis 主库的从库`
|
|
||||||
|
|
||||||
#### S3 备份桶
|
|
||||||
|
|
||||||
- Bucket: `hifast-prod-backups-200810848252-ap-east-1`
|
|
||||||
- Region: `ap-east-1`
|
|
||||||
- Public access: blocked
|
|
||||||
- Versioning: enabled
|
|
||||||
|
|
||||||
## 2. 架构总览
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
USER["用户 / 客户端"] --> DNS["域名 / DNS / 入口层"]
|
|
||||||
DNS --> APP["AWS EC2\nhifast-hk-app-01\n18.163.33.75\n10.0.1.201"]
|
|
||||||
|
|
||||||
APP --> RDS["AWS RDS MySQL\nhifast-mysql-prod-v2\n主库"]
|
|
||||||
APP --> REDISM["AWS Redis 主库\nDocker redis:8.2.1\n18.163.33.75:6379"]
|
|
||||||
|
|
||||||
RDS -. MySQL 备用 / 同步 .-> MYSQLS["104.238.220.230\nMySQL 备用库"]
|
|
||||||
REDISM -. Redis 主从复制 .-> REDISS["104.238.220.230\n原生 Redis 8.6.3\n从库"]
|
|
||||||
|
|
||||||
subgraph AWS["AWS ap-east-1"]
|
|
||||||
APP
|
|
||||||
RDS
|
|
||||||
REDISM
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph BACKUP["异地备用节点"]
|
|
||||||
MYSQLS
|
|
||||||
REDISS
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 访问链路
|
|
||||||
|
|
||||||
### 3.1 用户访问链路
|
|
||||||
|
|
||||||
当前生产访问链路可以概括为:
|
|
||||||
|
|
||||||
`用户 -> 域名 / DNS -> AWS EC2 应用机 -> MySQL / Redis`
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 当前主应用入口实际在 `hifast-hk-app-01`。
|
|
||||||
- `hifast-hk-app-01` 同时承担 Nginx、业务服务、Redis 主库。
|
|
||||||
- 当前业务服务不是 systemd 单进程部署,而是由 `/opt/ppanel/docker-compose.cloud.yml` 管理的 `ppanel-server` 容器提供 `8080`。
|
|
||||||
- 独立入口机 `hifast-hk-nginx-01` 已存在,但当前仅作为后续资源开通后的迁移目标。
|
|
||||||
- MySQL 在 AWS RDS。
|
|
||||||
- Redis 不在 ElastiCache,而是在 EC2 本机通过 Docker 提供。
|
|
||||||
|
|
||||||
### 3.2 应用访问数据链路
|
|
||||||
|
|
||||||
应用侧内部依赖关系如下:
|
|
||||||
|
|
||||||
```text
|
|
||||||
App / Nginx
|
|
||||||
-> RDS MySQL 主库
|
|
||||||
-> AWS EC2 Redis 主库
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 备用链路
|
|
||||||
|
|
||||||
备用服务器 `104.238.220.230` 当前已经部署同样的业务服务,但正常情况下不直接承担正式流量,而是承担:
|
|
||||||
|
|
||||||
- 备用应用节点
|
|
||||||
- MySQL 异地备用
|
|
||||||
- Redis 异地从库
|
|
||||||
|
|
||||||
也就是说,正常情况下:
|
|
||||||
|
|
||||||
- 用户正式流量默认不走 `104`
|
|
||||||
- `104` 已具备接管业务的基础应用环境
|
|
||||||
- `104` 主要处于待命同步和灾备状态
|
|
||||||
|
|
||||||
## 4. 网络与安全边界
|
|
||||||
|
|
||||||
### 4.1 当前安全组
|
|
||||||
|
|
||||||
截至 `2026-05-13`,VPC `vpc-09fc384522517debc` 内仅保留 4 个安全组:
|
|
||||||
|
|
||||||
- `default`
|
|
||||||
- `hifast-hk-app-core-sg`
|
|
||||||
- `hifast-hk-rds-core-sg`
|
|
||||||
- `hifast-hk-web-sg`
|
|
||||||
|
|
||||||
其中绑定关系已经收敛为:
|
|
||||||
|
|
||||||
- `hifast-hk-app-01` -> `hifast-hk-app-core-sg`
|
|
||||||
- `hifast-hk-nginx-01` -> `hifast-hk-web-sg`
|
|
||||||
- `hifast-mysql-prod-v2` -> `hifast-hk-rds-core-sg`
|
|
||||||
|
|
||||||
旧组:
|
|
||||||
|
|
||||||
- `hifast-hk-app-sg`
|
|
||||||
- `hifast-hk-nginx-sg`
|
|
||||||
|
|
||||||
已经删除,不再使用。
|
|
||||||
|
|
||||||
### 4.2 当前入站规则
|
|
||||||
|
|
||||||
#### `hifast-hk-app-core-sg`
|
|
||||||
|
|
||||||
- `22/tcp <- 0.0.0.0/0`
|
|
||||||
- `6379/tcp <- 104.238.220.230/32`
|
|
||||||
- `8080/tcp <- hifast-hk-web-sg`
|
|
||||||
|
|
||||||
#### `hifast-hk-rds-core-sg`
|
|
||||||
|
|
||||||
- `3306/tcp <- 104.238.220.230/32`
|
|
||||||
- `3306/tcp <- hifast-hk-app-core-sg`
|
|
||||||
|
|
||||||
#### `hifast-hk-web-sg`
|
|
||||||
|
|
||||||
- `22/tcp <- 0.0.0.0/0`
|
|
||||||
- `80/tcp <- 0.0.0.0/0`
|
|
||||||
- `443/tcp <- 0.0.0.0/0`
|
|
||||||
|
|
||||||
### 4.3 Redis 网络关系
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
SG["hifast-hk-app-core-sg"] --> REDIS["AWS Redis 主库\n18.163.33.75:6379"]
|
|
||||||
STANDBY["104.238.220.230/32"] --> SG
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 RDS 访问原则
|
|
||||||
|
|
||||||
RDS 当前状态已经比之前干净很多:
|
|
||||||
|
|
||||||
- 当前只对白名单和应用安全组开放 `3306`
|
|
||||||
- 已移除 `3306 <- 0.0.0.0/0`
|
|
||||||
- 当前 RDS 仅绑定 `hifast-hk-rds-core-sg`
|
|
||||||
|
|
||||||
建议原则:
|
|
||||||
|
|
||||||
- 不开放 `0.0.0.0/0` 到 MySQL `3306`
|
|
||||||
- 不开放 `0.0.0.0/0` 到 Redis `6379`
|
|
||||||
|
|
||||||
### 4.5 当前仍需继续收敛的网络点
|
|
||||||
|
|
||||||
#### 4.5.1 入口层当前属于过渡方案,不作为本阶段阻塞项
|
|
||||||
|
|
||||||
当前运行形态是:
|
|
||||||
|
|
||||||
- `hifast-hk-app-01` 本机 `nginx` 正在监听 `80/443`
|
|
||||||
- `hifast-hk-nginx-01` 当前停止
|
|
||||||
|
|
||||||
但当前安全组设计是按“独立 nginx 机”拆的:
|
|
||||||
|
|
||||||
- `hifast-hk-web-sg` 在 `hifast-hk-nginx-01`
|
|
||||||
- `hifast-hk-app-core-sg` 在 `hifast-hk-app-01`
|
|
||||||
|
|
||||||
这意味着当前真实入口和安全组角色划分还没有完全一致。
|
|
||||||
|
|
||||||
但这一点已经被确认为过渡设计,不作为当前阶段必须整改的问题。后续待 AWS 新资源到位后,再把公网入口迁到独立 `nginx` 服务器即可。
|
|
||||||
|
|
||||||
#### 4.5.2 RDS 公网复制链路仍是当前唯一核心结构性问题
|
|
||||||
|
|
||||||
为了让 `104.238.220.230` 通过公网白名单访问 RDS,当前实际依赖的是:
|
|
||||||
|
|
||||||
- RDS `PubliclyAccessible = true`
|
|
||||||
- RDS ENI 仍在 `subnet-0dcf46db665b6d8a7`
|
|
||||||
- 该子网名称是 `hifast-hk-private-1b`
|
|
||||||
- 但这个子网当前被临时关联到了公网路由表
|
|
||||||
|
|
||||||
这能用,但不属于最终规范的“纯公网子网组”设计。
|
|
||||||
|
|
||||||
本轮已经额外完成的准备动作:
|
|
||||||
|
|
||||||
- 已新建纯公网 `DB subnet group`:`hifast-hk-rds-public-only-sgprep`
|
|
||||||
- 该组状态:`Complete`
|
|
||||||
- 该组仅包含两条公网子网:
|
|
||||||
- `hifast-hk-public-1a`
|
|
||||||
- `hifast-hk-public-1b`
|
|
||||||
|
|
||||||
当前结论:
|
|
||||||
|
|
||||||
- 不建议继续在现有生产 RDS 上反复试在线切子网组。
|
|
||||||
- 更稳的收敛方案是:新建一台使用规范公网子网组的生产 RDS,再做一次短切换。
|
|
||||||
- 由于当前库体量只有约 `187 MB`,这条路线的执行成本并不高,风险也比在现网主库上硬改更可控。
|
|
||||||
|
|
||||||
## 4.6 104 如何连接 AWS
|
|
||||||
|
|
||||||
这里要特别区分:
|
|
||||||
|
|
||||||
- `104` 连接 AWS 数据层
|
|
||||||
- 本地电脑登录 AWS EC2
|
|
||||||
|
|
||||||
这不是同一件事。
|
|
||||||
|
|
||||||
### 4.6.1 104 连接 AWS Redis 的方式
|
|
||||||
|
|
||||||
`104.238.220.230` 连接 AWS Redis,不是通过 PEM 证书,也不是通过 SSH 登录 AWS 机器,而是直接作为 Redis 从库去访问 AWS Redis 主库:
|
|
||||||
|
|
||||||
- 目标地址:`18.163.33.75:6379`
|
|
||||||
- 连接方式:`TCP`
|
|
||||||
- 认证方式:`Redis 密码`
|
|
||||||
- 网络前提:AWS EC2 安全组已放行 `104.238.220.230/32 -> 6379`
|
|
||||||
|
|
||||||
也就是说:
|
|
||||||
|
|
||||||
```text
|
|
||||||
104 Redis 从库 -> 直连 AWS Redis 主库公网地址 -> 密码认证 -> 建立复制
|
|
||||||
```
|
|
||||||
|
|
||||||
示意命令:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redis-cli -h 18.163.33.75 -p 6379 -a '<REDIS_PASSWORD>'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6.2 104 连接 AWS MySQL RDS 的方式
|
|
||||||
|
|
||||||
`104.238.220.230` 连接 AWS RDS,也不是通过 PEM 证书,而是通过数据库连接:
|
|
||||||
|
|
||||||
- 目标地址:`hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306`
|
|
||||||
- 连接方式:`MySQL TCP`
|
|
||||||
- 认证方式:`MySQL 用户名 + 密码`
|
|
||||||
- 网络前提:RDS 白名单或安全组放行 `104.238.220.230`
|
|
||||||
|
|
||||||
也就是说:
|
|
||||||
|
|
||||||
```text
|
|
||||||
104 MySQL 从库 -> 直连 AWS RDS endpoint -> 账号密码认证 -> 建立复制
|
|
||||||
```
|
|
||||||
|
|
||||||
示意命令:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysql -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u admin -p
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6.3 本地电脑登录 AWS EC2 的方式
|
|
||||||
|
|
||||||
本地电脑这边之前进入 AWS EC2,用的不是 `hifast-hk-app-01-key.pem`,而是:
|
|
||||||
|
|
||||||
- `AWS Console`
|
|
||||||
- `EC2 Instance Connect`
|
|
||||||
- 浏览器里的 Web SSH 终端
|
|
||||||
|
|
||||||
所以当前要理解成 3 条不同链路:
|
|
||||||
|
|
||||||
1. 本地电脑 -> AWS 控制台 -> EC2 Instance Connect -> AWS EC2
|
|
||||||
2. `104` -> AWS Redis 主库公网地址 -> Redis 密码认证
|
|
||||||
3. `104` -> AWS RDS endpoint -> MySQL 用户密码认证
|
|
||||||
|
|
||||||
### 4.6.4 关键结论
|
|
||||||
|
|
||||||
当前灾备链路里,`104` 与 AWS 数据同步不依赖 `.pem` 证书文件。
|
|
||||||
|
|
||||||
真正依赖的是:
|
|
||||||
|
|
||||||
- Redis 安全组放行
|
|
||||||
- RDS 白名单 / 安全组放行
|
|
||||||
- 正确的 Redis 密码
|
|
||||||
- 正确的 MySQL 账号密码
|
|
||||||
|
|
||||||
## 5. Redis 实际部署与同步状态
|
|
||||||
|
|
||||||
### 5.1 AWS Redis 主库
|
|
||||||
|
|
||||||
- 部署方式:Docker
|
|
||||||
- 版本:`8.2.1`
|
|
||||||
- 主库地址:`18.163.33.75:6379`
|
|
||||||
- 运行容器:`hifast-redis`
|
|
||||||
|
|
||||||
### 5.2 104 Redis 从库
|
|
||||||
|
|
||||||
- 部署方式:宿主机原生安装
|
|
||||||
- 版本:`8.6.3`
|
|
||||||
- 角色:`replica / slave`
|
|
||||||
|
|
||||||
### 5.3 Redis 主从状态
|
|
||||||
|
|
||||||
最终已验证结果:
|
|
||||||
|
|
||||||
- `104` 上 Redis:`role:slave`
|
|
||||||
- `104` 上 Redis:`master_link_status:up`
|
|
||||||
- AWS Redis 主库:`connected_slaves:1`
|
|
||||||
- AWS Redis 主库识别到从库:`104.238.220.230:6379`
|
|
||||||
|
|
||||||
### 5.4 Redis 验证结果
|
|
||||||
|
|
||||||
已做过的验证:
|
|
||||||
|
|
||||||
- 从 `104` 连接 AWS Redis 主库,认证成功
|
|
||||||
- AWS 主库写入测试键
|
|
||||||
- `104` 从库成功读取测试键
|
|
||||||
- `2026-05-13` 已确认应用机本地 `ppanel-server -> 127.0.0.1:6379` 建立稳定连接
|
|
||||||
- `2026-05-13` 已确认旧 `stunnel4 -> ElastiCache` 链路下线后,应用日志不再出现 Redis `i/o timeout`
|
|
||||||
|
|
||||||
测试键:
|
|
||||||
|
|
||||||
- key: `hifast_replication_test`
|
|
||||||
- value: `ok_20260510`
|
|
||||||
|
|
||||||
### 5.5 Redis 主从拓扑
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
REDISMASTER["AWS Redis 主库\n18.163.33.75:6379\nDocker redis:8.2.1"]
|
|
||||||
REDISSLAVE["104.238.220.230\n原生 Redis 8.6.3\nrole: slave"]
|
|
||||||
REDISMASTER --> REDISSLAVE
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. MySQL 部署与备用关系
|
|
||||||
|
|
||||||
### 6.1 主库角色
|
|
||||||
|
|
||||||
- 主库在 `AWS RDS MySQL`
|
|
||||||
- 实例:`hifast-mysql-prod-v2`
|
|
||||||
|
|
||||||
### 6.2 外部备用角色
|
|
||||||
|
|
||||||
- `104.238.220.230` 上存在 MySQL 备用用途
|
|
||||||
- 目标是让 `104` 尽量实时同步 AWS 数据
|
|
||||||
|
|
||||||
### 6.3 当前文档说明
|
|
||||||
|
|
||||||
MySQL 当前已经完成实际验收,不再只是“待确认”状态。
|
|
||||||
|
|
||||||
`2026-05-13` 验收结果:
|
|
||||||
|
|
||||||
- `Source_Host = hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
|
|
||||||
- `Replica_IO_Running = Yes`
|
|
||||||
- `Replica_SQL_Running = Yes`
|
|
||||||
- `Seconds_Behind_Source = 0`
|
|
||||||
- `read_only = ON`
|
|
||||||
- `super_read_only = ON`
|
|
||||||
|
|
||||||
这表示 `104` 当前是健康的只读外部备用库。
|
|
||||||
|
|
||||||
MySQL 这部分在此前已经有专门 runbook:
|
|
||||||
|
|
||||||
- [ops/aws-rds-external-replica-runbook.md](/Users/Apple/code_vpn/vpn/ppanel-server/ops/aws-rds-external-replica-runbook.md)
|
|
||||||
|
|
||||||
## 7. 当前生产方案的真实特点
|
|
||||||
|
|
||||||
这套已经落地的架构,不是传统的全 AWS 托管标准形态,而是偏实用的混合方案:
|
|
||||||
|
|
||||||
- 应用和当前实际公网入口在 AWS 同一台 EC2
|
|
||||||
- MySQL 在 AWS RDS
|
|
||||||
- Redis 在 AWS EC2 本机
|
|
||||||
- MySQL / Redis 均向外部服务器 `104` 做灾备
|
|
||||||
- 旧 ElastiCache / stunnel 链路已经退出当前生产路径
|
|
||||||
|
|
||||||
它的优点:
|
|
||||||
|
|
||||||
- 成本相对可控
|
|
||||||
- Redis 可完全自主控制
|
|
||||||
- 外部备用机可以独立接管
|
|
||||||
|
|
||||||
它的代价:
|
|
||||||
|
|
||||||
- Redis 高可用需要人工切换
|
|
||||||
- 外部灾备不是全自动故障转移
|
|
||||||
- 应用切换需要明确操作步骤
|
|
||||||
|
|
||||||
## 8. 故障切换方案
|
|
||||||
|
|
||||||
### 8.1 正常状态
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A["用户访问"] --> B["AWS EC2 应用机"]
|
|
||||||
B --> C["AWS RDS MySQL 主库"]
|
|
||||||
B --> D["AWS Redis 主库"]
|
|
||||||
C -. 同步 .-> E["104 MySQL 备用"]
|
|
||||||
D -. 复制 .-> F["104 Redis 从库"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 AWS 故障后的目标切换状态
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A["AWS 故障"] --> B["应用入口切到 104"]
|
|
||||||
B --> C["104 MySQL 提供主服务"]
|
|
||||||
B --> D["104 Redis 提升为主库"]
|
|
||||||
D --> E["应用连接 104 Redis"]
|
|
||||||
C --> F["应用连接 104 MySQL"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2.1 Nginx 是否可以直接切到 104
|
|
||||||
|
|
||||||
可以,但前提不是“只切 Nginx 就完成故障切换”。
|
|
||||||
|
|
||||||
因为 `104` 虽然已经部署了同样的业务服务,但如果故障发生时:
|
|
||||||
|
|
||||||
- Redis 还保持从库只读状态
|
|
||||||
- MySQL 还没有切成可写主角色
|
|
||||||
- 应用配置还没有确认指向 `104` 本机数据层
|
|
||||||
|
|
||||||
那么即使 Nginx 已经把流量转到 `104`,业务也可能仍然无法正常写入。
|
|
||||||
|
|
||||||
所以更准确的原则是:
|
|
||||||
|
|
||||||
`104` 已具备应用接管能力,Nginx 切换可以作为最后一步对外放流量动作,但不能作为唯一动作。
|
|
||||||
|
|
||||||
### 8.3 Redis 切换动作
|
|
||||||
|
|
||||||
当 AWS Redis 不可用时,`104` 上的 Redis 需要解除主从关系:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
redis-cli -a '<REDIS_PASSWORD>' REPLICAOF NO ONE
|
|
||||||
```
|
|
||||||
|
|
||||||
切换后:
|
|
||||||
|
|
||||||
- `104` Redis 从库变为主库
|
|
||||||
- 业务应用把 Redis 地址改到 `104.238.220.230:6379`
|
|
||||||
|
|
||||||
### 8.4 MySQL 切换动作
|
|
||||||
|
|
||||||
当 AWS RDS 不可用时,需要让 `104` MySQL 接管写流量。
|
|
||||||
|
|
||||||
这部分是否能立即切,需要依赖:
|
|
||||||
|
|
||||||
- 当前 `104` MySQL 是否是健康从库
|
|
||||||
- 是否已经取消只读
|
|
||||||
- 应用数据库配置是否能快速切换到 `104`
|
|
||||||
|
|
||||||
### 8.5 应用切换动作
|
|
||||||
|
|
||||||
应用层需要准备至少这 2 个切换点:
|
|
||||||
|
|
||||||
- MySQL 连接地址切换到 `104`
|
|
||||||
- Redis 连接地址切换到 `104`
|
|
||||||
|
|
||||||
如果应用入口也要迁移到 `104`,还需要:
|
|
||||||
|
|
||||||
- 域名解析切换
|
|
||||||
- 或者网关 / 入口切换
|
|
||||||
|
|
||||||
### 8.6 推荐的实际切换顺序
|
|
||||||
|
|
||||||
因为 `104` 已经部署同样的应用服务,所以 AWS 故障时推荐按下面顺序操作:
|
|
||||||
|
|
||||||
1. 确认 `104` 上业务服务和 Nginx 进程正常。
|
|
||||||
2. 将 `104` 上 Redis 从库提升为主库。
|
|
||||||
3. 将 `104` 上 MySQL 从库切换为可写主库。
|
|
||||||
4. 确认 `104` 上应用配置已指向本机 MySQL / Redis。
|
|
||||||
5. 最后再把 Nginx 上游或域名流量切到 `104`。
|
|
||||||
|
|
||||||
可以把它理解成:
|
|
||||||
|
|
||||||
`先数据接管 -> 再应用确认 -> 最后入口切流量`
|
|
||||||
|
|
||||||
## 9. 建议的运维操作顺序
|
|
||||||
|
|
||||||
### 9.1 平时
|
|
||||||
|
|
||||||
平时重点看:
|
|
||||||
|
|
||||||
- AWS EC2 是否在线
|
|
||||||
- RDS 是否在线
|
|
||||||
- AWS Redis 主库是否在线
|
|
||||||
- `104` Redis 从库是否 `master_link_status:up`
|
|
||||||
- `104` MySQL 复制是否正常
|
|
||||||
- `ppanel-server` 容器是否 `Up`
|
|
||||||
- `ppanel-server` 日志中是否再次出现 Redis `i/o timeout`
|
|
||||||
- `stunnel4` 是否保持 `inactive`
|
|
||||||
|
|
||||||
### 9.2 Redis 故障时
|
|
||||||
|
|
||||||
1. 确认 AWS Redis 主库不可恢复。
|
|
||||||
2. 在 `104` 执行 `REPLICAOF NO ONE`。
|
|
||||||
3. 修改应用 Redis 地址到 `104.238.220.230:6379`。
|
|
||||||
4. 验证应用读写 Redis 正常。
|
|
||||||
|
|
||||||
### 9.3 MySQL 故障时
|
|
||||||
|
|
||||||
1. 确认 RDS 故障。
|
|
||||||
2. 确认 `104` MySQL 数据已同步到最新可用点。
|
|
||||||
3. 去掉 `104` MySQL 只读限制。
|
|
||||||
4. 修改应用 MySQL 地址到 `104`。
|
|
||||||
5. 验证应用读写数据库正常。
|
|
||||||
|
|
||||||
### 9.4 整体 AWS 故障时
|
|
||||||
|
|
||||||
1. 把 Redis 主角色切到 `104`。
|
|
||||||
2. 把 MySQL 主角色切到 `104`。
|
|
||||||
3. 确认 `104` 上同版本应用服务正常。
|
|
||||||
4. 把应用入口切到备用应用节点。
|
|
||||||
5. 更新 DNS 或 Nginx 上游流量入口。
|
|
||||||
6. 验证用户访问链路。
|
|
||||||
|
|
||||||
## 10. 当前方案与理想方案的差异
|
|
||||||
|
|
||||||
### 10.1 当前实际方案
|
|
||||||
|
|
||||||
`DNS -> hifast-hk-app-01(Nginx + App + Redis) -> RDS MySQL -> 104 灾备`
|
|
||||||
|
|
||||||
### 10.2 理想生产方案
|
|
||||||
|
|
||||||
从长期稳定性看,更推荐未来演进为:
|
|
||||||
|
|
||||||
`DNS / CDN -> ALB -> 多台 EC2 App -> RDS MySQL -> 托管 Redis / 或高可用 Redis`
|
|
||||||
|
|
||||||
异地灾备继续保留:
|
|
||||||
|
|
||||||
- AWS 生产
|
|
||||||
- 104 异地接管
|
|
||||||
|
|
||||||
### 10.3 当前最值得继续补的项
|
|
||||||
|
|
||||||
建议按优先级补齐:
|
|
||||||
|
|
||||||
1. 把 RDS 从“private 子网挂公网路由”的临时方案,收敛成真正的公网 DB subnet group,或改成私网复制方案
|
|
||||||
2. 明确 `104` 应用接管脚本与启动检查项
|
|
||||||
3. 明确 MySQL 故障切换脚本
|
|
||||||
4. 明确 Redis 故障切换脚本
|
|
||||||
5. 明确域名 / DNS 切换方式
|
|
||||||
6. 做一次完整灾备演练
|
|
||||||
7. 待 AWS 新资源到位后,再把公网入口迁到独立 `nginx` 服务器
|
|
||||||
|
|
||||||
### 10.4 RDS 推荐收敛路线
|
|
||||||
|
|
||||||
当前原先规划的“新建规范 RDS 再切换”路线已经完成,现网主库已经是 `hifast-mysql-prod-v2`。
|
|
||||||
|
|
||||||
后续更推荐的正式路线是:
|
|
||||||
|
|
||||||
1. 继续以 `hifast-mysql-prod-v2` 作为当前生产主库
|
|
||||||
2. 确认其持续使用 `hifast-hk-rds-public-only-sgprep`
|
|
||||||
3. 安全组持续只放:
|
|
||||||
- `104.238.220.230/32 -> 3306`
|
|
||||||
- `hifast-hk-app-core-sg -> 3306`
|
|
||||||
4. 持续确认应用连接目标为 `hifast-mysql-prod-v2`
|
|
||||||
5. 持续确认 `104` 的外部复制目标为 `hifast-mysql-prod-v2`
|
|
||||||
6. 验证通过后,再安排旧 RDS 下线
|
|
||||||
|
|
||||||
这条路线的优点:
|
|
||||||
|
|
||||||
- 最终拓扑规范清晰
|
|
||||||
- 不需要继续保留“private 子网挂公网路由”的临时设计
|
|
||||||
- 对当前现网主库的扰动最小
|
|
||||||
- 当前数据库体量较小,迁移成本可控
|
|
||||||
|
|
||||||
## 11. 建议的下一版目标拓扑
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
USER["用户 / 客户端"] --> DNS["DNS / CDN / 入口层"]
|
|
||||||
DNS --> APPAWS["AWS 应用集群"]
|
|
||||||
DNS -. 故障时切换 .-> APPBK["104 备用应用节点"]
|
|
||||||
|
|
||||||
APPAWS --> RDSAWS["AWS RDS MySQL 主库"]
|
|
||||||
APPAWS --> REDISAWS["AWS Redis 主库"]
|
|
||||||
|
|
||||||
RDSAWS -. 同步 .-> MYSQLBK["104 MySQL 备用"]
|
|
||||||
REDISAWS -. 复制 .-> REDISBK["104 Redis 备用"]
|
|
||||||
|
|
||||||
APPBK --> MYSQLBK
|
|
||||||
APPBK --> REDISBK
|
|
||||||
```
|
|
||||||
|
|
||||||
## 12. 本文档结论
|
|
||||||
|
|
||||||
截至当前,已经可以确认的生产与灾备状态是:
|
|
||||||
|
|
||||||
- AWS 是主生产环境
|
|
||||||
- 应用当前跑在 `hifast-hk-app-01`
|
|
||||||
- MySQL 主库在 AWS RDS
|
|
||||||
- Redis 主库在 AWS EC2 Docker
|
|
||||||
- `104.238.220.230` 是异地备用节点
|
|
||||||
- `104` 已部署与 AWS 相同的业务服务
|
|
||||||
- `104` 上 Redis 已切为宿主机原生安装
|
|
||||||
- `104` Redis 已成功作为 AWS Redis 主库的从库在线同步
|
|
||||||
- `104` MySQL 已恢复并保持健康复制
|
|
||||||
- 应用已经切回当前真实可用的本机 Redis 主库
|
|
||||||
- 旧 `stunnel4 -> ElastiCache` 链路已经退出生产路径
|
|
||||||
|
|
||||||
如果后续要继续完善这份方案,优先补充:
|
|
||||||
|
|
||||||
- RDS 子网与公网访问模型收敛
|
|
||||||
- 入口域名 / DNS 切换细则
|
|
||||||
- 应用层在 `104` 的接管与回切执行清单
|
|
||||||
- 待资源到位后的独立 `nginx` 迁移清单
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user