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 并填写真实值
|
||||
# cp .env.example .env
|
||||
|
||||
# MySQL root 密码(同时需要在 configs/ppanel.yaml 的 MySQL.Password 中填写相同的值)
|
||||
MYSQL_ROOT_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||
|
||||
# Grafana 管理员密码
|
||||
GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||
|
||||
# PPanel Server 镜像标签(由 CI/CD 传入不可变 tag,如 git SHA)
|
||||
PPANEL_SERVER_TAG=CHANGE_ME_TO_GIT_SHA
|
||||
|
||||
# AWS 区域(香港)
|
||||
AWS_REGION=ap-east-1
|
||||
|
||||
# Grafana 公开域名(如需反代)
|
||||
GRAFANA_DOMAIN=logs-new.hifast.biz
|
||||
GRAFANA_ROOT_URL=https://logs-new.hifast.biz
|
||||
|
||||
@@ -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
|
||||
*.key
|
||||
*.pem
|
||||
*.pub
|
||||
*id_rsa*
|
||||
*id_ed25519*
|
||||
*_bak
|
||||
*.go_bak
|
||||
deploy/**/keys/
|
||||
deliverables/
|
||||
|
||||
# ==================== 日志 ====================
|
||||
*.log
|
||||
@@ -35,6 +42,7 @@ logs/
|
||||
# ==================== 测试 ====================
|
||||
/test/
|
||||
*_test.go
|
||||
!tests/acceptance/*_test.go
|
||||
*_test_config.go
|
||||
**/logtest/
|
||||
*_test.yaml
|
||||
@@ -70,6 +78,7 @@ script/*.sh
|
||||
|
||||
# Codex local configuration
|
||||
.codex/
|
||||
.codex-tmp/
|
||||
|
||||
# Claude Flow runtime 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
|
||||
|
||||
> **TawCorp internal contributors**: read [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md) first. It documents the canonical end-to-end flow (Multica issue → branch → PR → CI → review → squash merge via GitHub UI → Deploy Staging → QA), agent role boundaries, branch / commit conventions, and the soft-constraint model used in place of branch protection. The guidelines below apply to all contributors (internal and external) as the baseline.
|
||||
|
||||
To ensure the quality of the codebase and maintainability of the project, please follow these guidelines before submitting a Pull Request (PR):
|
||||
|
||||
## 1. PR Title and Description
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Pull Request 提交须知
|
||||
|
||||
> **TawCorp 内部协作者**:请先阅读 [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md)。该文档定义了从 Multica issue 到 PR 合并到 Deploy Staging 到 QA 验收的端到端流程,以及 agent 角色边界、分支/commit 约定、和不依赖 GitHub branch protection 的软约束模型。下面的通用指南仍然适用,但内部协作以 `doc/development-workflow-zh.md` 为准。
|
||||
|
||||
为了确保代码库的质量和项目的可维护性,在提交 Pull Request(PR)之前,请务必遵循以下准则:
|
||||
|
||||
## 1. PR 标题和描述
|
||||
|
||||
@@ -1,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
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -21,8 +21,10 @@ type (
|
||||
InviteManageRecord {
|
||||
InviterId int64 `json:"inviter_id"`
|
||||
InviterIdentifier string `json:"inviter_identifier"`
|
||||
InviterDeviceNo string `json:"inviter_device_no"`
|
||||
InviteeId int64 `json:"invitee_id"`
|
||||
InviteeIdentifier string `json:"invitee_identifier"`
|
||||
InviteeDeviceNo string `json:"invitee_device_no"`
|
||||
InviteeAvatar string `json:"invitee_avatar"`
|
||||
InviteeEnable bool `json:"invitee_enable"`
|
||||
InvitedAt int64 `json:"invited_at"`
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ type (
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
|
||||
TrafficLimit *string `json:"traffic_limit,omitempty"`
|
||||
TrafficLimit []TrafficLimit `json:"traffic_limit,omitempty"`
|
||||
}
|
||||
GetUserLoginLogsRequest {
|
||||
Page int `form:"page"`
|
||||
|
||||
@@ -117,6 +117,7 @@ type (
|
||||
}
|
||||
WithdrawalLog {
|
||||
Id int64 `json:"id"`
|
||||
BizType string `json:"biz_type"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
@@ -134,6 +135,7 @@ type (
|
||||
QueryWithdrawalLogListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
||||
}
|
||||
QueryWithdrawalLogListResponse {
|
||||
List []WithdrawalLog `json:"list"`
|
||||
|
||||
@@ -575,6 +575,7 @@ type (
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
EntitlementSource string `json:"entitlement_source"`
|
||||
|
||||
@@ -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:
|
||||
# ----------------------------------------------------
|
||||
# 1. 业务后端 (PPanel Server)
|
||||
# host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo
|
||||
# PPANEL_SERVER_TAG 由 CI/CD 传入不可变镜像标签(如 git SHA)
|
||||
# ----------------------------------------------------
|
||||
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
|
||||
restart: always
|
||||
volumes:
|
||||
- ./configs:/app/etc
|
||||
- ./logs:/app/logs
|
||||
- ./cache:/app/cache # GeoLite2-City.mmdb IP 地理位置数据库
|
||||
- ./cache:/app/cache
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
network_mode: host
|
||||
@@ -37,215 +15,8 @@ services:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
depends_on:
|
||||
tempo:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 2. Tempo (链路追踪存储)
|
||||
# ----------------------------------------------------
|
||||
tempo:
|
||||
image: grafana/tempo:2.4.1
|
||||
container_name: ppanel-tempo
|
||||
user: root
|
||||
restart: always
|
||||
command:
|
||||
- "-config.file=/etc/tempo.yaml"
|
||||
- "-target=all"
|
||||
volumes:
|
||||
- ./tempo/tempo-config.yaml:/etc/tempo.yaml
|
||||
- ./tempo_data:/var/tempo
|
||||
ports:
|
||||
- "127.0.0.1:4317:4317" # OTLP gRPC,ppanel-server(host网络)通过127.0.0.1:4317发送trace
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 3. Loki (日志存储)
|
||||
# ----------------------------------------------------
|
||||
loki:
|
||||
image: grafana/loki:3.0.0
|
||||
container_name: ppanel-loki
|
||||
restart: always
|
||||
volumes:
|
||||
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
|
||||
- loki_data:/loki
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
# 不对外暴露端口,仅内网访问
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 4. Promtail (日志采集)
|
||||
# ----------------------------------------------------
|
||||
promtail:
|
||||
image: grafana/promtail:3.0.0
|
||||
container_name: ppanel-promtail
|
||||
restart: always
|
||||
volumes:
|
||||
- ./loki/promtail-config.yaml:/etc/promtail/config.yaml
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./logs:/var/log/ppanel-server:ro
|
||||
- /var/log/nginx:/var/log/nginx:ro
|
||||
command: -config.file=/etc/promtail/config.yaml
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- loki
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 5. Grafana (可观测面板)
|
||||
# 访问: ssh -L 3333:localhost:3333 your-server 后浏览器打开 http://localhost:3333
|
||||
# 或配置 Nginx 反代(建议加认证)
|
||||
# ----------------------------------------------------
|
||||
grafana:
|
||||
image: grafana/grafana:13.0.1
|
||||
container_name: ppanel-grafana
|
||||
restart: always
|
||||
ports:
|
||||
- "3333:3000" # 仅本机可访问,需 SSH 隧道或 Nginx 反代
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:?请在 .env 文件中设置 GRAFANA_PASSWORD}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_SERVER_DOMAIN=${GRAFANA_DOMAIN:-logsx.hifast.biz}
|
||||
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-https://logsx.hifast.biz}
|
||||
- GF_FEATURE_TOGGLES_ENABLE=appObservability
|
||||
- AWS_REGION=${AWS_REGION:-ap-east-1}
|
||||
- AWS_DEFAULT_REGION=${AWS_REGION:-ap-east-1}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
|
||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
|
||||
- AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- loki
|
||||
- tempo
|
||||
- prometheus
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 6. Prometheus (指标采集)
|
||||
# ----------------------------------------------------
|
||||
prometheus:
|
||||
image: prom/prometheus:v3.11.3
|
||||
container_name: ppanel-prometheus
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:9090:9090" # 仅本机可访问
|
||||
volumes:
|
||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.enable-lifecycle'
|
||||
- '--web.enable-remote-write-receiver'
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 7. Nginx Exporter (监控宿主机 Nginx)
|
||||
# ----------------------------------------------------
|
||||
nginx-exporter:
|
||||
image: nginx/nginx-prometheus-exporter:1.5.0
|
||||
container_name: ppanel-nginx-exporter
|
||||
restart: always
|
||||
command:
|
||||
- -nginx.scrape-uri=http://host.docker.internal:8090/nginx_status
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 8. Node Exporter (宿主机监控)
|
||||
# ----------------------------------------------------
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.11.1
|
||||
container_name: ppanel-node-exporter
|
||||
restart: always
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.sysfs=/host/sys'
|
||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 9. cAdvisor (容器监控)
|
||||
# ----------------------------------------------------
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:v0.55.1
|
||||
container_name: ppanel-cadvisor
|
||||
restart: always
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk/:/dev/disk:ro
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
prometheus_data:
|
||||
tempo_data:
|
||||
|
||||
networks:
|
||||
ppanel_net:
|
||||
name: ppanel_net
|
||||
driver: bridge
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
ppanel:
|
||||
container_name: ppanel-server
|
||||
|
||||
+8
-8
@@ -15,10 +15,10 @@ Logger: # 日志配置
|
||||
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
||||
|
||||
MySQL:
|
||||
Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
||||
Addr: 127.0.0.1:3306 # 本地开发默认;Docker bridge 模式可改为 mysql:3306
|
||||
Username: root # MySQL用户名
|
||||
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
||||
Dbname: hifast # MySQL数据库名
|
||||
Password: CHANGE_ME_TO_DB_PASSWORD # MySQL密码
|
||||
Dbname: ppanel # MySQL数据库名
|
||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||
MaxIdleConns: 10
|
||||
MaxOpenConns: 100
|
||||
@@ -42,9 +42,9 @@ Redis:
|
||||
|
||||
AppSignature:
|
||||
AppSecrets:
|
||||
android-client: uB4G,XxL2{7b # Android 客户端签名密钥
|
||||
ios-client: uB4G,XxL2{7b # iOS 客户端签名密钥
|
||||
web-client: uB4G,XxL2{7b # Web 客户端签名密钥
|
||||
android-client: CHANGE_ME_ANDROID_APP_SECRET # Android 客户端签名密钥
|
||||
ios-client: CHANGE_ME_IOS_APP_SECRET # iOS 客户端签名密钥
|
||||
web-client: CHANGE_ME_WEB_APP_SECRET # Web 客户端签名密钥
|
||||
ValidWindowSeconds: 300 # 签名时间窗口(秒)
|
||||
SkipPrefixes:
|
||||
- /v1/notify/ # 支付回调不验签
|
||||
@@ -58,8 +58,8 @@ Signature:
|
||||
Trace: # 链路追踪配置 (OpenTelemetry)
|
||||
Name: ppanel # 服务名
|
||||
Sampler: 1.0 # 采样率 0.0-1.0,生产建议 0.1
|
||||
Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc
|
||||
Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317
|
||||
Batcher: "" # 本地开发留空;生产如需链路追踪再配置 exporter
|
||||
Endpoint: ""
|
||||
|
||||
S3:
|
||||
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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@@ -15,31 +12,18 @@ import (
|
||||
func UpdateUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateUserSubscribeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
if err := validateUpdateUserSubscribeTrafficLimit(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewUpdateUserSubscribeLogic(c.Request.Context(), svcCtx)
|
||||
err := l.UpdateUserSubscribe(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
func validateUpdateUserSubscribeTrafficLimit(req *types.UpdateUserSubscribeRequest) error {
|
||||
if req.TrafficLimit == nil || *req.TrafficLimit == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rules []types.TrafficLimit
|
||||
if err := json.Unmarshal([]byte(*req.TrafficLimit), &rules); err != nil {
|
||||
return errors.New("traffic_limit must be a valid JSON array")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestUpdateUserSubscribeHandlerRejectsInvalidLimits(t *testing.T) {
|
||||
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"speed_limit":-1}`,
|
||||
},
|
||||
{
|
||||
name: "invalid traffic limit 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"}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/group"
|
||||
"github.com/perfect-panel/server/internal/model/node"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
var defaultSubscribeCount int64
|
||||
if err := l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("node_group_id = ?", nodeGroup.Id).Count(&defaultSubscribeCount).Error; err != nil {
|
||||
logger.Errorf("failed to count subscribes with default group: %v", err)
|
||||
return err
|
||||
}
|
||||
if defaultSubscribeCount > 0 {
|
||||
return fmt.Errorf("cannot delete group referenced by %d subscribes' default node group", defaultSubscribeCount)
|
||||
}
|
||||
|
||||
var subscribeGroupCount int64
|
||||
if err := l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroup.Id)).Count(&subscribeGroupCount).Error; err != nil {
|
||||
logger.Errorf("failed to count subscribes in group: %v", err)
|
||||
return err
|
||||
}
|
||||
if subscribeGroupCount > 0 {
|
||||
return fmt.Errorf("cannot delete group referenced by %d subscribes' node group list", subscribeGroupCount)
|
||||
}
|
||||
|
||||
var userSubscribeCount int64
|
||||
if err := l.svcCtx.DB.Table("user_subscribe").Where("node_group_id = ?", nodeGroup.Id).Count(&userSubscribeCount).Error; err != nil {
|
||||
logger.Errorf("failed to count user subscribes in group: %v", err)
|
||||
return err
|
||||
}
|
||||
if userSubscribeCount > 0 {
|
||||
return fmt.Errorf("cannot delete group referenced by %d user subscribes", userSubscribeCount)
|
||||
}
|
||||
|
||||
// 使用 GORM Transaction 删除节点组
|
||||
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 删除节点组
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/group"
|
||||
@@ -52,10 +53,9 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
|
||||
Email string `json:"email"`
|
||||
}
|
||||
var users []UserInfo
|
||||
if err := l.svcCtx.DB.Raw("SELECT * FROM JSON_ARRAY(?)", detail.UserData).Scan(&users).Error; err != nil {
|
||||
// 如果解析失败,尝试用标准 JSON 解析
|
||||
if err := json.Unmarshal([]byte(detail.UserData), &users); err != nil {
|
||||
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...)
|
||||
|
||||
// 生成文件名
|
||||
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
|
||||
}
|
||||
|
||||
@@ -76,16 +76,16 @@ func (l *GetGroupHistoryDetailLogic) GetGroupHistoryDetail(req *types.GetGroupHi
|
||||
configSnapshot := make(map[string]interface{})
|
||||
configSnapshot["group_details"] = details
|
||||
|
||||
// 获取配置快照(从 system_config 读取)
|
||||
// 获取配置快照(从 system 读取)
|
||||
var configValue string
|
||||
if history.GroupMode == "average" {
|
||||
l.svcCtx.DB.Table("system_config").
|
||||
Where("`key` = ?", "group.average_config").
|
||||
l.svcCtx.DB.Table("system").
|
||||
Where("`category` = ? AND `key` = ?", "group", "average_config").
|
||||
Select("value").
|
||||
Scan(&configValue)
|
||||
} else if history.GroupMode == "traffic" {
|
||||
l.svcCtx.DB.Table("system_config").
|
||||
Where("`key` = ?", "group.traffic_config").
|
||||
l.svcCtx.DB.Table("system").
|
||||
Where("`category` = ? AND `key` = ?", "group", "traffic_config").
|
||||
Select("value").
|
||||
Scan(&configValue)
|
||||
}
|
||||
|
||||
@@ -44,9 +44,9 @@ func (l *GetGroupHistoryLogic) GetGroupHistory(req *types.GetGroupHistoryRequest
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (req.Page - 1) * req.Size
|
||||
if err := query.Order("id DESC").Offset(offset).Limit(req.Size).Find(&histories).Error; err != nil {
|
||||
page, size := normalizePagination(req.Page, req.Size)
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&histories).Error; err != nil {
|
||||
logger.Errorf("failed to find group histories: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -38,9 +38,9 @@ func (l *GetNodeGroupListLogic) GetNodeGroupList(req *types.GetNodeGroupListRequ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (req.Page - 1) * req.Size
|
||||
if err := query.Order("sort ASC").Offset(offset).Limit(req.Size).Find(&nodeGroups).Error; err != nil {
|
||||
page, size := normalizePagination(req.Page, req.Size)
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("sort ASC").Offset(offset).Limit(size).Find(&nodeGroups).Error; err != nil {
|
||||
logger.Errorf("failed to find node groups: %v", 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
|
||||
}
|
||||
|
||||
// 6. 过滤出包含至少一个匹配节点组的节点(仅显示用户真正所在分组的节点,不包含公共节点)
|
||||
// 6. 过滤出公共节点和至少一个匹配节点组的节点,与真实订阅下发保持一致
|
||||
for _, n := range dbNodes {
|
||||
// 节点未配置节点组(公共节点),预览时不显示
|
||||
if len(n.NodeGroupIds) == 0 {
|
||||
filteredNodes = append(filteredNodes, n)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -450,9 +450,13 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
|
||||
}
|
||||
}
|
||||
|
||||
// 预览模式不显示公共节点(node_group_ids 为空的节点),只展示用户真正所在分组的节点
|
||||
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 {
|
||||
|
||||
@@ -679,21 +679,7 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
||||
// 将字节转换为 GB
|
||||
usedTrafficGB := float64(us.UsedTraffic) / (1024 * 1024 * 1024)
|
||||
|
||||
// 查找匹配的流量范围(使用左闭右开区间 [Min, Max))
|
||||
var targetNodeGroupId int64 = 0
|
||||
for _, ng := range nodeGroups {
|
||||
if ng.MinTrafficGB == nil || ng.MaxTrafficGB == nil {
|
||||
continue
|
||||
}
|
||||
minTraffic := float64(*ng.MinTrafficGB)
|
||||
maxTraffic := float64(*ng.MaxTrafficGB)
|
||||
|
||||
// 检查是否在区间内 [min, max)
|
||||
if usedTrafficGB >= minTraffic && usedTrafficGB < maxTraffic {
|
||||
targetNodeGroupId = ng.Id
|
||||
break
|
||||
}
|
||||
}
|
||||
targetNodeGroupId := matchTrafficNodeGroup(usedTrafficGB, nodeGroups)
|
||||
|
||||
// 如果没有匹配到任何范围,targetNodeGroupId 保持为 0(不分配节点组)
|
||||
|
||||
@@ -734,7 +720,14 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
||||
// 4. 创建分组历史详情记录(只统计有用户的节点组)
|
||||
nodeGroupCount := make(map[int64]int) // node_group_id -> node_count
|
||||
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 {
|
||||
@@ -764,6 +757,20 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
||||
return affectedCount, nil
|
||||
}
|
||||
|
||||
func matchTrafficNodeGroup(usedTrafficGB float64, nodeGroups []group.NodeGroup) int64 {
|
||||
for _, ng := range nodeGroups {
|
||||
if ng.MinTrafficGB == nil || ng.MaxTrafficGB == nil {
|
||||
continue
|
||||
}
|
||||
minTraffic := float64(*ng.MinTrafficGB)
|
||||
maxTraffic := float64(*ng.MaxTrafficGB)
|
||||
if usedTrafficGB >= minTraffic && usedTrafficGB <= maxTraffic {
|
||||
return ng.Id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// containsIgnoreCase checks if a string contains another substring (case-insensitive)
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"github.com/perfect-panel/server/internal/model/node"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/model/system"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ResetGroupsLogic struct {
|
||||
@@ -27,56 +29,65 @@ func NewResetGroupsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Reset
|
||||
}
|
||||
|
||||
func (l *ResetGroupsLogic) ResetGroups() error {
|
||||
err := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Delete all node groups
|
||||
err := l.svcCtx.DB.Where("1 = 1").Delete(&group.NodeGroup{}).Error
|
||||
if err != nil {
|
||||
if err := tx.Where("1 = 1").Delete(&group.NodeGroup{}).Error; err != nil {
|
||||
l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully deleted all node groups")
|
||||
|
||||
// 2. Clear node_group_ids for all subscribes (products)
|
||||
err = l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("1 = 1").Update("node_group_ids", "[]").Error
|
||||
if err != nil {
|
||||
l.Errorw("Failed to clear subscribes' node_group_ids", logger.Field("error", err.Error()))
|
||||
// 2. Clear node_group_id/node_group_ids for all subscribes (products)
|
||||
if err := tx.Table((&subscribe.Subscribe{}).TableName()).Where("1 = 1").Updates(map[string]interface{}{
|
||||
"node_group_id": 0,
|
||||
"node_group_ids": "[]",
|
||||
}).Error; err != nil {
|
||||
l.Errorw("Failed to clear subscribes' node groups", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared all subscribes' node_group_ids")
|
||||
l.Infow("Successfully cleared all subscribes' node groups")
|
||||
|
||||
// 3. Clear node_group_ids for all nodes
|
||||
err = l.svcCtx.DB.Model(&node.Node{}).Where("1 = 1").Update("node_group_ids", "[]").Error
|
||||
if err != nil {
|
||||
if err := tx.Table((&node.Node{}).TableName()).Where("1 = 1").Update("node_group_ids", "[]").Error; err != nil {
|
||||
l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared all nodes' node_group_ids")
|
||||
|
||||
// 4. Clear group history
|
||||
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistory{}).Error
|
||||
if err != nil {
|
||||
// 4. Clear user_subscribe node_group_id
|
||||
if err := tx.Table((&user.Subscribe{}).TableName()).Where("1 = 1").Update("node_group_id", 0).Error; err != nil {
|
||||
l.Errorw("Failed to clear user subscribes' node_group_id", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared all user subscribes' node_group_id")
|
||||
|
||||
// 5. Clear group history
|
||||
if err := tx.Where("1 = 1").Delete(&group.GroupHistory{}).Error; err != nil {
|
||||
l.Errorw("Failed to clear group history", logger.Field("error", err.Error()))
|
||||
// Non-critical error, continue anyway
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared group history")
|
||||
}
|
||||
|
||||
// 7. Clear group history details
|
||||
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error
|
||||
if err != nil {
|
||||
// 6. Clear group history details
|
||||
if err := tx.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error; err != nil {
|
||||
l.Errorw("Failed to clear group history details", logger.Field("error", err.Error()))
|
||||
// Non-critical error, continue anyway
|
||||
} else {
|
||||
l.Infow("Successfully cleared group history details")
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully cleared group history details")
|
||||
|
||||
// 5. Delete all group config settings
|
||||
err = l.svcCtx.DB.Where("`category` = ?", "group").Delete(&system.System{}).Error
|
||||
if err != nil {
|
||||
// 7. Delete all group config settings
|
||||
if err := tx.Where("`category` = ?", "group").Delete(&system.System{}).Error; err != nil {
|
||||
l.Errorw("Failed to delete group config", logger.Field("error", err.Error()))
|
||||
return err
|
||||
}
|
||||
l.Infow("Successfully deleted all group config settings")
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.Infow("Group reset completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package invite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
modellog "github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
@@ -113,6 +115,7 @@ func QueryBenefits(ctx context.Context, db *gorm.DB, relations []InviteRelation)
|
||||
for userId := range inviteeAndInviterSet {
|
||||
inviteeAndInviterIds = append(inviteeAndInviterIds, userId)
|
||||
}
|
||||
slices.Sort(inviteeAndInviterIds)
|
||||
|
||||
if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil {
|
||||
return nil, err
|
||||
@@ -186,26 +189,35 @@ func fillGiftBenefits(ctx context.Context, db *gorm.DB, benefits map[int64]Benef
|
||||
return nil
|
||||
}
|
||||
|
||||
func QueryIdentifiers(ctx context.Context, db *gorm.DB, userIds []int64) (map[int64]string, error) {
|
||||
identifiers := make(map[int64]string, len(userIds))
|
||||
type DeviceIdentifier struct {
|
||||
Identifier string
|
||||
DeviceNo string
|
||||
}
|
||||
|
||||
func QueryDeviceIdentifiers(ctx context.Context, db *gorm.DB, userIds []int64) (map[int64]DeviceIdentifier, error) {
|
||||
identifiers := make(map[int64]DeviceIdentifier, len(userIds))
|
||||
if len(userIds) == 0 {
|
||||
return identifiers, nil
|
||||
}
|
||||
|
||||
type identifierRow struct {
|
||||
UserId int64 `gorm:"column:user_id"`
|
||||
DeviceId int64 `gorm:"column:device_id"`
|
||||
Identifier string `gorm:"column:identifier"`
|
||||
}
|
||||
var rows []identifierRow
|
||||
if err := db.WithContext(ctx).
|
||||
Table("user_auth_methods uam").
|
||||
Select("uam.user_id, uam.auth_identifier as identifier").
|
||||
Joins("JOIN (SELECT user_id, MIN(id) AS id FROM user_auth_methods WHERE user_id IN ? GROUP BY user_id) first_uam ON first_uam.id = uam.id", userIds).
|
||||
Table("user_device ud").
|
||||
Select("ud.user_id, ud.id as device_id, ud.identifier as identifier").
|
||||
Joins("JOIN (SELECT user_id, MIN(id) AS id FROM user_device WHERE user_id IN ? GROUP BY user_id) first_ud ON first_ud.id = ud.id", userIds).
|
||||
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 {
|
||||
identifiers[row.UserId] = row.Identifier
|
||||
identifiers[row.UserId] = DeviceIdentifier{
|
||||
Identifier: row.Identifier,
|
||||
DeviceNo: tool.DeviceIdToHash(row.DeviceId),
|
||||
}
|
||||
}
|
||||
return identifiers, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -25,7 +26,7 @@ func TestQueryBenefitsCountsFamilyOwnerGiftAsInviteeGift(t *testing.T) {
|
||||
WithArgs(33, int64(100), "family-order", 331, 332).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
||||
mock.ExpectQuery("object_id IN").
|
||||
WithArgs(34, int64(900), int64(200), int64(100), "family-order").
|
||||
WithArgs(34, int64(100), int64(200), int64(900), "family-order").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
||||
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).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
||||
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"}).
|
||||
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)
|
||||
}
|
||||
|
||||
func TestQueryDeviceIdentifiersReturnsDeviceIdentifierAndNo(t *testing.T) {
|
||||
db, mock, cleanup := newBenefitsTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM user_device ud").
|
||||
WithArgs(int64(100), int64(200)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"user_id", "device_id", "identifier"}).
|
||||
AddRow(100, 11, "inviter-hash").
|
||||
AddRow(200, 22, "invitee-hash"))
|
||||
|
||||
identifiers, err := QueryDeviceIdentifiers(context.Background(), db, []int64{100, 200})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryDeviceIdentifiers returned error: %v", err)
|
||||
}
|
||||
|
||||
inviter := identifiers[100]
|
||||
if inviter.Identifier != "inviter-hash" {
|
||||
t.Fatalf("inviter identifier = %q, want inviter-hash", inviter.Identifier)
|
||||
}
|
||||
if inviter.DeviceNo != tool.DeviceIdToHash(11) {
|
||||
t.Fatalf("inviter device no = %q, want %q", inviter.DeviceNo, tool.DeviceIdToHash(11))
|
||||
}
|
||||
|
||||
invitee := identifiers[200]
|
||||
if invitee.Identifier != "invitee-hash" {
|
||||
t.Fatalf("invitee identifier = %q, want invitee-hash", invitee.Identifier)
|
||||
}
|
||||
if invitee.DeviceNo != tool.DeviceIdToHash(22) {
|
||||
t.Fatalf("invitee device no = %q, want %q", invitee.DeviceNo, tool.DeviceIdToHash(22))
|
||||
}
|
||||
assertBenefitsExpectations(t, mock)
|
||||
}
|
||||
|
||||
func newBenefitsTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ func (l *GetInviteManageListLogic) GetInviteManageList(req *types.GetInviteManag
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identifiers, err := QueryIdentifiers(l.ctx, l.svcCtx.DB, userIds)
|
||||
devices, err := QueryDeviceIdentifiers(l.ctx, l.svcCtx.DB, userIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,9 +78,11 @@ func (l *GetInviteManageListLogic) GetInviteManageList(req *types.GetInviteManag
|
||||
benefit := benefits[row.InviteeId]
|
||||
list = append(list, types.InviteManageRecord{
|
||||
InviterId: row.InviterId,
|
||||
InviterIdentifier: identifiers[row.InviterId],
|
||||
InviterIdentifier: devices[row.InviterId].Identifier,
|
||||
InviterDeviceNo: devices[row.InviterId].DeviceNo,
|
||||
InviteeId: row.InviteeId,
|
||||
InviteeIdentifier: identifiers[row.InviteeId],
|
||||
InviteeIdentifier: devices[row.InviteeId].Identifier,
|
||||
InviteeDeviceNo: devices[row.InviteeId].DeviceNo,
|
||||
InviteeAvatar: row.InviteeAvatar,
|
||||
InviteeEnable: row.InviteeEnable,
|
||||
InvitedAt: row.InvitedAt,
|
||||
|
||||
@@ -55,6 +55,8 @@ func orderStatusName(status uint8) string {
|
||||
case 5:
|
||||
return "finished"
|
||||
case 6:
|
||||
return "claimed"
|
||||
case 7:
|
||||
return "refunded"
|
||||
default:
|
||||
return "unknown"
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
orderStatusRefunded = 6
|
||||
orderStatusRefunded = 7
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 幂等校验:若该 order_no 已存在 333 退款日志,拒绝再次退款。
|
||||
// HIF-131 案例:订单状态被外部入口(stuckOrderRecovery 把 6 视为卡住的 claim)回退到 5,
|
||||
// 让 lockCommissionSource 误抓到原始 331/332 amount 再次扣减佣金。
|
||||
refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if refunded {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund 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)
|
||||
@@ -173,6 +170,9 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
|
||||
orderUser := &modeluser.User{Id: orderInfo.UserId}
|
||||
userCacheTargets = append(userCacheTargets, orderUser)
|
||||
}
|
||||
if userSub.UserId > 0 && userSub.UserId != orderInfo.UserId {
|
||||
userCacheTargets = append(userCacheTargets, &modeluser.User{Id: userSub.UserId})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -180,17 +180,19 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
|
||||
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 {
|
||||
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("order_id", req.Id))
|
||||
}
|
||||
}
|
||||
for _, subscribeID := range planCacheIDs {
|
||||
if l.svcCtx.SubscribeModel != nil {
|
||||
if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != nil {
|
||||
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID))
|
||||
}
|
||||
}
|
||||
if len(userCacheTargets) > 0 {
|
||||
}
|
||||
if len(userCacheTargets) > 0 && l.svcCtx.UserModel != nil {
|
||||
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userCacheTargets...); clearErr != nil {
|
||||
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) {
|
||||
var userSub modeluser.Subscribe
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&modeluser.Subscribe{}).
|
||||
Where("order_id = ?", orderInfo.Id).
|
||||
First(&userSub).Error
|
||||
if err == nil {
|
||||
return &userSub, nil
|
||||
if userSub, err := l.lockSubscriptionByOrderID(tx, orderInfo.Id); err != nil {
|
||||
return nil, err
|
||||
} else if userSub != nil {
|
||||
return userSub, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query refund subscription failed: %v", err)
|
||||
|
||||
if userSub, err := l.lockSubscriptionByEntitlement(tx, orderInfo); err != nil {
|
||||
return nil, err
|
||||
} else if userSub != nil {
|
||||
return userSub, nil
|
||||
}
|
||||
|
||||
if orderInfo.Type != 2 {
|
||||
@@ -219,17 +221,122 @@ 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)
|
||||
}
|
||||
|
||||
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
if userSub, err := l.lockSubscriptionByOrderID(tx, orderInfo.ParentId); err != nil {
|
||||
return nil, err
|
||||
} else if userSub != nil {
|
||||
return userSub, nil
|
||||
}
|
||||
|
||||
if userSub, err := l.lockRenewalParentEntitlementSubscription(tx, orderInfo); err != nil {
|
||||
return nil, err
|
||||
} else if userSub != nil {
|
||||
return userSub, nil
|
||||
}
|
||||
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d parent subscription not found", orderInfo.Id)
|
||||
}
|
||||
|
||||
func (l *RefundOrderLogic) lockSubscriptionByOrderID(tx *gorm.DB, orderID int64) (*modeluser.Subscribe, error) {
|
||||
if orderID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var userSub modeluser.Subscribe
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&modeluser.Subscribe{}).
|
||||
Where("order_id = ?", orderInfo.ParentId).
|
||||
Where("order_id = ?", orderID).
|
||||
First(&userSub).Error
|
||||
if err == nil {
|
||||
return &userSub, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query refund subscription failed: %v", err)
|
||||
}
|
||||
|
||||
func (l *RefundOrderLogic) lockSubscriptionByEntitlement(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) {
|
||||
entitlementUserID := orderInfo.SubscriptionUserId
|
||||
if entitlementUserID == 0 {
|
||||
entitlementUserID = orderInfo.UserId
|
||||
}
|
||||
if entitlementUserID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if userSub, err := l.lockExactEntitlementSubscription(tx, orderInfo, entitlementUserID); err != nil {
|
||||
return nil, err
|
||||
} else if userSub != nil {
|
||||
return userSub, nil
|
||||
}
|
||||
|
||||
var userSub modeluser.Subscribe
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&modeluser.Subscribe{}).
|
||||
Where("user_id = ? AND subscribe_id = ?", entitlementUserID, orderInfo.SubscribeId).
|
||||
Where("status IN ?", []int64{0, 1, 2, 3, 5}).
|
||||
Order("expire_time DESC").
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
First(&userSub).Error
|
||||
if err == nil {
|
||||
return &userSub, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query entitlement subscription failed: %v", err)
|
||||
}
|
||||
|
||||
func (l *RefundOrderLogic) lockExactEntitlementSubscription(tx *gorm.DB, orderInfo *modelorder.Order, entitlementUserID int64) (*modeluser.Subscribe, error) {
|
||||
if orderInfo.Id <= 0 && orderInfo.SubscribeToken == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Model(&modeluser.Subscribe{}).
|
||||
Where("user_id = ? AND subscribe_id = ?", entitlementUserID, orderInfo.SubscribeId)
|
||||
if orderInfo.Id > 0 && orderInfo.SubscribeToken != "" {
|
||||
query = query.Where("(order_id = ? OR token = ?)", orderInfo.Id, orderInfo.SubscribeToken)
|
||||
} else if orderInfo.Id > 0 {
|
||||
query = query.Where("order_id = ?", orderInfo.Id)
|
||||
} else {
|
||||
query = query.Where("token = ?", orderInfo.SubscribeToken)
|
||||
}
|
||||
|
||||
var userSub modeluser.Subscribe
|
||||
err := query.Order("id DESC").First(&userSub).Error
|
||||
if err == nil {
|
||||
return &userSub, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query exact entitlement subscription failed: %v", err)
|
||||
}
|
||||
|
||||
func (l *RefundOrderLogic) lockRenewalParentEntitlementSubscription(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) {
|
||||
var parentOrder modelorder.Order
|
||||
err := tx.Model(&modelorder.Order{}).
|
||||
Where("id = ?", orderInfo.ParentId).
|
||||
First(&parentOrder).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d parent subscription not found", orderInfo.Id)
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query renewal subscription failed: %v", err)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query renewal parent order failed: %v", err)
|
||||
}
|
||||
return &userSub, nil
|
||||
|
||||
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) {
|
||||
@@ -267,10 +374,15 @@ func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, ord
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
// hasRefundLog 检查指定 order_no 是否已有 333 (CommissionTypeRefund) 退款佣金日志。
|
||||
// 仅扫 type=33 + 内容含 order_no 的命中项,再用 JSON 二次确认 content.type==333,
|
||||
// 防止 content.order_no 子串误判。
|
||||
// hasRefundLog checks refund audit logs first, then falls back to legacy commission refund logs.
|
||||
func (l *RefundOrderLogic) hasRefundLog(tx *gorm.DB, orderNo string) (bool, error) {
|
||||
refunded, err := log.HasOrderRefundLog(tx, orderNo)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if refunded {
|
||||
return true, nil
|
||||
}
|
||||
return log.HasRefundCommissionLog(tx, orderNo)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
modelorder "github.com/perfect-panel/server/internal/model/order"
|
||||
modeluser "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
@@ -27,8 +27,8 @@ func TestOrderStatusName(t *testing.T) {
|
||||
3: "closed",
|
||||
4: "failed",
|
||||
5: "finished",
|
||||
6: "refunded",
|
||||
7: "unknown",
|
||||
6: "claimed",
|
||||
7: "refunded",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
@@ -96,14 +96,64 @@ func TestBuildRefundAuditLog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-131 / HIF-132 修复:
|
||||
// 当 system_logs 已存在该订单的 333 退款佣金日志时,再次调用 RefundOrder 必须:
|
||||
func TestRefundOrder_SetsStatusRefunded(t *testing.T) {
|
||||
const (
|
||||
orderID = int64(1000)
|
||||
orderNo = "ORD-REFUND-SUCCESS"
|
||||
operatorUID = int64(519)
|
||||
userID = int64(7000)
|
||||
subscribeID = int64(8000)
|
||||
userSubID = int64(9000)
|
||||
)
|
||||
|
||||
db, mock, cleanup := newRefundOrderTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `order`").
|
||||
WithArgs(orderID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}).
|
||||
AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID))
|
||||
mock.ExpectQuery("FROM `system_logs`").
|
||||
WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||
mock.ExpectQuery("FROM `system_logs`").
|
||||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(orderID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time"}).
|
||||
AddRow(userSubID, userID, orderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour)))
|
||||
mock.ExpectQuery("FROM `system_logs`").
|
||||
WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "content"}))
|
||||
mock.ExpectExec("UPDATE `order`").
|
||||
WithArgs(orderStatusRefunded, sqlmock.AnyArg(), orderID, 2, 5).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE `user_subscribe`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO `system_logs`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||||
err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "manual refund"})
|
||||
if err != nil {
|
||||
t.Fatalf("RefundOrder error: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-16 修复:
|
||||
// 当 system_logs 已存在该订单的 24 退款审计日志时,再次调用 RefundOrder 必须:
|
||||
// 1. 返回 OrderAlreadyRefunded 错误码;
|
||||
// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发);
|
||||
// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。
|
||||
//
|
||||
// 通过 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) {
|
||||
const (
|
||||
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"}).
|
||||
AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028)))
|
||||
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"}).
|
||||
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()
|
||||
|
||||
logic := newTestRefundOrderLogic(t, db, operatorUID)
|
||||
@@ -138,12 +188,12 @@ func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==6 拒绝路径,
|
||||
// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==7 拒绝路径,
|
||||
// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。
|
||||
func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) {
|
||||
const (
|
||||
orderID = int64(1001)
|
||||
orderNo = "ORD-STATUS-6"
|
||||
orderNo = "ORD-STATUS-7"
|
||||
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()) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ func NewUpdateOrderStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/group"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
@@ -61,12 +63,24 @@ func (l *GetUserSubscribeLogic) GetUserSubscribe(req *types.GetUserSubscribeList
|
||||
}
|
||||
|
||||
for _, item := range data {
|
||||
var sub types.UserSubscribe
|
||||
tool.DeepCopy(&sub, item)
|
||||
sub.Short, _ = tool.FixedUniqueString(item.Token, 8, "")
|
||||
sub.NodeGroupId = item.NodeGroupId
|
||||
sub.NodeGroupName = groupNames[item.NodeGroupId]
|
||||
sub := buildUserSubscribeListItem(item, groupNames[item.NodeGroupId])
|
||||
resp.List = append(resp.List, sub)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func buildUserSubscribeListItem(item *user.SubscribeDetails, groupName string) types.UserSubscribe {
|
||||
var sub types.UserSubscribe
|
||||
tool.DeepCopy(&sub, item)
|
||||
sub.Short, _ = tool.FixedUniqueString(item.Token, 8, "")
|
||||
sub.NodeGroupId = item.NodeGroupId
|
||||
sub.NodeGroupName = groupName
|
||||
sub.SpeedLimit = item.SpeedLimit
|
||||
if item.TrafficLimit != nil && *item.TrafficLimit != "" {
|
||||
_ = json.Unmarshal([]byte(*item.TrafficLimit), &sub.TrafficLimit)
|
||||
}
|
||||
if item.Subscribe != nil {
|
||||
sub.PlanSpeedLimit = item.Subscribe.SpeedLimit
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
@@ -45,7 +46,11 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
|
||||
}
|
||||
trafficLimit := userSub.TrafficLimit
|
||||
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{
|
||||
@@ -96,3 +101,12 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalUserSubscribeTrafficLimit(rules []types.TrafficLimit) (*string, error) {
|
||||
val, err := json.Marshal(rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trafficLimit := string(val)
|
||||
return &trafficLimit, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -165,13 +164,28 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
var discount float64 = 1
|
||||
if len(newUserDiscount.Discounts) > 0 {
|
||||
discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount)
|
||||
priceResult, err := calculateRenewalPrice(
|
||||
l.ctx,
|
||||
l.svcCtx,
|
||||
entitlement.EffectiveUserID,
|
||||
userSubscribe.SubscribeId,
|
||||
sub.UnitPrice,
|
||||
req.Quantity,
|
||||
newUserDiscount.Discounts,
|
||||
newUserDiscount.EligibleForDiscount,
|
||||
)
|
||||
if err != nil {
|
||||
l.Errorw("[Renewal] Promo price calculation error",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", u.Id),
|
||||
logger.Field("effective_user_id", entitlement.EffectiveUserID),
|
||||
logger.Field("subscribe_id", userSubscribe.SubscribeId),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
price := sub.UnitPrice * req.Quantity
|
||||
amount := int64(math.Round(float64(price) * discount))
|
||||
discountAmount := price - amount
|
||||
price := priceResult.OriginalPrice
|
||||
amount := priceResult.PayableBase
|
||||
discountAmount := priceResult.DiscountAmount
|
||||
|
||||
// Validate amount to prevent overflow
|
||||
if amount > MaxOrderAmount {
|
||||
@@ -269,6 +283,8 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
|
||||
Amount: amount,
|
||||
GiftAmount: deductionAmount,
|
||||
Discount: discountAmount,
|
||||
PromoRuleId: priceResult.PromoRuleId,
|
||||
PromoDiscount: priceResult.PromoDiscount,
|
||||
Coupon: req.Coupon,
|
||||
CouponDiscount: coupon,
|
||||
PaymentId: payment.Id,
|
||||
|
||||
@@ -3,6 +3,7 @@ package user
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
@@ -12,6 +13,11 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
withdrawalLogBizTypeWithdrawal = "withdrawal"
|
||||
withdrawalLogBizTypeCommissionRefund = "commission_refund"
|
||||
)
|
||||
|
||||
type QueryWithdrawalLogLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
@@ -43,15 +49,26 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
|
||||
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
|
||||
if err = query.Count(&total).Error; err != nil {
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count withdrawal logs failed: %v", err)
|
||||
}
|
||||
|
||||
var rows []user.Withdrawal
|
||||
if err = query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query withdrawal logs failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -59,6 +76,7 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
|
||||
for _, row := range rows {
|
||||
list = append(list, types.WithdrawalLog{
|
||||
Id: row.Id,
|
||||
BizType: withdrawalLogBizTypeWithdrawal,
|
||||
UserId: row.UserId,
|
||||
Amount: row.Amount,
|
||||
Content: row.Content,
|
||||
@@ -77,3 +95,53 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
|
||||
Total: total,
|
||||
}, 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/uuidx"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type GetServerUserListLogic struct {
|
||||
@@ -27,6 +28,46 @@ type GetServerUserListLogic struct {
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
type serverUserListPerfStats struct {
|
||||
serverID int64
|
||||
protocol string
|
||||
cacheHit bool
|
||||
nodesCount int
|
||||
subsCount int
|
||||
usersCount int
|
||||
speedLimitZeroCount int
|
||||
speedLimitPositiveCount int
|
||||
trafficCalcMS int64
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
type serverUserSpeedLimitCandidate struct {
|
||||
userID int64
|
||||
userSubscribeID int64
|
||||
baseSpeed int64
|
||||
trafficLimit string
|
||||
}
|
||||
|
||||
type serverUserTrafficWindow struct {
|
||||
statType string
|
||||
statValue int64
|
||||
start time.Time
|
||||
end time.Time
|
||||
}
|
||||
|
||||
type serverUserTrafficUsageKey struct {
|
||||
userID int64
|
||||
userSubscribeID int64
|
||||
statType string
|
||||
statValue int64
|
||||
}
|
||||
|
||||
type serverUserTrafficUsage struct {
|
||||
userID int64
|
||||
userSubscribeID int64
|
||||
usedGB float64
|
||||
}
|
||||
|
||||
// NewGetServerUserListLogic Get user list
|
||||
func NewGetServerUserListLogic(ctx *gin.Context, svcCtx *svc.ServiceContext) *GetServerUserListLogic {
|
||||
return &GetServerUserListLogic{
|
||||
@@ -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) {
|
||||
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()
|
||||
if err != nil && err != redis.Nil {
|
||||
l.Errorw("[ServerUserListCacheKey] redis get error", logger.Field("error", err.Error()))
|
||||
}
|
||||
if cache != "" {
|
||||
stats.cacheHit = true
|
||||
etag := tool.GenerateETag([]byte(cache))
|
||||
resp = &types.GetServerUserListResponse{}
|
||||
// Check If-None-Match header
|
||||
if match := l.ctx.GetHeader("If-None-Match"); match == etag {
|
||||
l.logServerUserListPerf(stats)
|
||||
return nil, xerr.StatusNotModified
|
||||
}
|
||||
l.ctx.Header("ETag", etag)
|
||||
@@ -52,6 +107,9 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
l.Errorw("[ServerUserListCacheKey] json unmarshal error", logger.Field("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
stats.usersCount = len(resp.Users)
|
||||
stats.recordSpeedLimits(resp.Users)
|
||||
l.logServerUserListPerf(stats)
|
||||
return resp, nil
|
||||
}
|
||||
server, err := l.svcCtx.NodeModel.FindOneServer(l.ctx, req.ServerId)
|
||||
@@ -64,14 +122,22 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
Page: 1,
|
||||
Size: 1000,
|
||||
ServerId: []int64{server.Id},
|
||||
Protocol: req.Protocol,
|
||||
Protocol: protocolRequest,
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorw("FilterNodeList error", logger.Field("error", err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
stats.nodesCount = len(nodes)
|
||||
|
||||
if len(nodes) == 0 {
|
||||
l.Errorw("[ServerUserList] fallback: no nodes matched server+protocol, returning placeholder without cache",
|
||||
logger.Field("server_id", req.ServerId),
|
||||
logger.Field("protocol", req.Protocol),
|
||||
)
|
||||
stats.usersCount = 1
|
||||
stats.speedLimitZeroCount = 1
|
||||
l.logServerUserListPerf(stats)
|
||||
return &types.GetServerUserListResponse{
|
||||
Users: []types.ServerUser{
|
||||
{
|
||||
@@ -139,6 +205,13 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
}
|
||||
|
||||
if len(subs) == 0 {
|
||||
l.Errorw("[ServerUserList] fallback: no subscriptions matched node group/tags, returning placeholder without cache",
|
||||
logger.Field("server_id", req.ServerId),
|
||||
logger.Field("protocol", req.Protocol),
|
||||
)
|
||||
stats.usersCount = 1
|
||||
stats.speedLimitZeroCount = 1
|
||||
l.logServerUserListPerf(stats)
|
||||
return &types.GetServerUserListResponse{
|
||||
Users: []types.ServerUser{
|
||||
{
|
||||
@@ -148,7 +221,9 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
stats.subsCount = len(subs)
|
||||
users := make([]types.ServerUser, 0)
|
||||
speedCandidates := make(map[int64]serverUserSpeedLimitCandidate)
|
||||
for _, sub := range subs {
|
||||
data, err := l.svcCtx.UserModel.FindUsersSubscribeBySubscribeId(l.ctx, sub.Id)
|
||||
if err != nil {
|
||||
@@ -159,17 +234,29 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算该用户的实际限速值(考虑按量限速规则)
|
||||
effectiveSpeedLimit := l.calculateEffectiveSpeedLimit(sub, datum)
|
||||
|
||||
baseSpeed, trafficLimit := serverUserSpeedLimitInputs(sub, datum)
|
||||
speedCandidates[datum.Id] = serverUserSpeedLimitCandidate{
|
||||
userID: datum.UserId,
|
||||
userSubscribeID: datum.Id,
|
||||
baseSpeed: baseSpeed,
|
||||
trafficLimit: trafficLimit,
|
||||
}
|
||||
users = append(users, types.ServerUser{
|
||||
Id: datum.Id,
|
||||
UUID: datum.UUID,
|
||||
SpeedLimit: effectiveSpeedLimit,
|
||||
SpeedLimit: baseSpeed,
|
||||
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 {
|
||||
@@ -183,14 +270,27 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
}
|
||||
|
||||
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,
|
||||
UUID: uuidx.NewUUID().String(),
|
||||
})
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
resp = &types.GetServerUserListResponse{
|
||||
Users: users,
|
||||
}
|
||||
stats.usersCount = len(users)
|
||||
stats.recordSpeedLimits(users)
|
||||
val, _ := json.Marshal(resp)
|
||||
etag := tool.GenerateETag(val)
|
||||
l.ctx.Header("ETag", etag)
|
||||
@@ -200,11 +300,24 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
|
||||
}
|
||||
// Check If-None-Match header
|
||||
if match := l.ctx.GetHeader("If-None-Match"); match == etag {
|
||||
l.logServerUserListPerf(stats)
|
||||
return nil, xerr.StatusNotModified
|
||||
}
|
||||
l.logServerUserListPerf(stats)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// normalizeServerUserListProtocol 将客户端可能携带的 hysteria2 兼容字段映射回
|
||||
// DB 中存储的规范名 "hysteria"。其它协议原样返回。
|
||||
// 缓存 key 与 FilterNodeList 查询都必须用归一化后的值,
|
||||
// 否则 hysteria2 永远查不到节点,永远走兜底。
|
||||
func normalizeServerUserListProtocol(protocol string) string {
|
||||
if protocol == Hysteria2 {
|
||||
return Hysteria
|
||||
}
|
||||
return protocol
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) serverUserListCacheTTL() time.Duration {
|
||||
pullInterval := l.svcCtx.Config.Node.NodePullInterval
|
||||
if pullInterval <= 0 {
|
||||
@@ -305,8 +418,7 @@ func (l *GetServerUserListLogic) canUseExpiredNodeGroup(userSub *user.Subscribe,
|
||||
return true
|
||||
}
|
||||
|
||||
// calculateEffectiveSpeedLimit 计算用户的实际限速值(考虑按量限速规则)
|
||||
func (l *GetServerUserListLogic) calculateEffectiveSpeedLimit(sub *subscribe.Subscribe, userSub *user.Subscribe) int64 {
|
||||
func serverUserSpeedLimitInputs(sub *subscribe.Subscribe, userSub *user.Subscribe) (int64, string) {
|
||||
baseSpeed := sub.SpeedLimit
|
||||
if userSub.SpeedLimit > 0 {
|
||||
baseSpeed = userSub.SpeedLimit
|
||||
@@ -317,15 +429,170 @@ func (l *GetServerUserListLogic) calculateEffectiveSpeedLimit(sub *subscribe.Sub
|
||||
trafficLimit = *userSub.TrafficLimit
|
||||
}
|
||||
|
||||
result := speedlimit.CalculateWithCache(
|
||||
l.ctx.Request.Context(),
|
||||
l.svcCtx.Redis,
|
||||
l.svcCtx.DB,
|
||||
userSub.UserId,
|
||||
userSub.Id,
|
||||
baseSpeed,
|
||||
trafficLimit,
|
||||
30*time.Second,
|
||||
)
|
||||
return result.EffectiveSpeed
|
||||
return baseSpeed, trafficLimit
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) calculateServerUserSpeedLimits(candidates map[int64]serverUserSpeedLimitCandidate) map[int64]int64 {
|
||||
speedLimits := make(map[int64]int64, len(candidates))
|
||||
rulesByUserSubscribeID := make(map[int64][]speedlimit.TrafficLimitRule)
|
||||
windowByKey := make(map[string]serverUserTrafficWindow)
|
||||
now := time.Now()
|
||||
|
||||
for userSubscribeID, candidate := range candidates {
|
||||
speedLimits[userSubscribeID] = candidate.baseSpeed
|
||||
if candidate.trafficLimit == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var rules []speedlimit.TrafficLimitRule
|
||||
if err := json.Unmarshal([]byte(candidate.trafficLimit), &rules); err != nil || len(rules) == 0 {
|
||||
continue
|
||||
}
|
||||
rulesByUserSubscribeID[userSubscribeID] = rules
|
||||
for _, rule := range rules {
|
||||
window, ok := serverUserTrafficRuleWindow(rule, now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
windowByKey[serverUserTrafficWindowKey(rule.StatType, rule.StatValue)] = window
|
||||
}
|
||||
}
|
||||
|
||||
if len(rulesByUserSubscribeID) == 0 || len(windowByKey) == 0 {
|
||||
return speedLimits
|
||||
}
|
||||
|
||||
usageByKey := make(map[serverUserTrafficUsageKey]float64)
|
||||
for _, window := range windowByKey {
|
||||
trafficUsage, err := l.queryServerUserTrafficUsage(candidates, window)
|
||||
if err != nil {
|
||||
l.Errorw("[ServerUserList] batch traffic usage query failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("stat_type", window.statType),
|
||||
logger.Field("stat_value", window.statValue),
|
||||
)
|
||||
continue
|
||||
}
|
||||
for _, usage := range trafficUsage {
|
||||
usageByKey[serverUserTrafficUsageKey{
|
||||
userID: usage.userID,
|
||||
userSubscribeID: usage.userSubscribeID,
|
||||
statType: window.statType,
|
||||
statValue: window.statValue,
|
||||
}] = usage.usedGB
|
||||
}
|
||||
}
|
||||
|
||||
for userSubscribeID, rules := range rulesByUserSubscribeID {
|
||||
candidate := candidates[userSubscribeID]
|
||||
for _, rule := range rules {
|
||||
if rule.SpeedLimit <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := serverUserTrafficRuleWindow(rule, now); !ok {
|
||||
continue
|
||||
}
|
||||
usedGB := usageByKey[serverUserTrafficUsageKey{
|
||||
userID: candidate.userID,
|
||||
userSubscribeID: candidate.userSubscribeID,
|
||||
statType: rule.StatType,
|
||||
statValue: rule.StatValue,
|
||||
}]
|
||||
if usedGB < float64(rule.TrafficUsage) {
|
||||
continue
|
||||
}
|
||||
current := speedLimits[userSubscribeID]
|
||||
if current == 0 || rule.SpeedLimit < current {
|
||||
speedLimits[userSubscribeID] = rule.SpeedLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return speedLimits
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) queryServerUserTrafficUsage(
|
||||
candidates map[int64]serverUserSpeedLimitCandidate,
|
||||
window serverUserTrafficWindow,
|
||||
) ([]serverUserTrafficUsage, error) {
|
||||
userSubscribeIDs := make([]int64, 0, len(candidates))
|
||||
for userSubscribeID := range candidates {
|
||||
userSubscribeIDs = append(userSubscribeIDs, userSubscribeID)
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
UserID int64
|
||||
UserSubscribeID int64
|
||||
Upload int64
|
||||
Download int64
|
||||
}
|
||||
err := l.svcCtx.DB.WithContext(l.ctx.Request.Context()).
|
||||
Table("traffic_log").
|
||||
Select("user_id, subscribe_id AS user_subscribe_id, COALESCE(SUM(upload), 0) AS upload, COALESCE(SUM(download), 0) AS download").
|
||||
Where("subscribe_id IN ? AND timestamp >= ? AND timestamp < ?", userSubscribeIDs, window.start, window.end).
|
||||
Group("user_id, subscribe_id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trafficUsage := make([]serverUserTrafficUsage, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
trafficUsage = append(trafficUsage, serverUserTrafficUsage{
|
||||
userID: row.UserID,
|
||||
userSubscribeID: row.UserSubscribeID,
|
||||
usedGB: float64(row.Upload+row.Download) / (1024 * 1024 * 1024),
|
||||
})
|
||||
}
|
||||
return trafficUsage, nil
|
||||
}
|
||||
|
||||
func serverUserTrafficRuleWindow(rule speedlimit.TrafficLimitRule, now time.Time) (serverUserTrafficWindow, bool) {
|
||||
if rule.StatValue <= 0 {
|
||||
return serverUserTrafficWindow{}, false
|
||||
}
|
||||
|
||||
window := serverUserTrafficWindow{
|
||||
statType: rule.StatType,
|
||||
statValue: rule.StatValue,
|
||||
end: now,
|
||||
}
|
||||
switch rule.StatType {
|
||||
case "hour":
|
||||
window.start = now.Add(-time.Duration(rule.StatValue) * time.Hour)
|
||||
case "day":
|
||||
window.start = now.AddDate(0, 0, -int(rule.StatValue))
|
||||
default:
|
||||
return serverUserTrafficWindow{}, false
|
||||
}
|
||||
return window, true
|
||||
}
|
||||
|
||||
func serverUserTrafficWindowKey(statType string, statValue int64) string {
|
||||
return fmt.Sprintf("%s:%d", statType, statValue)
|
||||
}
|
||||
|
||||
func (s *serverUserListPerfStats) recordSpeedLimits(users []types.ServerUser) {
|
||||
for _, user := range users {
|
||||
if user.SpeedLimit > 0 {
|
||||
s.speedLimitPositiveCount++
|
||||
continue
|
||||
}
|
||||
s.speedLimitZeroCount++
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GetServerUserListLogic) logServerUserListPerf(stats serverUserListPerfStats) {
|
||||
l.Infow("[ServerUserList] performance",
|
||||
logger.Field("server_id", stats.serverID),
|
||||
logger.Field("protocol", stats.protocol),
|
||||
logger.Field("cache_hit", stats.cacheHit),
|
||||
logger.Field("nodes_count", stats.nodesCount),
|
||||
logger.Field("subs_count", stats.subsCount),
|
||||
logger.Field("users_count", stats.usersCount),
|
||||
logger.Field("speed_limit_0_count", stats.speedLimitZeroCount),
|
||||
logger.Field("speed_limit_positive_count", stats.speedLimitPositiveCount),
|
||||
logger.Field("traffic_calc_ms", stats.trafficCalcMS),
|
||||
logger.Field("total_ms", time.Since(stats.startedAt).Milliseconds()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 退款佣金日志。
|
||||
// 用于 refund 主流程做幂等校验,以及 stuck-order recovery / activate worker
|
||||
// 区分「已退款」(terminal)与「短暂 claimed」(transient)这两种共用 status=6
|
||||
// 的语义。
|
||||
//
|
||||
// 实现细节:
|
||||
// 1. type=33 + content LIKE '%"order_no":"<orderNo>"%' 先走索引粗筛;
|
||||
@@ -36,3 +33,26 @@ func HasRefundCommissionLog(tx *gorm.DB, orderNo string) (bool, error) {
|
||||
}
|
||||
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()) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -25,6 +25,31 @@ const (
|
||||
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
|
||||
type FilterParams struct {
|
||||
Page int
|
||||
@@ -134,7 +159,7 @@ func (m *customServerModel) ClearNodeCache(ctx context.Context, params *FilterNo
|
||||
}
|
||||
var cacheKeys []string
|
||||
for _, node := range nodes {
|
||||
cacheKeys = append(cacheKeys, fmt.Sprintf("%s%d", ServerUserListCacheKey, node.ServerId))
|
||||
cacheKeys = append(cacheKeys, ServerUserListCacheKeysForServer(node.ServerId)...)
|
||||
if node.Protocol != "" {
|
||||
var cursor uint64
|
||||
for {
|
||||
@@ -162,8 +187,7 @@ func (m *customServerModel) ClearNodeCache(ctx context.Context, params *FilterNo
|
||||
|
||||
// ClearServerCache Clear Server Cache
|
||||
func (m *customServerModel) ClearServerCache(ctx context.Context, serverId int64) error {
|
||||
var cacheKeys []string
|
||||
cacheKeys = append(cacheKeys, fmt.Sprintf("%s%d", ServerUserListCacheKey, serverId))
|
||||
cacheKeys := ServerUserListCacheKeysForServer(serverId)
|
||||
var cursor uint64
|
||||
for {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
InviterId int64 `json:"inviter_id"`
|
||||
InviterIdentifier string `json:"inviter_identifier"`
|
||||
InviterDeviceNo string `json:"inviter_device_no"`
|
||||
InviteeId int64 `json:"invitee_id"`
|
||||
InviteeIdentifier string `json:"invitee_identifier"`
|
||||
InviteeDeviceNo string `json:"invitee_device_no"`
|
||||
InviteeAvatar string `json:"invitee_avatar"`
|
||||
InviteeEnable bool `json:"invitee_enable"`
|
||||
InvitedAt int64 `json:"invited_at"`
|
||||
@@ -2540,6 +2542,7 @@ type QueryUserSubscribeNodeListResponse struct {
|
||||
type QueryWithdrawalLogListRequest struct {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
||||
}
|
||||
|
||||
type QueryWithdrawalLogListResponse struct {
|
||||
@@ -3551,7 +3554,7 @@ type UpdateUserSubscribeRequest struct {
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
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 {
|
||||
@@ -3691,6 +3694,9 @@ type UserSubscribe struct {
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
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"`
|
||||
Status uint8 `json:"status"`
|
||||
EntitlementSource string `json:"entitlement_source"`
|
||||
@@ -3883,6 +3889,7 @@ type WeeklyStat struct {
|
||||
|
||||
type WithdrawalLog struct {
|
||||
Id int64 `json:"id"`
|
||||
BizType string `json:"biz_type"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
|
||||
@@ -16,3 +16,28 @@ commit-msg:
|
||||
commands:
|
||||
commitlint:
|
||||
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