Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 634b5a7bd0 | |||
| 5ef3f2717e | |||
| 2c1ee78bc4 | |||
| 13bafd5847 | |||
| e8e3a3a72b | |||
| 58c346abec | |||
| abd8c068b6 | |||
| eac0137069 | |||
| cce147108c | |||
| 980e5adb90 | |||
| 07409eb602 | |||
| 117dc0d6a7 | |||
| c92495c5b9 | |||
| 92cf2921dd | |||
| ce3babcc33 | |||
| a46fb83054 | |||
| 9933d34bdd | |||
| d2710d356f | |||
| f0a5288e20 | |||
| 77fa0cadd2 | |||
| 3d1a31a19f | |||
| 08434cfa32 | |||
| be09a115ec | |||
| 077dba3d98 | |||
| 39bd36b2f8 | |||
| cfb253d96f | |||
| f11097ab83 | |||
| 3e6318dcdf | |||
| c39bfd39dd | |||
| 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 | |||
| 87ebfa1fac | |||
| ac25eb4d91 | |||
| 54379976ec |
@@ -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,266 @@
|
||||
name: 发布验收测试
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- 测试环境部署
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- internal
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: release-acceptance
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
ACCEPTANCE_ARTIFACT_NAME: release-acceptance-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
ACCEPTANCE_REPORT_DIR: ${{ github.workspace }}/acceptance-artifacts
|
||||
ACCEPTANCE_REPORT_PATH: ${{ github.workspace }}/acceptance-artifacts/acceptance-report.json
|
||||
ACCEPTANCE_TEST_JSON: ${{ github.workspace }}/acceptance-artifacts/go-test.json
|
||||
ACCEPTANCE_FAILURE_LOG: ${{ github.workspace }}/acceptance-artifacts/failure.log
|
||||
ACCEPTANCE_NODE_SERVER_ID: ${{ vars.ACCEPTANCE_NODE_SERVER_ID || '31' }}
|
||||
ACCEPTANCE_NODE_PROTOCOL: ${{ vars.ACCEPTANCE_NODE_PROTOCOL || 'trojan' }}
|
||||
|
||||
jobs:
|
||||
acceptance:
|
||||
name: Staging acceptance
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }}
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
)
|
||||
}}
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: 配置 Go 环境
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: 准备报告目录
|
||||
run: mkdir -p "$ACCEPTANCE_REPORT_DIR"
|
||||
|
||||
- name: 校验必需配置
|
||||
env:
|
||||
ACCEPTANCE_ADMIN_EMAIL: ${{ secrets.ACCEPTANCE_ADMIN_EMAIL }}
|
||||
ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }}
|
||||
ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }}
|
||||
ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }}
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }}
|
||||
STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }}
|
||||
STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
|
||||
STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
|
||||
STAGING_DB_NAME: ${{ secrets.STAGING_DB_NAME }}
|
||||
STAGING_REDIS_ADDR: ${{ secrets.STAGING_REDIS_ADDR }}
|
||||
STAGING_REDIS_PASSWORD: ${{ secrets.STAGING_REDIS_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${STAGING_BASE_URL:-}" ]; then
|
||||
echo "STAGING_BASE_URL 未配置且默认值不可用" | tee "$ACCEPTANCE_FAILURE_LOG"
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "状态:配置错误"
|
||||
echo
|
||||
echo "- `STAGING_BASE_URL` 不能为空。"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
missing_optional=()
|
||||
optional=(
|
||||
ACCEPTANCE_ADMIN_EMAIL
|
||||
ACCEPTANCE_ADMIN_PASSWORD
|
||||
ACCEPTANCE_USER_EMAIL
|
||||
ACCEPTANCE_USER_PASSWORD
|
||||
STAGING_DB_HOST
|
||||
STAGING_DB_USER
|
||||
STAGING_DB_PASSWORD
|
||||
STAGING_DB_NAME
|
||||
STAGING_REDIS_ADDR
|
||||
STAGING_REDIS_PASSWORD
|
||||
)
|
||||
|
||||
for key in "${optional[@]}"; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
missing_optional+=("$key")
|
||||
fi
|
||||
done
|
||||
|
||||
: > "$ACCEPTANCE_FAILURE_LOG"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
printf '缺少可选 GitHub Actions secrets/vars,部分验收用例将被跳过:\n' | tee -a "$ACCEPTANCE_FAILURE_LOG"
|
||||
printf -- '- %s\n' "${missing_optional[@]}" | tee -a "$ACCEPTANCE_FAILURE_LOG"
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "状态:部分配置缺失"
|
||||
echo
|
||||
echo "缺少以下可选 secrets/vars,对应验收用例会在测试阶段自动跳过:"
|
||||
printf -- '- `%s`\n' "${missing_optional[@]}"
|
||||
echo
|
||||
echo "- `STAGING_BASE_URL`:${STAGING_BASE_URL}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "状态:配置检查通过"
|
||||
echo
|
||||
echo "- `STAGING_BASE_URL`:${STAGING_BASE_URL}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: 下载依赖模块
|
||||
run: go mod download
|
||||
|
||||
- name: 运行 acceptance 测试
|
||||
env:
|
||||
ACCEPTANCE_ADMIN_EMAIL: ${{ secrets.ACCEPTANCE_ADMIN_EMAIL }}
|
||||
ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }}
|
||||
ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }}
|
||||
ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }}
|
||||
ACCEPTANCE_NODE_SECRET: ${{ secrets.ACCEPTANCE_NODE_SECRET }}
|
||||
ACCEPTANCE_RUN_ID: qa_${{ github.run_id }}_${{ github.run_attempt }}
|
||||
STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }}
|
||||
STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
|
||||
STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
|
||||
STAGING_DB_NAME: ${{ secrets.STAGING_DB_NAME }}
|
||||
STAGING_REDIS_ADDR: ${{ secrets.STAGING_REDIS_ADDR }}
|
||||
STAGING_REDIS_PASSWORD: ${{ secrets.STAGING_REDIS_PASSWORD }}
|
||||
STAGING_REDIS_DB: ${{ vars.STAGING_REDIS_DB || '0' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
|
||||
go test -json ./tests/acceptance/... \
|
||||
-staging-url="$STAGING_BASE_URL" \
|
||||
-report-path="$ACCEPTANCE_REPORT_PATH" \
|
||||
> >(tee "$ACCEPTANCE_TEST_JSON") \
|
||||
2> >(tee "$ACCEPTANCE_FAILURE_LOG" >&2)
|
||||
|
||||
- name: 生成测试摘要
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "- 状态:${{ job.status }}"
|
||||
echo "- Staging URL:${STAGING_BASE_URL}"
|
||||
echo "- Artifact:${ACCEPTANCE_ARTIFACT_NAME}"
|
||||
echo "- Run URL:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
if [ "${{ github.event_name }}" = "workflow_run" ]; then
|
||||
echo "- Deploy run:${{ github.event.workflow_run.html_url }}"
|
||||
echo "- Deploy SHA:${{ github.event.workflow_run.head_sha }}"
|
||||
else
|
||||
echo "- 手动触发 SHA:${{ github.sha }}"
|
||||
fi
|
||||
echo
|
||||
} | tee "$ACCEPTANCE_REPORT_DIR/summary.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "${{ job.status }}" = "failure" ]; then
|
||||
if [ -s "$ACCEPTANCE_FAILURE_LOG" ]; then
|
||||
{
|
||||
echo
|
||||
echo "Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "Artifact: ${ACCEPTANCE_ARTIFACT_NAME}"
|
||||
} >> "$ACCEPTANCE_FAILURE_LOG"
|
||||
else
|
||||
{
|
||||
echo "Acceptance 测试失败。"
|
||||
echo "Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "Artifact: ${ACCEPTANCE_ARTIFACT_NAME}"
|
||||
echo
|
||||
tail -100 "$ACCEPTANCE_TEST_JSON" 2>/dev/null || true
|
||||
} > "$ACCEPTANCE_FAILURE_LOG"
|
||||
fi
|
||||
elif [ ! -f "$ACCEPTANCE_FAILURE_LOG" ]; then
|
||||
: > "$ACCEPTANCE_FAILURE_LOG"
|
||||
fi
|
||||
|
||||
- name: 上传 acceptance artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ env.ACCEPTANCE_ARTIFACT_NAME }}
|
||||
path: |
|
||||
${{ env.ACCEPTANCE_REPORT_PATH }}
|
||||
${{ env.ACCEPTANCE_TEST_JSON }}
|
||||
${{ env.ACCEPTANCE_FAILURE_LOG }}
|
||||
${{ env.ACCEPTANCE_REPORT_DIR }}/summary.md
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Telegram 成功通知
|
||||
if: success()
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram 密钥未配置,跳过通知。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MESSAGE="$(cat <<'EOF'
|
||||
✅ 发布验收测试通过
|
||||
仓库:${{ github.repository }}
|
||||
分支:${{ github.event.workflow_run.head_branch || github.ref_name }}
|
||||
提交:${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
Staging:${{ env.STAGING_BASE_URL }}
|
||||
Artifact:${{ env.ACCEPTANCE_ARTIFACT_NAME }}
|
||||
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EOF
|
||||
)"
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,但验收结果不受影响。"
|
||||
|
||||
- name: Telegram 失败通知
|
||||
if: failure()
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram 密钥未配置,跳过通知。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MESSAGE="$(cat <<'EOF'
|
||||
❌ 发布验收测试失败
|
||||
仓库:${{ github.repository }}
|
||||
分支:${{ github.event.workflow_run.head_branch || github.ref_name }}
|
||||
提交:${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
Staging:${{ env.STAGING_BASE_URL }}
|
||||
Artifact:${{ env.ACCEPTANCE_ARTIFACT_NAME }}
|
||||
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EOF
|
||||
)"
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,工作流失败状态已记录。"
|
||||
+10
@@ -26,6 +26,13 @@ Thumbs.db
|
||||
*.crt
|
||||
*.key
|
||||
*.pem
|
||||
*.pub
|
||||
*id_rsa*
|
||||
*id_ed25519*
|
||||
*_bak
|
||||
*.go_bak
|
||||
deploy/**/keys/
|
||||
deliverables/
|
||||
|
||||
# ==================== 日志 ====================
|
||||
*.log
|
||||
@@ -35,6 +42,8 @@ logs/
|
||||
# ==================== 测试 ====================
|
||||
/test/
|
||||
*_test.go
|
||||
!tests/acceptance/*_test.go
|
||||
!internal/handler/subscribe_test.go
|
||||
*_test_config.go
|
||||
**/logtest/
|
||||
*_test.yaml
|
||||
@@ -70,6 +79,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">
|
||||
|
||||
@@ -160,6 +160,30 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
|
||||
CertMode: protocol.CertMode,
|
||||
CertDNSProvider: protocol.CertDNSProvider,
|
||||
CertDNSEnv: protocol.CertDNSEnv,
|
||||
SimnetPsk: protocol.SimnetPsk,
|
||||
SimnetKeyID: protocol.SimnetKeyID,
|
||||
SimnetTicketID: protocol.SimnetTicketID,
|
||||
SimnetPath: protocol.SimnetPath,
|
||||
SimnetCarrier: protocol.SimnetCarrier,
|
||||
SimnetAfEnabled: protocol.SimnetAfEnabled,
|
||||
SimnetAfPathMode: protocol.SimnetAfPathMode,
|
||||
SimnetAfPathPrefix: protocol.SimnetAfPathPrefix,
|
||||
SimnetAfPathSuffix: protocol.SimnetAfPathSuffix,
|
||||
SimnetAfMagicMode: protocol.SimnetAfMagicMode,
|
||||
SimnetAfResponseJitterMs: protocol.SimnetAfResponseJitterMs,
|
||||
SimnetAfHandshakePolymorphism: protocol.SimnetAfHandshakePolymorphism,
|
||||
SimnetAfSettingsJitter: protocol.SimnetAfSettingsJitter,
|
||||
SimnetAfFakeHeaderInjection: protocol.SimnetAfFakeHeaderInjection,
|
||||
SimnetFallbackEnabled: protocol.SimnetFallbackEnabled,
|
||||
SimnetFallbackTargetScheme: protocol.SimnetFallbackTargetScheme,
|
||||
SimnetFallbackTargetHost: protocol.SimnetFallbackTargetHost,
|
||||
SimnetFallbackTargetPort: protocol.SimnetFallbackTargetPort,
|
||||
SimnetFallbackHostHeader: protocol.SimnetFallbackHostHeader,
|
||||
SimnetFallbackTLSSNI: protocol.SimnetFallbackTLSSNI,
|
||||
SimnetClientMaxConcurrentStreams: protocol.SimnetClientMaxConcurrentStreams,
|
||||
SimnetClientMaxStreamsPerSession: protocol.SimnetClientMaxStreamsPerSession,
|
||||
SimnetClientSessionIdleTimeoutSecs: protocol.SimnetClientSessionIdleTimeoutSecs,
|
||||
SimnetClientMaxUDPSessions: protocol.SimnetClientMaxUDPSessions,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+32
-1
@@ -81,10 +81,38 @@ type Proxy struct {
|
||||
CertMode string // Certificate mode, `none`|`http`|`dns`|`self`
|
||||
CertDNSProvider string // DNS provider for certificate
|
||||
CertDNSEnv string // Environment for DNS provider
|
||||
|
||||
// Simnet Options (server-side config; per-user psk/key_id are derived at
|
||||
// render time from UserInfo, never stored on the Proxy).
|
||||
SimnetPsk string // server-side PSK (key_id=0), used for AF derivation
|
||||
SimnetKeyID int // server key id (0)
|
||||
SimnetTicketID string
|
||||
SimnetPath string
|
||||
SimnetCarrier string
|
||||
SimnetAfEnabled bool
|
||||
SimnetAfPathMode string
|
||||
SimnetAfPathPrefix string
|
||||
SimnetAfPathSuffix string
|
||||
SimnetAfMagicMode string
|
||||
SimnetAfResponseJitterMs int
|
||||
SimnetAfHandshakePolymorphism bool
|
||||
SimnetAfSettingsJitter bool
|
||||
SimnetAfFakeHeaderInjection bool
|
||||
SimnetFallbackEnabled bool
|
||||
SimnetFallbackTargetScheme string
|
||||
SimnetFallbackTargetHost string
|
||||
SimnetFallbackTargetPort int
|
||||
SimnetFallbackHostHeader string
|
||||
SimnetFallbackTLSSNI string
|
||||
SimnetClientMaxConcurrentStreams int
|
||||
SimnetClientMaxStreamsPerSession int
|
||||
SimnetClientSessionIdleTimeoutSecs int
|
||||
SimnetClientMaxUDPSessions int
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Password string
|
||||
SubscribeID int64 // user_subscribe.id — derives the simnet per-user key_id
|
||||
ExpiredAt time.Time
|
||||
Download int64
|
||||
Upload int64
|
||||
@@ -104,7 +132,10 @@ type Client struct {
|
||||
|
||||
func (c *Client) Build() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
tmpl, err := template.New("client").Funcs(sprig.TxtFuncMap()).Parse(c.ClientTemplate)
|
||||
funcMap := sprig.TxtFuncMap()
|
||||
funcMap["buildOmnxtSimnetConfigs"] = buildOmnxtSimnetConfigs
|
||||
funcMap["buildOmnxtProtocolLinks"] = buildOmnxtProtocolLinks
|
||||
tmpl, err := template.New("client").Funcs(funcMap).Parse(c.ClientTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/simnet"
|
||||
)
|
||||
|
||||
// buildOmnxtSimnetConfigs is a subscription template function (registered in
|
||||
// Client.Build) that produces the per-user OmnXT SimNet JSON config array.
|
||||
//
|
||||
// It mirrors the Pro reference (NPanel-backend
|
||||
// internal/biz/public/subscription/template.go buildOmnxtSimnetConfigs):
|
||||
// - per-user simnet_psk / simnet_key_id are DERIVED from the user's
|
||||
// subscription (uuid + user_subscribe.id), never stored.
|
||||
// - the server PSK (key_id=0) is passed through as simnet_server_psk so the
|
||||
// client SDK can derive AF path/magic with the same key material.
|
||||
//
|
||||
// Template usage: {{ buildOmnxtSimnetConfigs .Proxies .UserInfo .Params | toPrettyJson }}
|
||||
func buildOmnxtSimnetConfigs(proxies []map[string]interface{}, userInfo User, params map[string]string) []map[string]interface{} {
|
||||
result := make([]map[string]interface{}, 0)
|
||||
|
||||
proxyMode := strings.TrimSpace(params["proxy_mode"])
|
||||
if proxyMode == "" {
|
||||
proxyMode = "global"
|
||||
}
|
||||
|
||||
dnsServers := []string{"1.1.1.1"}
|
||||
if raw := strings.TrimSpace(params["dns_servers"]); raw != "" {
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\r'
|
||||
})
|
||||
parsed := make([]string, 0, len(parts))
|
||||
for _, item := range parts {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
parsed = append(parsed, item)
|
||||
}
|
||||
}
|
||||
if len(parsed) > 0 {
|
||||
dnsServers = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Per-user credentials derived from the subscription record (see pkg/simnet).
|
||||
userKeyID := simnet.DeriveKeyID(userInfo.SubscribeID)
|
||||
userPSK := simnet.DeriveUserPSK(userInfo.Password)
|
||||
|
||||
for _, proxy := range proxies {
|
||||
if smString(proxy["Type"]) != "simnet" {
|
||||
continue
|
||||
}
|
||||
|
||||
afEnabled := smBool(proxy["SimnetAfEnabled"])
|
||||
item := map[string]interface{}{
|
||||
"tag": smString(proxy["Name"]),
|
||||
"server_addr": smString(proxy["Server"]),
|
||||
"server_port": smInt(proxy["Port"]),
|
||||
"protocol": "simnet",
|
||||
"sni": smString(proxy["SNI"]),
|
||||
"allow_insecure": smBool(proxy["AllowInsecure"]),
|
||||
"simnet_psk": userPSK,
|
||||
"simnet_key_id": userKeyID,
|
||||
// Server PSK is required for AF path/magic/content-type derivation.
|
||||
"simnet_server_psk": smStringOrNil(proxy["SimnetPsk"]),
|
||||
"simnet_server_key_id": smInt(proxy["SimnetKeyID"]),
|
||||
"simnet_ticket_id": smStringOrNil(proxy["SimnetTicketID"]),
|
||||
"simnet_path": smDefaultString(smString(proxy["SimnetPath"]), "/simnet/session"),
|
||||
"simnet_carrier": smDefaultString(smString(proxy["SimnetCarrier"]), "h2"),
|
||||
"simnet_af_enabled": afEnabled,
|
||||
"simnet_client_max_concurrent_streams": smDefaultInt(smInt(proxy["SimnetClientMaxConcurrentStreams"]), 32),
|
||||
"simnet_client_max_streams_per_session": smDefaultInt(smInt(proxy["SimnetClientMaxStreamsPerSession"]), 512),
|
||||
"simnet_client_session_idle_timeout_secs": smDefaultInt(smInt(proxy["SimnetClientSessionIdleTimeoutSecs"]), 90),
|
||||
"simnet_client_max_udp_sessions": smDefaultInt(smInt(proxy["SimnetClientMaxUDPSessions"]), 64),
|
||||
"proxy_mode": proxyMode,
|
||||
"dns_servers": dnsServers,
|
||||
}
|
||||
if afEnabled {
|
||||
item["simnet_af_path_mode"] = smDefaultString(smString(proxy["SimnetAfPathMode"]), "api")
|
||||
item["simnet_af_path_prefix"] = smStringOrNil(proxy["SimnetAfPathPrefix"])
|
||||
item["simnet_af_path_suffix"] = smStringOrNil(proxy["SimnetAfPathSuffix"])
|
||||
item["simnet_af_magic_mode"] = smDefaultString(smString(proxy["SimnetAfMagicMode"]), "derived")
|
||||
item["simnet_af_response_jitter_ms"] = smDefaultInt(smInt(proxy["SimnetAfResponseJitterMs"]), 50)
|
||||
item["simnet_af_handshake_polymorphism"] = smBool(proxy["SimnetAfHandshakePolymorphism"])
|
||||
item["simnet_af_settings_jitter"] = smBool(proxy["SimnetAfSettingsJitter"])
|
||||
item["simnet_af_fake_header_injection"] = smBool(proxy["SimnetAfFakeHeaderInjection"])
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func smString(v interface{}) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func smStringOrNil(v interface{}) interface{} {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func smBool(v interface{}) bool {
|
||||
b, ok := v.(bool)
|
||||
return ok && b
|
||||
}
|
||||
|
||||
func smInt(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int8:
|
||||
return int(n)
|
||||
case int16:
|
||||
return int(n)
|
||||
case int32:
|
||||
return int(n)
|
||||
case int64:
|
||||
return int(n)
|
||||
case uint:
|
||||
return int(n)
|
||||
case uint8:
|
||||
return int(n)
|
||||
case uint16:
|
||||
return int(n)
|
||||
case uint32:
|
||||
return int(n)
|
||||
case uint64:
|
||||
return int(n)
|
||||
case float32:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func smDefaultString(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func smDefaultInt(i, def int) int {
|
||||
if i == 0 {
|
||||
return def
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// buildOmnxtProtocolLinks wraps each simnet config into a base64 "simnet://"
|
||||
// link, matching the Pro reference's final delivery format (migration 02140,
|
||||
// template.go buildOmnxtProtocolLinks). Template usage:
|
||||
//
|
||||
// {{- range $link := buildOmnxtProtocolLinks .Proxies .UserInfo .Params }}{{ $link }}
|
||||
// {{- end }}
|
||||
func buildOmnxtProtocolLinks(proxies []map[string]interface{}, userInfo User, params map[string]string) []string {
|
||||
configs := buildOmnxtSimnetConfigs(proxies, userInfo, params)
|
||||
result := make([]string, 0, len(configs))
|
||||
|
||||
for _, item := range configs {
|
||||
serverAddr := smString(item["server_addr"])
|
||||
serverPort := smInt(item["server_port"])
|
||||
tag := smString(item["tag"])
|
||||
if serverAddr == "" || serverPort == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
afEnabled := smBool(item["simnet_af_enabled"])
|
||||
payload := map[string]interface{}{
|
||||
"protocol": "simnet",
|
||||
"server_addr": serverAddr,
|
||||
"server_port": serverPort,
|
||||
"sni": smString(item["sni"]),
|
||||
"simnet_psk": smString(item["simnet_psk"]),
|
||||
"simnet_key_id": smInt(item["simnet_key_id"]),
|
||||
"simnet_server_psk": item["simnet_server_psk"],
|
||||
"simnet_server_key_id": smInt(item["simnet_server_key_id"]),
|
||||
"simnet_ticket_id": item["simnet_ticket_id"],
|
||||
"simnet_path": item["simnet_path"],
|
||||
"simnet_carrier": smString(item["simnet_carrier"]),
|
||||
"simnet_af_enabled": afEnabled,
|
||||
"simnet_client_max_concurrent_streams": smInt(item["simnet_client_max_concurrent_streams"]),
|
||||
"simnet_client_max_streams_per_session": smInt(item["simnet_client_max_streams_per_session"]),
|
||||
"simnet_client_session_idle_timeout_secs": smInt(item["simnet_client_session_idle_timeout_secs"]),
|
||||
"simnet_client_max_udp_sessions": smInt(item["simnet_client_max_udp_sessions"]),
|
||||
"proxy_mode": item["proxy_mode"],
|
||||
"dns_servers": item["dns_servers"],
|
||||
}
|
||||
if afEnabled {
|
||||
payload["simnet_af_path_mode"] = smString(item["simnet_af_path_mode"])
|
||||
payload["simnet_af_path_prefix"] = item["simnet_af_path_prefix"]
|
||||
payload["simnet_af_path_suffix"] = item["simnet_af_path_suffix"]
|
||||
payload["simnet_af_magic_mode"] = smString(item["simnet_af_magic_mode"])
|
||||
payload["simnet_af_response_jitter_ms"] = smInt(item["simnet_af_response_jitter_ms"])
|
||||
payload["simnet_af_handshake_polymorphism"] = smBool(item["simnet_af_handshake_polymorphism"])
|
||||
payload["simnet_af_settings_jitter"] = smBool(item["simnet_af_settings_jitter"])
|
||||
payload["simnet_af_fake_header_injection"] = smBool(item["simnet_af_fake_header_injection"])
|
||||
}
|
||||
|
||||
encoded := encodeProtocolPayload(payload)
|
||||
if encoded == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, "simnet://"+encoded+"#"+url.QueryEscape(tag))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// encodeProtocolPayload url-encodes a payload map and base64-encodes it,
|
||||
// matching the reference encodeProtocolPayload.
|
||||
func encodeProtocolPayload(payload map[string]interface{}) string {
|
||||
values := url.Values{}
|
||||
for key, value := range payload {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
continue
|
||||
case string:
|
||||
if strings.TrimSpace(v) != "" {
|
||||
values.Set(key, v)
|
||||
}
|
||||
case bool:
|
||||
if v {
|
||||
values.Set(key, "1")
|
||||
}
|
||||
case int:
|
||||
if v != 0 {
|
||||
values.Set(key, strconv.Itoa(v))
|
||||
}
|
||||
case int32:
|
||||
if v != 0 {
|
||||
values.Set(key, strconv.FormatInt(int64(v), 10))
|
||||
}
|
||||
case int64:
|
||||
if v != 0 {
|
||||
values.Set(key, strconv.FormatInt(v, 10))
|
||||
}
|
||||
case []string:
|
||||
if len(v) > 0 {
|
||||
values.Set(key, strings.Join(v, ","))
|
||||
}
|
||||
case []interface{}:
|
||||
items := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s := smString(item); s != "" {
|
||||
items = append(items, s)
|
||||
}
|
||||
}
|
||||
if len(items) > 0 {
|
||||
values.Set(key, strings.Join(items, ","))
|
||||
}
|
||||
default:
|
||||
if s := smString(v); s != "" {
|
||||
values.Set(key, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString([]byte(values.Encode()))
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "Lottery Admin API"
|
||||
desc: "Admin-facing lottery endpoints for HIF-3 Stage 1"
|
||||
author: "hifast"
|
||||
version: "0.1.0"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
@server (
|
||||
prefix: v1/admin/lottery
|
||||
group: admin/lottery
|
||||
middleware: AuthMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Create a new activity (status=draft)"
|
||||
@handler CreateLotteryActivity
|
||||
post /activities (CreateAdminLotteryActivityRequest) returns (AdminLotteryActivity)
|
||||
|
||||
@doc "Update mutable activity fields"
|
||||
@handler UpdateLotteryActivity
|
||||
put /activities (UpdateAdminLotteryActivityRequest) returns (AdminLotteryActivity)
|
||||
|
||||
@doc "List activities (paginated)"
|
||||
@handler ListLotteryActivities
|
||||
get /activities (ListAdminLotteryActivitiesRequest) returns (ListAdminLotteryActivitiesResponse)
|
||||
|
||||
@doc "Get one activity"
|
||||
@handler GetLotteryActivity
|
||||
get /activities/detail (AdminActivityIdRequest) returns (AdminLotteryActivity)
|
||||
|
||||
@doc "Publish (draft/paused → running)"
|
||||
@handler PublishLotteryActivity
|
||||
post /activities/publish (AdminActivityIdRequest)
|
||||
|
||||
@doc "Pause (running → paused)"
|
||||
@handler PauseLotteryActivity
|
||||
post /activities/pause (AdminActivityIdRequest)
|
||||
|
||||
@doc "Update eligibility/chance_sources (rule-caps enforced)"
|
||||
@handler UpdateLotteryRules
|
||||
put /activities/rules (UpdateAdminLotteryRulesRequest)
|
||||
|
||||
@doc "Delete activity (soft-delete; running must be paused first)"
|
||||
@handler DeleteLotteryActivity
|
||||
delete /activities/:id (AdminActivityIdRequest)
|
||||
|
||||
@doc "Create prize"
|
||||
@handler CreateLotteryPrize
|
||||
post /prizes (CreateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||
|
||||
@doc "Update prize"
|
||||
@handler UpdateLotteryPrize
|
||||
put /prizes/:id (UpdateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||
|
||||
@doc "Delete prize"
|
||||
@handler DeleteLotteryPrize
|
||||
delete /prizes/:id (AdminPrizeIdRequest)
|
||||
|
||||
@doc "List prizes on an activity"
|
||||
@handler ListLotteryPrizes
|
||||
get /prizes (ListAdminLotteryPrizesRequest) returns (ListAdminLotteryPrizesResponse)
|
||||
|
||||
@doc "Manually grant N chances to a user (idempotent by source_ref)"
|
||||
@handler GrantLotteryChance
|
||||
post /chances/grant (GrantAdminLotteryChanceRequest)
|
||||
|
||||
// Stage 2 (HIF-4): 人工奖工单接口
|
||||
@doc "List manual-claim work orders (filter by type/status/activity/user/time)"
|
||||
@handler ListLotteryClaims
|
||||
get /claims (ListAdminLotteryClaimsRequest) returns (ListAdminLotteryClaimsResponse)
|
||||
|
||||
@doc "Summary counts for claims workbench"
|
||||
@handler LotteryClaimsSummary
|
||||
get /claims/summary returns (AdminLotteryClaimsSummary)
|
||||
|
||||
@doc "Approve a claim (reviewing -> paying)"
|
||||
@handler ApproveLotteryClaim
|
||||
post /claims/approve (AdminApproveClaimRequest)
|
||||
|
||||
@doc "Reject a claim (reviewing/paying -> rejected; user may resubmit)"
|
||||
@handler RejectLotteryClaim
|
||||
post /claims/reject (AdminRejectClaimRequest)
|
||||
|
||||
@doc "Mark as paid (paying -> paid, records tx_hash/delivery_ref)"
|
||||
@handler MarkPaidLotteryClaim
|
||||
post /claims/mark-paid (AdminMarkPaidClaimRequest)
|
||||
|
||||
@doc "List lottery draws (grant records)"
|
||||
@handler ListLotteryDraws
|
||||
get /draws (ListAdminLotteryDrawsRequest) returns (ListAdminLotteryDrawsResponse)
|
||||
}
|
||||
+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"`
|
||||
|
||||
@@ -64,6 +64,8 @@ type (
|
||||
ServerUser {
|
||||
Id int64 `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
// SpeedLimit 单位为 Mbps,0 表示不限速。
|
||||
// 节点端 (V2bX/XrayR 等) 按 Mbps 解释该值,服务端透传不做单位换算。
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "Lottery API"
|
||||
desc: "User-facing lottery endpoints for HIF-3 Stage 1"
|
||||
author: "hifast"
|
||||
version: "0.1.0"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
@server (
|
||||
prefix: v1/lottery
|
||||
group: public/lottery
|
||||
middleware: AuthMiddleware,DeviceMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Get lottery activity config + user status"
|
||||
@handler QueryLotteryConfig
|
||||
get /config (GetLotteryConfigRequest) returns (GetLotteryConfigResponse)
|
||||
|
||||
@doc "Draw once (nonce idempotent, rate limited 1/sec)"
|
||||
@handler DrawLottery
|
||||
post /draw (DrawLotteryRequest) returns (DrawLotteryResponse)
|
||||
|
||||
@doc "List my draws"
|
||||
@handler QueryLotteryRecords
|
||||
get /records (GetLotteryRecordsRequest) returns (GetLotteryRecordsResponse)
|
||||
|
||||
@doc "Claim a prize (Stage 1 returns 100010 not_claimable)"
|
||||
@handler ClaimLotteryPrize
|
||||
post /claim (ClaimLotteryPrizeRequest) returns (ClaimLotteryPrizeResponse)
|
||||
}
|
||||
+34
-1
@@ -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,10 +135,37 @@ type (
|
||||
QueryWithdrawalLogListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
||||
}
|
||||
WithdrawalLogSummary {
|
||||
CommissionBalance int64 `json:"commission_balance"`
|
||||
LockedByPending int64 `json:"locked_by_pending"`
|
||||
AvailableToWithdraw int64 `json:"available_to_withdraw"`
|
||||
TotalHistoricalAmount int64 `json:"total_historical_amount"`
|
||||
TotalRefundedAmount int64 `json:"total_refunded_amount"`
|
||||
TotalIncomeAmount int64 `json:"total_income_amount"`
|
||||
}
|
||||
QueryWithdrawalLogListResponse {
|
||||
List []WithdrawalLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Summary *WithdrawalLogSummary `json:"summary,omitempty"`
|
||||
}
|
||||
QueryCommissionReturnLogRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
CommissionReturnLog {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
EventType uint16 `json:"event_type"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
QueryCommissionReturnLogResponse {
|
||||
List []CommissionReturnLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
GetDeviceOnlineStatsResponse {
|
||||
WeeklyStats []WeeklyStat `json:"weekly_stats"`
|
||||
@@ -382,10 +410,14 @@ service ppanel {
|
||||
@handler CancelWithdrawal
|
||||
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
|
||||
|
||||
@doc "Query Withdrawal Log"
|
||||
@doc "Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log)"
|
||||
@handler QueryWithdrawalLog
|
||||
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
|
||||
|
||||
@doc "Query Commission Return Log"
|
||||
@handler QueryCommissionReturnLog
|
||||
get /commission_return_log (QueryCommissionReturnLogRequest) returns (QueryCommissionReturnLogResponse)
|
||||
|
||||
@doc "Device Online Statistics"
|
||||
@handler DeviceOnlineStatistics
|
||||
get /device_online_statistics returns (GetDeviceOnlineStatsResponse)
|
||||
@@ -445,3 +477,4 @@ service ppanel {
|
||||
@handler DeviceWsConnect
|
||||
get /device_ws_connect
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
@@ -1044,6 +1045,51 @@ type (
|
||||
CertMode string `json:"cert_mode,omitempty"` // Certificate mode, `none`|`http`|`dns`|`self`
|
||||
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
||||
CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider
|
||||
SimnetPsk string `json:"simnet_psk,omitempty"`
|
||||
SimnetKeyID int `json:"simnet_key_id,omitempty"`
|
||||
SimnetTicketID string `json:"simnet_ticket_id,omitempty"`
|
||||
SimnetPath string `json:"simnet_path,omitempty"`
|
||||
SimnetCarrier string `json:"simnet_carrier,omitempty"`
|
||||
SimnetAfEnabled bool `json:"simnet_af_enabled,omitempty"`
|
||||
SimnetAfPathMode string `json:"simnet_af_path_mode,omitempty"`
|
||||
SimnetAfPathPrefix string `json:"simnet_af_path_prefix,omitempty"`
|
||||
SimnetAfPathSuffix string `json:"simnet_af_path_suffix,omitempty"`
|
||||
SimnetAfMagicMode string `json:"simnet_af_magic_mode,omitempty"`
|
||||
SimnetAfResponseJitterMs int `json:"simnet_af_response_jitter_ms,omitempty"`
|
||||
SimnetAfHandshakePolymorphism bool `json:"simnet_af_handshake_polymorphism,omitempty"`
|
||||
SimnetAfSettingsJitter bool `json:"simnet_af_settings_jitter,omitempty"`
|
||||
SimnetAfFakeHeaderInjection bool `json:"simnet_af_fake_header_injection,omitempty"`
|
||||
SimnetReverseEnabled bool `json:"simnet_reverse_enabled,omitempty"`
|
||||
SimnetReverseListenAddr string `json:"simnet_reverse_listen_addr,omitempty"`
|
||||
SimnetReverseListenPort int `json:"simnet_reverse_listen_port,omitempty"`
|
||||
SimnetReverseTargetHost string `json:"simnet_reverse_target_host,omitempty"`
|
||||
SimnetReverseTargetPort int `json:"simnet_reverse_target_port,omitempty"`
|
||||
SimnetFallbackEnabled bool `json:"simnet_fallback_enabled,omitempty"`
|
||||
SimnetFallbackTargetScheme string `json:"simnet_fallback_target_scheme,omitempty"`
|
||||
SimnetFallbackTargetHost string `json:"simnet_fallback_target_host,omitempty"`
|
||||
SimnetFallbackTargetPort int `json:"simnet_fallback_target_port,omitempty"`
|
||||
SimnetFallbackHostHeader string `json:"simnet_fallback_host_header,omitempty"`
|
||||
SimnetFallbackTLSSNI string `json:"simnet_fallback_tls_sni,omitempty"`
|
||||
SimnetInboundMaxStreamsPerSession int `json:"simnet_inbound_max_streams_per_session,omitempty"`
|
||||
SimnetInboundMaxUDPStreamsPerSession int `json:"simnet_inbound_max_udp_streams_per_session,omitempty"`
|
||||
SimnetInboundMaxHandlerTasksPerSession int `json:"simnet_inbound_max_handler_tasks_per_session,omitempty"`
|
||||
SimnetStreamEventChannelCapacity int `json:"simnet_stream_event_channel_capacity,omitempty"`
|
||||
SimnetStreamDataChannelCapacity int `json:"simnet_stream_data_channel_capacity,omitempty"`
|
||||
SimnetTargetDialTimeoutMs int `json:"simnet_target_dial_timeout_ms,omitempty"`
|
||||
SimnetTargetMaxConcurrentDials int `json:"simnet_target_max_concurrent_dials,omitempty"`
|
||||
SimnetEgressBlockLoopback bool `json:"simnet_egress_block_loopback,omitempty"`
|
||||
SimnetEgressBlockPrivate bool `json:"simnet_egress_block_private,omitempty"`
|
||||
SimnetEgressBlockLinkLocal bool `json:"simnet_egress_block_link_local,omitempty"`
|
||||
SimnetEgressBlockMetadata bool `json:"simnet_egress_block_metadata,omitempty"`
|
||||
SimnetSendWindow int `json:"simnet_send_window,omitempty"`
|
||||
SimnetRecvWindow int `json:"simnet_recv_window,omitempty"`
|
||||
SimnetMaxConcurrentStreams int `json:"simnet_max_concurrent_streams,omitempty"`
|
||||
SimnetInitialWindowSize int `json:"simnet_initial_window_size,omitempty"`
|
||||
SimnetMaxFrameSize int `json:"simnet_max_frame_size,omitempty"`
|
||||
SimnetClientMaxConcurrentStreams int `json:"simnet_client_max_concurrent_streams,omitempty"`
|
||||
SimnetClientMaxStreamsPerSession int `json:"simnet_client_max_streams_per_session,omitempty"`
|
||||
SimnetClientSessionIdleTimeoutSecs int `json:"simnet_client_session_idle_timeout_secs,omitempty"`
|
||||
SimnetClientMaxUDPSessions int `json:"simnet_client_max_udp_sessions,omitempty"`
|
||||
}
|
||||
// reset user subscribe token
|
||||
ResetUserSubscribeTokenRequest {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
# PPanel Server Simnet 协议接入实施计划
|
||||
|
||||
本文档用于指导在现有自维护后端 `/Users/Apple/code_vpn/vpn/ppanel-server` 中接入 `simnet` 协议。目标不是把 Pro 新版后端整体迁移进来,而是在保留旧系统架构、数据库主链路和现有节点管理模型的前提下,把 `simnet` 做到管理端可配置、OmnXT 节点可拉取、SlagClient 可订阅连接、用户授权和流量统计闭环。
|
||||
|
||||
参考实现来自新版 Pro 后端:`/Users/Apple/Downloads/NPanelPro-pro/NPanel-backend`。
|
||||
|
||||
## 1. 项目背景
|
||||
|
||||
当前旧后端已经有完整的 Server、Node、Subscribe、Traffic、Online User 等链路,协议配置主要保存在 Server 的 `protocols` JSON 字段里,Node 侧用 `protocol + port + address` 描述对外节点。新版 Pro 后端已经加入了 `simnet` 协议字段、管理端接口、节点兼容接口和订阅交付逻辑,但它的整体工程结构和旧仓库不同。
|
||||
|
||||
旧仓库是 Gin/goctl/Gorm 风格,核心入口包括:
|
||||
|
||||
- API 定义:`apis/admin/server.api`、`apis/node/node.api`、`apis/public/subscribe.api`、`apis/types.api`
|
||||
- 生成类型:`internal/types/types.go`
|
||||
- 管理端 Server 逻辑:`internal/logic/admin/server/*`
|
||||
- 节点服务端配置拉取:`internal/logic/server/getServerConfigLogic.go`
|
||||
- 节点用户列表拉取:`internal/logic/server/getServerUserListLogic.go`
|
||||
- 公共订阅节点返回:`internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||
- 节点在线与流量上报:`internal/logic/server/pushOnlineUsersLogic.go`、`internal/logic/server/serverPushUserTrafficLogic.go`
|
||||
|
||||
新版 Pro 的关键参考入口包括:
|
||||
|
||||
- Simnet 管理端字段:`api/admin/server/v1/server.proto`
|
||||
- OmnXT 节点兼容接口:`internal/server/http_compat_server.go`
|
||||
- 公共订阅响应:`api/public/subscribe/v1/subscribe.proto`
|
||||
- 公共订阅映射:`internal/service/public/subscribe/subscribe.go`
|
||||
- UA/capability 过滤:`internal/biz/public/subscribe/subscribe.go`
|
||||
- 节点交付数据:`internal/data/delivery_node.go`
|
||||
- 协议模型和默认值:`internal/model/server/protocol.go`
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 目标
|
||||
|
||||
1. 在旧后端中完整支持 `simnet` 协议的保存、查询、下发、订阅和统计。
|
||||
2. 继续使用旧系统 Server 的 `protocols` JSON 保存协议配置,不强制拆表保存管理端协议配置。
|
||||
3. 第一版支持当前实际需要的能力:H2、TLS/SNI、AF、HTTPS Fallback。
|
||||
4. Reverse 字段先纳入模型和接口,默认关闭;不在第一版强制上线 Reverse 转发能力。
|
||||
5. 管理端配置、OmnXT 服务端运行配置、SlagClient 客户端订阅配置使用不同 DTO,避免敏感字段误下发。
|
||||
6. 使用 `type + port` 唯一定位一个 Server 内的协议实例,支持同一 Server 未来存在多个协议。
|
||||
7. OmnXT 拉取配置必须校验 `secret_key`。
|
||||
8. Server 级 PSK 不得下发给普通用户。
|
||||
9. 优先设计每用户独立 Simnet Key ID/PSK,使用户隔离、封禁、重置和审计可控。
|
||||
10. SlagClient 订阅响应兼容 `protocols` JSON 和顶层 `simnet_*` 字段。
|
||||
|
||||
### 非目标
|
||||
|
||||
1. 不整体替换旧后端为 Pro 新后端。
|
||||
2. 不一次性迁移 Pro 的全部协议字段、路由系统、完整 delivery node 架构。
|
||||
3. 不第一版实现 OmniFlow 或其他新协议。
|
||||
4. 不改变现有套餐、订单、余额、邀请等业务主链路。
|
||||
5. 不把生产服务器凭据、JWT、节点 SSH 密码写入代码或文档。
|
||||
|
||||
## 3. 总体技术策略
|
||||
|
||||
最科学的迁移方式是“协议纵向切入”,而不是“代码横向搬运”。也就是沿着 `simnet` 从管理端保存到节点运行,再到用户订阅、授权、流量统计的完整链路逐层补齐。
|
||||
|
||||
建议分三段落地:
|
||||
|
||||
1. Server 侧先闭环:管理端能保存 `simnet`,OmnXT 能用 `secret_key` 拉到运行配置。
|
||||
2. User 侧再闭环:每个用户生成独立凭据,OmnXT 用户列表和 SlagClient 订阅使用同一套凭据。
|
||||
3. 运维侧最后闭环:流量、在线、到期、限额、TLS/AF/Fallback、灰度和回滚全部验证。
|
||||
|
||||
核心原则:
|
||||
|
||||
- 旧架构优先:沿用 goctl API、`internal/types`、现有 logic/model 风格。
|
||||
- DTO 分层:管理端 DTO 可以看到完整配置;节点 DTO 只给 OmnXT 运行需要;订阅 DTO 只给用户连接需要。
|
||||
- 敏感字段隔离:Server PSK、证书 DNS 环境变量、节点密钥不得进入普通用户订阅响应。
|
||||
- 渐进兼容:老协议、老客户端、老节点不受影响。
|
||||
- 可回滚:每个阶段都能通过关闭 `simnet` 协议或恢复旧接口行为回滚。
|
||||
|
||||
## 4. Simnet 数据链路
|
||||
|
||||
完整链路如下:
|
||||
|
||||
```text
|
||||
Admin UI
|
||||
-> POST /api/v1/admin/server/create or update
|
||||
-> Server.protocols JSON contains type=simnet
|
||||
|
||||
OmnXT Node
|
||||
-> GET /api/v1/server/config?server_id=...&protocol=simnet&secret_key=...
|
||||
-> receives server runtime config, including server-side PSK and TLS/AF/Fallback settings
|
||||
|
||||
OmnXT Node
|
||||
-> GET /api/v1/server/user/list?server_id=...&protocol=simnet&secret_key=...
|
||||
-> receives active user authorization list and per-user simnet credentials
|
||||
|
||||
SlagClient
|
||||
-> GET /api/v1/public/subscribe?token=... with capability headers
|
||||
-> receives node address, port, TLS/SNI, path, AF/Fallback public fields and user credential
|
||||
|
||||
OmnXT Node
|
||||
-> POST traffic / online user report
|
||||
-> backend maps simnet user credential to user subscribe and records traffic
|
||||
```
|
||||
|
||||
`simnet` 的运行配置不能只靠 `server.protocols` 原样下发,因为同一份 JSON 同时包含管理端字段、Server 密钥字段和用户连接字段。必须在每个出口做字段筛选和转换。
|
||||
|
||||
## 5. 阶段 0:建立基线与确认契约
|
||||
|
||||
### 目标
|
||||
|
||||
确认旧后端、OmnXT、SlagClient 对 `simnet` 的最小契约,先把边界钉牢,避免后续实现时字段名、鉴权方式或客户端解析格式反复改。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 从新版 Pro 提取 `simnet` 管理字段、服务端字段、订阅字段的差异表。
|
||||
2. 用当前 OmnXT 安装脚本部署的版本抓取真实请求路径和请求参数。
|
||||
3. 用 SlagClient 抓取订阅请求 header,确认 capability header 名称和版本值。
|
||||
4. 确认 `secret_key` 当前在旧仓库 `internal/middleware/serverMiddleware.go` 或节点接口 handler 中的校验方式。
|
||||
5. 确认 `server_id + protocol` 是否已经足够定位节点运行配置;如果端口也会重复,需要补充 `port` 查询参数。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
本阶段原则上不改业务代码,只新增测试夹具或临时验证脚本。可新增:
|
||||
|
||||
- `tests/simnet/fixtures/`
|
||||
- `docs/simnet-contract.md`,如需要更细的契约文档
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 需要可运行的旧后端本地环境或测试库。
|
||||
- 需要 OmnXT 当前版本真实请求样本。
|
||||
- 需要 SlagClient 当前版本订阅响应解析规则。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. 明确 OmnXT 配置接口路径、方法、请求参数和响应字段。
|
||||
2. 明确 SlagClient 识别 `simnet` 的字段格式。
|
||||
3. 明确 capability header 优先级:先 capability header,再 User-Agent 兜底。
|
||||
4. 明确 `type + port` 是协议实例唯一键。
|
||||
|
||||
### 回滚点
|
||||
|
||||
本阶段不涉及生产行为,无需业务回滚。
|
||||
|
||||
## 6. 阶段 1:协议模型与参数校验
|
||||
|
||||
### 目标
|
||||
|
||||
让旧后端的 `Protocol` 类型可以完整表达第一版 `simnet` 配置,并在创建/更新 Server 时有默认值和校验。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 在 `apis/types.api` 的 `Protocol` 结构加入 `simnet` 字段。
|
||||
2. 重新生成 `internal/types/types.go`。
|
||||
3. 在 `internal/model/node` 中的协议模型加入同名 JSON 字段,保证 Server 的 `protocols` JSON 能完整 marshal/unmarshal。
|
||||
4. 新增 `simnet` 默认值函数,例如 `ApplySimnetDefaults`。
|
||||
5. 新增 `simnet` 参数校验函数,例如 `ValidateSimnetProtocol`。
|
||||
6. 校验 `type + port` 唯一,避免同一 Server 下出现两个 `simnet:443`。
|
||||
7. 限制第一版允许值:`simnet_carrier=h2`、`security=tls|none`,生产建议默认 `tls`。
|
||||
8. 校验 path 必须以 `/` 开头,fallback host 非空时端口必须在 1-65535。
|
||||
9. 校验 `simnet_psk` 最小长度和字符集;自动生成时使用安全随机。
|
||||
|
||||
### 字段范围
|
||||
|
||||
核心字段:
|
||||
|
||||
```text
|
||||
simnet_psk
|
||||
simnet_key_id
|
||||
simnet_ticket_id
|
||||
simnet_path
|
||||
simnet_carrier
|
||||
```
|
||||
|
||||
TLS 字段:
|
||||
|
||||
```text
|
||||
security
|
||||
sni
|
||||
allow_insecure
|
||||
cert_mode
|
||||
cert_dns_provider
|
||||
cert_dns_env
|
||||
```
|
||||
|
||||
AF 字段:
|
||||
|
||||
```text
|
||||
simnet_af_enabled
|
||||
simnet_af_path_mode
|
||||
simnet_af_path_prefix
|
||||
simnet_af_path_suffix
|
||||
simnet_af_magic_mode
|
||||
simnet_af_response_jitter_ms
|
||||
simnet_af_handshake_polymorphism
|
||||
simnet_af_settings_jitter
|
||||
simnet_af_fake_header_injection
|
||||
```
|
||||
|
||||
Fallback 字段:
|
||||
|
||||
```text
|
||||
simnet_fallback_enabled
|
||||
simnet_fallback_target_scheme
|
||||
simnet_fallback_target_host
|
||||
simnet_fallback_target_port
|
||||
simnet_fallback_host_header
|
||||
simnet_fallback_tls_sni
|
||||
```
|
||||
|
||||
Reverse 字段:
|
||||
|
||||
```text
|
||||
simnet_reverse_enabled
|
||||
simnet_reverse_listen_addr
|
||||
simnet_reverse_listen_port
|
||||
simnet_reverse_target_host
|
||||
simnet_reverse_target_port
|
||||
```
|
||||
|
||||
### 默认值
|
||||
|
||||
建议默认值如下:
|
||||
|
||||
```text
|
||||
port: 443
|
||||
simnet_path: /simnet/session
|
||||
simnet_carrier: h2
|
||||
security: tls
|
||||
allow_insecure: false
|
||||
simnet_af_path_mode: api
|
||||
simnet_af_magic_mode: derived
|
||||
simnet_af_response_jitter_ms: 1
|
||||
simnet_reverse_enabled: false
|
||||
simnet_reverse_listen_addr: 127.0.0.1
|
||||
simnet_fallback_enabled: true
|
||||
simnet_fallback_target_scheme: https
|
||||
simnet_fallback_target_port: 443
|
||||
```
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/types.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/model/node/*` 或实际定义 `node.Protocol` 的文件
|
||||
- `internal/logic/admin/server/createServerLogic.go`
|
||||
- `internal/logic/admin/server/updateServerLogic.go`
|
||||
- 可新增 `internal/logic/admin/server/protocol_simnet.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 0 的字段契约。
|
||||
- goctl 代码生成命令可用。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. 管理端提交 `type=simnet` 时,Server 可以保存完整 JSON。
|
||||
2. 未传默认字段时自动补齐默认值。
|
||||
3. 非法 path、非法 port、重复 `type + port` 会被拒绝。
|
||||
4. 旧协议保存和返回不变。
|
||||
|
||||
### 回滚点
|
||||
|
||||
关闭管理端提交 `simnet` 的入口校验;或恢复 `apis/types.api` 和生成类型,旧协议数据仍可继续工作。
|
||||
|
||||
## 7. 阶段 2:管理端 Server 接口
|
||||
|
||||
### 目标
|
||||
|
||||
让管理端 Server 创建、更新、查询能完整展示和编辑 `simnet`,并保持 Node 更新接口与 Server 协议配置一致。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 更新 `CreateServerRequest`、`UpdateServerRequest`、`FilterServerListResponse`、`GetServerProtocolsResponse` 中的协议字段。
|
||||
2. 在 create/update Server 时对每个 protocol 先做 normalize,再落库。
|
||||
3. 在 filter/list/detail 接口中返回规范化后的 `simnet` 字段。
|
||||
4. 检查 `CreateNodeRequest`、`UpdateNodeRequest` 是否允许 `protocol=simnet`。
|
||||
5. Node 端 `node_type=front` 的创建/更新要允许 `simnet`,并校验其 `port` 与 Server 里的 `simnet` 协议端口一致。
|
||||
6. 如果管理端前端需要协议选项,`GetServerProtocols` 要返回 `simnet`,并带默认字段方便 UI 填充。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/admin/server.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/logic/admin/server/createServerLogic.go`
|
||||
- `internal/logic/admin/server/updateServerLogic.go`
|
||||
- `internal/logic/admin/server/filterServerListLogic.go`
|
||||
- `internal/logic/admin/server/getServerProtocolsLogic.go`
|
||||
- `internal/logic/admin/server/createNodeLogic.go`
|
||||
- `internal/logic/admin/server/updateNodeLogic.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 1 协议模型已经可表达 `simnet`。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. 管理端能创建一个 Server,包含 `simnet:443`。
|
||||
2. 管理端能更新 `simnet_path`、`sni`、AF 和 fallback 字段。
|
||||
3. 管理端节点列表显示 `HK simnet` 这类节点时,协议类型不丢失。
|
||||
4. `GetServerProtocols` 返回的 `protocols` JSON 与数据库一致且字段完整。
|
||||
|
||||
### 回滚点
|
||||
|
||||
从管理端把 `simnet` 协议 disabled,保留数据但不对节点下发;或回滚 Server 相关 API 和 logic。
|
||||
|
||||
## 8. 阶段 3:OmnXT 服务端配置下发
|
||||
|
||||
### 目标
|
||||
|
||||
让 OmnXT 节点通过旧后端节点 API 拉到可运行的 `simnet` 服务端配置。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 检查 `apis/node/node.api` 中 `GetServerConfigRequest` 是否有 `secret_key`、`server_id`、`protocol`。
|
||||
2. 在 `GetServerConfigLogic` 中加入 `protocol=simnet` 分支。
|
||||
3. 根据 `server_id + protocol + port` 找到启用的 `simnet` 协议配置。
|
||||
4. 验证 `secret_key`,失败时返回明确错误,并记录来源 IP 和 server_id。
|
||||
5. 构造 OmnXT 服务端运行 DTO,包含 Server 运行需要的 PSK、path、carrier、TLS、SNI、AF、fallback、reverse 默认关闭字段。
|
||||
6. 不把管理端专用字段、无关协议字段原样塞给 OmnXT。
|
||||
7. 缓存 key 要包含 `server_id + protocol + port`,避免同端口多协议污染缓存。
|
||||
8. OmnXT 配置变更后要能通过更新 Server 或清理缓存生效。
|
||||
|
||||
### 服务端 DTO 建议
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "simnet",
|
||||
"port": 443,
|
||||
"listen": ":443",
|
||||
"simnet_psk": "server-side-secret",
|
||||
"simnet_path": "/simnet/session",
|
||||
"simnet_carrier": "h2",
|
||||
"security": "tls",
|
||||
"sni": "example.com",
|
||||
"allow_insecure": false,
|
||||
"simnet_af_enabled": true,
|
||||
"simnet_fallback_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/node/node.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/logic/server/getServerConfigLogic.go`
|
||||
- `internal/logic/server/constant.go`
|
||||
- `internal/middleware/serverMiddleware.go`
|
||||
- 可新增 `internal/logic/server/simnet_config.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 1 和阶段 2。
|
||||
- OmnXT 实际接口字段确认完成。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. `secret_key` 正确时,OmnXT 能拉到 `simnet` 服务端配置。
|
||||
2. `secret_key` 错误时,请求被拒绝。
|
||||
3. 修改管理端 `simnet_path` 后,OmnXT 重启或刷新能拿到新 path。
|
||||
4. Server PSK 只出现在 OmnXT 服务端配置中,不出现在普通用户订阅中。
|
||||
|
||||
### 回滚点
|
||||
|
||||
关闭 `simnet.enable` 或回滚 `GetServerConfigLogic` 的 `simnet` 分支;旧协议节点不受影响。
|
||||
|
||||
## 9. 阶段 4:用户级 Simnet 凭据
|
||||
|
||||
### 目标
|
||||
|
||||
为每个有效用户订阅生成独立 `simnet` 凭据,避免所有用户共享 Server PSK,支持单用户封禁、重置和流量归属。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 新增用户级凭据模型,建议按 `user_subscribe_id + server_id + protocol + port` 维度唯一。
|
||||
2. 字段建议包括:`id`、`user_id`、`user_subscribe_id`、`server_id`、`protocol`、`port`、`key_id`、`psk`、`ticket_id`、`enabled`、`created_at`、`updated_at`、`rotated_at`。
|
||||
3. 添加数据库 migration,并在初始化兼容逻辑中保证表存在。
|
||||
4. 用户第一次订阅或节点第一次拉用户列表时懒生成凭据。
|
||||
5. 支持管理员重置某个用户订阅 token 时同步重置 `simnet` 凭据,避免旧凭据继续可用。
|
||||
6. 凭据生成使用加密安全随机;`key_id` 可用递增 id 或稳定 hash,但必须避免全局冲突。
|
||||
7. 保留 `ticket_id` 字段,第一版可为空或由 OmnXT 需要时生成。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `internal/model/user/*` 或新增 `internal/model/simnet/*`
|
||||
- `initialize/migrate/*`
|
||||
- `initialize/schema_compat.go`
|
||||
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||
- `internal/logic/server/getServerUserListLogic.go`
|
||||
- 用户订阅 token 重置逻辑:`internal/logic/admin/user/resetUserSubscribeTokenHandler.go` 对应 logic
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 0 确认 OmnXT 和 SlagClient 需要的用户凭据格式。
|
||||
- 阶段 1 的协议模型完成。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. 同一用户同一节点多次订阅拿到稳定凭据。
|
||||
2. 不同用户拿到不同凭据。
|
||||
3. 重置用户订阅 token 后旧凭据失效,新凭据生效。
|
||||
4. 凭据表有唯一约束,重复生成不会产生两条有效凭据。
|
||||
|
||||
### 回滚点
|
||||
|
||||
可以停止向 OmnXT 下发 `simnet` 用户授权,并禁用 `simnet` 节点。数据库表可保留,不影响旧协议。
|
||||
|
||||
## 10. 阶段 5:OmnXT 用户授权同步
|
||||
|
||||
### 目标
|
||||
|
||||
让 OmnXT 拉取用户列表时获得 `simnet` 可认证用户,并且用户到期、限额、禁用、套餐节点组变化后同步生效。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 在 `GetServerUserListLogic` 中加入 `simnet` 用户映射。
|
||||
2. 沿用旧系统的有效用户筛选条件:订阅有效、未到期、流量未超限、用户未禁用、节点组有权限。
|
||||
3. 对 `simnet` 用户返回 `user_id`、`subscribe_id`、`uuid`、`key_id`、`psk`、`ticket_id`、限速字段。
|
||||
4. OmnXT 请求 `protocol=simnet` 时,只返回有 `simnet` 权限的用户。
|
||||
5. 缓存 key 加入 `protocol + port`,用户订阅变更、流量变更、节点组变更时能失效。
|
||||
6. 对 `hysteria2` 等旧兼容映射不做破坏;`normalizeServerUserListProtocol` 仅新增 `simnet` 透传。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/node/node.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/logic/server/getServerUserListLogic.go`
|
||||
- `internal/logic/server/constant.go`
|
||||
- 用户订阅、节点组、流量相关 model/service
|
||||
- 可新增 `internal/logic/server/simnet_user.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 4 用户级凭据。
|
||||
- 现有用户有效性判断需要梳理清楚。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. OmnXT 拉用户列表时能看到有效用户的 `simnet` 凭据。
|
||||
2. 用户到期、禁用或流量超限后,从 OmnXT 用户列表消失。
|
||||
3. 套餐节点组取消该节点后,从 OmnXT 用户列表消失。
|
||||
4. 老协议用户列表响应不变。
|
||||
|
||||
### 回滚点
|
||||
|
||||
保留凭据表,但关闭 `GetServerUserListLogic` 的 `simnet` 分支或禁用节点。
|
||||
|
||||
## 11. 阶段 6:公共订阅与 SlagClient
|
||||
|
||||
### 目标
|
||||
|
||||
让 SlagClient 冷启动、重启、重新订阅时都能拿到完整 `simnet` 节点,并正确构造连接。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 在 `apis/public/subscribe.api` 的 `UserSubscribeNodeInfo` 加入用户连接需要的顶层 `simnet_*` 字段。
|
||||
2. 保留 `protocols` JSON,确保 SlagClient 旧解析路径仍可读取。
|
||||
3. 在 `QueryUserSubscribeNodeListLogic` 中解析 Server 的 `protocols` JSON,并把匹配 `node.protocol + node.port` 的 `simnet` 配置映射到订阅响应。
|
||||
4. 订阅响应只下发用户级 `simnet_key_id`、用户级 `simnet_psk`、可公开 path/carrier/TLS/SNI/AF/Fallback 字段。
|
||||
5. 不下发 Server 级 `simnet_psk`、DNS provider env、管理端密钥字段。
|
||||
6. 新增 capability header 判断,例如 `X-Client-Capabilities: simnet` 或当前 SlagClient 实际 header。
|
||||
7. 如果没有 capability header,则使用 User-Agent 作为兼容兜底;不应单纯依赖 UA。
|
||||
8. 对不支持 `simnet` 的客户端隐藏 `simnet` 节点,避免客户端崩溃或展示不可用节点。
|
||||
9. 如果 SlagClient 同时支持 `protocols` JSON 和顶层字段,优先让顶层字段完整,`protocols` 作为兼容冗余。
|
||||
|
||||
### 订阅 DTO 建议
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "HK simnet",
|
||||
"protocol": "simnet",
|
||||
"port": 443,
|
||||
"address": "node.example.com",
|
||||
"sni": "net.example.com",
|
||||
"simnet_key_id": 10001,
|
||||
"simnet_psk": "user-side-secret",
|
||||
"simnet_ticket_id": "",
|
||||
"simnet_path": "/simnet/session",
|
||||
"simnet_carrier": "h2",
|
||||
"security": "tls",
|
||||
"allow_insecure": false,
|
||||
"simnet_af_enabled": true,
|
||||
"simnet_af_path_mode": "api",
|
||||
"simnet_af_magic_mode": "derived",
|
||||
"simnet_fallback_enabled": true,
|
||||
"simnet_fallback_target_scheme": "https",
|
||||
"simnet_fallback_target_host": "www.example.com",
|
||||
"simnet_fallback_target_port": 443
|
||||
}
|
||||
```
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/public/subscribe.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||
- `internal/logic/common/subscriptionTrace.go`,如有订阅 UA 或设备记录
|
||||
- 可新增 `internal/logic/public/subscribe/simnet_mapper.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 4 用户级凭据。
|
||||
- SlagClient capability header 契约确认。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. SlagClient 冷启动订阅后能看到 `simnet` 节点。
|
||||
2. SlagClient 重启后仍能从订阅恢复连接配置。
|
||||
3. 不支持 `simnet` 的客户端订阅不返回 `simnet` 节点。
|
||||
4. 普通用户订阅响应不包含 Server 级 PSK。
|
||||
|
||||
### 回滚点
|
||||
|
||||
订阅侧隐藏 `simnet` 节点或关闭 capability 开关;旧协议订阅不受影响。
|
||||
|
||||
## 12. 阶段 7:流量和在线用户映射
|
||||
|
||||
### 目标
|
||||
|
||||
让 OmnXT 上报的 `simnet` 在线用户和流量能正确归属到用户订阅,并触发旧系统现有的限额、日志、后台统计。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 确认 OmnXT 上报用户标识是 `uuid`、`key_id`、`user_id` 还是其他字段。
|
||||
2. 如果 OmnXT 上报 `key_id`,后端通过用户级凭据表反查 `user_subscribe_id` 和 `user_id`。
|
||||
3. 如果 OmnXT 上报 `uuid`,需要确认 `uuid` 与 `simnet` 凭据绑定关系,不允许跨用户伪造。
|
||||
4. 在 `serverPushUserTrafficLogic` 中加入 `simnet` 标识解析。
|
||||
5. 在 `pushOnlineUsersLogic` 中加入 `simnet` 在线用户映射。
|
||||
6. 更新后台节点在线数统计,确保 `simnet:443` 与其他协议隔离。
|
||||
7. 失败上报要记录协议、server_id、port、用户标识和错误原因,方便排查。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `apis/node/node.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/logic/server/serverPushUserTrafficLogic.go`
|
||||
- `internal/logic/server/pushOnlineUsersLogic.go`
|
||||
- `internal/model/traffic/*`
|
||||
- `internal/model/node/*`
|
||||
- 凭据表 model
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 4 用户级凭据。
|
||||
- OmnXT 上报格式确认。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. `simnet` 连接产生流量后,用户已用流量增加。
|
||||
2. 节点后台能看到 `simnet` 在线人数。
|
||||
3. 用户超限后 OmnXT 用户列表不再包含该用户。
|
||||
4. 旧协议流量统计不受影响。
|
||||
|
||||
### 回滚点
|
||||
|
||||
禁用 `simnet` 流量上报分支或关闭 `simnet` 节点;旧协议统计不受影响。
|
||||
|
||||
## 13. 阶段 8:TLS、AF 与 Fallback
|
||||
|
||||
### 目标
|
||||
|
||||
把当前实际部署需要的 TLS/SNI、AF 和 HTTPS Fallback 做到可配置、可验证、可运维。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. TLS:支持 `security=tls`、`sni`、`allow_insecure=false`。
|
||||
2. 证书模式:第一版支持 `cert_mode=http`;DNS provider 字段先保留,不在普通订阅下发。
|
||||
3. AF:支持 `simnet_af_enabled`、`path_mode=api`、`magic_mode=derived`、`response_jitter_ms`。
|
||||
4. Fallback:支持 fallback scheme、host、port、host header、TLS SNI。
|
||||
5. Reverse:字段保存和下发给 OmnXT,但默认关闭;如果开启必须要求 target host/port 完整。
|
||||
6. 添加配置快照日志,OmnXT 拉取时打印非敏感字段,便于确认线上配置是否生效。
|
||||
7. 对真实节点做 `443` 端口监听、证书申请、fallback 站点访问验证。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `internal/logic/admin/server/protocol_simnet.go`
|
||||
- `internal/logic/server/simnet_config.go`
|
||||
- `internal/logic/public/subscribe/simnet_mapper.go`
|
||||
- `etc/ppanel.yaml`,如需要新增全局开关
|
||||
- 节点部署文档或运维脚本,视 OmnXT 实际需求决定
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 3 OmnXT 配置下发。
|
||||
- 节点服务器域名、证书、端口和 fallback 目标准备完成。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. OmnXT 能在 `443` 启动 `simnet` H2 TLS。
|
||||
2. SNI 与证书匹配。
|
||||
3. AF 开启后 SlagClient 仍可连接。
|
||||
4. Fallback 目标在非协议请求时可访问。
|
||||
5. OmnXT 重启后配置仍然生效。
|
||||
|
||||
### 回滚点
|
||||
|
||||
关闭 AF 或 fallback;必要时把 `simnet.enable=false`,保留旧协议节点承载用户。
|
||||
|
||||
## 14. 阶段 9:自动化测试
|
||||
|
||||
### 目标
|
||||
|
||||
用测试保护 `simnet` 的关键契约,减少后续修改协议字段时再次出现“面板有配置、节点拿不到、客户端不识别”的问题。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 协议模型测试:默认值、校验、marshal/unmarshal。
|
||||
2. 管理端测试:create/update Server 保存 `simnet` 字段完整。
|
||||
3. 节点配置测试:`secret_key` 正确/错误、`simnet` DTO 字段筛选。
|
||||
4. 用户凭据测试:生成稳定性、用户隔离、重置失效。
|
||||
5. 订阅测试:capability header 支持时返回 `simnet`;不支持时隐藏。
|
||||
6. 敏感字段测试:普通订阅中不得出现 Server PSK、DNS env。
|
||||
7. 流量测试:OmnXT 上报 `key_id` 后可归属用户。
|
||||
8. 回归测试:现有 vless、trojan、hysteria2、shadowsocks 订阅不变。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `tests/acceptance/*`
|
||||
- `internal/logic/admin/server/*_test.go`
|
||||
- `internal/logic/server/*_test.go`
|
||||
- `internal/logic/public/subscribe/*_test.go`
|
||||
- `internal/model/simnet/*_test.go`
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 1 到阶段 7 基本实现完成。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. `go test ./...` 通过,或项目当前可执行测试集全部通过。
|
||||
2. 新增测试能覆盖 Server、OmnXT、SlagClient、Traffic 四条主链路。
|
||||
3. 任意敏感字段泄露测试失败时,CI 阻断。
|
||||
|
||||
### 回滚点
|
||||
|
||||
测试本身不影响生产;如果某阶段实现回滚,相应测试应标记待实现或一并回滚。
|
||||
|
||||
## 15. 阶段 10:灰度发布与回滚
|
||||
|
||||
### 目标
|
||||
|
||||
把 `simnet` 以可控方式上线,先让一个节点和少量测试用户跑通,再扩大范围。
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 增加全局或配置级开关:`simnet_enabled`。
|
||||
2. 管理端先创建一个独立测试 Server 和一个 `simnet` front node。
|
||||
3. 只给测试套餐或测试节点组分配该节点。
|
||||
4. 部署 OmnXT,确认能拉配置、拉用户、启动监听。
|
||||
5. 用测试用户订阅 SlagClient,验证冷启动、重启、切换网络、重拉订阅。
|
||||
6. 观察在线用户、流量上报、错误日志、证书续期和 fallback 访问。
|
||||
7. 稳定后把节点加入正式套餐节点组。
|
||||
8. 保留旧协议节点作为回退路径,不把全部用户一次性切到 `simnet`。
|
||||
|
||||
### 预计修改位置
|
||||
|
||||
- `etc/ppanel.yaml`,如需要全局开关
|
||||
- `internal/config/config.go`
|
||||
- `internal/svc/serviceContext.go`
|
||||
- 运维部署文档
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- 阶段 1 到阶段 9 完成。
|
||||
- 测试节点服务器、域名、证书、OmnXT 可用。
|
||||
|
||||
### 验收条件
|
||||
|
||||
1. 测试用户能稳定连接 `simnet`。
|
||||
2. SlagClient 重启后无需人工操作即可恢复。
|
||||
3. OmnXT 重启后能自动拉配置和用户授权。
|
||||
4. 管理端能看到在线和流量。
|
||||
5. 关闭 `simnet` 后用户可回退到旧协议节点。
|
||||
|
||||
### 回滚点
|
||||
|
||||
1. 管理端将 `simnet` 协议 `enable=false`。
|
||||
2. 从套餐节点组移除 `simnet` 节点。
|
||||
3. OmnXT 停止 `simnet` inbound。
|
||||
4. 回滚后端到上一版本。
|
||||
5. 保留凭据表和字段,后续排查后可再次启用。
|
||||
|
||||
## 16. 文件改动范围
|
||||
|
||||
预计完整生产可用版本会影响 27-45 个业务/配置文件、12-20 个测试文件,新增约 3,000-6,000 行代码和测试。实际数量取决于 goctl 生成文件体积、现有 model 组织方式和 OmnXT/SlagClient 契约是否稳定。
|
||||
|
||||
### 必改范围
|
||||
|
||||
- `apis/types.api`
|
||||
- `apis/admin/server.api`
|
||||
- `apis/node/node.api`
|
||||
- `apis/public/subscribe.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/model/node/*`
|
||||
- `internal/logic/admin/server/createServerLogic.go`
|
||||
- `internal/logic/admin/server/updateServerLogic.go`
|
||||
- `internal/logic/admin/server/getServerProtocolsLogic.go`
|
||||
- `internal/logic/admin/server/filterServerListLogic.go`
|
||||
- `internal/logic/server/getServerConfigLogic.go`
|
||||
- `internal/logic/server/getServerUserListLogic.go`
|
||||
- `internal/logic/server/serverPushUserTrafficLogic.go`
|
||||
- `internal/logic/server/pushOnlineUsersLogic.go`
|
||||
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||
|
||||
### 可能新增范围
|
||||
|
||||
- `internal/model/simnet/*`
|
||||
- `internal/logic/admin/server/protocol_simnet.go`
|
||||
- `internal/logic/server/simnet_config.go`
|
||||
- `internal/logic/server/simnet_user.go`
|
||||
- `internal/logic/public/subscribe/simnet_mapper.go`
|
||||
- `initialize/migrate/*simnet*`
|
||||
- `tests/simnet/*`
|
||||
- `docs/simnet-contract.md`
|
||||
|
||||
### 前端联动范围
|
||||
|
||||
如果管理端前端也要同步配置,需要在前端仓库补齐:
|
||||
|
||||
- Server 创建/编辑表单的 `simnet` 协议字段
|
||||
- 协议默认值填充
|
||||
- 字段校验提示
|
||||
- Node 创建/更新时允许 `protocol=simnet`
|
||||
- 隐藏 Server PSK 的展示或复制入口
|
||||
|
||||
## 17. 提交拆分
|
||||
|
||||
建议按以下提交拆分,方便 review 和回滚:
|
||||
|
||||
1. `simnet: add protocol model fields and validation`
|
||||
2. `simnet: support admin server create/update/list`
|
||||
3. `simnet: expose server runtime config for OmnXT`
|
||||
4. `simnet: add per-user credentials`
|
||||
5. `simnet: sync OmnXT user authorization`
|
||||
6. `simnet: expose public subscribe fields for SlagClient`
|
||||
7. `simnet: map traffic and online reports`
|
||||
8. `simnet: add tls af fallback handling`
|
||||
9. `simnet: add tests and rollout switch`
|
||||
|
||||
每个提交都应该能单独说明行为变化,并尽量避免把 goctl 生成文件和手写逻辑混在一个巨大提交里。如果生成文件不可避免较大,提交说明中要明确哪些是生成结果。
|
||||
|
||||
## 18. 验收标准
|
||||
|
||||
最终验收必须覆盖下面场景:
|
||||
|
||||
1. 管理端能创建 Server,协议为 `simnet`,端口 `443`,TLS/SNI、AF、Fallback 字段保存完整。
|
||||
2. 管理端能创建或更新 Node,`protocol=simnet`,`address` 指向实际节点服务器。
|
||||
3. OmnXT 使用正确 `secret_key` 能拉取 `simnet` 服务端运行配置。
|
||||
4. OmnXT 使用错误 `secret_key` 被拒绝。
|
||||
5. OmnXT 重启后自动恢复 `simnet` inbound。
|
||||
6. 有效用户能通过 OmnXT 用户列表获得授权。
|
||||
7. 不同用户的 `simnet_key_id` 或 `simnet_psk` 不相同。
|
||||
8. 用户禁用、到期或流量超限后,OmnXT 用户列表移除该用户。
|
||||
9. SlagClient 冷启动能通过订阅拿到 `simnet` 节点并连接。
|
||||
10. SlagClient 重启后不丢失协议配置。
|
||||
11. 不支持 `simnet` 的客户端订阅不会收到 `simnet` 节点。
|
||||
12. 普通用户订阅响应不泄露 Server PSK、DNS provider env、节点 `secret_key`。
|
||||
13. `simnet` 连接产生流量后,用户流量、节点流量、后台日志同步更新。
|
||||
14. 关闭 `simnet` 后,旧协议订阅、节点运行和流量统计不受影响。
|
||||
15. `go test ./...` 或项目当前有效测试集通过。
|
||||
|
||||
## 19. 风险清单
|
||||
|
||||
| 风险 | 影响 | 控制方式 |
|
||||
| --- | --- | --- |
|
||||
| Server PSK 被下发给普通用户 | 所有用户共享密钥,泄露后整节点风险扩大 | DTO 分层,订阅敏感字段测试阻断 |
|
||||
| OmnXT 和后端字段名不一致 | 节点启动失败或配置不生效 | 阶段 0 固化契约,用真实 OmnXT 请求回放测试 |
|
||||
| SlagClient 只读顶层字段或只读 protocols JSON | 客户端拿到节点但无法连接 | 双格式兼容,顶层字段和 protocols 都保持可读 |
|
||||
| 单用户凭据缺失 | 无法隔离用户,封禁和流量归属困难 | 阶段 4 必须先做凭据表,不走全员共享 PSK |
|
||||
| capability 判断不准确 | 老客户端看到不可用节点 | capability header 优先,UA 只兜底,默认隐藏不支持客户端 |
|
||||
| 缓存 key 未包含 port | 多协议或同协议多端口串配置 | cache key 包含 `server_id + protocol + port` |
|
||||
| 流量上报标识不明确 | 用户流量无法入账或串账 | 与 OmnXT 明确上报 `key_id`,后端反查凭据表 |
|
||||
| TLS/证书/fallback 运维失败 | 节点 443 无法正常服务 | 灰度节点先跑,保留旧协议回退 |
|
||||
| goctl 生成覆盖手写改动 | 代码冲突或字段丢失 | 所有类型先改 api 文件,再生成;手写扩展放独立文件 |
|
||||
|
||||
## 20. 工期估算
|
||||
|
||||
在 OmnXT 和 SlagClient 契约清楚、测试环境可用的情况下:
|
||||
|
||||
- 阶段 0:0.5-1 天
|
||||
- 阶段 1-2:1.5-2 天
|
||||
- 阶段 3:1-1.5 天
|
||||
- 阶段 4:1.5-2 天
|
||||
- 阶段 5:1-1.5 天
|
||||
- 阶段 6:1-1.5 天
|
||||
- 阶段 7:1-2 天
|
||||
- 阶段 8:1 天
|
||||
- 阶段 9:2-3 天
|
||||
- 阶段 10:1 天
|
||||
|
||||
完整生产可用版本预计 10-15 个有效开发日。如果 OmnXT 或 SlagClient 字段契约需要同步改动,额外预留 2-4 天联调时间。
|
||||
|
||||
## 21. 推荐执行顺序
|
||||
|
||||
第一周先完成最小闭环:
|
||||
|
||||
1. 阶段 0:确认契约。
|
||||
2. 阶段 1:协议模型与校验。
|
||||
3. 阶段 2:管理端保存和查询。
|
||||
4. 阶段 3:OmnXT 配置下发。
|
||||
|
||||
第二周完成用户链路:
|
||||
|
||||
1. 阶段 4:用户级凭据。
|
||||
2. 阶段 5:OmnXT 用户授权。
|
||||
3. 阶段 6:SlagClient 订阅。
|
||||
4. 阶段 7:流量和在线用户映射。
|
||||
|
||||
最后做生产化:
|
||||
|
||||
1. 阶段 8:TLS、AF、Fallback 运维验证。
|
||||
2. 阶段 9:自动化测试补齐。
|
||||
3. 阶段 10:灰度发布和回滚演练。
|
||||
|
||||
## 22. 当前结论
|
||||
|
||||
最合理的方案是在旧后端内部补齐 `simnet` 的纵向链路,不建议整体迁移 Pro 新后端。这样风险最小,旧业务稳定性最好,也最贴近当前问题:SlagClient 和 OmnXT 需要的是一个一致、完整、不会泄露敏感字段的 `simnet` 契约。
|
||||
|
||||
第一版真正必须做的是:协议模型、管理端保存、OmnXT 配置、用户级凭据、OmnXT 授权、SlagClient 订阅、流量归属。只要这七个点闭环,`simnet` 就不是“配置看起来存在”,而是能在真实客户端和真实节点上稳定使用。
|
||||
+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
|
||||
|
||||
@@ -82,6 +82,7 @@ require (
|
||||
github.com/alibabacloud-go/tea-utils v1.4.5 // indirect
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 // indirect
|
||||
github.com/aliyun/credentials-go v1.3.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||
@@ -145,6 +146,7 @@ require (
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
||||
|
||||
@@ -54,6 +54,8 @@ github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/
|
||||
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||
github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA=
|
||||
@@ -395,6 +397,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 02156 抽奖活动 Stage 1 回滚
|
||||
-- 反向删除 7 张表。因存在业务耦合数据(用户次数、抽奖记录、快照)在生产回滚前
|
||||
-- 必须先备份,回滚只删表结构。执行顺序按外键依赖反向:先删依赖别人的,再删被依赖的。
|
||||
|
||||
DROP TABLE IF EXISTS `lottery_eligibility_snapshot`;
|
||||
DROP TABLE IF EXISTS `lottery_prize_snapshot`;
|
||||
DROP TABLE IF EXISTS `lottery_draw`;
|
||||
DROP TABLE IF EXISTS `lottery_chance_grant`;
|
||||
DROP TABLE IF EXISTS `lottery_chance_balance`;
|
||||
DROP TABLE IF EXISTS `lottery_prize`;
|
||||
DROP TABLE IF EXISTS `lottery_activity`;
|
||||
@@ -0,0 +1,125 @@
|
||||
-- 02156 抽奖活动 Stage 1(后端核心闭环)
|
||||
--
|
||||
-- 新建 7 张表 + 全部索引 + 幂等约束。
|
||||
-- 幂等设计:全部 `CREATE TABLE IF NOT EXISTS`;索引通过 INFORMATION_SCHEMA 预检
|
||||
-- 后再补齐。可重复执行不报错,符合 `doc/development-workflow-zh.md` 迁移规范。
|
||||
--
|
||||
-- 关键唯一索引(都是并发/幂等正确性的核心,切勿删):
|
||||
-- 1) lottery_prize (activity_id, slot) — 一个活动一个位置只能挂一个奖品
|
||||
-- 2) lottery_draw (user_id, client_nonce) — 用户端幂等键,重放同一 nonce 返回同一 draw
|
||||
-- 3) lottery_chance_balance (user_id, activity_id) — 每人每活动一个次数余额行
|
||||
-- 4) lottery_chance_grant (activity_id, source, source_ref) — 次数入账幂等键(避免同订单发两次机会)
|
||||
-- 5) lottery_prize_snapshot (draw_id) — 抽奖时刻的奖品快照,1:1
|
||||
-- 6) lottery_eligibility_snapshot (draw_id) — 抽奖时刻的门槛评估快照,1:1
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_activity` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '活动标题',
|
||||
`description` TEXT COMMENT '活动描述(Markdown)',
|
||||
`start_at` DATETIME NOT NULL COMMENT '开始时间',
|
||||
`end_at` DATETIME NOT NULL COMMENT '结束时间',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '状态:draft / running / paused / ended',
|
||||
`grid_size` TINYINT NOT NULL DEFAULT 8 COMMENT '前端九宫格数量(HIF-4 F8:布局 A 3×3 挖中心 → 8 个奖品格;老 schema 是 9)',
|
||||
`eligibility` JSON NOT NULL COMMENT '参与门槛(AND/OR 嵌套规则)',
|
||||
`chance_sources` JSON NOT NULL COMMENT '次数来源列表(daily_signin / new_subscription / invite_success / manual_grant)',
|
||||
`unmet_action` VARCHAR(32) NOT NULL DEFAULT 'block' COMMENT '未达门槛策略:block / show_reason',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_status_time` (`status`, `start_at`, `end_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖活动';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_prize` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '所属活动 ID',
|
||||
`slot` TINYINT NOT NULL COMMENT '九宫格位置(0-based)',
|
||||
`type` VARCHAR(32) NOT NULL COMMENT '奖品类型:vpn_duration / commission / balance / gift_amount / coupon / points / encrypted / physical / manual_other / none',
|
||||
`name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '奖品名称',
|
||||
`icon_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '奖品图标 URL',
|
||||
`config` JSON NOT NULL COMMENT '类型专属配置(如 {"duration_days":3})',
|
||||
`weight` INT NOT NULL DEFAULT 0 COMMENT '加权随机权重(0 表示不参与随机)',
|
||||
`total_stock` BIGINT COMMENT '总库存(NULL 表示无限)',
|
||||
`remaining_stock` BIGINT COMMENT '剩余库存(NULL 表示无限,与 total_stock 同 NULL)',
|
||||
`is_fallback` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为保底奖(1: 是,抽中限量奖降级到此;weight 被忽略)',
|
||||
`version` BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_activity_slot` (`activity_id`, `slot`),
|
||||
KEY `idx_activity_fallback` (`activity_id`, `is_fallback`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖奖品定义';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_chance_balance` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`remaining` BIGINT NOT NULL DEFAULT 0 COMMENT '剩余次数(下一次抽奖要读这里并 -1)',
|
||||
`total_earned` BIGINT NOT NULL DEFAULT 0 COMMENT '累计入账次数(审计用)',
|
||||
`total_spent` BIGINT NOT NULL DEFAULT 0 COMMENT '累计消耗次数(审计用)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数余额';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_chance_grant` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '发放对象用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`source` VARCHAR(32) NOT NULL COMMENT '触发源:daily_signin / new_subscription / invite_success / manual_grant',
|
||||
`source_ref` VARCHAR(128) NOT NULL COMMENT '外部业务幂等键(如 order_no、"signin:{yyyymmdd}"、"manual:{admin_id}:{ts}")',
|
||||
`amount` INT NOT NULL DEFAULT 0 COMMENT '本次发放次数',
|
||||
`expires_at` DATETIME DEFAULT NULL COMMENT '本次入账的到期时间(NULL 表示不过期)',
|
||||
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_activity_source_ref` (`activity_id`, `source`, `source_ref`),
|
||||
KEY `idx_user_activity_expires` (`user_id`, `activity_id`, `expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数入账流水(幂等键 = activity_id+source+source_ref)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_draw` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`client_nonce` VARCHAR(64) NOT NULL COMMENT '前端幂等键(UUID)',
|
||||
`prize_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '中奖奖品 ID(未中奖为 NULL)',
|
||||
`is_win` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否中奖(未中奖=谢谢参与,也会写 draw)',
|
||||
`dispatch_state` VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '发放状态:none(无需发) / auto_claimed(自动已发) / pending_claim(等待人工领) / paid(人工发完) / expired(超时未领) / failed',
|
||||
`dispatch_error` TEXT COMMENT '发放失败的错误信息(仅失败时写)',
|
||||
`dispatched_at` DATETIME DEFAULT NULL COMMENT '发放完成时间(自动类=事务提交时;人工类=运营录入后)',
|
||||
`drawn_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '抽奖时间',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_nonce` (`user_id`, `client_nonce`),
|
||||
KEY `idx_user_time` (`user_id`, `drawn_at`),
|
||||
KEY `idx_activity_win_time` (`activity_id`, `is_win`, `drawn_at`),
|
||||
KEY `idx_activity_prize` (`activity_id`, `prize_id`),
|
||||
KEY `idx_dispatch_state` (`dispatch_state`, `drawn_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_prize_snapshot` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`prize_id` BIGINT UNSIGNED NOT NULL COMMENT '奖品 ID(快照当时的 id)',
|
||||
`slot` TINYINT NOT NULL COMMENT '九宫格位置(快照)',
|
||||
`type` VARCHAR(32) NOT NULL COMMENT '奖品类型(快照)',
|
||||
`name` VARCHAR(128) NOT NULL COMMENT '奖品名称(快照)',
|
||||
`config` JSON NOT NULL COMMENT '类型专属配置(快照)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_draw_id` (`draw_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的奖品快照(对账/纠纷用)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_eligibility_snapshot` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`passed` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否通过门槛(未通过=拒绝抽奖或前端提示)',
|
||||
`unmet_reasons` JSON COMMENT '未通过项(rule/hint/current/required)',
|
||||
`evaluated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_draw_id` (`draw_id`),
|
||||
KEY `idx_user_activity_time` (`user_id`, `activity_id`, `evaluated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的门槛评估快照(对账/申诉用)';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 02157 抽奖发奖账本回滚
|
||||
DROP TABLE IF EXISTS `lottery_grant_ledger`;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 02157 抽奖发奖账本(PR B)
|
||||
--
|
||||
-- 目的:以 external_ref 作为 DB 层唯一键,做每个 draw 的发奖幂等。
|
||||
-- 各 PrizeHandler.Dispatch 内先 SELECT/INSERT lottery_grant_ledger,命中即幂等返回,
|
||||
-- 未命中再调下游发放(UpdateSubscribe / UpdateCommission + WriteCommissionLog),
|
||||
-- 全部在同一 tx 内完成 → 抽奖事务与发奖账本同生共死。
|
||||
--
|
||||
-- 关键唯一索引:external_ref。惯例值 = "lottery:{activity_id}:{draw_id}"。
|
||||
-- handler_type:与 lottery_prize.type 一致(vpn_duration / commission / …),用于统计。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_grant_ledger` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`external_ref` VARCHAR(128) NOT NULL COMMENT '幂等键:lottery:{activity_id}:{draw_id}',
|
||||
`handler_type` VARCHAR(32) NOT NULL COMMENT 'handler 类型,与 lottery_prize.type 对齐',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '发放对象用户 ID(家庭组已归位到 owner)',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`amount` BIGINT NOT NULL DEFAULT 0 COMMENT '发放数量(天/佣金金额,单位与 handler 一致)',
|
||||
`payload` JSON COMMENT '发放后的关键结果快照(订阅 ID、佣金前后余额等)',
|
||||
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发放完成时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_external_ref` (`external_ref`),
|
||||
KEY `idx_user_activity` (`user_id`, `activity_id`),
|
||||
KEY `idx_draw_id` (`draw_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖发奖账本(幂等键 = external_ref)';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 02158 admin_action_log 回滚
|
||||
DROP TABLE IF EXISTS `admin_action_log`;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 02158 admin_action_log —— 管理端写操作审计(PR C 起要求)
|
||||
--
|
||||
-- 每一条 admin CRUD/rules 更新都在同事务内插入一行审计流水,方便后续追责
|
||||
-- 与合规审查。actor_user_id 是操作者的 user.id;action 是操作动作
|
||||
-- (lottery.activity.create / lottery.prize.update / lottery.rules.put / ...);
|
||||
-- target_ids 是被操作对象的主键数组(JSON);request_hash 是请求 body 的 sha1
|
||||
-- 摘要(对同一批次多次写入去重);ip/user_agent 从上下文取。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `admin_action_log` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`actor_user_id` BIGINT UNSIGNED NOT NULL COMMENT '操作者 user.id',
|
||||
`action` VARCHAR(64) NOT NULL COMMENT '动作 code(点分层级)',
|
||||
`target_ids` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '被操作对象 ID 逗号分隔或 JSON 数组',
|
||||
`request_hash` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求 body sha1 摘要',
|
||||
`ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '操作者 IP',
|
||||
`user_agent` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '操作者 UA',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_actor_time` (`actor_user_id`, `created_at`),
|
||||
KEY `idx_action_time` (`action`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台写操作审计流水';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 02159 抽奖活动 Stage 2 down migration
|
||||
-- Stage 2 只新增 1 张表,回滚直接 drop 即可。
|
||||
DROP TABLE IF EXISTS `lottery_claim`;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 02159 抽奖活动 Stage 2(人工奖领奖工单)
|
||||
--
|
||||
-- 新建 `lottery_claim` 表:承载 crypto / physical / manual_other 三类人工奖
|
||||
-- 从"抽中"到"运营打款/发货"的完整工单状态机。
|
||||
--
|
||||
-- 幂等设计:`CREATE TABLE IF NOT EXISTS`;一个 draw_id 只能有一条 claim 行
|
||||
-- (UNIQUE 约束保证 POST /draw 事务不会重复挂单,避免用户端重放时重复入队)。
|
||||
--
|
||||
-- 关键索引:
|
||||
-- 1) UNIQUE (draw_id) — 抽奖记录 ↔ 领奖工单 一对一
|
||||
-- 2) (activity_id, status) — 后台工单列表按活动 + 状态过滤
|
||||
-- 3) (user_id, activity_id) — GET /records 按用户拉工单
|
||||
-- 4) (status, expires_at) — 过期定时任务扫描
|
||||
--
|
||||
-- 状态机(详细见 doc/lottery-stage2 或 issue HIF-4):
|
||||
-- pending_claim ─── 用户提交 ──→ reviewing
|
||||
-- └── 超时 ──→ expired
|
||||
-- reviewing ─── 运营 approve ──→ paying
|
||||
-- └── 运营 reject ──→ rejected(用户可再次提交)
|
||||
-- paying ─── 运营 mark-paid ──→ paid(终态)
|
||||
-- └── 运营 reject ──→ rejected
|
||||
-- rejected ─── 用户再提交 ──→ reviewing
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_claim` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`prize_type` VARCHAR(32) NOT NULL COMMENT '奖品类型(crypto/physical/manual_other,冗余便于后台按类型过滤)',
|
||||
`claim_data` JSON COMMENT '用户提交的领奖表单数据(结构随 prize_type 变化)',
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending_claim' COMMENT '状态:pending_claim / reviewing / paying / paid / rejected / expired',
|
||||
`submitted_at` DATETIME DEFAULT NULL COMMENT '用户提交领奖信息时间(首次提交后写;重新提交会覆盖)',
|
||||
`expires_at` DATETIME NOT NULL COMMENT '领奖窗口截止时间(默认 now+7d,可被奖品 config.claim_ttl_hours 覆盖)',
|
||||
`reviewed_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '最近一次审核操作者 user.id',
|
||||
`reviewed_at` DATETIME DEFAULT NULL COMMENT '最近一次审核时间',
|
||||
`reject_reason` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '拒绝原因',
|
||||
`tx_hash` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '链上交易哈希(crypto 打款)',
|
||||
`delivery_ref` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '快递单号 / 发货单据编号(physical 发货)',
|
||||
`paid_at` DATETIME DEFAULT NULL COMMENT '运营标记打款/发货完成时间',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_draw_id` (`draw_id`),
|
||||
KEY `idx_activity_status` (`activity_id`, `status`),
|
||||
KEY `idx_user_activity` (`user_id`, `activity_id`),
|
||||
KEY `idx_status_expires` (`status`, `expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖人工奖领奖工单';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 02160 down: 恢复 lottery_activity.grid_size 默认值到 9
|
||||
--
|
||||
-- 与 up.sql 对称,只回退默认值,不动数据。
|
||||
ALTER TABLE `lottery_activity` ALTER COLUMN `grid_size` SET DEFAULT 9;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 02160 抽奖 Stage 2 F8:lottery_activity.grid_size 默认值从 9 改成 8
|
||||
--
|
||||
-- 前端与产品对齐后确认布局 A:3×3 挖中心 → 中心是"点击抽奖"按钮(不是奖品格),
|
||||
-- 其余 8 格挂奖品。因此 grid_size 的默认值应为 8,不再是 9。
|
||||
--
|
||||
-- 兼容性:
|
||||
-- * up.sql 的 CREATE TABLE 已在 02156 里跑过,MySQL 的 CREATE TABLE IF NOT EXISTS
|
||||
-- 不会改动既存表结构。所以老部署的 lottery_activity.grid_size 默认值仍是 9,
|
||||
-- 必须用一条独立的 ALTER 迁移把默认值改过来。
|
||||
-- * 已有数据(grid_size=9 的老活动)不动 —— ALTER DEFAULT 只影响新插入行且未提供
|
||||
-- grid_size 的场景;Go 侧 admin/lottery.go 的兜底也已配套改成 8。
|
||||
--
|
||||
-- 幂等:ALTER COLUMN ... SET DEFAULT 在 MySQL 8.0+ 是幂等的(重复执行等值 SET
|
||||
-- 不会报错),重跑安全。
|
||||
ALTER TABLE `lottery_activity` ALTER COLUMN `grid_size` SET DEFAULT 8;
|
||||
@@ -0,0 +1 @@
|
||||
DELETE FROM `subscribe_application` WHERE `id` = 1001 AND `name` = 'OmnXT SimNet';
|
||||
@@ -0,0 +1,10 @@
|
||||
-- OmnXT SimNet subscription application.
|
||||
-- Delivers simnet nodes as base64 "simnet://" protocol links built by the
|
||||
-- adapter template function buildOmnxtProtocolLinks (per-user psk/key_id derived
|
||||
-- from the subscription; server PSK carried for AF derivation). Matched by
|
||||
-- User-Agent containing "omnxt". Mirrors the Pro reference final format
|
||||
-- (migrations 02138 + 02140).
|
||||
INSERT IGNORE INTO `subscribe_application`
|
||||
(`id`, `name`, `icon`, `description`, `scheme`, `user_agent`, `is_default`, `subscribe_template`, `output_format`, `download_link`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1001, 'OmnXT SimNet', '', 'OmnXT SimNet base64 subscription', '', 'OmnXT', 0, '{{- range $link := buildOmnxtProtocolLinks .Proxies .UserInfo .Params }}{{ $link }}\n{{- end }}', 'base64', '{}', NOW(3), NOW(3));
|
||||
@@ -70,3 +70,6 @@ const RegisterIpKeyPrefix = "register:ip:"
|
||||
|
||||
// UserSessionsKeyPrefix per-user sessions zset key prefix
|
||||
const UserSessionsKeyPrefix = "auth:user_sessions:"
|
||||
|
||||
// UserEnableKeyPrefix user enable state cache key prefix
|
||||
const UserEnableKeyPrefix = "user:enable:"
|
||||
|
||||
@@ -39,6 +39,7 @@ type Config struct {
|
||||
Currency Currency `yaml:"Currency"`
|
||||
Trace trace.Config `yaml:"Trace"`
|
||||
S3 S3Config `yaml:"S3"`
|
||||
Lottery LotteryConfig `yaml:"Lottery"`
|
||||
Administrator struct {
|
||||
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
||||
Password string `yaml:"Password" default:"password"`
|
||||
@@ -250,6 +251,13 @@ type InviteConfig struct {
|
||||
GiftDays int64 `yaml:"GiftDays" default:"3"`
|
||||
}
|
||||
|
||||
// LotteryConfig 是抽奖 Stage 1 的 feature flag。默认关闭,交 QA 前手动打开。
|
||||
// 关闭时用户端 POST /draw 返回 4003 activity_ended(前端展示"活动已结束",
|
||||
// 与"配置关闭"避免暴露内部状态);后台 CRUD 仍然可用,方便配置好活动再开。
|
||||
type LotteryConfig struct {
|
||||
Enable bool `yaml:"Enable" default:"false"`
|
||||
}
|
||||
|
||||
// KuttConfig Kutt 短链接服务配置
|
||||
type KuttConfig struct {
|
||||
Enable bool `yaml:"Enable" default:"false"` // 是否启用 Kutt 短链接
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// admin_claims_handler.go 提供 Stage 2 后台工单接口的 gin handler 层。
|
||||
// 路径注册在 internal/handler/lottery_routes.go 里;handler 只负责参数绑定 +
|
||||
// 委派到 internal/logic/admin/lottery/admin_claims.go。
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// ListLotteryClaimsHandler GET /v1/admin/lottery/claims
|
||||
func ListLotteryClaimsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ListAdminLotteryClaimsRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := adminlottery.NewListLotteryClaimsLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ListLotteryClaims(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ApproveLotteryClaimHandler POST /v1/admin/lottery/claims/approve
|
||||
func ApproveLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminApproveClaimRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewApproveLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.ApproveLotteryClaim(&req))
|
||||
}
|
||||
}
|
||||
|
||||
// RejectLotteryClaimHandler POST /v1/admin/lottery/claims/reject
|
||||
func RejectLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminRejectClaimRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewRejectLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.RejectLotteryClaim(&req))
|
||||
}
|
||||
}
|
||||
|
||||
// MarkPaidLotteryClaimHandler POST /v1/admin/lottery/claims/mark-paid
|
||||
func MarkPaidLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminMarkPaidClaimRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewMarkPaidLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.MarkPaidLotteryClaim(&req))
|
||||
}
|
||||
}
|
||||
|
||||
// LotteryClaimsSummaryHandler GET /v1/admin/lottery/claims/summary
|
||||
func LotteryClaimsSummaryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
l := adminlottery.NewLotteryClaimsSummaryLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.LotteryClaimsSummary()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ListLotteryDrawsHandler GET /v1/admin/lottery/draws
|
||||
func ListLotteryDrawsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ListAdminLotteryDrawsRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := adminlottery.NewListLotteryDrawsLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ListLotteryDraws(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Package lottery contains gin handlers for the admin-side lottery endpoints.
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func CreateLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.CreateAdminLotteryActivityRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewCreateLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.CreateLotteryActivity(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateAdminLotteryActivityRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewUpdateLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.UpdateLotteryActivity(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func ListLotteryActivitiesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ListAdminLotteryActivitiesRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := adminlottery.NewListLotteryActivitiesLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ListLotteryActivities(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func GetLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminActivityIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewGetLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetLotteryActivity(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func PublishLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminActivityIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewPublishLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.PublishLotteryActivity(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func PauseLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminActivityIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewPauseLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.PauseLotteryActivity(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLotteryRulesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateAdminLotteryRulesRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewUpdateLotteryRulesLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.UpdateLotteryRules(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func CreateLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.CreateAdminLotteryPrizeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewCreateLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.CreateLotteryPrize(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateAdminLotteryPrizeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if id, err := strconv.ParseInt(c.Param("id"), 10, 64); err == nil {
|
||||
req.Id = id
|
||||
}
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewUpdateLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.UpdateLotteryPrize(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminPrizeIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if id, err := strconv.ParseInt(c.Param("id"), 10, 64); err == nil {
|
||||
req.Id = id
|
||||
}
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewDeleteLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.DeleteLotteryPrize(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func ListLotteryPrizesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ListAdminLotteryPrizesRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewListLotteryPrizesLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ListLotteryPrizes(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func GrantLotteryChanceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GrantAdminLotteryChanceRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewGrantLotteryChanceLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.GrantLotteryChance(&req))
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteLotteryActivityHandler DELETE /v1/admin/lottery/activities/:id
|
||||
func DeleteLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminActivityIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if id, err := strconv.ParseInt(c.Param("id"), 10, 64); err == nil {
|
||||
req.Id = id
|
||||
}
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewDeleteLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.DeleteLotteryActivity(&req))
|
||||
}
|
||||
}
|
||||
@@ -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"}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
adminLottery "github.com/perfect-panel/server/internal/handler/admin/lottery"
|
||||
publicLottery "github.com/perfect-panel/server/internal/handler/public/lottery"
|
||||
"github.com/perfect-panel/server/internal/middleware"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
)
|
||||
|
||||
// registerLotteryRoutes wires the Stage 1 lottery endpoints. Kept in its own
|
||||
// file to avoid ballooning routes.go and to make the lottery surface easy to
|
||||
// audit end-to-end. The path prefix "/v1/lottery" is under the user middleware
|
||||
// stack (AuthMiddleware + DeviceMiddleware); "/v1/admin/lottery" uses the
|
||||
// admin-detecting AuthMiddleware (path contains "admin" segment).
|
||||
func registerLotteryRoutes(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
userGroup := router.Group("/v1/lottery")
|
||||
userGroup.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||
{
|
||||
userGroup.GET("/config", publicLottery.QueryLotteryConfigHandler(serverCtx))
|
||||
userGroup.POST("/draw", publicLottery.DrawLotteryHandler(serverCtx))
|
||||
userGroup.GET("/records", publicLottery.QueryLotteryRecordsHandler(serverCtx))
|
||||
userGroup.POST("/claim", publicLottery.ClaimLotteryPrizeHandler(serverCtx))
|
||||
}
|
||||
|
||||
adminGroup := router.Group("/v1/admin/lottery")
|
||||
adminGroup.Use(middleware.AuthMiddleware(serverCtx), middleware.AdminMetaMiddleware())
|
||||
{
|
||||
adminGroup.POST("/activities", adminLottery.CreateLotteryActivityHandler(serverCtx))
|
||||
adminGroup.PUT("/activities", adminLottery.UpdateLotteryActivityHandler(serverCtx))
|
||||
adminGroup.GET("/activities", adminLottery.ListLotteryActivitiesHandler(serverCtx))
|
||||
adminGroup.GET("/activities/detail", adminLottery.GetLotteryActivityHandler(serverCtx))
|
||||
adminGroup.POST("/activities/publish", adminLottery.PublishLotteryActivityHandler(serverCtx))
|
||||
adminGroup.POST("/activities/pause", adminLottery.PauseLotteryActivityHandler(serverCtx))
|
||||
adminGroup.PUT("/activities/rules", adminLottery.UpdateLotteryRulesHandler(serverCtx))
|
||||
adminGroup.DELETE("/activities/:id", adminLottery.DeleteLotteryActivityHandler(serverCtx))
|
||||
|
||||
adminGroup.POST("/prizes", adminLottery.CreateLotteryPrizeHandler(serverCtx))
|
||||
adminGroup.PUT("/prizes/:id", adminLottery.UpdateLotteryPrizeHandler(serverCtx))
|
||||
adminGroup.DELETE("/prizes/:id", adminLottery.DeleteLotteryPrizeHandler(serverCtx))
|
||||
adminGroup.GET("/prizes", adminLottery.ListLotteryPrizesHandler(serverCtx))
|
||||
|
||||
adminGroup.POST("/chances/grant", adminLottery.GrantLotteryChanceHandler(serverCtx))
|
||||
|
||||
// Stage 2 (HIF-4): 人工奖工单接口
|
||||
adminGroup.GET("/claims", adminLottery.ListLotteryClaimsHandler(serverCtx))
|
||||
adminGroup.GET("/claims/summary", adminLottery.LotteryClaimsSummaryHandler(serverCtx))
|
||||
adminGroup.POST("/claims/approve", adminLottery.ApproveLotteryClaimHandler(serverCtx))
|
||||
adminGroup.POST("/claims/reject", adminLottery.RejectLotteryClaimHandler(serverCtx))
|
||||
adminGroup.POST("/claims/mark-paid", adminLottery.MarkPaidLotteryClaimHandler(serverCtx))
|
||||
|
||||
// Stage 3: 抽奖记录(发放流水)
|
||||
adminGroup.GET("/draws", adminLottery.ListLotteryDrawsHandler(serverCtx))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package lottery contains the user-facing lottery HTTP handlers. Each handler
|
||||
// binds request params via gin, validates, delegates to the logic package,
|
||||
// and renders through pkg/result to keep the API response envelope consistent.
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// QueryLotteryConfigHandler serves GET /api/v1/lottery/config.
|
||||
func QueryLotteryConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetLotteryConfigRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := lottery.NewQueryLotteryConfigLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.QueryLotteryConfig(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// DrawLotteryHandler serves POST /api/v1/lottery/draw.
|
||||
func DrawLotteryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.DrawLotteryRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := lottery.NewDrawLotteryLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.DrawLottery(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// QueryLotteryRecordsHandler serves GET /api/v1/lottery/records.
|
||||
func QueryLotteryRecordsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetLotteryRecordsRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := lottery.NewQueryLotteryRecordsLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.QueryLotteryRecords(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ClaimLotteryPrizeHandler serves POST /api/v1/lottery/claim. Stage 1
|
||||
// always returns 4010 not_claimable.
|
||||
func ClaimLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ClaimLotteryPrizeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := lottery.NewClaimLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ClaimLotteryPrize(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func QueryUserSubscribeNodeListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx)
|
||||
l := subscribe.NewQueryUserSubscribeNodeListLogic(c.Request.Context(), svcCtx, c.GetHeader("User-Agent"))
|
||||
resp, err := l.QueryUserSubscribeNodeList()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Query Commission Return Log
|
||||
func QueryCommissionReturnLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.QueryCommissionReturnLogRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewQueryCommissionReturnLogLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.QueryCommissionReturnLog(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCommissionReturnLogHandler_HTTPResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, cleanup := newCommissionReturnHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnHTTPQueries(mock, 42, 3, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, time.Unix(1700000000, 0)).
|
||||
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, time.Unix(1700000000, 0)).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0)))
|
||||
|
||||
router := gin.New()
|
||||
svcCtx := &svc.ServiceContext{DB: db}
|
||||
router.Use(injectTestUser(42))
|
||||
router.GET("/v1/public/user/commission_return_log", QueryCommissionReturnLogHandler(svcCtx))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/user/commission_return_log?page=1&size=10", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(rec.Body.String())
|
||||
t.Logf("commission_return_log response: %s", body)
|
||||
if !strings.Contains(body, `"event_type":338`) || !strings.Contains(body, `"event_type":337`) || !strings.Contains(body, `"event_type":333`) {
|
||||
t.Fatalf("response body = %s", body)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalLogHandler_CommissionRefundHTTPResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, cleanup := newCommissionReturnHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectCommissionReturnHTTPQueries(mock, 42, 1, logmodel.CommissionTypeRefund)
|
||||
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ?) ORDER BY id DESC LIMIT ?").
|
||||
WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
|
||||
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0)))
|
||||
|
||||
router := gin.New()
|
||||
svcCtx := &svc.ServiceContext{DB: db}
|
||||
router.Use(injectTestUser(42))
|
||||
router.GET("/v1/public/user/withdrawal_log", QueryWithdrawalLogHandler(svcCtx))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/public/user/withdrawal_log?page=1&size=10&biz_type=commission_refund", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(rec.Body.String())
|
||||
t.Logf("withdrawal_log commission_refund response: %s", body)
|
||||
if !strings.Contains(body, `"biz_type":"commission_refund"`) || !strings.Contains(body, `"amount":2500`) || strings.Contains(body, `"amount":1500`) || strings.Contains(body, `"amount":2000`) {
|
||||
t.Fatalf("response body = %s", body)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newCommissionReturnHandlerTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||
if strings.Contains(actualSQL, expectedSQL) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
|
||||
return db, mock, func() {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func injectTestUser(userID int64) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), constant.CtxKeyUser, &usermodel.User{Id: userID})
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func expectCommissionReturnHTTPQueries(mock sqlmock.Sqlmock, userID int64, total int64, eventTypes ...uint16) {
|
||||
query := "SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ?"
|
||||
args := []driver.Value{logmodel.TypeCommission.Uint8(), userID}
|
||||
if len(eventTypes) > 0 {
|
||||
clauses := make([]string, 0, len(eventTypes))
|
||||
for _, eventType := range eventTypes {
|
||||
clauses = append(clauses, "`content` LIKE ?")
|
||||
args = append(args, fmt.Sprintf("%%\"type\":%d%%", eventType))
|
||||
}
|
||||
query += " AND (" + strings.Join(clauses, " OR ") + ")"
|
||||
}
|
||||
mock.ExpectQuery(query).
|
||||
WithArgs(args...).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total))
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Query Withdrawal Log
|
||||
// Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log)
|
||||
func QueryWithdrawalLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.QueryWithdrawalLogListRequest
|
||||
|
||||
@@ -1180,6 +1180,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// Verify Email
|
||||
publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx))
|
||||
|
||||
// Query Commission Return Log
|
||||
publicUserGroupRouter.GET("/commission_return_log", publicUser.QueryCommissionReturnLogHandler(serverCtx))
|
||||
|
||||
// Query Withdrawal Log
|
||||
publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx))
|
||||
}
|
||||
@@ -1218,4 +1221,7 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// Get Server Protocol Config
|
||||
serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
|
||||
}
|
||||
|
||||
// ---- Lottery (Stage 1) --------------------------------------------------
|
||||
registerLotteryRoutes(router, serverCtx)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"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/result"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
)
|
||||
|
||||
@@ -84,7 +85,7 @@ func SubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
l := subscribe.NewSubscribeLogic(c, svcCtx)
|
||||
resp, err := l.Handler(&req)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Internal Server")
|
||||
result.HttpResult(c, nil, err)
|
||||
return
|
||||
}
|
||||
c.Header("subscription-userinfo", resp.Header)
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/client"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSubscribeHandlerReturnsBusinessErrorForDisabledUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
redisServer, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Run() error = %v", err)
|
||||
}
|
||||
defer redisServer.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||
defer func() {
|
||||
_ = rdb.Close()
|
||||
}()
|
||||
if err := rdb.Set(context.Background(), logiccommon.UserEnableCacheKey(83696), "false", 0).Err(); err != nil {
|
||||
t.Fatalf("seed user enable cache: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/subscribe", SubscribeHandler(&svc.ServiceContext{
|
||||
Config: config.Config{
|
||||
Subscribe: config.SubscribeConfig{
|
||||
SubscribePath: "/api/subscribe",
|
||||
},
|
||||
},
|
||||
ClientModel: subscribeClientModelStub{
|
||||
list: []*client.SubscribeApplication{
|
||||
{
|
||||
Id: 1,
|
||||
UserAgent: "clashmeta",
|
||||
IsDefault: true,
|
||||
OutputFormat: "yaml",
|
||||
},
|
||||
},
|
||||
},
|
||||
Redis: rdb,
|
||||
UserModel: subscribeUserModelStub{
|
||||
subscribe: &user.Subscribe{Id: 35446, UserId: 83696, SubscribeId: 1, Token: "disabled-token"},
|
||||
},
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/subscribe?token=disabled-token", nil)
|
||||
req.Header.Set("User-Agent", "ClashMetaForAndroid/2.11.7.Meta")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Code uint32 `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Code != xerr.UserDisabled {
|
||||
t.Fatalf("expected code %d, got %d (%s)", xerr.UserDisabled, resp.Code, resp.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
type subscribeClientModelStub struct {
|
||||
list []*client.SubscribeApplication
|
||||
err error
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Insert(context.Context, *client.SubscribeApplication) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) FindOne(context.Context, int64) (*client.SubscribeApplication, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Update(context.Context, *client.SubscribeApplication) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Delete(context.Context, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) List(context.Context) ([]*client.SubscribeApplication, error) {
|
||||
return s.list, s.err
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Transaction(context.Context, func(*gorm.DB) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type subscribeUserModelStub struct {
|
||||
subscribe *user.Subscribe
|
||||
subErr error
|
||||
findOne *user.User
|
||||
findOneErr error
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Insert(context.Context, *user.User, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOne(context.Context, int64) (*user.User, error) {
|
||||
if s.findOneErr != nil {
|
||||
return nil, s.findOneErr
|
||||
}
|
||||
return s.findOne, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Update(context.Context, *user.User, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateCommission(context.Context, int64, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Delete(context.Context, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Transaction(context.Context, func(*gorm.DB) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryPageList(context.Context, int, int, *user.UserFilterParams) ([]*user.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneByReferCode(context.Context, string) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) BatchDeleteUser(context.Context, []int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) InsertSubscribe(context.Context, *user.Subscribe, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribeByToken(context.Context, string) (*user.Subscribe, error) {
|
||||
if s.subErr != nil {
|
||||
return nil, s.subErr
|
||||
}
|
||||
return s.subscribe, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindSingleModeAnchorSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribeByOrderId(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateSubscribe(context.Context, *user.Subscribe, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteSubscribe(context.Context, string, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteSubscribeById(context.Context, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryUserSubscribe(context.Context, int64, ...int64) ([]*user.SubscribeDetails, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribeDetailsById(context.Context, int64) (*user.SubscribeDetails, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneUserSubscribe(context.Context, int64) (*user.SubscribeDetails, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUsersSubscribeBySubscribeId(context.Context, int64) ([]*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserSubscribeWithTraffic(context.Context, int64, int64, int64, bool, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryResisterUserTotalByDate(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryResisterUserTotalByMonthly(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryResisterUserTotal(context.Context) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryAdminUsers(context.Context) ([]*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserCache(context.Context, *user.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserSubscribeCache(context.Context, *user.Subscribe) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryActiveSubscriptions(context.Context, ...int64) (map[int64]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethods(context.Context, int64) ([]*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) InsertUserAuthMethods(context.Context, *user.AuthMethods, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserAuthMethods(context.Context, *user.AuthMethods, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteUserAuthMethods(context.Context, int64, string, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethodByOpenID(context.Context, string, string) (*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethodByUserId(context.Context, string, int64) (*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethodByPlatform(context.Context, int64, string) (*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneByEmail(context.Context, string) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneDevice(context.Context, int64) (*user.Device, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDeviceList(context.Context, int64) ([]*user.Device, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDeviceListByUserIds(context.Context, []int64) ([]*user.Device, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDevicePageList(context.Context, int64, int64, int, int) ([]*user.Device, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateDevice(context.Context, *user.Device, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneDeviceByIdentifier(context.Context, string) (*user.Device, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteDevice(context.Context, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) InsertDevice(context.Context, *user.Device, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) ClearSubscribeCache(context.Context, ...*user.Subscribe) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) ClearUserCache(context.Context, ...*user.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) ClearDeviceCache(context.Context, ...*user.Device) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDailyUserStatisticsList(context.Context, time.Time) ([]user.UserStatisticsWithDate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryMonthlyUserStatisticsList(context.Context, time.Time) ([]user.UserStatisticsWithDate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindActiveSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindActiveSubscribesByUserIds(context.Context, []int64) (map[int64]*user.UserStatusInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user