Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 并填写真实值
|
# 复制此文件为 .env 并填写真实值
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
|
|
||||||
# MySQL root 密码(同时需要在 configs/ppanel.yaml 的 MySQL.Password 中填写相同的值)
|
|
||||||
MYSQL_ROOT_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
|
||||||
|
|
||||||
# Grafana 管理员密码
|
|
||||||
GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
|
||||||
|
|
||||||
# PPanel Server 镜像标签(由 CI/CD 传入不可变 tag,如 git SHA)
|
# PPanel Server 镜像标签(由 CI/CD 传入不可变 tag,如 git SHA)
|
||||||
PPANEL_SERVER_TAG=CHANGE_ME_TO_GIT_SHA
|
PPANEL_SERVER_TAG=CHANGE_ME_TO_GIT_SHA
|
||||||
|
|
||||||
# AWS 区域(香港)
|
|
||||||
AWS_REGION=ap-east-1
|
|
||||||
|
|
||||||
# Grafana 公开域名(如需反代)
|
|
||||||
GRAFANA_DOMAIN=logs-new.hifast.biz
|
|
||||||
GRAFANA_ROOT_URL=https://logs-new.hifast.biz
|
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
name: Build docker and publish
|
|
||||||
run-name: 简化的Docker构建和部署流程
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- internal
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- internal
|
|
||||||
|
|
||||||
env:
|
|
||||||
# Docker镜像仓库
|
|
||||||
REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }}
|
|
||||||
# SSH连接信息 (根据分支自动选择服务器和用户)
|
|
||||||
SSH_HOST: ${{ github.ref_name == 'main' && vars.SSH_HOST || vars.DEV_SSH_HOST }}
|
|
||||||
SSH_PORT: ${{ vars.SSH_PORT }}
|
|
||||||
SSH_USER: ${{ github.ref_name == 'main' && 'ubuntu' || 'root' }}
|
|
||||||
# SSH私钥(Gitea Secret 名称:AWS)
|
|
||||||
SSH_KEY: ${{ secrets.AWS }}
|
|
||||||
# TG通知
|
|
||||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
|
||||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
|
||||||
# Go构建变量
|
|
||||||
SERVICE: vpn
|
|
||||||
SERVICE_STYLE: vpn
|
|
||||||
VERSION: ${{ github.sha }}
|
|
||||||
BUILDTIME: ${{ github.event.head_commit.timestamp }}
|
|
||||||
GOARCH: amd64
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ario-server
|
|
||||||
container:
|
|
||||||
image: node:20
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
# 只有node支持版本号别名
|
|
||||||
node: ['20.15.1']
|
|
||||||
steps:
|
|
||||||
# 步骤1: 下载代码
|
|
||||||
- name: 📥 下载代码
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# 步骤2: 设置动态环境变量
|
|
||||||
- name: ⚙️ 设置动态环境变量
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
|
||||||
echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV
|
|
||||||
echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV
|
|
||||||
echo "DEPLOY_PATH=/opt/ppanel" >> $GITHUB_ENV
|
|
||||||
echo "DEPLOY_ENV_LABEL=🚀 服务已成功部署到生产环境" >> $GITHUB_ENV
|
|
||||||
echo "为 main 分支设置生产环境变量"
|
|
||||||
elif [ "${{ github.ref_name }}" = "internal" ]; then
|
|
||||||
echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV
|
|
||||||
echo "CONTAINER_NAME=ppanel-server-internal" >> $GITHUB_ENV
|
|
||||||
echo "DEPLOY_PATH=/root/bindbox" >> $GITHUB_ENV
|
|
||||||
echo "DEPLOY_ENV_LABEL=🧪 服务已成功部署到测试环境" >> $GITHUB_ENV
|
|
||||||
echo "为 internal 分支设置开发环境变量"
|
|
||||||
else
|
|
||||||
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
|
|
||||||
echo "CONTAINER_NAME=ppanel-server-${{ github.ref_name }}" >> $GITHUB_ENV
|
|
||||||
echo "DEPLOY_PATH=/root/vpn_server_other" >> $GITHUB_ENV
|
|
||||||
echo "DEPLOY_ENV_LABEL=🔧 服务已成功部署到其他环境" >> $GITHUB_ENV
|
|
||||||
echo "为其他分支 (${{ github.ref_name }}) 设置环境变量"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 步骤3: 安装系统工具 (curl, jq) 并升级 Docker CLI 到 1.44+
|
|
||||||
- name: 🔧 安装系统工具并升级 Docker CLI
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
echo "等待 apt/dpkg 锁释放 (unattended-upgrades)..."
|
|
||||||
end=$((SECONDS+300))
|
|
||||||
while true; do
|
|
||||||
LOCKS_BUSY=0
|
|
||||||
if pgrep -x unattended-upgrades >/dev/null 2>&1; then LOCKS_BUSY=1; fi
|
|
||||||
if command -v fuser >/dev/null 2>&1; then
|
|
||||||
if fuser /var/lib/dpkg/lock >/dev/null 2>&1 \
|
|
||||||
|| fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
|
|
||||||
|| fuser /var/lib/apt/lists/lock >/dev/null 2>&1; then
|
|
||||||
LOCKS_BUSY=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [ "$LOCKS_BUSY" -eq 0 ]; then break; fi
|
|
||||||
if [ $SECONDS -ge $end ]; then
|
|
||||||
echo "等待 apt/dpkg 锁超时,使用 Dpkg::Lock::Timeout 继续..."
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "仍在等待锁释放..."; sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
# 基础工具
|
|
||||||
apt-get update -y -o Dpkg::Lock::Timeout=600
|
|
||||||
apt-get install -y -o Dpkg::Lock::Timeout=600 jq curl ca-certificates gnupg lsb-release
|
|
||||||
|
|
||||||
# 移除旧版 docker.io,避免客户端过旧 (API 1.41)
|
|
||||||
if dpkg -s docker.io >/dev/null 2>&1; then
|
|
||||||
apt-get remove -y docker.io || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 安装 Docker 官方仓库的 CLI (确保 API >= 1.44)
|
|
||||||
distro_codename=$(. /etc/os-release && echo "$VERSION_CODENAME")
|
|
||||||
install_repo="deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${distro_codename} stable"
|
|
||||||
mkdir -p /etc/apt/keyrings
|
|
||||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
|
||||||
echo "$install_repo" > /etc/apt/sources.list.d/docker.list
|
|
||||||
apt-get update -y -o Dpkg::Lock::Timeout=600
|
|
||||||
apt-get install -y -o Dpkg::Lock::Timeout=600 docker-ce-cli docker-buildx-plugin
|
|
||||||
|
|
||||||
# 版本检查
|
|
||||||
docker --version || true
|
|
||||||
docker version || true
|
|
||||||
echo "客户端 API 版本:" $(docker version --format '{{.Client.APIVersion}}')
|
|
||||||
|
|
||||||
# 步骤4: 构建镜像
|
|
||||||
- name: 🏗️ 构建镜像
|
|
||||||
run: |
|
|
||||||
echo "开始构建镜像..."
|
|
||||||
echo "仓库: ${{ env.REPO }}"
|
|
||||||
echo "版本标签: ${{ env.VERSION }}"
|
|
||||||
echo "分支标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
|
||||||
|
|
||||||
BUILD_TAG_ARGS="-t ${{ env.REPO }}:${{ env.VERSION }}"
|
|
||||||
if [ "${{ github.event_name }}" = "push" ]; then
|
|
||||||
BUILD_TAG_ARGS="$BUILD_TAG_ARGS -t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
|
|
||||||
else
|
|
||||||
echo "PR事件仅构建版本标签,不推送镜像、不部署"
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker build -f Dockerfile \
|
|
||||||
--platform linux/amd64 \
|
|
||||||
--build-arg TARGETARCH=amd64 \
|
|
||||||
--build-arg VERSION=${{ env.VERSION }} \
|
|
||||||
--build-arg BUILDTIME=${{ env.BUILDTIME }} \
|
|
||||||
$BUILD_TAG_ARGS \
|
|
||||||
.
|
|
||||||
|
|
||||||
echo "镜像构建完成"
|
|
||||||
|
|
||||||
# 步骤5: 发布到镜像仓库
|
|
||||||
- name: 📤 发布到镜像仓库
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
run: |
|
|
||||||
echo "开始推送镜像..."
|
|
||||||
|
|
||||||
echo "推送版本标签镜像: ${{ env.REPO }}:${{ env.VERSION }}"
|
|
||||||
docker push ${{ env.REPO }}:${{ env.VERSION }}
|
|
||||||
|
|
||||||
echo "推送分支标签镜像: ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
|
|
||||||
docker push ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}
|
|
||||||
|
|
||||||
echo "镜像推送完成"
|
|
||||||
|
|
||||||
# 步骤6: 调试 - 打印部署目标(不输出敏感信息)
|
|
||||||
- name: 🔍 调试 - 打印部署目标
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
run: |
|
|
||||||
echo "========== 部署目标调试 =========="
|
|
||||||
echo "当前分支: ${{ github.ref_name }}"
|
|
||||||
echo "SSH_HOST: ${{ env.SSH_HOST }}"
|
|
||||||
echo "SSH_PORT: ${{ env.SSH_PORT }}"
|
|
||||||
echo "SSH_USER: ${{ env.SSH_USER }}"
|
|
||||||
echo "SSH认证方式: 私钥 (AWS)"
|
|
||||||
echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}"
|
|
||||||
echo "====================================="
|
|
||||||
|
|
||||||
# 步骤7: 传输配置文件
|
|
||||||
- name: 📂 传输配置文件
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: appleboy/scp-action@v0.1.7
|
|
||||||
with:
|
|
||||||
host: ${{ env.SSH_HOST }}
|
|
||||||
username: ${{ env.SSH_USER }}
|
|
||||||
key: ${{ env.SSH_KEY }}
|
|
||||||
port: ${{ env.SSH_PORT }}
|
|
||||||
source: "docker-compose.cloud.yml"
|
|
||||||
target: "/tmp/ppanel-deploy/"
|
|
||||||
|
|
||||||
# 步骤8: 连接服务器更新、健康检查并按需回滚
|
|
||||||
- name: 🚀 连接服务器更新并启动
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ env.SSH_HOST }}
|
|
||||||
username: ${{ env.SSH_USER }}
|
|
||||||
key: ${{ env.SSH_KEY }}
|
|
||||||
port: ${{ env.SSH_PORT }}
|
|
||||||
timeout: 300s
|
|
||||||
command_timeout: 600s
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "连接服务器成功,开始部署..."
|
|
||||||
echo "部署目录: ${{ env.DEPLOY_PATH }}"
|
|
||||||
echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
|
||||||
echo "登录用户: ${{ env.SSH_USER }}"
|
|
||||||
|
|
||||||
HEALTHCHECK_URL="http://127.0.0.1:8080/v1/common/heartbeat"
|
|
||||||
NEW_TAG="${{ env.DOCKER_TAG_SUFFIX }}"
|
|
||||||
ROLLBACK_TAG="rollback-${{ env.VERSION }}"
|
|
||||||
SUDO=""
|
|
||||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
|
||||||
SUDO="sudo"
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker_cmd() {
|
|
||||||
if [ -n "$SUDO" ]; then
|
|
||||||
sudo docker "$@"
|
|
||||||
else
|
|
||||||
docker "$@"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
compose_with_tag() {
|
|
||||||
tag="$1"
|
|
||||||
shift
|
|
||||||
if [ -n "$SUDO" ]; then
|
|
||||||
sudo env PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
|
|
||||||
else
|
|
||||||
PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
write_previous_tag() {
|
|
||||||
if [ -n "$SUDO" ]; then
|
|
||||||
printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" | sudo tee .previous-ppanel-image-tag >/dev/null
|
|
||||||
else
|
|
||||||
printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" > .previous-ppanel-image-tag
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
health_check() {
|
|
||||||
attempt=1
|
|
||||||
while [ "$attempt" -le 3 ]; do
|
|
||||||
if curl -sf "$HEALTHCHECK_URL"; then
|
|
||||||
echo
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "健康检查第 ${attempt}/3 次失败,10s 后重试..."
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
|
||||||
sudo mkdir -p ${{ env.DEPLOY_PATH }}
|
|
||||||
sudo cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
|
|
||||||
else
|
|
||||||
mkdir -p ${{ env.DEPLOY_PATH }}
|
|
||||||
cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd ${{ env.DEPLOY_PATH }}
|
|
||||||
|
|
||||||
PREVIOUS_IMAGE_TAG="$(docker_cmd inspect --format '{{.Config.Image}}' ppanel-server 2>/dev/null || true)"
|
|
||||||
PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)"
|
|
||||||
echo "上一版本镜像tag: ${PREVIOUS_IMAGE_TAG:-未发现}"
|
|
||||||
echo "上一版本镜像ID: ${PREVIOUS_IMAGE_ID:-未发现}"
|
|
||||||
write_previous_tag
|
|
||||||
|
|
||||||
echo "📥 拉取镜像: ${{ env.REPO }}:${NEW_TAG}"
|
|
||||||
compose_with_tag "$NEW_TAG" pull ppanel-server
|
|
||||||
echo "🚀 启动服务..."
|
|
||||||
compose_with_tag "$NEW_TAG" up -d ppanel-server
|
|
||||||
|
|
||||||
echo "🩺 部署后健康检查: ${HEALTHCHECK_URL}"
|
|
||||||
if health_check; then
|
|
||||||
docker_cmd image prune -f || true
|
|
||||||
echo "✅ 部署后健康检查通过"
|
|
||||||
echo "✅ 部署命令执行完成"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "❌ 部署后健康检查连续 3 次失败,开始回滚..."
|
|
||||||
if [ -n "$PREVIOUS_IMAGE_ID" ]; then
|
|
||||||
docker_cmd tag "$PREVIOUS_IMAGE_ID" "${{ env.REPO }}:${ROLLBACK_TAG}"
|
|
||||||
echo "回滚镜像tag: ${{ env.REPO }}:${ROLLBACK_TAG}"
|
|
||||||
compose_with_tag "$ROLLBACK_TAG" up -d ppanel-server
|
|
||||||
|
|
||||||
echo "🩺 回滚后健康检查: ${HEALTHCHECK_URL}"
|
|
||||||
if health_check; then
|
|
||||||
echo "✅ 回滚后健康检查通过"
|
|
||||||
else
|
|
||||||
echo "❌ 回滚后健康检查仍失败"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "未找到上一版本镜像ID,无法自动回滚"
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker_cmd image prune -f || true
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
# 步骤9: TG通知 (成功)
|
|
||||||
- name: 📱 发送成功通知到Telegram
|
|
||||||
if: success() && github.event_name == 'push'
|
|
||||||
uses: appleboy/telegram-action@master
|
|
||||||
with:
|
|
||||||
token: ${{ env.TG_BOT_TOKEN }}
|
|
||||||
to: ${{ env.TG_CHAT_ID }}
|
|
||||||
message: |
|
|
||||||
✅ 部署成功!
|
|
||||||
|
|
||||||
📦 项目: ${{ github.repository }}
|
|
||||||
🌿 分支: ${{ github.ref_name }}
|
|
||||||
📝 提交: ${{ github.sha }}
|
|
||||||
👤 提交者: ${{ github.actor }}
|
|
||||||
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
|
||||||
|
|
||||||
${{ env.DEPLOY_ENV_LABEL }}
|
|
||||||
🩺 健康检查: 通过 (http://127.0.0.1:8080/v1/common/heartbeat)
|
|
||||||
parse_mode: Markdown
|
|
||||||
|
|
||||||
# 步骤10: TG通知 (失败)
|
|
||||||
- name: 📱 发送失败通知到Telegram
|
|
||||||
if: failure() && github.event_name == 'push'
|
|
||||||
uses: appleboy/telegram-action@master
|
|
||||||
with:
|
|
||||||
token: ${{ env.TG_BOT_TOKEN }}
|
|
||||||
to: ${{ env.TG_CHAT_ID }}
|
|
||||||
message: |
|
|
||||||
❌ 部署失败!
|
|
||||||
|
|
||||||
📦 项目: ${{ github.repository }}
|
|
||||||
🌿 分支: ${{ github.ref_name }}
|
|
||||||
📝 提交: ${{ github.sha }}
|
|
||||||
👤 提交者: ${{ github.actor }}
|
|
||||||
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
|
||||||
|
|
||||||
🩺 健康检查: 失败或未完成;若新版本健康检查连续 3 次失败,已自动尝试回滚并重新检查
|
|
||||||
⚠️ 请检查构建日志获取详细信息
|
|
||||||
parse_mode: Markdown
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Code owners — 自动 request review
|
||||||
|
#
|
||||||
|
# 仓库私有 + 当前 plan 不支持 branch protection(详见 doc/development-workflow-zh.md
|
||||||
|
# 「平台层约束的现状」一节),CODEOWNERS 在此用作"自动 request review + 显性责任划分",
|
||||||
|
# 而非强制门禁。
|
||||||
|
#
|
||||||
|
# 任何 PR 默认 request 给 @shanshanzhong147 (owner) review。若后续引入团队
|
||||||
|
# handle(例如 @TawCorp/backend),把对应 path 改成 team handle 即可。
|
||||||
|
|
||||||
|
# 全部路径 — owner 默认 reviewer
|
||||||
|
* @shanshanzhong147
|
||||||
|
|
||||||
|
# 部署/CI/Docker — 改这些要再确认一次(涉及生产部署链路)
|
||||||
|
/.github/ @shanshanzhong147
|
||||||
|
/Dockerfile @shanshanzhong147
|
||||||
|
/docker-compose.*.yml @shanshanzhong147
|
||||||
|
/scripts/ @shanshanzhong147
|
||||||
|
/Makefile @shanshanzhong147
|
||||||
|
|
||||||
|
# 流程文档自身 — 改这里就是改流程
|
||||||
|
/CONTRIBUTING.md @shanshanzhong147
|
||||||
|
/CONTRIBUTING_ZH.md @shanshanzhong147
|
||||||
|
/doc/development-workflow-zh.md @shanshanzhong147
|
||||||
|
/.github/CODEOWNERS @shanshanzhong147
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!--
|
||||||
|
完整流程见 doc/development-workflow-zh.md
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 关联 Issue
|
||||||
|
|
||||||
|
Closes HIF-XXX
|
||||||
|
<!-- 如关联多个:Closes HIF-XXX, Closes HIF-YYY -->
|
||||||
|
|
||||||
|
## 改动摘要
|
||||||
|
|
||||||
|
<!-- 1-3 句话说清楚做了什么、为什么 -->
|
||||||
|
|
||||||
|
## 改动细节
|
||||||
|
|
||||||
|
<!-- 按文件/模块逐条列;引用代码用 `file.go:行号` 格式 -->
|
||||||
|
|
||||||
|
-
|
||||||
|
-
|
||||||
|
|
||||||
|
## 测试计划
|
||||||
|
|
||||||
|
- [ ] `go build ./...` 通过
|
||||||
|
- [ ] `go vet ./...` 通过
|
||||||
|
- [ ] `go test -race ./... -count=1` 通过
|
||||||
|
- [ ] golangci-lint 通过
|
||||||
|
- [ ] 新增/修改的逻辑有对应单测覆盖
|
||||||
|
- [ ] (如涉及 DB 变更)migration up/down 双向验证
|
||||||
|
- [ ] (如涉及 API)curl / Postman 验证命令贴在下面
|
||||||
|
|
||||||
|
<!-- 贴 curl 或测试输出 -->
|
||||||
|
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
## 风险 / 回滚
|
||||||
|
|
||||||
|
<!-- 这次改动失败时怎么回滚;是否影响线上数据;是否需要 feature flag -->
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Reviewer 自检清单
|
||||||
|
|
||||||
|
- [ ] PR 标题符合 commitlint 规范(`修复/新功能/重构/文档/配置(#<num>): ...`)
|
||||||
|
- [ ] 分支命名 `fix/<num>-…` / `feat/<num>-…` / `chore/…`
|
||||||
|
- [ ] 目标分支 = `internal`
|
||||||
|
- [ ] 改动 scope 与 Issue 描述一致,无 scope creep
|
||||||
|
- [ ] **无无关代码改动**(架构师红线)
|
||||||
|
- [ ] 无密钥/凭证泄露
|
||||||
|
- [ ] CI 全绿
|
||||||
|
- [ ] 测试工程师已验收(如涉及业务逻辑)
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Production Environment Configuration for GitHub Actions
|
|
||||||
# This file defines production-specific deployment settings
|
|
||||||
|
|
||||||
environment:
|
|
||||||
name: production
|
|
||||||
url: https://api.ppanel.example.com
|
|
||||||
protection_rules:
|
|
||||||
- type: wait_timer
|
|
||||||
minutes: 5
|
|
||||||
- type: reviewers
|
|
||||||
reviewers:
|
|
||||||
- "@admin-team"
|
|
||||||
- "@devops-team"
|
|
||||||
variables:
|
|
||||||
ENVIRONMENT: production
|
|
||||||
LOG_LEVEL: info
|
|
||||||
DEPLOY_TIMEOUT: 300
|
|
||||||
|
|
||||||
# Environment-specific secrets required:
|
|
||||||
# PRODUCTION_HOST - Production server hostname/IP
|
|
||||||
# PRODUCTION_USER - SSH username for production server
|
|
||||||
# PRODUCTION_SSH_KEY - SSH private key for production server
|
|
||||||
# PRODUCTION_PORT - SSH port (default: 22)
|
|
||||||
# PRODUCTION_URL - Application URL for health checks
|
|
||||||
# DATABASE_PASSWORD - Production database password
|
|
||||||
# REDIS_PASSWORD - Production Redis password
|
|
||||||
# JWT_SECRET - JWT secret key for production
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Staging Environment Configuration for GitHub Actions
|
|
||||||
# This file defines staging-specific deployment settings
|
|
||||||
|
|
||||||
environment:
|
|
||||||
name: staging
|
|
||||||
url: https://staging-api.ppanel.example.com
|
|
||||||
protection_rules:
|
|
||||||
- type: wait_timer
|
|
||||||
minutes: 2
|
|
||||||
variables:
|
|
||||||
ENVIRONMENT: staging
|
|
||||||
LOG_LEVEL: debug
|
|
||||||
DEPLOY_TIMEOUT: 180
|
|
||||||
|
|
||||||
# Environment-specific secrets required:
|
|
||||||
# STAGING_HOST - Staging server hostname/IP
|
|
||||||
# STAGING_USER - SSH username for staging server
|
|
||||||
# STAGING_SSH_KEY - SSH private key for staging server
|
|
||||||
# STAGING_PORT - SSH port (default: 22)
|
|
||||||
# STAGING_URL - Application URL for health checks
|
|
||||||
# DATABASE_PASSWORD - Staging database password
|
|
||||||
# REDIS_PASSWORD - Staging Redis password
|
|
||||||
# JWT_SECRET - JWT secret key for staging
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
name: 持续集成
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- internal
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
name: 构建/Vet/测试
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: 配置 Go 环境
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: 下载依赖模块
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: 构建
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
|
- name: 运行 go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: 运行测试
|
||||||
|
run: go test -race -count=1 ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: golangci-lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# Fetch base ref so golangci-lint can diff against it for only-new-issues.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 配置 Go 环境
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v9
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: --timeout=5m
|
||||||
|
# Legacy codebase has ~47 pre-existing lint issues (errcheck / unused
|
||||||
|
# carried over from upstream perfect-panel/server). Only flag NEW
|
||||||
|
# issues introduced by this PR so CI stays useful without forcing a
|
||||||
|
# mass cleanup. Backlog cleanup tracked separately.
|
||||||
|
only-new-issues: true
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
name: Build Linux Binary
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, master ]
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Version to build (leave empty for auto)'
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build Linux Binary
|
|
||||||
runs-on: ario-server
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.23.3'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 0
|
|
||||||
GOOS: linux
|
|
||||||
GOARCH: amd64
|
|
||||||
run: |
|
|
||||||
VERSION=${{ github.event.inputs.version }}
|
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
VERSION=$(git describe --tags --always --dirty)
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Building ppanel-server $VERSION"
|
|
||||||
BUILD_TIME=$(date +"%Y-%m-%d_%H:%M:%S")
|
|
||||||
go build -ldflags="-w -s -X github.com/perfect-panel/server/pkg/constant.Version=$VERSION -X github.com/perfect-panel/server/pkg/constant.BuildTime=$BUILD_TIME" -o ppanel-server ./ppanel.go
|
|
||||||
tar -czf ppanel-server-${VERSION}-linux-amd64.tar.gz ppanel-server
|
|
||||||
sha256sum ppanel-server ppanel-server-${VERSION}-linux-amd64.tar.gz > checksum.txt
|
|
||||||
|
|
||||||
- name: Upload artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ppanel-server-linux-amd64
|
|
||||||
path: |
|
|
||||||
ppanel-server
|
|
||||||
ppanel-server-*-linux-amd64.tar.gz
|
|
||||||
checksum.txt
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
VERSION=${GITHUB_REF#refs/tags/}
|
|
||||||
|
|
||||||
# Check if release exists
|
|
||||||
if gh release view $VERSION >/dev/null 2>&1; then
|
|
||||||
echo "Release $VERSION already exists, deleting old assets..."
|
|
||||||
# Delete existing assets if they exist
|
|
||||||
gh release delete-asset $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz --yes 2>/dev/null || true
|
|
||||||
gh release delete-asset $VERSION checksum.txt --yes 2>/dev/null || true
|
|
||||||
else
|
|
||||||
echo "Creating new release $VERSION..."
|
|
||||||
gh release create $VERSION --title "PPanel Server $VERSION" --notes "Release $VERSION"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Upload assets (will overwrite if --clobber is supported, otherwise will fail gracefully)
|
|
||||||
echo "Uploading assets..."
|
|
||||||
gh release upload $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz checksum.txt --clobber
|
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
name: 测试环境部署
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- internal
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-staging-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY_HOST: ${{ vars.REGISTRY_HOST || 'registry.kxsw.us' }}
|
||||||
|
IMAGE_NAME: ${{ vars.REGISTRY_IMAGE || 'registry.kxsw.us/vpn-server' }}
|
||||||
|
STAGING_HOST: ${{ vars.STAGING_HOST || '154.12.35.103' }}
|
||||||
|
STAGING_DEPLOY_PATH: ${{ vars.STAGING_DEPLOY_PATH || '/opt/hifast-server' }}
|
||||||
|
STAGING_HEALTHCHECK_URL: ${{ vars.STAGING_HEALTHCHECK_URL || 'http://127.0.0.1:8080/v1/common/heartbeat' }}
|
||||||
|
# 关闭 docker/build-push-action 自动生成的英文 job summary
|
||||||
|
# 我们用 Telegram 通知传递部署结果,不需要 GitHub Actions 页面上那段英文
|
||||||
|
DOCKER_BUILD_SUMMARY: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
name: 构建镜像并部署到测试环境
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 收集发布说明
|
||||||
|
id: release-notes
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
|
||||||
|
RANGE="${{ github.event.before }}..${{ github.sha }}"
|
||||||
|
else
|
||||||
|
RANGE="-5"
|
||||||
|
fi
|
||||||
|
|
||||||
|
NOTES="$(git log "$RANGE" --pretty=format:'- %h %s' --no-merges | head -20)"
|
||||||
|
if [ -z "$NOTES" ]; then
|
||||||
|
NOTES="$(git log -1 --pretty=format:'- %h %s')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "notes<<EOF"
|
||||||
|
echo "$NOTES"
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: 配置 Go 环境
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: 运行测试
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: 配置 Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: 构建并推送镜像
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
TARGETARCH=amd64
|
||||||
|
VERSION=${{ github.sha }}
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
${{ env.IMAGE_NAME }}:staging
|
||||||
|
|
||||||
|
- name: 上传 compose 配置
|
||||||
|
uses: appleboy/scp-action@v1.0.0
|
||||||
|
with:
|
||||||
|
host: ${{ env.STAGING_HOST }}
|
||||||
|
username: ${{ secrets.STAGING_SSH_USER }}
|
||||||
|
password: ${{ secrets.STAGING_SSH_PASSWORD }}
|
||||||
|
port: ${{ secrets.STAGING_SSH_PORT || 22 }}
|
||||||
|
source: docker-compose.cloud.yml
|
||||||
|
target: /tmp/hifast-server-deploy/
|
||||||
|
|
||||||
|
- name: 在测试服务器上部署
|
||||||
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
|
with:
|
||||||
|
host: ${{ env.STAGING_HOST }}
|
||||||
|
username: ${{ secrets.STAGING_SSH_USER }}
|
||||||
|
password: ${{ secrets.STAGING_SSH_PASSWORD }}
|
||||||
|
port: ${{ secrets.STAGING_SSH_PORT || 22 }}
|
||||||
|
timeout: 300s
|
||||||
|
command_timeout: 600s
|
||||||
|
script: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE_NAME="${{ env.IMAGE_NAME }}"
|
||||||
|
NEW_TAG="${{ github.sha }}"
|
||||||
|
DEPLOY_PATH="${{ env.STAGING_DEPLOY_PATH }}"
|
||||||
|
HEALTHCHECK_URL="${{ env.STAGING_HEALTHCHECK_URL }}"
|
||||||
|
ROLLBACK_TAG="rollback-${NEW_TAG}"
|
||||||
|
|
||||||
|
if command -v sudo >/dev/null 2>&1 && ! docker ps >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
else
|
||||||
|
SUDO=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker_cmd() {
|
||||||
|
if [ -n "$SUDO" ]; then
|
||||||
|
sudo docker "$@"
|
||||||
|
else
|
||||||
|
docker "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_cmd() {
|
||||||
|
tag="$1"
|
||||||
|
shift
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
if [ -n "$SUDO" ]; then
|
||||||
|
sudo env PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker compose -f docker-compose.cloud.yml "$@"
|
||||||
|
else
|
||||||
|
PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker compose -f docker-compose.cloud.yml "$@"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ -n "$SUDO" ]; then
|
||||||
|
sudo env PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
|
||||||
|
else
|
||||||
|
PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
health_check() {
|
||||||
|
attempt=1
|
||||||
|
while [ "$attempt" -le 6 ]; do
|
||||||
|
if curl -fsS "$HEALTHCHECK_URL"; then
|
||||||
|
echo
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Health check ${attempt}/6 failed; retrying in 10s..."
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "$SUDO" ]; then
|
||||||
|
sudo mkdir -p "$DEPLOY_PATH"
|
||||||
|
sudo cp /tmp/hifast-server-deploy/docker-compose.cloud.yml "$DEPLOY_PATH/docker-compose.cloud.yml"
|
||||||
|
else
|
||||||
|
mkdir -p "$DEPLOY_PATH"
|
||||||
|
cp /tmp/hifast-server-deploy/docker-compose.cloud.yml "$DEPLOY_PATH/docker-compose.cloud.yml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
|
||||||
|
PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)"
|
||||||
|
echo "Previous image ID: ${PREVIOUS_IMAGE_ID:-none}"
|
||||||
|
|
||||||
|
echo "Pulling ${IMAGE_NAME}:${NEW_TAG}"
|
||||||
|
compose_cmd "$NEW_TAG" pull ppanel-server
|
||||||
|
|
||||||
|
echo "Starting ppanel-server"
|
||||||
|
compose_cmd "$NEW_TAG" up -d ppanel-server
|
||||||
|
|
||||||
|
echo "Checking ${HEALTHCHECK_URL}"
|
||||||
|
if health_check; then
|
||||||
|
docker_cmd image prune -f || true
|
||||||
|
echo "Staging deployment succeeded"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Health check failed; attempting rollback"
|
||||||
|
if [ -n "$PREVIOUS_IMAGE_ID" ]; then
|
||||||
|
docker_cmd tag "$PREVIOUS_IMAGE_ID" "${IMAGE_NAME}:${ROLLBACK_TAG}"
|
||||||
|
compose_cmd "$ROLLBACK_TAG" up -d ppanel-server
|
||||||
|
health_check || true
|
||||||
|
else
|
||||||
|
echo "No previous image found; rollback skipped"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker_cmd image prune -f || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Telegram 成功通知
|
||||||
|
if: success()
|
||||||
|
env:
|
||||||
|
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||||
|
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||||
|
run: |
|
||||||
|
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||||
|
echo "Telegram 密钥未配置,跳过通知。"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
MESSAGE="$(cat <<'EOF'
|
||||||
|
✅ 测试环境发布成功
|
||||||
|
仓库:${{ github.repository }}
|
||||||
|
分支:${{ github.ref_name }}
|
||||||
|
提交:${{ github.sha }}
|
||||||
|
操作人:${{ github.actor }}
|
||||||
|
镜像:${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
本次更新:
|
||||||
|
${{ steps.release-notes.outputs.notes }}
|
||||||
|
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
|
||||||
|
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||||
|
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,但发布结果不受影响。"
|
||||||
|
|
||||||
|
- name: Telegram 失败通知
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||||
|
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||||
|
run: |
|
||||||
|
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||||
|
echo "Telegram 密钥未配置,跳过通知。"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
MESSAGE="$(cat <<'EOF'
|
||||||
|
❌ 测试环境发布失败
|
||||||
|
仓库:${{ github.repository }}
|
||||||
|
分支:${{ github.ref_name }}
|
||||||
|
提交:${{ github.sha }}
|
||||||
|
操作人:${{ github.actor }}
|
||||||
|
镜像:${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
本次更新:
|
||||||
|
${{ steps.release-notes.outputs.notes }}
|
||||||
|
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
|
||||||
|
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||||
|
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,工作流失败状态已记录。"
|
||||||
@@ -0,0 +1,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
|
*.crt
|
||||||
*.key
|
*.key
|
||||||
*.pem
|
*.pem
|
||||||
|
*.pub
|
||||||
|
*id_rsa*
|
||||||
|
*id_ed25519*
|
||||||
|
*_bak
|
||||||
|
*.go_bak
|
||||||
|
deploy/**/keys/
|
||||||
|
deliverables/
|
||||||
|
|
||||||
# ==================== 日志 ====================
|
# ==================== 日志 ====================
|
||||||
*.log
|
*.log
|
||||||
@@ -35,6 +42,8 @@ logs/
|
|||||||
# ==================== 测试 ====================
|
# ==================== 测试 ====================
|
||||||
/test/
|
/test/
|
||||||
*_test.go
|
*_test.go
|
||||||
|
!tests/acceptance/*_test.go
|
||||||
|
!internal/handler/subscribe_test.go
|
||||||
*_test_config.go
|
*_test_config.go
|
||||||
**/logtest/
|
**/logtest/
|
||||||
*_test.yaml
|
*_test.yaml
|
||||||
@@ -70,6 +79,7 @@ script/*.sh
|
|||||||
|
|
||||||
# Codex local configuration
|
# Codex local configuration
|
||||||
.codex/
|
.codex/
|
||||||
|
.codex-tmp/
|
||||||
|
|
||||||
# Claude Flow runtime data
|
# Claude Flow runtime data
|
||||||
.claude-flow/data/
|
.claude-flow/data/
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
project_name: ppanel
|
|
||||||
version: 1
|
|
||||||
release:
|
|
||||||
prerelease: auto
|
|
||||||
builds:
|
|
||||||
- # If true, skip the build.
|
|
||||||
# Useful for library projects.
|
|
||||||
# Default is false
|
|
||||||
skip: true
|
|
||||||
|
|
||||||
changelog:
|
|
||||||
# Set it to true if you wish to skip the changelog generation.
|
|
||||||
# This may result in an empty release notes on GitHub/GitLab/Gitea.
|
|
||||||
disable: false
|
|
||||||
|
|
||||||
# Changelog generation implementation to use.
|
|
||||||
#
|
|
||||||
# Valid options are:
|
|
||||||
# - `git`: uses `git log`;
|
|
||||||
# - `github`: uses the compare GitHub API, appending the author login to the changelog.
|
|
||||||
# - `gitlab`: uses the compare GitLab API, appending the author name and email to the changelog.
|
|
||||||
# - `github-native`: uses the GitHub release notes generation API, disables the groups feature.
|
|
||||||
#
|
|
||||||
# Defaults to `git`.
|
|
||||||
use: github
|
|
||||||
|
|
||||||
# Sorts the changelog by the commit's messages.
|
|
||||||
# Could either be asc, desc or empty
|
|
||||||
# Default is empty
|
|
||||||
sort: asc
|
|
||||||
|
|
||||||
# Format to use for commit formatting.
|
|
||||||
# Only available when use is one of `github`, `gitea`, or `gitlab`.
|
|
||||||
#
|
|
||||||
# Default: '{{ .SHA }}: {{ .Message }} ({{ with .AuthorUsername }}@{{ . }}{{ else }}{{ .AuthorName }} <{{ .AuthorEmail }}>{{ end }})'.
|
|
||||||
# Extra template fields: `SHA`, `Message`, `AuthorName`, `AuthorEmail`, and
|
|
||||||
# `AuthorUsername`.
|
|
||||||
format: "{{ .Message }}"
|
|
||||||
|
|
||||||
# Group commits messages by given regex and title.
|
|
||||||
# Order value defines the order of the groups.
|
|
||||||
# Proving no regex means all commits will be grouped under the default group.
|
|
||||||
# Groups are disabled when using github-native, as it already groups things by itself.
|
|
||||||
#
|
|
||||||
# Default is no groups.
|
|
||||||
groups:
|
|
||||||
- title: "✨ Features"
|
|
||||||
regexp: "^.*feat[(\\w)]*:+.*$"
|
|
||||||
order: 0
|
|
||||||
- title: "🐛 Bug Fixes"
|
|
||||||
regexp: "^.*fix[(\\w)]*:+.*$"
|
|
||||||
order: 1
|
|
||||||
- title: "🎫 Chores"
|
|
||||||
regexp: "^.*chore[(\\w)]*:+.*$"
|
|
||||||
order: 2
|
|
||||||
- title: "🔨 Refactor"
|
|
||||||
regexp: "^.*refactor[(\\w)]*:+.*$"
|
|
||||||
order: 3
|
|
||||||
- title: "🔧 Build"
|
|
||||||
regexp: "^.*?(ci)(\\(.+\\))??!?:.+$"
|
|
||||||
order: 4
|
|
||||||
- title: "📝 Documentation"
|
|
||||||
regexp: "^.*?docs?(\\(.+\\))??!?:.+$"
|
|
||||||
order: 5
|
|
||||||
- title: "✨ Others"
|
|
||||||
order: 999
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Pull Request Submission Guidelines
|
# Pull Request Submission Guidelines
|
||||||
|
|
||||||
|
> **TawCorp internal contributors**: read [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md) first. It documents the canonical end-to-end flow (Multica issue → branch → PR → CI → review → squash merge via GitHub UI → Deploy Staging → QA), agent role boundaries, branch / commit conventions, and the soft-constraint model used in place of branch protection. The guidelines below apply to all contributors (internal and external) as the baseline.
|
||||||
|
|
||||||
To ensure the quality of the codebase and maintainability of the project, please follow these guidelines before submitting a Pull Request (PR):
|
To ensure the quality of the codebase and maintainability of the project, please follow these guidelines before submitting a Pull Request (PR):
|
||||||
|
|
||||||
## 1. PR Title and Description
|
## 1. PR Title and Description
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Pull Request 提交须知
|
# Pull Request 提交须知
|
||||||
|
|
||||||
|
> **TawCorp 内部协作者**:请先阅读 [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md)。该文档定义了从 Multica issue 到 PR 合并到 Deploy Staging 到 QA 验收的端到端流程,以及 agent 角色边界、分支/commit 约定、和不依赖 GitHub branch protection 的软约束模型。下面的通用指南仍然适用,但内部协作以 `doc/development-workflow-zh.md` 为准。
|
||||||
|
|
||||||
为了确保代码库的质量和项目的可维护性,在提交 Pull Request(PR)之前,请务必遵循以下准则:
|
为了确保代码库的质量和项目的可维护性,在提交 Pull Request(PR)之前,请务必遵循以下准则:
|
||||||
|
|
||||||
## 1. PR 标题和描述
|
## 1. PR 标题和描述
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
<!--
|
||||||
|
TawCorp internal fork header. Upstream PPanel content begins below.
|
||||||
|
Do not remove this header without updating Multica workspace context and doc/development-workflow-zh.md.
|
||||||
|
-->
|
||||||
|
|
||||||
|
> ### TawCorp hifast-server (internal fork)
|
||||||
|
>
|
||||||
|
> This repository is the **canonical** TawCorp fork of [perfect-panel/server](https://github.com/perfect-panel/server). Migrated from `git.kxsw.us/HI-VPN/hi-server` on **2026-06-03**; the old Gitea remote is deprecated — do not push or pull from it.
|
||||||
|
>
|
||||||
|
> - **Development workflow (required reading for internal contributors)**: [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md)
|
||||||
|
> - **Branch model**: `internal` (dev mainline, auto-deploys to staging) → `main` (release)
|
||||||
|
> - **Merge policy**: PR + architect review + GitHub UI "Squash and merge"; direct push to `internal`/`main` is blocked by `lefthook` pre-push and forbidden by policy
|
||||||
|
> - **Issue tracker**: Multica workspace `Hifast` (prefix `HIF-`)
|
||||||
|
>
|
||||||
|
> External contributors: read [`CONTRIBUTING.md`](CONTRIBUTING.md) for the upstream-compatible baseline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# PPanel Server
|
# PPanel Server
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ type (
|
|||||||
InviteManageRecord {
|
InviteManageRecord {
|
||||||
InviterId int64 `json:"inviter_id"`
|
InviterId int64 `json:"inviter_id"`
|
||||||
InviterIdentifier string `json:"inviter_identifier"`
|
InviterIdentifier string `json:"inviter_identifier"`
|
||||||
|
InviterDeviceNo string `json:"inviter_device_no"`
|
||||||
InviteeId int64 `json:"invitee_id"`
|
InviteeId int64 `json:"invitee_id"`
|
||||||
InviteeIdentifier string `json:"invitee_identifier"`
|
InviteeIdentifier string `json:"invitee_identifier"`
|
||||||
|
InviteeDeviceNo string `json:"invitee_device_no"`
|
||||||
InviteeAvatar string `json:"invitee_avatar"`
|
InviteeAvatar string `json:"invitee_avatar"`
|
||||||
InviteeEnable bool `json:"invitee_enable"`
|
InviteeEnable bool `json:"invitee_enable"`
|
||||||
InvitedAt int64 `json:"invited_at"`
|
InvitedAt int64 `json:"invited_at"`
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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 "Create prize"
|
||||||
|
@handler CreateLotteryPrize
|
||||||
|
post /prizes (CreateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||||
|
|
||||||
|
@doc "Update prize"
|
||||||
|
@handler UpdateLotteryPrize
|
||||||
|
put /prizes (UpdateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||||
|
|
||||||
|
@doc "Delete prize"
|
||||||
|
@handler DeleteLotteryPrize
|
||||||
|
delete /prizes (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)
|
||||||
|
}
|
||||||
+1
-1
@@ -152,7 +152,7 @@ type (
|
|||||||
Upload int64 `json:"upload"`
|
Upload int64 `json:"upload"`
|
||||||
Download int64 `json:"download"`
|
Download int64 `json:"download"`
|
||||||
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
|
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
|
||||||
TrafficLimit *string `json:"traffic_limit,omitempty"`
|
TrafficLimit []TrafficLimit `json:"traffic_limit,omitempty"`
|
||||||
}
|
}
|
||||||
GetUserLoginLogsRequest {
|
GetUserLoginLogsRequest {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ type (
|
|||||||
ServerUser {
|
ServerUser {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
|
// SpeedLimit 单位为 Mbps,0 表示不限速。
|
||||||
|
// 节点端 (V2bX/XrayR 等) 按 Mbps 解释该值,服务端透传不做单位换算。
|
||||||
SpeedLimit int64 `json:"speed_limit"`
|
SpeedLimit int64 `json:"speed_limit"`
|
||||||
DeviceLimit int64 `json:"device_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)
|
||||||
|
}
|
||||||
+37
-4
@@ -117,6 +117,7 @@ type (
|
|||||||
}
|
}
|
||||||
WithdrawalLog {
|
WithdrawalLog {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
|
BizType string `json:"biz_type"`
|
||||||
UserId int64 `json:"user_id"`
|
UserId int64 `json:"user_id"`
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
@@ -132,12 +133,39 @@ type (
|
|||||||
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||||
}
|
}
|
||||||
QueryWithdrawalLogListRequest {
|
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"`
|
Page int `form:"page"`
|
||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
}
|
}
|
||||||
QueryWithdrawalLogListResponse {
|
CommissionReturnLog {
|
||||||
List []WithdrawalLog `json:"list"`
|
Id int64 `json:"id"`
|
||||||
Total int64 `json:"total"`
|
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 {
|
GetDeviceOnlineStatsResponse {
|
||||||
WeeklyStats []WeeklyStat `json:"weekly_stats"`
|
WeeklyStats []WeeklyStat `json:"weekly_stats"`
|
||||||
@@ -382,10 +410,14 @@ service ppanel {
|
|||||||
@handler CancelWithdrawal
|
@handler CancelWithdrawal
|
||||||
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
|
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
|
@handler QueryWithdrawalLog
|
||||||
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
|
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
|
||||||
|
|
||||||
|
@doc "Query Commission Return Log"
|
||||||
|
@handler QueryCommissionReturnLog
|
||||||
|
get /commission_return_log (QueryCommissionReturnLogRequest) returns (QueryCommissionReturnLogResponse)
|
||||||
|
|
||||||
@doc "Device Online Statistics"
|
@doc "Device Online Statistics"
|
||||||
@handler DeviceOnlineStatistics
|
@handler DeviceOnlineStatistics
|
||||||
get /device_online_statistics returns (GetDeviceOnlineStatsResponse)
|
get /device_online_statistics returns (GetDeviceOnlineStatsResponse)
|
||||||
@@ -445,3 +477,4 @@ service ppanel {
|
|||||||
@handler DeviceWsConnect
|
@handler DeviceWsConnect
|
||||||
get /device_ws_connect
|
get /device_ws_connect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -575,6 +575,7 @@ type (
|
|||||||
Traffic int64 `json:"traffic"`
|
Traffic int64 `json:"traffic"`
|
||||||
Download int64 `json:"download"`
|
Download int64 `json:"download"`
|
||||||
Upload int64 `json:"upload"`
|
Upload int64 `json:"upload"`
|
||||||
|
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Status uint8 `json:"status"`
|
Status uint8 `json:"status"`
|
||||||
EntitlementSource string `json:"entitlement_source"`
|
EntitlementSource string `json:"entitlement_source"`
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
EC2 SSH 连接资料
|
|
||||||
|
|
||||||
服务器名称: hifast-hk-app-01
|
|
||||||
公网 IP: 43.198.248.161
|
|
||||||
登录用户: ubuntu
|
|
||||||
|
|
||||||
私钥文件:
|
|
||||||
- hifast-hk-app-01-reset
|
|
||||||
|
|
||||||
公钥文件:
|
|
||||||
- hifast-hk-app-01-reset.pub
|
|
||||||
|
|
||||||
连接命令:
|
|
||||||
ssh -i hifast-hk-app-01-reset ubuntu@43.198.248.161
|
|
||||||
|
|
||||||
如果在 Mac / Linux 上使用,先执行:
|
|
||||||
chmod 600 hifast-hk-app-01-reset
|
|
||||||
|
|
||||||
如果要给别人使用,只需要把私钥文件 hifast-hk-app-01-reset 发给对方即可。
|
|
||||||
出于安全考虑,建议通过安全渠道传输,并在后续需要时重新轮换密钥。
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
|
||||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
|
||||||
QyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCgAAAKjy5KDa8uSg
|
|
||||||
2gAAAAtzc2gtZWQyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCg
|
|
||||||
AAAEDwSb0b/0S6Tw8Od5hAtIKqt1JvomqQQS44Ty3xL+FjPtvqawOqZIcu/SZaAI/i9+ND
|
|
||||||
cDnnaq/3m5B2wf3hGegKAAAAIWhpZmFzdC1oay1hcHAtMDEtcmVzZXQtMjAyNi0wNS0xMA
|
|
||||||
ECAwQ=
|
|
||||||
-----END OPENSSH PRIVATE KEY-----
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvqawOqZIcu/SZaAI/i9+NDcDnnaq/3m5B2wf3hGegK hifast-hk-app-01-reset-2026-05-10
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
# PPanel 香港区新 AWS 账号部署说明
|
|
||||||
|
|
||||||
本目录用于在 **新 AWS 账号** 中按 **香港区 `ap-east-1`** 重建一套全新空环境。
|
|
||||||
|
|
||||||
目标架构:
|
|
||||||
|
|
||||||
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL`
|
|
||||||
|
|
||||||
## 1. 资源清单
|
|
||||||
|
|
||||||
按下面顺序创建资源:
|
|
||||||
|
|
||||||
1. VPC
|
|
||||||
2. 2 个公有子网 + 2 个私有子网
|
|
||||||
3. Internet Gateway
|
|
||||||
4. 公有 / 私有路由表
|
|
||||||
5. 安全组
|
|
||||||
6. RDS MySQL
|
|
||||||
7. EC2 本机 Redis Docker
|
|
||||||
8. EC2
|
|
||||||
9. ACM 证书
|
|
||||||
10. ALB + Target Group
|
|
||||||
11. WAF Web ACL
|
|
||||||
12. 平行环境域名
|
|
||||||
|
|
||||||
建议命名:
|
|
||||||
|
|
||||||
- VPC: `ppanel-hk-prod`
|
|
||||||
- EC2: `ppanel-app-hk-01`
|
|
||||||
- RDS: `ppanel-mysql-hk`
|
|
||||||
- Redis container: `hifast-redis`
|
|
||||||
- ALB: `ppanel-alb-hk`
|
|
||||||
- WAF: `ppanel-waf-hk`
|
|
||||||
|
|
||||||
## 2. 默认规格
|
|
||||||
|
|
||||||
### EC2
|
|
||||||
|
|
||||||
- Region: `ap-east-1`
|
|
||||||
- OS: Ubuntu 24.04 LTS
|
|
||||||
- Instance type: `t4g.large` 起步
|
|
||||||
- Disk: `gp3 80GB`
|
|
||||||
- Public subnet: 是
|
|
||||||
- IAM Role: 允许读取 CloudWatch / SSM(如使用)
|
|
||||||
- 如果要在 AWS EC2 本机执行 S3 备份:额外允许写入专用备份桶
|
|
||||||
|
|
||||||
### RDS MySQL
|
|
||||||
|
|
||||||
- Engine: MySQL 8.0
|
|
||||||
- Class: `db.r7g.xlarge`
|
|
||||||
- Storage: `gp3 100GB`
|
|
||||||
- DB name: `hifast`
|
|
||||||
- Username: `admin`
|
|
||||||
- Public access: `No`
|
|
||||||
- Charset: `utf8mb4`
|
|
||||||
- Backup: `7-14 days`
|
|
||||||
|
|
||||||
### Redis
|
|
||||||
|
|
||||||
- 部署位置:业务 EC2 本机
|
|
||||||
- 部署方式:Docker
|
|
||||||
- 版本:`redis:8.2.1`
|
|
||||||
- 监听:`0.0.0.0:6379`
|
|
||||||
- 应用连接:`127.0.0.1:6379`
|
|
||||||
- 安全组:仅对白名单备用节点或同机应用开放
|
|
||||||
|
|
||||||
## 3. 网络与安全组
|
|
||||||
|
|
||||||
### 子网布局
|
|
||||||
|
|
||||||
- `public-a`, `public-b`: ALB / EC2
|
|
||||||
- `private-a`, `private-b`: RDS
|
|
||||||
|
|
||||||
### 安全组建议
|
|
||||||
|
|
||||||
#### `sg-alb`
|
|
||||||
|
|
||||||
- Inbound
|
|
||||||
- `80/tcp` from `0.0.0.0/0`
|
|
||||||
- `443/tcp` from `0.0.0.0/0`
|
|
||||||
- Outbound
|
|
||||||
- `80/tcp` to `sg-ec2`
|
|
||||||
|
|
||||||
#### `sg-ec2`
|
|
||||||
|
|
||||||
- Inbound
|
|
||||||
- `80/tcp` from `sg-alb`
|
|
||||||
- `22/tcp` from `你的固定运维 IP`
|
|
||||||
- Outbound
|
|
||||||
- all
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 应用容器监听 `127.0.0.1:8080`
|
|
||||||
- EC2 对外只让 Nginx 监听 `80`
|
|
||||||
- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1`
|
|
||||||
|
|
||||||
#### `sg-rds`
|
|
||||||
|
|
||||||
- Inbound
|
|
||||||
- `3306/tcp` from `sg-ec2`
|
|
||||||
|
|
||||||
#### `sg-ec2` 额外说明
|
|
||||||
|
|
||||||
- 如果需要外部备用节点复制 Redis,再额外放行:
|
|
||||||
- `6379/tcp` from `104.238.220.230/32`
|
|
||||||
|
|
||||||
## 4. ALB / Target Group / 健康检查
|
|
||||||
|
|
||||||
### Target Group
|
|
||||||
|
|
||||||
- Type: `Instance`
|
|
||||||
- Protocol: `HTTP`
|
|
||||||
- Port: `80`
|
|
||||||
- Health check path: `/v1/common/heartbeat`
|
|
||||||
- Success code: `200`
|
|
||||||
|
|
||||||
这个路径已由项目现有接口提供,无需额外改代码。
|
|
||||||
|
|
||||||
### ALB 监听器
|
|
||||||
|
|
||||||
- `80` -> redirect to `443`
|
|
||||||
- `443` -> forward 到 target group
|
|
||||||
|
|
||||||
### ACM
|
|
||||||
|
|
||||||
- 在 `ap-east-1` 申请证书
|
|
||||||
- 先给平行环境域名,例如:
|
|
||||||
- `api-new.hifast.biz`
|
|
||||||
- `logs-new.hifast.biz`
|
|
||||||
|
|
||||||
## 5. WAF 规则
|
|
||||||
|
|
||||||
首版至少启用:
|
|
||||||
|
|
||||||
1. `AWSManagedRulesCommonRuleSet`
|
|
||||||
2. `AWSManagedRulesKnownBadInputsRuleSet`
|
|
||||||
3. `AWSManagedRulesAmazonIpReputationList`
|
|
||||||
4. 全站 rate-based rule
|
|
||||||
5. 针对高风险路径的 rate-based rule
|
|
||||||
|
|
||||||
建议的第一版限流:
|
|
||||||
|
|
||||||
- 全站:每 IP `2000 / 5 分钟`
|
|
||||||
- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟`
|
|
||||||
- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟`
|
|
||||||
|
|
||||||
节点上报接口建议后续补:
|
|
||||||
|
|
||||||
- `/v1/server/status`
|
|
||||||
- `/v1/server/online`
|
|
||||||
- `/v1/server/traffic`
|
|
||||||
|
|
||||||
优先用节点出口 IP 白名单;没有固定出口 IP 的节点暂时保留 `secret_key`,但不要把它当成唯一防线。
|
|
||||||
|
|
||||||
## 6. EC2 文件落地
|
|
||||||
|
|
||||||
在 EC2 上建议使用:
|
|
||||||
|
|
||||||
- 应用目录:`/opt/ppanel`
|
|
||||||
- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf`
|
|
||||||
|
|
||||||
需要上传这些文件 / 目录:
|
|
||||||
|
|
||||||
- `docker-compose.cloud.yml`
|
|
||||||
- `deploy/aws/ap-east-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml`
|
|
||||||
- `deploy/aws/ap-east-1/nginx/ppanel-api.conf`
|
|
||||||
- `grafana/`
|
|
||||||
- `loki/`
|
|
||||||
- `prometheus/`
|
|
||||||
- `tempo/`
|
|
||||||
- `.env.example` -> 重命名为 `.env`
|
|
||||||
|
|
||||||
目标目录示例:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/ppanel/
|
|
||||||
docker-compose.cloud.yml
|
|
||||||
.env
|
|
||||||
configs/ppanel.yaml
|
|
||||||
grafana/
|
|
||||||
loki/
|
|
||||||
prometheus/
|
|
||||||
tempo/
|
|
||||||
logs/
|
|
||||||
cache/
|
|
||||||
tempo_data/
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 应用配置
|
|
||||||
|
|
||||||
基线模板见:
|
|
||||||
|
|
||||||
- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example)
|
|
||||||
- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf)
|
|
||||||
|
|
||||||
关键值必须替换:
|
|
||||||
|
|
||||||
- `MySQL.Addr`
|
|
||||||
- `MySQL.Password`
|
|
||||||
- `Redis.Host`
|
|
||||||
- `Redis.Pass`
|
|
||||||
- `JwtAuth.AccessSecret`
|
|
||||||
- `Administrator.Email`
|
|
||||||
- `Administrator.Password`
|
|
||||||
- `AppSignature.AppSecrets.*`
|
|
||||||
- `device.security_secret`
|
|
||||||
- `Site.Host`
|
|
||||||
- `Site.SiteName`
|
|
||||||
|
|
||||||
Redis 约定保持不变:
|
|
||||||
|
|
||||||
- 业务缓存:DB `0`
|
|
||||||
- Asynq:DB `5`(代码内部已固定使用)
|
|
||||||
|
|
||||||
## 8. 部署步骤
|
|
||||||
|
|
||||||
### 8.1 初始化 EC2
|
|
||||||
|
|
||||||
把脚本上传到 EC2 后执行:
|
|
||||||
|
|
||||||
## 9. 104 灾备节点常用运维脚本
|
|
||||||
|
|
||||||
如果你要在 `104.238.220.230` 上执行数据迁移、主从重拉、主库提升,可以直接复用仓库里的这几份脚本:
|
|
||||||
|
|
||||||
- 数据导出 / 导入交互工具:
|
|
||||||
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
|
|
||||||
- MySQL 主从运维工具:
|
|
||||||
- [`deploy/scripts/mysql_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/mysql_replica_ops.sh)
|
|
||||||
- Redis 主从运维工具:
|
|
||||||
- [`deploy/scripts/redis_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/redis_replica_ops.sh)
|
|
||||||
- 统一总入口:
|
|
||||||
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
|
|
||||||
- 主从运维环境模板:
|
|
||||||
- [`deploy/aws/ap-east-1/configs/replica-ops.env.example`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/aws/ap-east-1/configs/replica-ops.env.example)
|
|
||||||
|
|
||||||
### 9.1 数据迁移工具
|
|
||||||
|
|
||||||
支持:
|
|
||||||
|
|
||||||
- 备份 MySQL 到 S3
|
|
||||||
- 备份 Redis 到 S3
|
|
||||||
- 从正式库导出 MySQL `sql.gz`
|
|
||||||
- 把 `sql.gz` 导入 AWS RDS
|
|
||||||
- 从正式 Redis 导出 `RDB`
|
|
||||||
- 将 `RDB` 导入 Docker Redis 或宿主机 Redis
|
|
||||||
- 查看 MySQL / Redis 当前主从状态
|
|
||||||
- 强制重拉 MySQL / Redis 主从
|
|
||||||
- 把 MySQL / Redis 从库提升为可写主库
|
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash deploy/scripts/hifast_data_sync_tool.sh /root/replica-ops.env
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x deploy/scripts/bootstrap_aws_ec2.sh
|
|
||||||
sudo APP_DIR=/opt/ppanel deploy/scripts/bootstrap_aws_ec2.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 安装 Nginx 配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo cp deploy/aws/ap-east-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf
|
|
||||||
sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 启动容器
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/ppanel
|
|
||||||
docker compose -f docker-compose.cloud.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.4 预检
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x deploy/scripts/preflight_aws_hk.sh
|
|
||||||
APP_DIR=/opt/ppanel \
|
|
||||||
RDS_HOST=<new-rds-endpoint> \
|
|
||||||
REDIS_HOST=127.0.0.1 \
|
|
||||||
deploy/scripts/preflight_aws_hk.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. 平行环境验证
|
|
||||||
|
|
||||||
先验证 `api-new.hifast.biz`,不要直接切正式域名。
|
|
||||||
|
|
||||||
必测项:
|
|
||||||
|
|
||||||
1. `ALB target` 为 healthy
|
|
||||||
2. `GET /v1/common/heartbeat` 返回 200
|
|
||||||
3. 管理员登录
|
|
||||||
4. 用户注册 / 登录
|
|
||||||
5. 订阅查询
|
|
||||||
6. 节点上报 `/v1/server/status`
|
|
||||||
7. 本机 Redis 可写缓存
|
|
||||||
8. Asynq 可入队并消费
|
|
||||||
|
|
||||||
## 10. 正式切换
|
|
||||||
|
|
||||||
切换前检查:
|
|
||||||
|
|
||||||
1. ALB 5xx 为 0
|
|
||||||
2. EC2 CPU / Memory 正常
|
|
||||||
3. RDS CPU / Connections 正常
|
|
||||||
4. 本机 Redis CPU / Connections / Memory 正常
|
|
||||||
5. WAF 已挂到 ALB
|
|
||||||
6. EC2 安全组没有对公网放 `8080/3333/9090/4317`
|
|
||||||
|
|
||||||
切换方式:
|
|
||||||
|
|
||||||
1. 保持新环境先跑平行域名
|
|
||||||
2. 正式域名切到新 ALB
|
|
||||||
3. 观察至少 1 小时
|
|
||||||
4. 确认无误后再处理旧环境
|
|
||||||
|
|
||||||
## 11. 监控建议
|
|
||||||
|
|
||||||
至少建这些 CloudWatch / Grafana 观测项:
|
|
||||||
|
|
||||||
- ALB `RequestCount`, `HTTPCode_ELB_5XX_Count`, `TargetResponseTime`
|
|
||||||
- EC2 `CPUUtilization`, `NetworkIn`, `NetworkOut`, `StatusCheckFailed`
|
|
||||||
- RDS `CPUUtilization`, `DatabaseConnections`, `ReadLatency`, `WriteLatency`
|
|
||||||
- Redis 容器 CPU / Memory / restart count
|
|
||||||
|
|
||||||
## 12. 这次方案的边界
|
|
||||||
|
|
||||||
本目录交付的是:
|
|
||||||
|
|
||||||
- 香港区新账号的部署模板
|
|
||||||
- 新空环境启动与验证流程
|
|
||||||
- ALB / WAF / EC2 / RDS / 本机 Redis 的落地约定
|
|
||||||
|
|
||||||
不包含:
|
|
||||||
|
|
||||||
- 旧数据迁移
|
|
||||||
- Terraform / CloudFormation 自动建资源
|
|
||||||
- Redis 托管版改造
|
|
||||||
- 多活 / 自动扩缩容
|
|
||||||
|
|
||||||
## 13. S3 备份补强
|
|
||||||
|
|
||||||
当前已落地的 S3 备份桶:
|
|
||||||
|
|
||||||
- `hifast-prod-backups-200810848252-ap-east-1`
|
|
||||||
|
|
||||||
建议与现网结合方式:
|
|
||||||
|
|
||||||
1. `RDS automated backup` 继续保留,作为第一层恢复能力
|
|
||||||
2. `104` 外部 MySQL 从库执行逻辑备份并上传到 S3,作为第二层可下载备份
|
|
||||||
3. `104` 外部 Redis 从库按需导出 `RDB` 到 S3,补齐缓存类灾备材料
|
|
||||||
|
|
||||||
仓库中已补充:
|
|
||||||
|
|
||||||
- 环境变量模板:[`configs/backup-to-s3.env.example`](./configs/backup-to-s3.env.example)
|
|
||||||
- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh)
|
|
||||||
- Redis 备份脚本:[`../../scripts/redis_rdb_backup_to_s3.sh`](../../scripts/redis_rdb_backup_to_s3.sh)
|
|
||||||
|
|
||||||
建议把 MySQL 备份脚本优先部署到 `104`,因为它直接连接本地只读从库,对 AWS 主库扰动最小。
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
AWS_REGION=ap-east-1
|
|
||||||
S3_BUCKET=hifast-prod-backups-200810848252-ap-east-1
|
|
||||||
S3_PREFIX=mysql
|
|
||||||
BACKUP_DIR=/var/backups/hifast
|
|
||||||
HOST_TAG=104-standby
|
|
||||||
KEEP_LOCAL_DAYS=3
|
|
||||||
CHECK_REPLICA=1
|
|
||||||
|
|
||||||
MYSQL_HOST=127.0.0.1
|
|
||||||
MYSQL_PORT=3306
|
|
||||||
MYSQL_USER=backup_reader
|
|
||||||
MYSQL_PASSWORD=CHANGE_ME
|
|
||||||
MYSQL_SOCKET=
|
|
||||||
MYSQL_DATABASE=hifast
|
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=CHANGE_ME
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
PRIMARY_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
|
|
||||||
PRIMARY_PORT=3306
|
|
||||||
PRIMARY_USER=admin
|
|
||||||
PRIMARY_PASSWORD=CHANGE_ME
|
|
||||||
PRIMARY_DB=hifast
|
|
||||||
|
|
||||||
PRIMARY_REPL_USER=repl
|
|
||||||
PRIMARY_REPL_PASSWORD=CHANGE_ME
|
|
||||||
PRIMARY_REPL_HOST=104.238.220.230
|
|
||||||
PRIMARY_BINLOG_RETENTION_HOURS=24
|
|
||||||
|
|
||||||
REPLICA_HOST=127.0.0.1
|
|
||||||
REPLICA_PORT=3306
|
|
||||||
REPLICA_USER=root
|
|
||||||
REPLICA_PASSWORD=
|
|
||||||
REPLICA_SOCKET=/var/run/mysqld/mysqld.sock
|
|
||||||
REPLICA_DB=hifast
|
|
||||||
REPLICA_SOURCE_SSL=1
|
|
||||||
|
|
||||||
DUMP_FILE=
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
Host: 0.0.0.0
|
|
||||||
Port: 8080
|
|
||||||
Debug: false
|
|
||||||
|
|
||||||
JwtAuth:
|
|
||||||
AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET
|
|
||||||
AccessExpire: 604800
|
|
||||||
|
|
||||||
Logger:
|
|
||||||
ServiceName: PPanel
|
|
||||||
Mode: console
|
|
||||||
Encoding: plain
|
|
||||||
TimeFormat: "2006-01-02 15:04:05.000"
|
|
||||||
Path: logs
|
|
||||||
Level: info
|
|
||||||
MaxContentLength: 0
|
|
||||||
Compress: false
|
|
||||||
Stat: true
|
|
||||||
KeepDays: 7
|
|
||||||
StackCooldownMillis: 100
|
|
||||||
MaxBackups: 7
|
|
||||||
MaxSize: 100
|
|
||||||
Rotation: daily
|
|
||||||
FileTimeFormat: "2006-01-02T15:04:05.000Z07:00"
|
|
||||||
|
|
||||||
MySQL:
|
|
||||||
Addr: YOUR_RDS_ENDPOINT:3306
|
|
||||||
Dbname: hifast
|
|
||||||
Username: admin
|
|
||||||
Password: CHANGE_ME_TO_RDS_PASSWORD
|
|
||||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
|
||||||
MaxIdleConns: 10
|
|
||||||
MaxOpenConns: 100
|
|
||||||
SlowThreshold: 1000
|
|
||||||
|
|
||||||
Redis:
|
|
||||||
Host: 127.0.0.1:6379
|
|
||||||
Pass: CHANGE_ME_TO_REDIS_PASSWORD
|
|
||||||
DB: 0
|
|
||||||
PoolSize: 100
|
|
||||||
MinIdleConns: 10
|
|
||||||
MaxRetries: 3
|
|
||||||
PoolTimeout: 4
|
|
||||||
IdleTimeout: 300
|
|
||||||
MaxConnAge: 0
|
|
||||||
DialTimeout: 5
|
|
||||||
ReadTimeout: 3
|
|
||||||
WriteTimeout: 3
|
|
||||||
|
|
||||||
Trace:
|
|
||||||
Name: ppanel-server
|
|
||||||
Endpoint: 127.0.0.1:4317
|
|
||||||
Sampler: 0.1
|
|
||||||
Batcher: otlpgrpc
|
|
||||||
|
|
||||||
Site:
|
|
||||||
Host: api-new.hifast.biz
|
|
||||||
SiteName: HiFastVPN
|
|
||||||
|
|
||||||
Administrator:
|
|
||||||
Email: admin@example.com
|
|
||||||
Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD
|
|
||||||
|
|
||||||
Telegram:
|
|
||||||
Enable: false
|
|
||||||
BotID: 0
|
|
||||||
BotName: ""
|
|
||||||
BotToken: ""
|
|
||||||
GroupChatID: ""
|
|
||||||
EnableNotify: false
|
|
||||||
WebHookDomain: ""
|
|
||||||
|
|
||||||
Kutt:
|
|
||||||
Enable: false
|
|
||||||
ApiURL: ""
|
|
||||||
ApiKey: ""
|
|
||||||
TargetURL: ""
|
|
||||||
Domain: ""
|
|
||||||
|
|
||||||
OpenInstall:
|
|
||||||
Enable: false
|
|
||||||
AppKey: ""
|
|
||||||
ApiKey: ""
|
|
||||||
|
|
||||||
Loki:
|
|
||||||
Enable: true
|
|
||||||
URL: "http://localhost:3100"
|
|
||||||
|
|
||||||
AppSignature:
|
|
||||||
AppSecrets:
|
|
||||||
android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET
|
|
||||||
ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET
|
|
||||||
web-client: CHANGE_ME_WEB_SIGNATURE_SECRET
|
|
||||||
ValidWindowSeconds: 300
|
|
||||||
SkipPrefixes:
|
|
||||||
- /v1/notify/
|
|
||||||
- /v1/iap/notifications
|
|
||||||
- /v1/telegram/webhook
|
|
||||||
- /v1/subscribe/config
|
|
||||||
|
|
||||||
Signature:
|
|
||||||
EnableSignature: false
|
|
||||||
|
|
||||||
device:
|
|
||||||
enable: true
|
|
||||||
security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET
|
|
||||||
|
|
||||||
Register:
|
|
||||||
EnableTrial: true
|
|
||||||
EnableTrialEmailWhitelist: true
|
|
||||||
TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
MYSQL_HOST=127.0.0.1
|
|
||||||
MYSQL_PORT=3306
|
|
||||||
MYSQL_USER=root
|
|
||||||
MYSQL_PASSWORD=CHANGE_ME
|
|
||||||
MYSQL_SOCKET=
|
|
||||||
|
|
||||||
REPL_SOURCE_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
|
|
||||||
REPL_SOURCE_PORT=3306
|
|
||||||
REPL_SOURCE_USER=repl
|
|
||||||
REPL_SOURCE_PASSWORD=CHANGE_ME
|
|
||||||
REPL_SOURCE_SSL=1
|
|
||||||
REPL_SOURCE_LOG_FILE=
|
|
||||||
REPL_SOURCE_LOG_POS=
|
|
||||||
REPL_SOURCE_AUTO_POSITION=1
|
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
REDIS_SOURCE_HOST=18.163.33.75
|
|
||||||
REDIS_SOURCE_PORT=6379
|
|
||||||
REDIS_SOURCE_USER=
|
|
||||||
REDIS_SOURCE_PASSWORD=CHANGE_ME
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80 default_server;
|
|
||||||
listen [::]:80 default_server;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
client_max_body_size 20m;
|
|
||||||
|
|
||||||
access_log /var/log/nginx/ppanel-access.log;
|
|
||||||
error_log /var/log/nginx/ppanel-error.log warn;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_pass http://127.0.0.1:8080;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
|
||||||
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
proxy_send_timeout 60s;
|
|
||||||
proxy_read_timeout 60s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /nginx_status {
|
|
||||||
stub_status;
|
|
||||||
access_log off;
|
|
||||||
allow 127.0.0.1;
|
|
||||||
deny all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
# PPanel 日本东京区 AWS 部署说明
|
|
||||||
|
|
||||||
本目录用于在 **AWS 日本东京区 `ap-northeast-1`** 重建一套全新生产环境,并承接当前香港区 `ap-east-1` 的正式迁移。
|
|
||||||
|
|
||||||
如果你要看“当前已经真实跑起来的东京架构”,优先看:
|
|
||||||
|
|
||||||
- [`ops/hifast-current-architecture-zh.md`](/Users/Apple/code_vpn/vpn/ppanel-server/ops/hifast-current-architecture-zh.md)
|
|
||||||
- [`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md)
|
|
||||||
|
|
||||||
这份 README 更偏向:
|
|
||||||
|
|
||||||
- 目标架构
|
|
||||||
- 资源规划
|
|
||||||
- 部署方法
|
|
||||||
- 后续待完成项
|
|
||||||
|
|
||||||
目标架构:
|
|
||||||
|
|
||||||
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL`
|
|
||||||
|
|
||||||
灾备链路:
|
|
||||||
|
|
||||||
`RDS MySQL / EC2 Redis -> 104.238.220.230 外部灾备`
|
|
||||||
|
|
||||||
当前仓库内已补充:
|
|
||||||
|
|
||||||
- 东京基础设施参数模板:[`configs/aws-jp-infra.env.example`](./configs/aws-jp-infra.env.example)
|
|
||||||
- 东京真实实施状态登记:[`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md)
|
|
||||||
- 东京底座资源创建脚本:[`../../scripts/aws_jp_create_base_infra.sh`](../../scripts/aws_jp_create_base_infra.sh)
|
|
||||||
- 东京资源状态检查脚本:[`../../scripts/aws_jp_describe_state.sh`](../../scripts/aws_jp_describe_state.sh)
|
|
||||||
- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh)
|
|
||||||
- 10 分钟 MySQL 备份定时器安装脚本:[`../../scripts/install_mysql_backup_timer.sh`](../../scripts/install_mysql_backup_timer.sh)
|
|
||||||
|
|
||||||
## 1. 资源清单
|
|
||||||
|
|
||||||
按下面顺序创建资源:
|
|
||||||
|
|
||||||
1. VPC
|
|
||||||
2. 2 个公有子网 + 2 个私有子网
|
|
||||||
3. Internet Gateway
|
|
||||||
4. 公有 / 私有路由表
|
|
||||||
5. 安全组
|
|
||||||
6. RDS MySQL
|
|
||||||
7. EC2 本机 Redis Docker
|
|
||||||
8. EC2
|
|
||||||
9. ACM 证书
|
|
||||||
10. ALB + Target Group
|
|
||||||
11. WAF Web ACL
|
|
||||||
12. 东京平行环境域名
|
|
||||||
13. 东京 S3 备份桶
|
|
||||||
|
|
||||||
建议命名:
|
|
||||||
|
|
||||||
- VPC: `ppanel-jp-prod`
|
|
||||||
- EC2: `ppanel-app-jp-01`
|
|
||||||
- RDS: `ppanel-mysql-jp`
|
|
||||||
- Redis container: `hifast-redis`
|
|
||||||
- ALB: `ppanel-alb-jp`
|
|
||||||
- WAF: `ppanel-waf-jp`
|
|
||||||
- S3: `hifast-prod-backups-200810848252-ap-northeast-1`
|
|
||||||
|
|
||||||
## 2. 默认规格
|
|
||||||
|
|
||||||
### EC2
|
|
||||||
|
|
||||||
- Region: `ap-northeast-1`
|
|
||||||
- OS: Ubuntu 24.04 LTS
|
|
||||||
- Instance type: `t4g.large`
|
|
||||||
- Disk: `gp3 80GB`
|
|
||||||
- Public subnet: 是
|
|
||||||
- IAM Role:
|
|
||||||
- 允许读取 CloudWatch / SSM(如使用)
|
|
||||||
- 如果要在东京 EC2 上执行 S3 备份:额外允许写入东京备份桶
|
|
||||||
|
|
||||||
### RDS MySQL
|
|
||||||
|
|
||||||
- Engine: `MySQL 8.4`
|
|
||||||
- Class: `db.r7g.xlarge`
|
|
||||||
- Storage: `gp3 100GB`
|
|
||||||
- DB name: `hifast`
|
|
||||||
- Username: `admin`
|
|
||||||
- Public access: `Yes`
|
|
||||||
- Charset: `utf8mb4`
|
|
||||||
- Backup retention: `7-14 days`
|
|
||||||
- Deletion protection: `On`
|
|
||||||
- Multi-AZ: `Yes`(当前按 2 实例 Multi-AZ 创建)
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 当前东京 RDS 需要允许 `104.238.220.230` 从公网直连 `3306`,用于外部 MySQL 从库复制
|
|
||||||
- 因此本阶段 RDS 使用 `public subnet group + Publicly accessible = Yes`
|
|
||||||
- 访问面只通过 `sg-rds` 严格限制到业务 EC2 安全组和 `104.238.220.230/32`
|
|
||||||
|
|
||||||
### Redis
|
|
||||||
|
|
||||||
- 部署位置:业务 EC2 本机
|
|
||||||
- 部署方式:Docker
|
|
||||||
- 版本:`redis:8.2.1`
|
|
||||||
- 监听:`0.0.0.0:6379`
|
|
||||||
- 应用连接:`127.0.0.1:6379`
|
|
||||||
- 安全组:仅对白名单备用节点 `104.238.220.230/32` 或同机应用开放
|
|
||||||
|
|
||||||
## 3. 网络与安全组
|
|
||||||
|
|
||||||
### 子网布局
|
|
||||||
|
|
||||||
- `public-a`, `public-c`: ALB / EC2
|
|
||||||
- `private-a`, `private-c`: RDS
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 东京优先使用 `ap-northeast-1a` 和 `ap-northeast-1c`
|
|
||||||
- 如果账户映射不同,也可以用任意 2 个可用区,但公私网必须各 2 个子网
|
|
||||||
|
|
||||||
### 安全组建议
|
|
||||||
|
|
||||||
#### `sg-alb`
|
|
||||||
|
|
||||||
- Inbound
|
|
||||||
- `80/tcp` from `0.0.0.0/0`
|
|
||||||
- `443/tcp` from `0.0.0.0/0`
|
|
||||||
- Outbound
|
|
||||||
- `80/tcp` to `sg-ec2`
|
|
||||||
|
|
||||||
#### `sg-ec2`
|
|
||||||
|
|
||||||
- Inbound
|
|
||||||
- `80/tcp` from `sg-alb`
|
|
||||||
- `22/tcp` from `你的固定运维 IP`
|
|
||||||
- `6379/tcp` from `104.238.220.230/32`
|
|
||||||
- Outbound
|
|
||||||
- all
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 应用容器监听 `127.0.0.1:8080`
|
|
||||||
- EC2 对外只让 Nginx 监听 `80`
|
|
||||||
- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1`
|
|
||||||
|
|
||||||
#### `sg-rds`
|
|
||||||
|
|
||||||
- Inbound
|
|
||||||
- `3306/tcp` from `sg-ec2`
|
|
||||||
- `3306/tcp` from `104.238.220.230/32`
|
|
||||||
|
|
||||||
## 4. ALB / Target Group / 健康检查
|
|
||||||
|
|
||||||
### Target Group
|
|
||||||
|
|
||||||
- Type: `Instance`
|
|
||||||
- Protocol: `HTTP`
|
|
||||||
- Port: `80`
|
|
||||||
- Health check path: `/v1/common/heartbeat`
|
|
||||||
- Success code: `200`
|
|
||||||
|
|
||||||
### ALB 监听器
|
|
||||||
|
|
||||||
- `80` -> redirect to `443`
|
|
||||||
- `443` -> forward 到 target group
|
|
||||||
|
|
||||||
### ACM
|
|
||||||
|
|
||||||
- 在 `ap-northeast-1` 重新申请证书
|
|
||||||
- 先给平行环境域名,例如:
|
|
||||||
- `api-jp.hifast.biz`
|
|
||||||
- `logs-jp.hifast.biz`
|
|
||||||
|
|
||||||
## 5. WAF 规则
|
|
||||||
|
|
||||||
首版至少启用:
|
|
||||||
|
|
||||||
1. `AWSManagedRulesCommonRuleSet`
|
|
||||||
2. `AWSManagedRulesKnownBadInputsRuleSet`
|
|
||||||
3. `AWSManagedRulesAmazonIpReputationList`
|
|
||||||
4. 全站 rate-based rule
|
|
||||||
5. 针对高风险路径的 rate-based rule
|
|
||||||
|
|
||||||
建议的第一版限流:
|
|
||||||
|
|
||||||
- 全站:每 IP `2000 / 5 分钟`
|
|
||||||
- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟`
|
|
||||||
- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟`
|
|
||||||
|
|
||||||
节点上报接口建议后续补:
|
|
||||||
|
|
||||||
- `/v1/server/status`
|
|
||||||
- `/v1/server/online`
|
|
||||||
- `/v1/server/traffic`
|
|
||||||
|
|
||||||
## 6. EC2 文件落地
|
|
||||||
|
|
||||||
在 EC2 上建议使用:
|
|
||||||
|
|
||||||
- 应用目录:`/opt/ppanel`
|
|
||||||
- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf`
|
|
||||||
|
|
||||||
需要上传这些文件 / 目录:
|
|
||||||
|
|
||||||
- `docker-compose.cloud.yml`
|
|
||||||
- `deploy/aws/ap-northeast-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml`
|
|
||||||
- `deploy/aws/ap-northeast-1/nginx/ppanel-api.conf`
|
|
||||||
- `grafana/`
|
|
||||||
- `loki/`
|
|
||||||
- `prometheus/`
|
|
||||||
- `tempo/`
|
|
||||||
- `.env.example` -> 重命名为 `.env`
|
|
||||||
|
|
||||||
目标目录示例:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/ppanel/
|
|
||||||
docker-compose.cloud.yml
|
|
||||||
.env
|
|
||||||
configs/ppanel.yaml
|
|
||||||
grafana/
|
|
||||||
loki/
|
|
||||||
prometheus/
|
|
||||||
tempo/
|
|
||||||
logs/
|
|
||||||
cache/
|
|
||||||
tempo_data/
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 应用配置
|
|
||||||
|
|
||||||
基线模板见:
|
|
||||||
|
|
||||||
- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example)
|
|
||||||
- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf)
|
|
||||||
|
|
||||||
关键值必须替换:
|
|
||||||
|
|
||||||
- `MySQL.Addr`
|
|
||||||
- `MySQL.Password`
|
|
||||||
- `Redis.Host`
|
|
||||||
- `Redis.Pass`
|
|
||||||
- `JwtAuth.AccessSecret`
|
|
||||||
- `Administrator.Email`
|
|
||||||
- `Administrator.Password`
|
|
||||||
- `AppSignature.AppSecrets.*`
|
|
||||||
- `device.security_secret`
|
|
||||||
- `Site.Host`
|
|
||||||
- `Site.SiteName`
|
|
||||||
|
|
||||||
Redis 约定保持不变:
|
|
||||||
|
|
||||||
- 业务缓存:DB `0`
|
|
||||||
- Asynq:DB `5`
|
|
||||||
|
|
||||||
## 8. 部署步骤
|
|
||||||
|
|
||||||
### 8.0 创建东京基础设施
|
|
||||||
|
|
||||||
如果本机或跳板机已经配置好 AWS CLI 凭据,可以先直接执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp deploy/aws/ap-northeast-1/configs/aws-jp-infra.env.example /root/aws-jp-infra.env
|
|
||||||
chmod 600 /root/aws-jp-infra.env
|
|
||||||
vim /root/aws-jp-infra.env
|
|
||||||
|
|
||||||
chmod +x deploy/scripts/aws_jp_create_base_infra.sh
|
|
||||||
bash deploy/scripts/aws_jp_create_base_infra.sh /root/aws-jp-infra.env
|
|
||||||
```
|
|
||||||
|
|
||||||
执行后可用下面命令随时核对东京底座状态:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x deploy/scripts/aws_jp_describe_state.sh
|
|
||||||
bash deploy/scripts/aws_jp_describe_state.sh /root/aws-jp-infra.env
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.1 初始化 EC2
|
|
||||||
|
|
||||||
把脚本上传到东京 EC2 后执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x deploy/scripts/bootstrap_aws_ec2.sh
|
|
||||||
sudo APP_DIR=/opt/ppanel APP_USER=ubuntu deploy/scripts/bootstrap_aws_ec2.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 安装 Nginx 配置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo cp deploy/aws/ap-northeast-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf
|
|
||||||
sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 启动容器
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/ppanel
|
|
||||||
docker compose -f docker-compose.cloud.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.4 预检
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x deploy/scripts/preflight_aws_jp.sh
|
|
||||||
APP_DIR=/opt/ppanel \
|
|
||||||
RDS_HOST=<TOKYO_RDS_ENDPOINT> \
|
|
||||||
REDIS_HOST=127.0.0.1 \
|
|
||||||
deploy/scripts/preflight_aws_jp.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. 数据迁移与切换
|
|
||||||
|
|
||||||
正式迁移请按:
|
|
||||||
|
|
||||||
- [`ops/hifast-aws-jp-migration-runbook-zh.md`](/Users/Apple/code_vpn/vpn/ppanel-server/ops/hifast-aws-jp-migration-runbook-zh.md)
|
|
||||||
|
|
||||||
执行。
|
|
||||||
|
|
||||||
核心原则:
|
|
||||||
|
|
||||||
- 先搭平行环境
|
|
||||||
- 停机后再导出香港主数据
|
|
||||||
- 东京验收通过后再切正式域名
|
|
||||||
- 切换后再重挂 `104` 灾备
|
|
||||||
|
|
||||||
## 10. 104 灾备节点常用模板
|
|
||||||
|
|
||||||
如果迁移完成后要把 `104.238.220.230` 重挂为东京主站从库,可复用:
|
|
||||||
|
|
||||||
- [`deploy/scripts/hifast_mysql_seed_primary_and_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_mysql_seed_primary_and_replica.sh)
|
|
||||||
- [`deploy/scripts/hifast_mysql_attach_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_mysql_attach_replica.sh)
|
|
||||||
- [`deploy/scripts/hifast_redis_attach_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_redis_attach_replica.sh)
|
|
||||||
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
|
|
||||||
- [`configs/replica-ops.env.example`](./configs/replica-ops.env.example)
|
|
||||||
|
|
||||||
## 11. 东京资源创建前置检查
|
|
||||||
|
|
||||||
在 AWS 控制台里至少先确认:
|
|
||||||
|
|
||||||
- 东京区已启用
|
|
||||||
- `ap-northeast-1` 可创建 `t4g.large`
|
|
||||||
- `ap-northeast-1` RDS 可创建 `db.r7g.xlarge`
|
|
||||||
- ACM / ALB / WAF / S3 服务在东京区可正常使用
|
|
||||||
- Tokyo 对应配额满足:
|
|
||||||
- On-Demand Standard vCPU
|
|
||||||
- ALB 数量
|
|
||||||
- Elastic IP(如需)
|
|
||||||
- RDS 实例数
|
|
||||||
|
|
||||||
## 12. 当前已知真实进度
|
|
||||||
|
|
||||||
截至 `2026-05-20`,已知状态如下:
|
|
||||||
|
|
||||||
- 东京 VPC `ppanel-jp-prod` 已创建
|
|
||||||
- VPC ID: `vpc-0846b23b4a7d64eac`
|
|
||||||
- VPC CIDR: `10.20.0.0/16`
|
|
||||||
- 4 个子网在 AWS 控制台里曾填写完成,但提交时控制台 session 失效
|
|
||||||
- 因此:
|
|
||||||
- 子网是否真正创建成功,需要重新核实
|
|
||||||
- IGW / 路由表 / 安全组 / RDS / EC2 / ALB / WAF 都应按“未完成”处理,重新复核
|
|
||||||
|
|
||||||
实时状态请以后续更新的 [`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md) 为准。
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
AWS_REGION=ap-northeast-1
|
|
||||||
AWS_ACCOUNT_ID=200810848252
|
|
||||||
|
|
||||||
VPC_NAME=ppanel-jp-prod
|
|
||||||
VPC_ID=
|
|
||||||
VPC_CIDR=10.20.0.0/16
|
|
||||||
|
|
||||||
PUBLIC_SUBNET_A_NAME=ppanel-jp-public-a
|
|
||||||
PUBLIC_SUBNET_A_AZ=ap-northeast-1a
|
|
||||||
PUBLIC_SUBNET_A_CIDR=10.20.0.0/24
|
|
||||||
|
|
||||||
PUBLIC_SUBNET_C_NAME=ppanel-jp-public-c
|
|
||||||
PUBLIC_SUBNET_C_AZ=ap-northeast-1c
|
|
||||||
PUBLIC_SUBNET_C_CIDR=10.20.1.0/24
|
|
||||||
|
|
||||||
PRIVATE_SUBNET_A_NAME=ppanel-jp-private-a
|
|
||||||
PRIVATE_SUBNET_A_AZ=ap-northeast-1a
|
|
||||||
PRIVATE_SUBNET_A_CIDR=10.20.10.0/24
|
|
||||||
|
|
||||||
PRIVATE_SUBNET_C_NAME=ppanel-jp-private-c
|
|
||||||
PRIVATE_SUBNET_C_AZ=ap-northeast-1c
|
|
||||||
PRIVATE_SUBNET_C_CIDR=10.20.11.0/24
|
|
||||||
|
|
||||||
IGW_NAME=ppanel-jp-igw
|
|
||||||
PUBLIC_ROUTE_TABLE_NAME=ppanel-jp-public-rt
|
|
||||||
PRIVATE_ROUTE_TABLE_NAME=ppanel-jp-private-rt
|
|
||||||
|
|
||||||
SG_ALB_NAME=ppanel-jp-sg-alb
|
|
||||||
SG_EC2_NAME=ppanel-jp-sg-ec2
|
|
||||||
SG_RDS_NAME=ppanel-jp-sg-rds
|
|
||||||
|
|
||||||
OPS_SSH_CIDR=CHANGE_ME_TO_YOUR_FIXED_PUBLIC_IP_OR_CIDR
|
|
||||||
DR_REPLICA_IP=104.238.220.230/32
|
|
||||||
|
|
||||||
EC2_NAME=ppanel-app-jp-01
|
|
||||||
EC2_AMI_FAMILY=ubuntu-24.04
|
|
||||||
EC2_INSTANCE_TYPE=t4g.large
|
|
||||||
EC2_DISK_GB=80
|
|
||||||
EC2_KEY_PAIR=CHANGE_ME
|
|
||||||
|
|
||||||
RDS_IDENTIFIER=ppanel-mysql-jp
|
|
||||||
RDS_DB_NAME=hifast
|
|
||||||
RDS_ADMIN_USER=admin
|
|
||||||
RDS_INSTANCE_CLASS=db.r7g.xlarge
|
|
||||||
RDS_STORAGE_GB=100
|
|
||||||
|
|
||||||
ALB_NAME=ppanel-alb-jp
|
|
||||||
TARGET_GROUP_NAME=ppanel-tg-jp
|
|
||||||
WAF_NAME=ppanel-waf-jp
|
|
||||||
|
|
||||||
PARALLEL_API_DOMAIN=api-jp.hifast.biz
|
|
||||||
PARALLEL_LOGS_DOMAIN=logs-jp.hifast.biz
|
|
||||||
PRODUCTION_API_DOMAIN=CHANGE_ME
|
|
||||||
|
|
||||||
S3_BACKUP_BUCKET=hifast-prod-backups-200810848252-ap-northeast-1
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
AWS_REGION=ap-northeast-1
|
|
||||||
S3_BUCKET=hifast-prod-backups-200810848252-ap-northeast-1
|
|
||||||
S3_PREFIX=mysql
|
|
||||||
BACKUP_DIR=/var/backups/hifast
|
|
||||||
HOST_TAG=104-standby-for-jp
|
|
||||||
KEEP_LOCAL_DAYS=3
|
|
||||||
CHECK_REPLICA=1
|
|
||||||
|
|
||||||
MYSQL_HOST=127.0.0.1
|
|
||||||
MYSQL_PORT=3306
|
|
||||||
MYSQL_USER=backup_reader
|
|
||||||
MYSQL_PASSWORD=CHANGE_ME
|
|
||||||
MYSQL_SOCKET=
|
|
||||||
MYSQL_DATABASE=hifast
|
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
PRIMARY_HOST=ppanel-mysql-jp.<CHANGE_ME>.ap-northeast-1.rds.amazonaws.com
|
|
||||||
PRIMARY_PORT=3306
|
|
||||||
PRIMARY_USER=admin
|
|
||||||
PRIMARY_PASSWORD=CHANGE_ME
|
|
||||||
PRIMARY_DB=hifast
|
|
||||||
|
|
||||||
PRIMARY_REPL_USER=repl
|
|
||||||
PRIMARY_REPL_PASSWORD=CHANGE_ME
|
|
||||||
PRIMARY_REPL_HOST=104.238.220.230
|
|
||||||
PRIMARY_BINLOG_RETENTION_HOURS=24
|
|
||||||
|
|
||||||
REPLICA_HOST=127.0.0.1
|
|
||||||
REPLICA_PORT=3306
|
|
||||||
REPLICA_USER=root
|
|
||||||
REPLICA_PASSWORD=
|
|
||||||
REPLICA_SOCKET=/var/run/mysqld/mysqld.sock
|
|
||||||
REPLICA_DB=hifast
|
|
||||||
REPLICA_SOURCE_SSL=1
|
|
||||||
|
|
||||||
DUMP_FILE=
|
|
||||||
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
Host: 0.0.0.0
|
|
||||||
Port: 8080
|
|
||||||
Debug: false
|
|
||||||
|
|
||||||
JwtAuth:
|
|
||||||
AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET
|
|
||||||
AccessExpire: 604800
|
|
||||||
|
|
||||||
Logger:
|
|
||||||
ServiceName: PPanel
|
|
||||||
Mode: console
|
|
||||||
Encoding: plain
|
|
||||||
TimeFormat: "2006-01-02 15:04:05.000"
|
|
||||||
Path: logs
|
|
||||||
Level: info
|
|
||||||
MaxContentLength: 0
|
|
||||||
Compress: false
|
|
||||||
Stat: true
|
|
||||||
KeepDays: 7
|
|
||||||
StackCooldownMillis: 100
|
|
||||||
MaxBackups: 7
|
|
||||||
MaxSize: 100
|
|
||||||
Rotation: daily
|
|
||||||
FileTimeFormat: "2006-01-02T15:04:05.000Z07:00"
|
|
||||||
|
|
||||||
MySQL:
|
|
||||||
Addr: YOUR_TOKYO_RDS_ENDPOINT:3306
|
|
||||||
Dbname: hifast
|
|
||||||
Username: admin
|
|
||||||
Password: CHANGE_ME_TO_TOKYO_RDS_PASSWORD
|
|
||||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FTokyo
|
|
||||||
MaxIdleConns: 10
|
|
||||||
MaxOpenConns: 100
|
|
||||||
SlowThreshold: 1000
|
|
||||||
|
|
||||||
Redis:
|
|
||||||
Host: 127.0.0.1:6379
|
|
||||||
Pass: CHANGE_ME_TO_TOKYO_REDIS_PASSWORD
|
|
||||||
DB: 0
|
|
||||||
PoolSize: 100
|
|
||||||
MinIdleConns: 10
|
|
||||||
MaxRetries: 3
|
|
||||||
PoolTimeout: 4
|
|
||||||
IdleTimeout: 300
|
|
||||||
MaxConnAge: 0
|
|
||||||
DialTimeout: 5
|
|
||||||
ReadTimeout: 3
|
|
||||||
WriteTimeout: 3
|
|
||||||
|
|
||||||
Trace:
|
|
||||||
Name: ppanel-server
|
|
||||||
Endpoint: 127.0.0.1:4317
|
|
||||||
Sampler: 0.1
|
|
||||||
Batcher: otlpgrpc
|
|
||||||
|
|
||||||
Site:
|
|
||||||
Host: api-jp.hifast.biz
|
|
||||||
SiteName: HiFastVPN
|
|
||||||
|
|
||||||
Administrator:
|
|
||||||
Email: admin@example.com
|
|
||||||
Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD
|
|
||||||
|
|
||||||
Telegram:
|
|
||||||
Enable: false
|
|
||||||
BotID: 0
|
|
||||||
BotName: ""
|
|
||||||
BotToken: ""
|
|
||||||
GroupChatID: ""
|
|
||||||
EnableNotify: false
|
|
||||||
WebHookDomain: ""
|
|
||||||
|
|
||||||
Kutt:
|
|
||||||
Enable: false
|
|
||||||
ApiURL: ""
|
|
||||||
ApiKey: ""
|
|
||||||
TargetURL: ""
|
|
||||||
Domain: ""
|
|
||||||
|
|
||||||
OpenInstall:
|
|
||||||
Enable: false
|
|
||||||
AppKey: ""
|
|
||||||
ApiKey: ""
|
|
||||||
|
|
||||||
Loki:
|
|
||||||
Enable: true
|
|
||||||
URL: "http://localhost:3100"
|
|
||||||
|
|
||||||
AppSignature:
|
|
||||||
AppSecrets:
|
|
||||||
android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET
|
|
||||||
ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET
|
|
||||||
web-client: CHANGE_ME_WEB_SIGNATURE_SECRET
|
|
||||||
ValidWindowSeconds: 300
|
|
||||||
SkipPrefixes:
|
|
||||||
- /v1/notify/
|
|
||||||
- /v1/iap/notifications
|
|
||||||
- /v1/telegram/webhook
|
|
||||||
- /v1/subscribe/config
|
|
||||||
|
|
||||||
Signature:
|
|
||||||
EnableSignature: false
|
|
||||||
|
|
||||||
device:
|
|
||||||
enable: true
|
|
||||||
security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET
|
|
||||||
|
|
||||||
Register:
|
|
||||||
EnableTrial: true
|
|
||||||
EnableTrialEmailWhitelist: true
|
|
||||||
TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com"
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
MYSQL_HOST=127.0.0.1
|
|
||||||
MYSQL_PORT=3306
|
|
||||||
MYSQL_USER=root
|
|
||||||
MYSQL_PASSWORD=CHANGE_ME
|
|
||||||
MYSQL_SOCKET=
|
|
||||||
|
|
||||||
REPL_SOURCE_HOST=ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com
|
|
||||||
REPL_SOURCE_PORT=3306
|
|
||||||
REPL_SOURCE_USER=repl
|
|
||||||
REPL_SOURCE_PASSWORD=XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer
|
|
||||||
REPL_SOURCE_SSL=1
|
|
||||||
REPL_SOURCE_LOG_FILE=mysql-bin-changelog.000189
|
|
||||||
REPL_SOURCE_LOG_POS=185053
|
|
||||||
REPL_SOURCE_AUTO_POSITION=1
|
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
REDIS_SOURCE_HOST=3.114.29.208
|
|
||||||
REDIS_SOURCE_PORT=6379
|
|
||||||
REDIS_SOURCE_USER=
|
|
||||||
REDIS_SOURCE_PASSWORD=hifast67yj
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
# Tokyo Resource Inventory
|
|
||||||
|
|
||||||
最后更新:`2026-05-21`
|
|
||||||
|
|
||||||
这个文件记录当前东京迁移的真实实施状态,不是示例。
|
|
||||||
|
|
||||||
## Region
|
|
||||||
|
|
||||||
- AWS account: `hifastvpn (200810848252)`
|
|
||||||
- Region: `ap-northeast-1`
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
- 东京迁移方案已在仓库内落地为执行资产
|
|
||||||
- 东京 VPC 已创建
|
|
||||||
- 东京子网、IGW、路由表已在 AWS 控制台创建并复核
|
|
||||||
- 东京三层安全组已在 AWS 控制台创建并复核
|
|
||||||
- 东京 VPC DNS 开关已开启,可支持公网可访问 RDS
|
|
||||||
- 东京 S3 备份桶已在 AWS 控制台创建并复核
|
|
||||||
- 东京 ACM 证书请求已创建,等待 DNS 验证
|
|
||||||
- 东京 RDS MySQL 已创建完成并可用
|
|
||||||
- 东京业务 EC2 已创建完成并绑定固定 EIP
|
|
||||||
- 因当前本机没有可用 AWS CLI 凭据,云上资源状态仍需在 AWS 控制台或已登录环境中复查
|
|
||||||
|
|
||||||
## Networking
|
|
||||||
|
|
||||||
- VPC
|
|
||||||
- Name: `ppanel-jp-prod`
|
|
||||||
- VPC ID: `vpc-0846b23b4a7d64eac`
|
|
||||||
- CIDR: `10.20.0.0/16`
|
|
||||||
- Status: `created`
|
|
||||||
- Public subnet A
|
|
||||||
- Name: `ppanel-jp-public-a`
|
|
||||||
- AZ: `ap-northeast-1a`
|
|
||||||
- CIDR: `10.20.0.0/24`
|
|
||||||
- Subnet ID: `subnet-091232bdb53e71490`
|
|
||||||
- Status: `created`
|
|
||||||
- Public subnet C
|
|
||||||
- Name: `ppanel-jp-public-c`
|
|
||||||
- AZ: `ap-northeast-1c`
|
|
||||||
- CIDR: `10.20.1.0/24`
|
|
||||||
- Subnet ID: `subnet-01ba0975c525ce8cf`
|
|
||||||
- Status: `created`
|
|
||||||
- Private subnet A
|
|
||||||
- Name: `ppanel-jp-private-a`
|
|
||||||
- AZ: `ap-northeast-1a`
|
|
||||||
- CIDR: `10.20.10.0/24`
|
|
||||||
- Subnet ID: `subnet-0bd13111c02f0edbe`
|
|
||||||
- Status: `created`
|
|
||||||
- Private subnet C
|
|
||||||
- Name: `ppanel-jp-private-c`
|
|
||||||
- AZ: `ap-northeast-1c`
|
|
||||||
- CIDR: `10.20.11.0/24`
|
|
||||||
- Subnet ID: `subnet-0d86c5c756dbc84b2`
|
|
||||||
- Status: `created`
|
|
||||||
- Internet Gateway
|
|
||||||
- Name: `ppanel-jp-igw`
|
|
||||||
- IGW ID: `igw-028041bcbf63b672c`
|
|
||||||
- Status: `created`
|
|
||||||
- Public route table
|
|
||||||
- Name: `ppanel-jp-public-rt`
|
|
||||||
- Route Table ID: `rtb-061b101080e4800e5`
|
|
||||||
- Default route: `0.0.0.0/0 -> igw-028041bcbf63b672c`
|
|
||||||
- Status: `created`
|
|
||||||
- Private route table
|
|
||||||
- Name: `ppanel-jp-private-rt`
|
|
||||||
- Route Table ID: `rtb-0d7a191a515031c45`
|
|
||||||
- Status: `created`
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- `sg-alb`
|
|
||||||
- Name: `ppanel-jp-sg-alb`
|
|
||||||
- Security Group ID: `sg-0b3a23c31041a5a5a`
|
|
||||||
- Inbound:
|
|
||||||
- `80/tcp <- 0.0.0.0/0`
|
|
||||||
- `443/tcp <- 0.0.0.0/0`
|
|
||||||
- Status: `created`
|
|
||||||
- `sg-ec2`
|
|
||||||
- Name: `ppanel-jp-sg-ec2`
|
|
||||||
- Security Group ID: `sg-01f2a5a81e7505c91`
|
|
||||||
- Inbound:
|
|
||||||
- `80/tcp <- sg-0b3a23c31041a5a5a`
|
|
||||||
- `22/tcp <- 64.118.144.142/32`
|
|
||||||
- `6379/tcp <- 104.238.220.230/32`
|
|
||||||
- Status: `created`
|
|
||||||
- `sg-rds`
|
|
||||||
- Name: `ppanel-jp-sg-rds`
|
|
||||||
- Security Group ID: `sg-0b71db1e2c18b57c0`
|
|
||||||
- Inbound:
|
|
||||||
- `3306/tcp <- sg-01f2a5a81e7505c91`
|
|
||||||
- `3306/tcp <- 104.238.220.230/32`
|
|
||||||
- Status: `created`
|
|
||||||
|
|
||||||
## Compute / Database / Edge
|
|
||||||
|
|
||||||
- EC2 `ppanel-app-jp-01`:
|
|
||||||
- Instance ID: `i-07839130074cd7ed9`
|
|
||||||
- Type: `c7i.xlarge`
|
|
||||||
- Platform: `Ubuntu 26.04 / Linux`
|
|
||||||
- AZ: `ap-northeast-1c`
|
|
||||||
- VPC: `ppanel-jp-prod (vpc-0846b23b4a7d64eac)`
|
|
||||||
- Subnet: `ppanel-jp-public-c (subnet-01ba0975c525ce8cf)`
|
|
||||||
- Private IP: `10.20.1.168`
|
|
||||||
- Public IP / Elastic IP: `3.114.29.208`
|
|
||||||
- Public DNS: `ec2-3-114-29-208.ap-northeast-1.compute.amazonaws.com`
|
|
||||||
- Security group: `ppanel-jp-sg-ec2 (sg-01f2a5a81e7505c91)`
|
|
||||||
- Key pair: `ppanel-jp-key-20260521`
|
|
||||||
- Root volume: `gp3 100GiB`
|
|
||||||
- ENI: `eni-08accb427a470c9f7`
|
|
||||||
- EIP allocation ID: `eipalloc-038b32d5c0119accf`
|
|
||||||
- EIP association ID: `eipassoc-09184aa9161b6a4d9`
|
|
||||||
- Status: `running`
|
|
||||||
- SSH recovery private key: `deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery`
|
|
||||||
- SSH recovery public key: `deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery.pub`
|
|
||||||
- RDS subnet group:
|
|
||||||
- Name: `ppanel-jp-rds-subnet-group`
|
|
||||||
- VPC: `vpc-0846b23b4a7d64eac`
|
|
||||||
- Subnets:
|
|
||||||
- `subnet-0bd13111c02f0edbe` / `ppanel-jp-private-a`
|
|
||||||
- `subnet-0d86c5c756dbc84b2` / `ppanel-jp-private-c`
|
|
||||||
- Status: `created`
|
|
||||||
- RDS public subnet group:
|
|
||||||
- Name: `ppanel-jp-rds-public-subnet-group`
|
|
||||||
- VPC: `vpc-0846b23b4a7d64eac`
|
|
||||||
- Subnets:
|
|
||||||
- `subnet-091232bdb53e71490` / `ppanel-jp-public-a`
|
|
||||||
- `subnet-01ba0975c525ce8cf` / `ppanel-jp-public-c`
|
|
||||||
- Status: `created`
|
|
||||||
- RDS `ppanel-mysql-jp`:
|
|
||||||
- Engine: `MySQL Community 8.4.8`
|
|
||||||
- Class: `db.r7g.xlarge`
|
|
||||||
- Storage: `gp3 100GiB`
|
|
||||||
- Deployment: `Single instance (current actual state)`
|
|
||||||
- VPC: `ppanel-jp-prod (vpc-0846b23b4a7d64eac)`
|
|
||||||
- Subnet group: `ppanel-jp-rds-public-subnet-group`
|
|
||||||
- Security group: `ppanel-jp-sg-rds (sg-0b71db1e2c18b57c0)`
|
|
||||||
- Master username: `admin`
|
|
||||||
- Credential management: `self-managed`
|
|
||||||
- Secrets Manager managed password: `disabled`
|
|
||||||
- Current master password visibility: `not retrievable from AWS console; reset only`
|
|
||||||
- Public access: `enabled (set at creation time for external replication)`
|
|
||||||
- Status: `available`
|
|
||||||
- Endpoint: `ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com`
|
|
||||||
- Public IP (resolved via public DNS): `52.196.204.186`
|
|
||||||
- Connection test from Tokyo EC2:
|
|
||||||
- `mysql -h ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com -u admin -e "select 1"`
|
|
||||||
- Result: `ERROR 1045 (28000): Access denied for user 'admin'@'ip-10-20-1-168.ap-northeast-1.compute.internal' (using password: NO)`
|
|
||||||
- Meaning: `network path and security group are working; only the password is missing`
|
|
||||||
- Current admin password: `TkyRds20260521!N9mQ8sKe2vLp7Xa`
|
|
||||||
- External replica prep for `104.238.220.230`:
|
|
||||||
- binlog retention hours: `24`
|
|
||||||
- replication user: `repl@104.238.220.230`
|
|
||||||
- replication password: `XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer`
|
|
||||||
- current binlog file: `mysql-bin-changelog.000189`
|
|
||||||
- current binlog position: `185053`
|
|
||||||
- ALB `ppanel-alb-jp`: `not created`
|
|
||||||
- WAF `ppanel-waf-jp`: `not created`
|
|
||||||
- ACM certificate in `ap-northeast-1`:
|
|
||||||
- Certificate ID: `29d0b9b6-ab37-44d9-ad9e-18fa7e9aae3a`
|
|
||||||
- Domains:
|
|
||||||
- `api-jp.hifast.biz`
|
|
||||||
- `logs-jp.hifast.biz`
|
|
||||||
- Status: `pending_validation`
|
|
||||||
- Route 53 hosted zone in current AWS account: `not found`
|
|
||||||
- S3 backup bucket `hifast-prod-backups-200810848252-ap-northeast-1`: `created`
|
|
||||||
|
|
||||||
## Domains
|
|
||||||
|
|
||||||
- Parallel API domain: `api-jp.hifast.biz`
|
|
||||||
- Parallel logs domain: `logs-jp.hifast.biz`
|
|
||||||
- Production API domain: `pending user final confirmation`
|
|
||||||
- ACM DNS validation records pending external DNS add:
|
|
||||||
- `api-jp.hifast.biz`
|
|
||||||
- Name: `_0de5970dfbadaf46759447b2ea627a10.api-jp.hifast.biz.`
|
|
||||||
- Type: `CNAME`
|
|
||||||
- Value: `_6a632a5b85c5b3f304cc492090b741b3.jkddzztszm.acm-validations.aws.`
|
|
||||||
- `logs-jp.hifast.biz`
|
|
||||||
- Name: `_349a2b2bc4678d76c3ab341ccf73db61.logs-jp.hifast.biz.`
|
|
||||||
- Type: `CNAME`
|
|
||||||
- Value: `_74ced20dc96aa39070188605cf0ced18.jkddzztszm.acm-validations.aws.`
|
|
||||||
|
|
||||||
## DR
|
|
||||||
|
|
||||||
- DR host: `104.238.220.230`
|
|
||||||
- Planned MySQL upstream after cutover: `Tokyo RDS`
|
|
||||||
- Planned Redis upstream after cutover: `Tokyo EC2 public IP`
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Tokyo Resource Inventory Example
|
|
||||||
|
|
||||||
Use this file as the single source of truth while building the Tokyo environment.
|
|
||||||
|
|
||||||
## Region
|
|
||||||
|
|
||||||
- AWS account: `hifastvpn (200810848252)`
|
|
||||||
- Region: `ap-northeast-1`
|
|
||||||
|
|
||||||
## DNS
|
|
||||||
|
|
||||||
- Production API domain: `CHANGE_ME`
|
|
||||||
- Parallel API domain: `api-jp.hifast.biz`
|
|
||||||
- Parallel logs domain: `logs-jp.hifast.biz`
|
|
||||||
|
|
||||||
## Networking
|
|
||||||
|
|
||||||
- VPC name: `ppanel-jp-prod`
|
|
||||||
- VPC CIDR: `10.20.0.0/16`
|
|
||||||
- Public subnet A: `10.20.0.0/24`
|
|
||||||
- Public subnet C: `10.20.1.0/24`
|
|
||||||
- Private subnet A: `10.20.10.0/24`
|
|
||||||
- Private subnet C: `10.20.11.0/24`
|
|
||||||
- Ops CIDR for SSH: `CHANGE_ME`
|
|
||||||
|
|
||||||
## Compute
|
|
||||||
|
|
||||||
- EC2 name: `ppanel-app-jp-01`
|
|
||||||
- EC2 type: `t4g.large`
|
|
||||||
- EC2 disk: `gp3 80GB`
|
|
||||||
- SSH key pair: `CHANGE_ME`
|
|
||||||
|
|
||||||
## Database
|
|
||||||
|
|
||||||
- RDS identifier: `ppanel-mysql-jp`
|
|
||||||
- RDS engine: `MySQL 8.4`
|
|
||||||
- RDS class: `db.r7g.xlarge`
|
|
||||||
- RDS storage: `gp3 100GB`
|
|
||||||
- DB name: `hifast`
|
|
||||||
- DB admin user: `admin`
|
|
||||||
|
|
||||||
## Cache
|
|
||||||
|
|
||||||
- Redis container: `hifast-redis`
|
|
||||||
- Redis port: `6379`
|
|
||||||
- Redis password: `CHANGE_ME`
|
|
||||||
|
|
||||||
## Security / Secrets
|
|
||||||
|
|
||||||
- JWT secret: `CHANGE_ME`
|
|
||||||
- Admin email: `CHANGE_ME`
|
|
||||||
- Admin password: `CHANGE_ME`
|
|
||||||
- Android app signature secret: `CHANGE_ME`
|
|
||||||
- iOS app signature secret: `CHANGE_ME`
|
|
||||||
- Web app signature secret: `CHANGE_ME`
|
|
||||||
- Device security secret: `CHANGE_ME`
|
|
||||||
|
|
||||||
## Backup
|
|
||||||
|
|
||||||
- S3 backup bucket: `hifast-prod-backups-200810848252-ap-northeast-1`
|
|
||||||
- Versioning: `Enabled`
|
|
||||||
|
|
||||||
## DR
|
|
||||||
|
|
||||||
- DR host: `104.238.220.230`
|
|
||||||
- MySQL repl user: `repl`
|
|
||||||
- MySQL repl password: `CHANGE_ME`
|
|
||||||
- Redis source password: `CHANGE_ME`
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
|
||||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
|
||||||
QyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1YwAAAKBaQXT3WkF0
|
|
||||||
9wAAAAtzc2gtZWQyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1Yw
|
|
||||||
AAAEArWQWWwPoJp+za5JhSnrE0Pw1/TtqWRrdY5e8hULqYDGwnFkOMq9oh1WN6vdAwdFjD
|
|
||||||
jfCRWfMe6sDZNCoeWvVjAAAAF0FwcGxlQE1hY0Jvb2stUHJvLmxvY2FsAQIDBAUG
|
|
||||||
-----END OPENSSH PRIVATE KEY-----
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGwnFkOMq9oh1WN6vdAwdFjDjfCRWfMe6sDZNCoeWvVj Apple@MacBook-Pro.local
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80 default_server;
|
|
||||||
listen [::]:80 default_server;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
client_max_body_size 20m;
|
|
||||||
|
|
||||||
access_log /var/log/nginx/ppanel-access.log;
|
|
||||||
error_log /var/log/nginx/ppanel-error.log warn;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_pass http://127.0.0.1:8080;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
|
||||||
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
proxy_send_timeout 60s;
|
|
||||||
proxy_read_timeout 60s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /nginx_status {
|
|
||||||
stub_status;
|
|
||||||
access_log off;
|
|
||||||
allow 127.0.0.1;
|
|
||||||
deny all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Hifast MySQL backup to S3
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
EnvironmentFile=/root/backup-to-s3.env
|
|
||||||
ExecStart=/usr/bin/env bash -lc 'exec /opt/ppanel/deploy/scripts/mysql_backup_to_s3.sh'
|
|
||||||
Nice=10
|
|
||||||
IOSchedulingClass=best-effort
|
|
||||||
IOSchedulingPriority=7
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Run Hifast MySQL backup to S3 every 10 minutes
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnCalendar=*:0/10
|
|
||||||
Persistent=true
|
|
||||||
RandomizedDelaySec=30
|
|
||||||
Unit=hifast-mysql-backup.service
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# 开发流程(迁移到 GitHub 后)
|
||||||
|
|
||||||
|
> 适用:`github.com/TawCorp/hifast-server`(canonical 仓库)及配套的 Multica agent 工作区。
|
||||||
|
> 历史背景:本仓库于 2026-06-03 从 `git.kxsw.us/HI-VPN/hi-server` 迁移到 GitHub。**`git.kxsw.us` 已废弃**,所有新开发只走本仓库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. TL;DR
|
||||||
|
|
||||||
|
```
|
||||||
|
Multica issue → 分支 fix/<num>-中文简述 → 推 github → 开 PR → CI 绿 → 架构师 approve
|
||||||
|
→ GitHub UI Squash and merge 进 internal → 测试环境部署 (deploy-staging.yml) 自动跑
|
||||||
|
→ QA 在 staging 验收 → Multica issue → done
|
||||||
|
|
||||||
|
发版时:架构师把 internal squash 进 main → 对外正式版本
|
||||||
|
```
|
||||||
|
|
||||||
|
不要在 GitHub UI 之外 squash 后直接 push `internal` / `main`。不要往 `git.kxsw.us` 推。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 分支模型
|
||||||
|
|
||||||
|
| 分支 | 角色 | 写入方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| `main` | 对外正式版本(生产) | **只**由架构师 squash merge `internal` → `main` |
|
||||||
|
| `internal` | 日常开发主干,staging 触发分支 | **只**由架构师在 GitHub UI 上 Squash and merge PR |
|
||||||
|
| `fix/<num>-…` | bug 修复分支 | 工程师自己开自己删(PR 合并后 GitHub 自动删) |
|
||||||
|
| `feat/<num>-…` | 新功能分支 | 同上 |
|
||||||
|
| `chore/…` `refactor/…` `hotfix/…` | 杂项 / 重构 / 紧急修复 | 同上 |
|
||||||
|
|
||||||
|
**分支命名约定**(严格遵守,便于 CI / CODEOWNERS / 历史追溯):
|
||||||
|
|
||||||
|
| 前缀 | 用途 | 示例 |
|
||||||
|
|---|---|---|
|
||||||
|
| `fix/<num>-` | 关联 Multica issue 编号的 bug 修复 | `fix/143-邀请权益单测flake` |
|
||||||
|
| `feat/<num>-` | 关联 Multica issue 编号的新功能 | `feat/77-套餐列表促销信息` |
|
||||||
|
| `chore/` | 配置 / 文档 / 工具链(无关联 issue 可不带编号) | `chore/dev-workflow-docs` |
|
||||||
|
| `hotfix/<num>-` | 生产紧急修复 | `hotfix/151-payment-callback-503` |
|
||||||
|
| `refactor/<num>-` | 重构(行为不变) | `refactor/138-promo-eligibility` |
|
||||||
|
|
||||||
|
**单分支存活上限:3 天**(架构师 CLAUDE.md 约定)。超时未合并的分支由架构师在 issue 上 ping 持有者收尾或重切。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 标准 PR 生命周期
|
||||||
|
|
||||||
|
### 2.1 立项
|
||||||
|
|
||||||
|
- Multica 上有对应 issue(HIF-XXX)。
|
||||||
|
- issue 已 assigned,状态 `todo` 或 `in_progress`。
|
||||||
|
|
||||||
|
### 2.2 写代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 worktree 里
|
||||||
|
git fetch origin
|
||||||
|
git checkout -B fix/<num>-中文简述 origin/internal
|
||||||
|
|
||||||
|
# 编码 + 写测试
|
||||||
|
# lefthook pre-commit 会自动跑 fmt / imports / lint / vet / test
|
||||||
|
git commit -m "修复(#<num>): 一句话说清楚做了什么"
|
||||||
|
|
||||||
|
# 推到 github(不再推 git.kxsw.us)
|
||||||
|
git push origin fix/<num>-中文简述
|
||||||
|
```
|
||||||
|
|
||||||
|
**commit message**(commitlint 强制):
|
||||||
|
|
||||||
|
```
|
||||||
|
<类型>(#<num>): <一句话描述,<= 72 字符>
|
||||||
|
|
||||||
|
<可选正文,说明 why>
|
||||||
|
```
|
||||||
|
|
||||||
|
类型只有这五个:**`修复` / `新功能` / `重构` / `文档` / `配置`**。其它一律不允许。
|
||||||
|
|
||||||
|
### 2.3 开 PR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create --base internal --title '修复(#<num>): ...' --body-file <path>
|
||||||
|
```
|
||||||
|
|
||||||
|
或 GitHub UI 开。PR 模板(`.github/PULL_REQUEST_TEMPLATE.md`)会自动填入,按指引补内容。
|
||||||
|
|
||||||
|
**PR 标题必须等于 squash 后的合入 commit 标题**(用 `<类型>(#<num>): ...` 形式)。
|
||||||
|
|
||||||
|
### 2.4 CI 自动跑
|
||||||
|
|
||||||
|
`.github/workflows/ci.yml` 在 PR 上触发:
|
||||||
|
|
||||||
|
| Job | 执行 |
|
||||||
|
|---|---|
|
||||||
|
| `build-and-test` | `go build ./...` + `go vet ./...` + `go test -race -count=1 ./...` |
|
||||||
|
| `lint` | `golangci-lint run` |
|
||||||
|
|
||||||
|
红 → 工程师本地复现 + 修,反复直到全绿。
|
||||||
|
|
||||||
|
### 2.5 Code review
|
||||||
|
|
||||||
|
- `CODEOWNERS` 自动 request review。
|
||||||
|
- 架构师(或被指派的 reviewer)逐 hunk 看,特别关注:
|
||||||
|
- scope 与 issue 一致性(无 scope creep——架构师红线之一)
|
||||||
|
- 错误处理 / SQL 注入 / N+1
|
||||||
|
- 测试覆盖关键边界
|
||||||
|
- 是否引入了无关的代码改动(架构师红线:"发现成员修改了无关代码 → 立即打回重做")
|
||||||
|
- review 通过即在 PR 上点 Approve。
|
||||||
|
|
||||||
|
### 2.6 Merge to `internal`
|
||||||
|
|
||||||
|
- **必须用 GitHub UI 的 "Squash and merge"**。
|
||||||
|
- squash 后 commit message 由架构师编辑确认:保持 `<类型>(#<num>): ...` 形式 + 必要正文。
|
||||||
|
- 合并按钮按下后 PR 自动 close,分支由 GitHub 自动删("Automatically delete head branches" 应开启)。
|
||||||
|
- 架构师在 Multica issue 上 `@运维工程师` 附 commit hash,通知可以部署(虽然测试环境部署 workflow 已自动跑,但运维需要确认部署状态)。
|
||||||
|
|
||||||
|
**禁止做的事**:
|
||||||
|
- ❌ 在本地 squash 再 `git push` 到 `internal` / `main`
|
||||||
|
- ❌ Rebase merge / Create a merge commit(保持 linear history)
|
||||||
|
- ❌ Force push 到 `internal` / `main`
|
||||||
|
- ❌ Bypass CI(CI 红时不要 merge)
|
||||||
|
- ❌ 自己 approve 自己的 PR
|
||||||
|
- ❌ 未经测试工程师验收的分支合并(架构师红线)
|
||||||
|
|
||||||
|
### 2.7 测试环境部署自动触发 (`.github/workflows/deploy-staging.yml`)
|
||||||
|
|
||||||
|
合并到 `internal` 后,`.github/workflows/deploy-staging.yml` 自动触发:
|
||||||
|
|
||||||
|
1. `go test ./...`(已被 CI 保证过,理论上不会再红)
|
||||||
|
2. Build Docker image `registry.kxsw.us/vpn-server:<sha>` + `:staging`
|
||||||
|
3. scp `docker-compose.cloud.yml` 到 staging host
|
||||||
|
4. ssh 重启 `ppanel-server` 服务
|
||||||
|
5. healthcheck + Telegram 通知
|
||||||
|
|
||||||
|
部署失败 → Telegram 告警 → 运维介入回滚。Deploy 不阻塞下一个 PR,但**修复 deploy 是当前 deploy 失败者的责任**。
|
||||||
|
|
||||||
|
### 2.8 QA 验收
|
||||||
|
|
||||||
|
- 测试工程师在 staging(`tapi.hifast.biz` / 配套前端)按 issue 描述的 acceptance criteria 验收。
|
||||||
|
- 验收通过 → Multica issue 推 `done`。
|
||||||
|
- 任何一项失败 → 单独开子 issue 指派对应工程师,**不要**回退 PR / revert(除非生产数据安全风险)。
|
||||||
|
|
||||||
|
### 2.9 发版到 `main`(对外正式版本)
|
||||||
|
|
||||||
|
由架构师在合适的时机(feature 集齐、staging 跑稳一段时间后)执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create --base main --head internal --title '发版: <版本号>'
|
||||||
|
```
|
||||||
|
|
||||||
|
走和普通 PR 一样的流程(CI 绿 + review + Squash and merge)。`main` 的 push 也可以触发后续生产部署 workflow(如未来增加 `deploy-production.yml`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Multica issue 状态映射
|
||||||
|
|
||||||
|
| Multica 状态 | 对应阶段 |
|
||||||
|
|---|---|
|
||||||
|
| `backlog` | 还没排期 |
|
||||||
|
| `todo` | 已排期,待 assigned agent 开始 |
|
||||||
|
| `in_progress` | 分支已开始写,未 push |
|
||||||
|
| `in_review` | PR 已开,等 review / CI / merge |
|
||||||
|
| `done` | PR merged + 测试环境部署通过 + QA 验收通过 |
|
||||||
|
|
||||||
|
**三个条件没全满足就不要标 done**——否则验收链路看不到真问题。
|
||||||
|
|
||||||
|
Issue metadata 建议字段(与现有 agent CLAUDE.md 推荐一致):
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `pr_url` | https://github.com/TawCorp/hifast-server/pull/XX |
|
||||||
|
| `merge_commit` | merge 后的 squash commit SHA |
|
||||||
|
| `deploy_url` | `tapi.hifast.biz` 或对应前端域名 |
|
||||||
|
| `pipeline_status` | `coding` / `pr_open` / `merged` / `deployed` / `qa_passed` |
|
||||||
|
| `waiting_on` | 当前阻塞点(如 `qa_e2e_access` / `architect_review`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Agent 边界
|
||||||
|
|
||||||
|
| Agent | 可以做 | 不可以做 |
|
||||||
|
|---|---|---|
|
||||||
|
| 后端 / 前端 / 运维工程师 | push 自己的 fix/feat 分支;开 PR;回评 issue;本地 squash 自己分支 | 合 PR;push `internal` / `main`;改 CODEOWNERS;自己 approve 自己 PR |
|
||||||
|
| 架构师(Squad Leader) | review;approve;GitHub UI squash merge;在 issue 上拆解需求 + 分派子任务;维护 doc/ 和 CLAUDE.md | 在本地 merge 后 push `internal`(绕过 CI gate);直接编写业务逻辑或 UI 代码 |
|
||||||
|
| 测试工程师 | 在 staging 验收;回评 issue;开子 issue 报 bug | 改 prod 代码;改 CI workflow;自己合 PR |
|
||||||
|
| 仓库 owner(人类) | 任何越界操作 + 流程治理决策(如 branch protection 升级) | — |
|
||||||
|
|
||||||
|
任何越界都需要在 issue 上声明 + 走另一个 PR。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 平台层约束的现状(重要)
|
||||||
|
|
||||||
|
> 私有仓库 + 当前 GitHub plan 不支持 branch protection / rulesets API(HTTP 403)。
|
||||||
|
|
||||||
|
这意味着 "禁止直接 push internal" / "require CI pass" / "require approval" 这些**在 GitHub 平台层面没法硬强制**。当前依赖三层软约束:
|
||||||
|
|
||||||
|
1. **客户端层**:`lefthook.yml` 的 `pre-push` 钩子会在尝试直接 push `internal` / `main` 时报错。绕过去要明确加 `--no-verify`,会留 git trailers。
|
||||||
|
2. **流程层**:本文档 + `CODEOWNERS` 自动 request review + 架构师作为单一合并执行人。
|
||||||
|
3. **审计层**:所有 merge 都对应 Multica issue,事后任何"非 PR 路径上去的 commit"都能在 git log + Multica 对照查出来。
|
||||||
|
|
||||||
|
如果未来组织规模扩大、人类协作者增多,建议升级 GitHub Team($4/user/月)拿到 branch protection 平台保障。**目前规模下软约束足够。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 常见场景
|
||||||
|
|
||||||
|
### Hotfix(生产紧急修复)
|
||||||
|
|
||||||
|
走和 fix 同样的流程,只是分支前缀 `hotfix/`,PR 标题前加 `[HOTFIX]`。架构师可酌情把 review 等待时间压缩到 30 分钟以内。Hotfix 通常直接合 `internal` 后立即由架构师再开 `internal → main` 的 PR 一起发版。
|
||||||
|
|
||||||
|
### Revert
|
||||||
|
|
||||||
|
- 在 GitHub UI 上找到要 revert 的 PR,点 "Revert"。
|
||||||
|
- GitHub 会生成 revert PR,按正常流程过 review + merge。
|
||||||
|
- Revert PR 标题用 `修复(#<原 issue 编号>): revert <原 PR 标题>`。
|
||||||
|
|
||||||
|
### 文档 / 配置改动
|
||||||
|
|
||||||
|
走 `chore/` 分支。CI 一样跑(保险),review 可走 fast-track。
|
||||||
|
|
||||||
|
### 跨多个 issue 的大改动
|
||||||
|
|
||||||
|
- 优先拆成多个独立 PR,每个 PR 对应一个 issue。
|
||||||
|
- 如果实在拆不开,PR title 用 `<类型>(#XXX/#YYY/#ZZZ): ...`,PR body 里 `Closes` 三个。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. FAQ
|
||||||
|
|
||||||
|
**Q: 为什么不允许在本地 squash 再推 internal?**
|
||||||
|
A: 绕过 GitHub PR review 流程 + CI gate + 审计记录。即使你确定改动 100% 正确,也走 PR——这是文化约束,避免架构师红线被逐渐侵蚀。
|
||||||
|
|
||||||
|
**Q: CI 跑得慢,PR 一直 yellow,可以先 merge 吗?**
|
||||||
|
A: 不可以。CI 通常 5 分钟内出结果;如果超过 15 分钟仍 pending,检查是否有 stuck job,必要时 re-run。
|
||||||
|
|
||||||
|
**Q: 如果架构师不在,怎么办?**
|
||||||
|
A: 备份 reviewer = 仓库 owner(@shanshanzhong147)。CODEOWNERS 已经把 owner 列为兜底 reviewer。
|
||||||
|
|
||||||
|
**Q: lefthook pre-push 不让我 push internal,但我确实需要紧急修一行小字?**
|
||||||
|
A: 没有"紧急修一行小字"这种例外。开 hotfix 分支 + 开 PR + 走 fast-track review。
|
||||||
|
|
||||||
|
**Q: Multica issue 状态没流转到 done,是不是要手动推?**
|
||||||
|
A: 不要直接推。先确认 PR merged + deploy 绿 + QA 通过三个条件全满足。任一项没满足就维持 `in_review` 并加评论说卡在哪。
|
||||||
|
|
||||||
|
**Q: 我能不能用 Rebase / Merge commit 模式合 PR?**
|
||||||
|
A: 不行。必须用 Squash and merge。`internal` 必须保持 linear history(一个 PR = 一个 commit),方便回滚和审计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 紧急联系
|
||||||
|
|
||||||
|
- Deploy 红 / staging 挂:运维工程师(Multica agent `071d94d9-38bb-43cc-8c58-29e3b52d7bd4`)
|
||||||
|
- 流程问题 / branch protection 决策:仓库 owner @shanshanzhong147
|
||||||
|
- 架构 / API 设计:架构师(Multica agent `0de10589-f101-49ef-8dfa-0e9e992fe27c`)
|
||||||
+2
-231
@@ -1,34 +1,12 @@
|
|||||||
# PPanel 服务部署 (云端/无源码版)
|
|
||||||
# 使用方法:
|
|
||||||
# 1. 确保已将 docker-compose.cloud.yml, configs/, loki/, grafana/, prometheus/, tempo/ 目录上传到服务器同一目录
|
|
||||||
# 2. 确保 configs/ 目录下有 ppanel.yaml 配置文件(参考 etc/ppanel.yaml)
|
|
||||||
# 3. 确保 logs/ cache/ tempo_data/ 目录存在 (mkdir -p logs cache tempo_data)
|
|
||||||
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
|
|
||||||
#
|
|
||||||
# 网络说明:
|
|
||||||
# ppanel-server 使用 host 网络(可出外网,直接访问 AWS RDS / 本机 Redis)
|
|
||||||
# 监控服务(Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中
|
|
||||||
# Tempo(4317) 将端口映射到 127.0.0.1,ppanel-server 通过 host 网络访问
|
|
||||||
# 监控端口绑定 127.0.0.1,需通过 SSH 隧道或 Nginx 反代访问
|
|
||||||
#
|
|
||||||
# 未来多开 ppanel-server 时:
|
|
||||||
# 修复宿主机 iptables bridge 出网规则后,可将 ppanel-server 切回 bridge 网络
|
|
||||||
# 多实例用不同端口: ports: ["8081:8080"] + container_name: ppanel-server-2
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ----------------------------------------------------
|
|
||||||
# 1. 业务后端 (PPanel Server)
|
|
||||||
# host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo
|
|
||||||
# PPANEL_SERVER_TAG 由 CI/CD 传入不可变镜像标签(如 git SHA)
|
|
||||||
# ----------------------------------------------------
|
|
||||||
ppanel-server:
|
ppanel-server:
|
||||||
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag}
|
image: ${PPANEL_SERVER_IMAGE:-registry.kxsw.us/vpn-server}:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag}
|
||||||
container_name: ppanel-server
|
container_name: ppanel-server
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./configs:/app/etc
|
- ./configs:/app/etc
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
- ./cache:/app/cache # GeoLite2-City.mmdb IP 地理位置数据库
|
- ./cache:/app/cache
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -37,215 +15,8 @@ services:
|
|||||||
nofile:
|
nofile:
|
||||||
soft: 65535
|
soft: 65535
|
||||||
hard: 65535
|
hard: 65535
|
||||||
depends_on:
|
|
||||||
tempo:
|
|
||||||
condition: service_started
|
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 2. Tempo (链路追踪存储)
|
|
||||||
# ----------------------------------------------------
|
|
||||||
tempo:
|
|
||||||
image: grafana/tempo:2.4.1
|
|
||||||
container_name: ppanel-tempo
|
|
||||||
user: root
|
|
||||||
restart: always
|
|
||||||
command:
|
|
||||||
- "-config.file=/etc/tempo.yaml"
|
|
||||||
- "-target=all"
|
|
||||||
volumes:
|
|
||||||
- ./tempo/tempo-config.yaml:/etc/tempo.yaml
|
|
||||||
- ./tempo_data:/var/tempo
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:4317:4317" # OTLP gRPC,ppanel-server(host网络)通过127.0.0.1:4317发送trace
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 3. Loki (日志存储)
|
|
||||||
# ----------------------------------------------------
|
|
||||||
loki:
|
|
||||||
image: grafana/loki:3.0.0
|
|
||||||
container_name: ppanel-loki
|
|
||||||
restart: always
|
|
||||||
volumes:
|
|
||||||
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
|
|
||||||
- loki_data:/loki
|
|
||||||
command: -config.file=/etc/loki/local-config.yaml
|
|
||||||
# 不对外暴露端口,仅内网访问
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 4. Promtail (日志采集)
|
|
||||||
# ----------------------------------------------------
|
|
||||||
promtail:
|
|
||||||
image: grafana/promtail:3.0.0
|
|
||||||
container_name: ppanel-promtail
|
|
||||||
restart: always
|
|
||||||
volumes:
|
|
||||||
- ./loki/promtail-config.yaml:/etc/promtail/config.yaml
|
|
||||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
- ./logs:/var/log/ppanel-server:ro
|
|
||||||
- /var/log/nginx:/var/log/nginx:ro
|
|
||||||
command: -config.file=/etc/promtail/config.yaml
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
depends_on:
|
|
||||||
- loki
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 5. Grafana (可观测面板)
|
|
||||||
# 访问: ssh -L 3333:localhost:3333 your-server 后浏览器打开 http://localhost:3333
|
|
||||||
# 或配置 Nginx 反代(建议加认证)
|
|
||||||
# ----------------------------------------------------
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:13.0.1
|
|
||||||
container_name: ppanel-grafana
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "3333:3000" # 仅本机可访问,需 SSH 隧道或 Nginx 反代
|
|
||||||
environment:
|
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:?请在 .env 文件中设置 GRAFANA_PASSWORD}
|
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
|
||||||
- GF_SERVER_DOMAIN=${GRAFANA_DOMAIN:-logsx.hifast.biz}
|
|
||||||
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-https://logsx.hifast.biz}
|
|
||||||
- GF_FEATURE_TOGGLES_ENABLE=appObservability
|
|
||||||
- AWS_REGION=${AWS_REGION:-ap-east-1}
|
|
||||||
- AWS_DEFAULT_REGION=${AWS_REGION:-ap-east-1}
|
|
||||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
|
|
||||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
|
|
||||||
- AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}
|
|
||||||
volumes:
|
|
||||||
- grafana_data:/var/lib/grafana
|
|
||||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
depends_on:
|
|
||||||
- loki
|
|
||||||
- tempo
|
|
||||||
- prometheus
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 6. Prometheus (指标采集)
|
|
||||||
# ----------------------------------------------------
|
|
||||||
prometheus:
|
|
||||||
image: prom/prometheus:v3.11.3
|
|
||||||
container_name: ppanel-prometheus
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:9090:9090" # 仅本机可访问
|
|
||||||
volumes:
|
|
||||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
|
||||||
- prometheus_data:/prometheus
|
|
||||||
command:
|
|
||||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
|
||||||
- '--storage.tsdb.path=/prometheus'
|
|
||||||
- '--web.enable-lifecycle'
|
|
||||||
- '--web.enable-remote-write-receiver'
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 7. Nginx Exporter (监控宿主机 Nginx)
|
|
||||||
# ----------------------------------------------------
|
|
||||||
nginx-exporter:
|
|
||||||
image: nginx/nginx-prometheus-exporter:1.5.0
|
|
||||||
container_name: ppanel-nginx-exporter
|
|
||||||
restart: always
|
|
||||||
command:
|
|
||||||
- -nginx.scrape-uri=http://host.docker.internal:8090/nginx_status
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 8. Node Exporter (宿主机监控)
|
|
||||||
# ----------------------------------------------------
|
|
||||||
node-exporter:
|
|
||||||
image: prom/node-exporter:v1.11.1
|
|
||||||
container_name: ppanel-node-exporter
|
|
||||||
restart: always
|
|
||||||
volumes:
|
|
||||||
- /proc:/host/proc:ro
|
|
||||||
- /sys:/host/sys:ro
|
|
||||||
- /:/rootfs:ro
|
|
||||||
command:
|
|
||||||
- '--path.procfs=/host/proc'
|
|
||||||
- '--path.sysfs=/host/sys'
|
|
||||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 9. cAdvisor (容器监控)
|
|
||||||
# ----------------------------------------------------
|
|
||||||
cadvisor:
|
|
||||||
image: gcr.io/cadvisor/cadvisor:v0.55.1
|
|
||||||
container_name: ppanel-cadvisor
|
|
||||||
restart: always
|
|
||||||
volumes:
|
|
||||||
- /:/rootfs:ro
|
|
||||||
- /var/run:/var/run:ro
|
|
||||||
- /sys:/sys:ro
|
|
||||||
- /var/lib/docker/:/var/lib/docker:ro
|
|
||||||
- /dev/disk/:/dev/disk:ro
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
loki_data:
|
|
||||||
grafana_data:
|
|
||||||
prometheus_data:
|
|
||||||
tempo_data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
ppanel_net:
|
|
||||||
name: ppanel_net
|
|
||||||
driver: bridge
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ppanel:
|
ppanel:
|
||||||
container_name: ppanel-server
|
container_name: ppanel-server
|
||||||
|
|||||||
+8
-8
@@ -15,10 +15,10 @@ Logger: # 日志配置
|
|||||||
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
||||||
|
|
||||||
MySQL:
|
MySQL:
|
||||||
Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
Addr: 127.0.0.1:3306 # 本地开发默认;Docker bridge 模式可改为 mysql:3306
|
||||||
Username: root # MySQL用户名
|
Username: root # MySQL用户名
|
||||||
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
Password: CHANGE_ME_TO_DB_PASSWORD # MySQL密码
|
||||||
Dbname: hifast # MySQL数据库名
|
Dbname: ppanel # MySQL数据库名
|
||||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||||
MaxIdleConns: 10
|
MaxIdleConns: 10
|
||||||
MaxOpenConns: 100
|
MaxOpenConns: 100
|
||||||
@@ -42,9 +42,9 @@ Redis:
|
|||||||
|
|
||||||
AppSignature:
|
AppSignature:
|
||||||
AppSecrets:
|
AppSecrets:
|
||||||
android-client: uB4G,XxL2{7b # Android 客户端签名密钥
|
android-client: CHANGE_ME_ANDROID_APP_SECRET # Android 客户端签名密钥
|
||||||
ios-client: uB4G,XxL2{7b # iOS 客户端签名密钥
|
ios-client: CHANGE_ME_IOS_APP_SECRET # iOS 客户端签名密钥
|
||||||
web-client: uB4G,XxL2{7b # Web 客户端签名密钥
|
web-client: CHANGE_ME_WEB_APP_SECRET # Web 客户端签名密钥
|
||||||
ValidWindowSeconds: 300 # 签名时间窗口(秒)
|
ValidWindowSeconds: 300 # 签名时间窗口(秒)
|
||||||
SkipPrefixes:
|
SkipPrefixes:
|
||||||
- /v1/notify/ # 支付回调不验签
|
- /v1/notify/ # 支付回调不验签
|
||||||
@@ -58,8 +58,8 @@ Signature:
|
|||||||
Trace: # 链路追踪配置 (OpenTelemetry)
|
Trace: # 链路追踪配置 (OpenTelemetry)
|
||||||
Name: ppanel # 服务名
|
Name: ppanel # 服务名
|
||||||
Sampler: 1.0 # 采样率 0.0-1.0,生产建议 0.1
|
Sampler: 1.0 # 采样率 0.0-1.0,生产建议 0.1
|
||||||
Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc
|
Batcher: "" # 本地开发留空;生产如需链路追踪再配置 exporter
|
||||||
Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317
|
Endpoint: ""
|
||||||
|
|
||||||
S3:
|
S3:
|
||||||
Enable: false
|
Enable: false
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ require (
|
|||||||
github.com/alibabacloud-go/tea-utils v1.4.5 // indirect
|
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-utils/v2 v2.0.7 // indirect
|
||||||
github.com/alibabacloud-go/tea-xml v1.1.3 // 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/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/aws/protocol/eventstream v1.7.10 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // 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/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // 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/exporters/otlp/otlptrace v1.29.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.3.1 // 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.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 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
|
||||||
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
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.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.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||||
github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA=
|
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.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
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/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 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
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=
|
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 9 COMMENT '前端九宫格数量(3/6/8/9/12)',
|
||||||
|
`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='抽奖人工奖领奖工单';
|
||||||
@@ -70,3 +70,6 @@ const RegisterIpKeyPrefix = "register:ip:"
|
|||||||
|
|
||||||
// UserSessionsKeyPrefix per-user sessions zset key prefix
|
// UserSessionsKeyPrefix per-user sessions zset key prefix
|
||||||
const UserSessionsKeyPrefix = "auth:user_sessions:"
|
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"`
|
Currency Currency `yaml:"Currency"`
|
||||||
Trace trace.Config `yaml:"Trace"`
|
Trace trace.Config `yaml:"Trace"`
|
||||||
S3 S3Config `yaml:"S3"`
|
S3 S3Config `yaml:"S3"`
|
||||||
|
Lottery LotteryConfig `yaml:"Lottery"`
|
||||||
Administrator struct {
|
Administrator struct {
|
||||||
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
||||||
Password string `yaml:"Password" default:"password"`
|
Password string `yaml:"Password" default:"password"`
|
||||||
@@ -250,6 +251,13 @@ type InviteConfig struct {
|
|||||||
GiftDays int64 `yaml:"GiftDays" default:"3"`
|
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 短链接服务配置
|
// KuttConfig Kutt 短链接服务配置
|
||||||
type KuttConfig struct {
|
type KuttConfig struct {
|
||||||
Enable bool `yaml:"Enable" default:"false"` // 是否启用 Kutt 短链接
|
Enable bool `yaml:"Enable" default:"false"` // 是否启用 Kutt 短链接
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// Package lottery contains gin handlers for the admin-side lottery endpoints.
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 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 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/perfect-panel/server/internal/logic/admin/user"
|
"github.com/perfect-panel/server/internal/logic/admin/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
@@ -15,31 +12,18 @@ import (
|
|||||||
func UpdateUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
func UpdateUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req types.UpdateUserSubscribeRequest
|
var req types.UpdateUserSubscribeRequest
|
||||||
_ = c.ShouldBind(&req)
|
if err := c.ShouldBind(&req); err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
validateErr := svcCtx.Validate(&req)
|
validateErr := svcCtx.Validate(&req)
|
||||||
if validateErr != nil {
|
if validateErr != nil {
|
||||||
result.ParamErrorResult(c, validateErr)
|
result.ParamErrorResult(c, validateErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateUpdateUserSubscribeTrafficLimit(&req); err != nil {
|
|
||||||
result.ParamErrorResult(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := user.NewUpdateUserSubscribeLogic(c.Request.Context(), svcCtx)
|
l := user.NewUpdateUserSubscribeLogic(c.Request.Context(), svcCtx)
|
||||||
err := l.UpdateUserSubscribe(&req)
|
err := l.UpdateUserSubscribe(&req)
|
||||||
result.HttpResult(c, nil, err)
|
result.HttpResult(c, nil, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateUpdateUserSubscribeTrafficLimit(req *types.UpdateUserSubscribeRequest) error {
|
|
||||||
if req.TrafficLimit == nil || *req.TrafficLimit == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var rules []types.TrafficLimit
|
|
||||||
if err := json.Unmarshal([]byte(*req.TrafficLimit), &rules); err != nil {
|
|
||||||
return errors.New("traffic_limit must be a valid JSON array")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestUpdateUserSubscribeHandlerRejectsInvalidLimits(t *testing.T) {
|
|||||||
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"speed_limit":-1}`,
|
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"speed_limit":-1}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid traffic limit json",
|
name: "invalid traffic limit type",
|
||||||
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"traffic_limit":"not-json"}`,
|
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"traffic_limit":"not-json"}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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.POST("/prizes", adminLottery.CreateLotteryPrizeHandler(serverCtx))
|
||||||
|
adminGroup.PUT("/prizes", adminLottery.UpdateLotteryPrizeHandler(serverCtx))
|
||||||
|
adminGroup.DELETE("/prizes", 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
"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) {
|
func QueryWithdrawalLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req types.QueryWithdrawalLogListRequest
|
var req types.QueryWithdrawalLogListRequest
|
||||||
|
|||||||
@@ -1180,6 +1180,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
// Verify Email
|
// Verify Email
|
||||||
publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx))
|
publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx))
|
||||||
|
|
||||||
|
// Query Commission Return Log
|
||||||
|
publicUserGroupRouter.GET("/commission_return_log", publicUser.QueryCommissionReturnLogHandler(serverCtx))
|
||||||
|
|
||||||
// Query Withdrawal Log
|
// Query Withdrawal Log
|
||||||
publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx))
|
publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx))
|
||||||
}
|
}
|
||||||
@@ -1218,4 +1221,7 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
// Get Server Protocol Config
|
// Get Server Protocol Config
|
||||||
serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
|
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/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
"github.com/perfect-panel/server/pkg/tool"
|
"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)
|
l := subscribe.NewSubscribeLogic(c, svcCtx)
|
||||||
resp, err := l.Handler(&req)
|
resp, err := l.Handler(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "Internal Server")
|
result.HttpResult(c, nil, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Header("subscription-userinfo", resp.Header)
|
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/group"
|
||||||
"github.com/perfect-panel/server/internal/model/node"
|
"github.com/perfect-panel/server/internal/model/node"
|
||||||
|
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@@ -48,6 +49,33 @@ func (l *DeleteNodeGroupLogic) DeleteNodeGroup(req *types.DeleteNodeGroupRequest
|
|||||||
return fmt.Errorf("cannot delete group with %d associated nodes, please migrate nodes first", nodeCount)
|
return fmt.Errorf("cannot delete group with %d associated nodes, please migrate nodes first", nodeCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var defaultSubscribeCount int64
|
||||||
|
if err := l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("node_group_id = ?", nodeGroup.Id).Count(&defaultSubscribeCount).Error; err != nil {
|
||||||
|
logger.Errorf("failed to count subscribes with default group: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if defaultSubscribeCount > 0 {
|
||||||
|
return fmt.Errorf("cannot delete group referenced by %d subscribes' default node group", defaultSubscribeCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscribeGroupCount int64
|
||||||
|
if err := l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroup.Id)).Count(&subscribeGroupCount).Error; err != nil {
|
||||||
|
logger.Errorf("failed to count subscribes in group: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if subscribeGroupCount > 0 {
|
||||||
|
return fmt.Errorf("cannot delete group referenced by %d subscribes' node group list", subscribeGroupCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var userSubscribeCount int64
|
||||||
|
if err := l.svcCtx.DB.Table("user_subscribe").Where("node_group_id = ?", nodeGroup.Id).Count(&userSubscribeCount).Error; err != nil {
|
||||||
|
logger.Errorf("failed to count user subscribes in group: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if userSubscribeCount > 0 {
|
||||||
|
return fmt.Errorf("cannot delete group referenced by %d user subscribes", userSubscribeCount)
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 GORM Transaction 删除节点组
|
// 使用 GORM Transaction 删除节点组
|
||||||
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
// 删除节点组
|
// 删除节点组
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/group"
|
"github.com/perfect-panel/server/internal/model/group"
|
||||||
@@ -52,10 +53,9 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
var users []UserInfo
|
var users []UserInfo
|
||||||
if err := l.svcCtx.DB.Raw("SELECT * FROM JSON_ARRAY(?)", detail.UserData).Scan(&users).Error; err != nil {
|
if err := json.Unmarshal([]byte(detail.UserData), &users); err != nil {
|
||||||
// 如果解析失败,尝试用标准 JSON 解析
|
|
||||||
logger.Errorf("failed to parse user data: %v", err)
|
logger.Errorf("failed to parse user data: %v", err)
|
||||||
continue
|
return nil, "", fmt.Errorf("parse group history user_data failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询节点组名称
|
// 查询节点组名称
|
||||||
@@ -123,7 +123,10 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
|
|||||||
result = append(result, csvData...)
|
result = append(result, csvData...)
|
||||||
|
|
||||||
// 生成文件名
|
// 生成文件名
|
||||||
filename := fmt.Sprintf("group_result_%d.csv", req.HistoryId)
|
filename := "group_result_current.csv"
|
||||||
|
if req.HistoryId != nil {
|
||||||
|
filename = fmt.Sprintf("group_result_%d.csv", *req.HistoryId)
|
||||||
|
}
|
||||||
|
|
||||||
return result, filename, nil
|
return result, filename, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,16 +76,16 @@ func (l *GetGroupHistoryDetailLogic) GetGroupHistoryDetail(req *types.GetGroupHi
|
|||||||
configSnapshot := make(map[string]interface{})
|
configSnapshot := make(map[string]interface{})
|
||||||
configSnapshot["group_details"] = details
|
configSnapshot["group_details"] = details
|
||||||
|
|
||||||
// 获取配置快照(从 system_config 读取)
|
// 获取配置快照(从 system 读取)
|
||||||
var configValue string
|
var configValue string
|
||||||
if history.GroupMode == "average" {
|
if history.GroupMode == "average" {
|
||||||
l.svcCtx.DB.Table("system_config").
|
l.svcCtx.DB.Table("system").
|
||||||
Where("`key` = ?", "group.average_config").
|
Where("`category` = ? AND `key` = ?", "group", "average_config").
|
||||||
Select("value").
|
Select("value").
|
||||||
Scan(&configValue)
|
Scan(&configValue)
|
||||||
} else if history.GroupMode == "traffic" {
|
} else if history.GroupMode == "traffic" {
|
||||||
l.svcCtx.DB.Table("system_config").
|
l.svcCtx.DB.Table("system").
|
||||||
Where("`key` = ?", "group.traffic_config").
|
Where("`category` = ? AND `key` = ?", "group", "traffic_config").
|
||||||
Select("value").
|
Select("value").
|
||||||
Scan(&configValue)
|
Scan(&configValue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ func (l *GetGroupHistoryLogic) GetGroupHistory(req *types.GetGroupHistoryRequest
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页查询
|
page, size := normalizePagination(req.Page, req.Size)
|
||||||
offset := (req.Page - 1) * req.Size
|
offset := (page - 1) * size
|
||||||
if err := query.Order("id DESC").Offset(offset).Limit(req.Size).Find(&histories).Error; err != nil {
|
if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&histories).Error; err != nil {
|
||||||
logger.Errorf("failed to find group histories: %v", err)
|
logger.Errorf("failed to find group histories: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ func (l *GetNodeGroupListLogic) GetNodeGroupList(req *types.GetNodeGroupListRequ
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页查询
|
page, size := normalizePagination(req.Page, req.Size)
|
||||||
offset := (req.Page - 1) * req.Size
|
offset := (page - 1) * size
|
||||||
if err := query.Order("sort ASC").Offset(offset).Limit(req.Size).Find(&nodeGroups).Error; err != nil {
|
if err := query.Order("sort ASC").Offset(offset).Limit(size).Find(&nodeGroups).Error; err != nil {
|
||||||
logger.Errorf("failed to find node groups: %v", err)
|
logger.Errorf("failed to find node groups: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
modelgroup "github.com/perfect-panel/server/internal/model/group"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetGroupHistoryDetailReadsConfigFromSystem(t *testing.T) {
|
||||||
|
db, mock, cleanup := newGroupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
now := time.Unix(1710000000, 0)
|
||||||
|
mock.ExpectQuery("FROM `group_history`").
|
||||||
|
WithArgs(int64(9), 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"id", "group_mode", "trigger_type", "state", "total_users", "success_count", "failed_count", "start_time", "end_time", "error_message", "created_at",
|
||||||
|
}).AddRow(int64(9), "traffic", "manual", "completed", 1, 1, 0, now, now, "", now))
|
||||||
|
mock.ExpectQuery("FROM `group_history_detail`").
|
||||||
|
WithArgs(int64(9)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
|
||||||
|
}).AddRow(int64(1), int64(9), int64(3), 1, 2, `[{"id":7,"email":"u@example.com"}]`, now))
|
||||||
|
mock.ExpectQuery("FROM `system`").
|
||||||
|
WithArgs("group", "traffic_config").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(`{"strategy":"closed"}`))
|
||||||
|
|
||||||
|
logic := newTestGroupHistoryDetailLogic(db)
|
||||||
|
resp, err := logic.GetGroupHistoryDetail(&types.GetGroupHistoryDetailRequest{Id: 9})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetGroupHistoryDetail error: %v", err)
|
||||||
|
}
|
||||||
|
if got := resp.ConfigSnapshot["config"].(map[string]interface{})["strategy"]; got != "closed" {
|
||||||
|
t.Fatalf("config snapshot strategy = %v, want closed", got)
|
||||||
|
}
|
||||||
|
assertGroupExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportGroupResultParsesHistoryUserDataJSON(t *testing.T) {
|
||||||
|
db, mock, cleanup := newGroupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
historyID := int64(11)
|
||||||
|
now := time.Unix(1710000000, 0)
|
||||||
|
mock.ExpectQuery("FROM `group_history_detail`").
|
||||||
|
WithArgs(historyID).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
|
||||||
|
}).AddRow(int64(1), historyID, int64(8), 1, 2, `[{"id":42,"email":"u@example.com"}]`, now))
|
||||||
|
mock.ExpectQuery("FROM `node_group`").
|
||||||
|
WithArgs(int64(8), 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(8), "VIP"))
|
||||||
|
|
||||||
|
logic := newTestExportGroupResultLogic(db)
|
||||||
|
data, filename, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportGroupResult error: %v", err)
|
||||||
|
}
|
||||||
|
if filename != "group_result_11.csv" {
|
||||||
|
t.Fatalf("filename = %q, want group_result_11.csv", filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(string(data), "\ufeff"))).ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read csv: %v", err)
|
||||||
|
}
|
||||||
|
if len(records) != 2 || strings.Join(records[1], ",") != "42,8,VIP" {
|
||||||
|
t.Fatalf("csv records = %#v, want user/group row", records)
|
||||||
|
}
|
||||||
|
assertGroupExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportGroupResultRejectsInvalidHistoryUserDataJSON(t *testing.T) {
|
||||||
|
db, mock, cleanup := newGroupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
historyID := int64(12)
|
||||||
|
now := time.Unix(1710000000, 0)
|
||||||
|
mock.ExpectQuery("FROM `group_history_detail`").
|
||||||
|
WithArgs(historyID).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
|
||||||
|
}).AddRow(int64(1), historyID, int64(8), 1, 2, `{bad-json`, now))
|
||||||
|
|
||||||
|
logic := newTestExportGroupResultLogic(db)
|
||||||
|
_, _, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "parse group history user_data failed") {
|
||||||
|
t.Fatalf("ExportGroupResult error = %v, want parse error", err)
|
||||||
|
}
|
||||||
|
assertGroupExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteNodeGroupRejectsSubscribeReferences(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
defaultSubscribeCount int64
|
||||||
|
subscribeGroupCount int64
|
||||||
|
userSubscribeCount int64
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "subscribe default group", defaultSubscribeCount: 1, wantErr: "default node group"},
|
||||||
|
{name: "subscribe group list", subscribeGroupCount: 1, wantErr: "node group list"},
|
||||||
|
{name: "user subscribe group", userSubscribeCount: 1, wantErr: "user subscribes"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
db, mock, cleanup := newGroupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
expectDeleteNodeGroupBaseChecks(mock, 5)
|
||||||
|
mock.ExpectQuery("FROM `subscribe`").
|
||||||
|
WithArgs(int64(5)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.defaultSubscribeCount))
|
||||||
|
if tt.defaultSubscribeCount == 0 {
|
||||||
|
mock.ExpectQuery("FROM `subscribe`").
|
||||||
|
WithArgs("[5]").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.subscribeGroupCount))
|
||||||
|
}
|
||||||
|
if tt.defaultSubscribeCount == 0 && tt.subscribeGroupCount == 0 {
|
||||||
|
mock.ExpectQuery("FROM `user_subscribe`").
|
||||||
|
WithArgs(int64(5)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.userSubscribeCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
logic := newTestDeleteNodeGroupLogic(db)
|
||||||
|
err := logic.DeleteNodeGroup(&types.DeleteNodeGroupRequest{Id: 5})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("DeleteNodeGroup error = %v, want contains %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
assertGroupExpectations(t, mock)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetGroupsRollsBackOnFailure(t *testing.T) {
|
||||||
|
db, mock, cleanup := newGroupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("DELETE FROM `node_group`").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec("UPDATE `subscribe`").
|
||||||
|
WithArgs(int64(0), "[]").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec("UPDATE `nodes`").
|
||||||
|
WithArgs("[]").
|
||||||
|
WillReturnError(fmt.Errorf("node update failed"))
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
logic := newTestResetGroupsLogic(db)
|
||||||
|
err := logic.ResetGroups()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "node update failed") {
|
||||||
|
t.Fatalf("ResetGroups error = %v, want node update failure", err)
|
||||||
|
}
|
||||||
|
assertGroupExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewUserNodesIncludesPublicNodesInGroupMode(t *testing.T) {
|
||||||
|
db, mock, cleanup := newGroupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
now := time.Unix(1710000000, 0)
|
||||||
|
mock.ExpectQuery("FROM `user_subscribe`").
|
||||||
|
WithArgs(int64(100), int8(0), int8(1)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subscribe_id", "node_group_id"}).
|
||||||
|
AddRow(int64(1), int64(100), int64(10), int64(5)))
|
||||||
|
mock.ExpectQuery("FROM `subscribe`").
|
||||||
|
WithArgs(int64(10)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "node_group_id", "node_group_ids", "nodes", "node_tags"}).
|
||||||
|
AddRow(int64(10), int64(0), "[]", "", ""))
|
||||||
|
mock.ExpectQuery("FROM `system`").
|
||||||
|
WithArgs("group", "enabled").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("true"))
|
||||||
|
mock.ExpectQuery("FROM `nodes`").
|
||||||
|
WithArgs(true).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"id", "name", "tags", "port", "address", "server_id", "protocol", "enabled", "sort", "node_group_ids", "created_at", "updated_at",
|
||||||
|
}).
|
||||||
|
AddRow(int64(1), "group-node", "", uint16(443), "g.example.com", int64(1), "vless", true, 1, "[5]", now, now).
|
||||||
|
AddRow(int64(2), "public-node", "", uint16(443), "p.example.com", int64(1), "vless", true, 2, "[]", now, now))
|
||||||
|
mock.ExpectQuery("FROM `node_group`").
|
||||||
|
WithArgs(int64(5)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(5), "Group A"))
|
||||||
|
|
||||||
|
logic := newTestPreviewUserNodesLogic(db)
|
||||||
|
resp, err := logic.PreviewUserNodes(&types.PreviewUserNodesRequest{UserId: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PreviewUserNodes error: %v", err)
|
||||||
|
}
|
||||||
|
if !hasNodeGroup(resp.NodeGroups, 0, "public-node") {
|
||||||
|
t.Fatalf("PreviewUserNodes node groups = %#v, want public node group", resp.NodeGroups)
|
||||||
|
}
|
||||||
|
assertGroupExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrafficRangeMatchingUsesClosedBounds(t *testing.T) {
|
||||||
|
min0, max10 := int64(0), int64(10)
|
||||||
|
min10, max20 := int64(10), int64(20)
|
||||||
|
nodeGroups := []modelgroup.NodeGroup{
|
||||||
|
{Id: 1, MinTrafficGB: &min0, MaxTrafficGB: &max10},
|
||||||
|
{Id: 2, MinTrafficGB: &min10, MaxTrafficGB: &max20},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := map[float64]int64{
|
||||||
|
0: 1,
|
||||||
|
10: 1,
|
||||||
|
15: 2,
|
||||||
|
21: 0,
|
||||||
|
}
|
||||||
|
for used, want := range tests {
|
||||||
|
if got := matchTrafficNodeGroup(used, nodeGroups); got != want {
|
||||||
|
t.Fatalf("matchTrafficNodeGroup(%v) = %d, want %d", used, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePaginationDefaultsAndMax(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
page, size int
|
||||||
|
wantPage int
|
||||||
|
wantSize int
|
||||||
|
}{
|
||||||
|
{page: 0, size: 0, wantPage: 1, wantSize: defaultPageSize},
|
||||||
|
{page: -1, size: -5, wantPage: 1, wantSize: defaultPageSize},
|
||||||
|
{page: 2, size: maxPageSize + 1, wantPage: 2, wantSize: maxPageSize},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
gotPage, gotSize := normalizePagination(tt.page, tt.size)
|
||||||
|
if gotPage != tt.wantPage || gotSize != tt.wantSize {
|
||||||
|
t.Fatalf("normalizePagination(%d, %d) = (%d, %d), want (%d, %d)", tt.page, tt.size, gotPage, gotSize, tt.wantPage, tt.wantSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGroupTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||||
|
if strings.Contains(actualSQL, expectedSQL) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||||
|
})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
t.Fatalf("open gorm db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, mock, func() {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestGroupHistoryDetailLogic(db *gorm.DB) *GetGroupHistoryDetailLogic {
|
||||||
|
ctx := context.Background()
|
||||||
|
return &GetGroupHistoryDetailLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: &svc.ServiceContext{DB: db},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestExportGroupResultLogic(db *gorm.DB) *ExportGroupResultLogic {
|
||||||
|
ctx := context.Background()
|
||||||
|
return &ExportGroupResultLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: &svc.ServiceContext{DB: db},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestDeleteNodeGroupLogic(db *gorm.DB) *DeleteNodeGroupLogic {
|
||||||
|
ctx := context.Background()
|
||||||
|
return &DeleteNodeGroupLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: &svc.ServiceContext{DB: db},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestResetGroupsLogic(db *gorm.DB) *ResetGroupsLogic {
|
||||||
|
ctx := context.Background()
|
||||||
|
return &ResetGroupsLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: &svc.ServiceContext{DB: db},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestPreviewUserNodesLogic(db *gorm.DB) *PreviewUserNodesLogic {
|
||||||
|
ctx := context.Background()
|
||||||
|
return &PreviewUserNodesLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: &svc.ServiceContext{DB: db},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectDeleteNodeGroupBaseChecks(mock sqlmock.Sqlmock, nodeGroupId int64) {
|
||||||
|
now := time.Unix(1710000000, 0)
|
||||||
|
mock.ExpectQuery("FROM `node_group`").
|
||||||
|
WithArgs(nodeGroupId, 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at"}).
|
||||||
|
AddRow(nodeGroupId, "Group", now, now))
|
||||||
|
mock.ExpectQuery("FROM `nodes`").
|
||||||
|
WithArgs(fmt.Sprintf("[%d]", nodeGroupId)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasNodeGroup(items []types.NodeGroupItem, id int64, nodeName string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Id != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, n := range item.Nodes {
|
||||||
|
if n.Name == nodeName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertGroupExpectations(t *testing.T, mock sqlmock.Sqlmock) {
|
||||||
|
t.Helper()
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/node"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPageSize = 20
|
||||||
|
maxPageSize = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizePagination(page, size int) (int, int) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size <= 0 {
|
||||||
|
size = defaultPageSize
|
||||||
|
}
|
||||||
|
if size > maxPageSize {
|
||||||
|
size = maxPageSize
|
||||||
|
}
|
||||||
|
return page, size
|
||||||
|
}
|
||||||
|
|
||||||
|
func countNodesInGroup(db *gorm.DB, nodeGroupId int64) (int, error) {
|
||||||
|
var count int64
|
||||||
|
err := db.Model(&node.Node{}).
|
||||||
|
Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroupId)).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int(count), nil
|
||||||
|
}
|
||||||
@@ -196,10 +196,10 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 过滤出包含至少一个匹配节点组的节点(仅显示用户真正所在分组的节点,不包含公共节点)
|
// 6. 过滤出公共节点和至少一个匹配节点组的节点,与真实订阅下发保持一致
|
||||||
for _, n := range dbNodes {
|
for _, n := range dbNodes {
|
||||||
// 节点未配置节点组(公共节点),预览时不显示
|
|
||||||
if len(n.NodeGroupIds) == 0 {
|
if len(n.NodeGroupIds) == 0 {
|
||||||
|
filteredNodes = append(filteredNodes, n)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,9 +450,13 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预览模式不显示公共节点(node_group_ids 为空的节点),只展示用户真正所在分组的节点
|
|
||||||
if len(publicNodes) > 0 {
|
if len(publicNodes) > 0 {
|
||||||
logger.Infof("[PreviewUserNodes] skipping %d public nodes (not in user's assigned group)", len(publicNodes))
|
nodeGroupItems = append(nodeGroupItems, types.NodeGroupItem{
|
||||||
|
Id: 0,
|
||||||
|
Name: "公共节点",
|
||||||
|
Nodes: publicNodes,
|
||||||
|
})
|
||||||
|
logger.Infof("[PreviewUserNodes] adding public nodes group: nodes=%d", len(publicNodes))
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -679,21 +679,7 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
|||||||
// 将字节转换为 GB
|
// 将字节转换为 GB
|
||||||
usedTrafficGB := float64(us.UsedTraffic) / (1024 * 1024 * 1024)
|
usedTrafficGB := float64(us.UsedTraffic) / (1024 * 1024 * 1024)
|
||||||
|
|
||||||
// 查找匹配的流量范围(使用左闭右开区间 [Min, Max))
|
targetNodeGroupId := matchTrafficNodeGroup(usedTrafficGB, nodeGroups)
|
||||||
var targetNodeGroupId int64 = 0
|
|
||||||
for _, ng := range nodeGroups {
|
|
||||||
if ng.MinTrafficGB == nil || ng.MaxTrafficGB == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
minTraffic := float64(*ng.MinTrafficGB)
|
|
||||||
maxTraffic := float64(*ng.MaxTrafficGB)
|
|
||||||
|
|
||||||
// 检查是否在区间内 [min, max)
|
|
||||||
if usedTrafficGB >= minTraffic && usedTrafficGB < maxTraffic {
|
|
||||||
targetNodeGroupId = ng.Id
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有匹配到任何范围,targetNodeGroupId 保持为 0(不分配节点组)
|
// 如果没有匹配到任何范围,targetNodeGroupId 保持为 0(不分配节点组)
|
||||||
|
|
||||||
@@ -734,7 +720,14 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
|||||||
// 4. 创建分组历史详情记录(只统计有用户的节点组)
|
// 4. 创建分组历史详情记录(只统计有用户的节点组)
|
||||||
nodeGroupCount := make(map[int64]int) // node_group_id -> node_count
|
nodeGroupCount := make(map[int64]int) // node_group_id -> node_count
|
||||||
for _, ng := range nodeGroups {
|
for _, ng := range nodeGroups {
|
||||||
nodeGroupCount[ng.Id] = 1 // 每个节点组计为1
|
count, err := countNodesInGroup(tx, ng.Id)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("failed to count nodes in group",
|
||||||
|
logger.Field("node_group_id", ng.Id),
|
||||||
|
logger.Field("error", err.Error()))
|
||||||
|
return affectedCount, err
|
||||||
|
}
|
||||||
|
nodeGroupCount[ng.Id] = count
|
||||||
}
|
}
|
||||||
|
|
||||||
for nodeGroupId, userCount := range groupUserCount {
|
for nodeGroupId, userCount := range groupUserCount {
|
||||||
@@ -764,6 +757,20 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in
|
|||||||
return affectedCount, nil
|
return affectedCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func matchTrafficNodeGroup(usedTrafficGB float64, nodeGroups []group.NodeGroup) int64 {
|
||||||
|
for _, ng := range nodeGroups {
|
||||||
|
if ng.MinTrafficGB == nil || ng.MaxTrafficGB == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
minTraffic := float64(*ng.MinTrafficGB)
|
||||||
|
maxTraffic := float64(*ng.MaxTrafficGB)
|
||||||
|
if usedTrafficGB >= minTraffic && usedTrafficGB <= maxTraffic {
|
||||||
|
return ng.Id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// containsIgnoreCase checks if a string contains another substring (case-insensitive)
|
// containsIgnoreCase checks if a string contains another substring (case-insensitive)
|
||||||
func containsIgnoreCase(s, substr string) bool {
|
func containsIgnoreCase(s, substr string) bool {
|
||||||
if len(substr) == 0 {
|
if len(substr) == 0 {
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"github.com/perfect-panel/server/internal/model/node"
|
"github.com/perfect-panel/server/internal/model/node"
|
||||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||||
"github.com/perfect-panel/server/internal/model/system"
|
"github.com/perfect-panel/server/internal/model/system"
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ResetGroupsLogic struct {
|
type ResetGroupsLogic struct {
|
||||||
@@ -27,55 +29,64 @@ func NewResetGroupsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Reset
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *ResetGroupsLogic) ResetGroups() error {
|
func (l *ResetGroupsLogic) ResetGroups() error {
|
||||||
// 1. Delete all node groups
|
err := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
err := l.svcCtx.DB.Where("1 = 1").Delete(&group.NodeGroup{}).Error
|
// 1. Delete all node groups
|
||||||
if err != nil {
|
if err := tx.Where("1 = 1").Delete(&group.NodeGroup{}).Error; err != nil {
|
||||||
l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error()))
|
l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
l.Infow("Successfully deleted all node groups")
|
l.Infow("Successfully deleted all node groups")
|
||||||
|
|
||||||
// 2. Clear node_group_ids for all subscribes (products)
|
// 2. Clear node_group_id/node_group_ids for all subscribes (products)
|
||||||
err = l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("1 = 1").Update("node_group_ids", "[]").Error
|
if err := tx.Table((&subscribe.Subscribe{}).TableName()).Where("1 = 1").Updates(map[string]interface{}{
|
||||||
if err != nil {
|
"node_group_id": 0,
|
||||||
l.Errorw("Failed to clear subscribes' node_group_ids", logger.Field("error", err.Error()))
|
"node_group_ids": "[]",
|
||||||
return err
|
}).Error; err != nil {
|
||||||
}
|
l.Errorw("Failed to clear subscribes' node groups", logger.Field("error", err.Error()))
|
||||||
l.Infow("Successfully cleared all subscribes' node_group_ids")
|
return err
|
||||||
|
}
|
||||||
|
l.Infow("Successfully cleared all subscribes' node groups")
|
||||||
|
|
||||||
// 3. Clear node_group_ids for all nodes
|
// 3. Clear node_group_ids for all nodes
|
||||||
err = l.svcCtx.DB.Model(&node.Node{}).Where("1 = 1").Update("node_group_ids", "[]").Error
|
if err := tx.Table((&node.Node{}).TableName()).Where("1 = 1").Update("node_group_ids", "[]").Error; err != nil {
|
||||||
if err != nil {
|
l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error()))
|
||||||
l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error()))
|
return err
|
||||||
return err
|
}
|
||||||
}
|
l.Infow("Successfully cleared all nodes' node_group_ids")
|
||||||
l.Infow("Successfully cleared all nodes' node_group_ids")
|
|
||||||
|
|
||||||
// 4. Clear group history
|
// 4. Clear user_subscribe node_group_id
|
||||||
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistory{}).Error
|
if err := tx.Table((&user.Subscribe{}).TableName()).Where("1 = 1").Update("node_group_id", 0).Error; err != nil {
|
||||||
if err != nil {
|
l.Errorw("Failed to clear user subscribes' node_group_id", logger.Field("error", err.Error()))
|
||||||
l.Errorw("Failed to clear group history", logger.Field("error", err.Error()))
|
return err
|
||||||
// Non-critical error, continue anyway
|
}
|
||||||
} else {
|
l.Infow("Successfully cleared all user subscribes' node_group_id")
|
||||||
|
|
||||||
|
// 5. Clear group history
|
||||||
|
if err := tx.Where("1 = 1").Delete(&group.GroupHistory{}).Error; err != nil {
|
||||||
|
l.Errorw("Failed to clear group history", logger.Field("error", err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
l.Infow("Successfully cleared group history")
|
l.Infow("Successfully cleared group history")
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Clear group history details
|
// 6. Clear group history details
|
||||||
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error
|
if err := tx.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error; err != nil {
|
||||||
if err != nil {
|
l.Errorw("Failed to clear group history details", logger.Field("error", err.Error()))
|
||||||
l.Errorw("Failed to clear group history details", logger.Field("error", err.Error()))
|
return err
|
||||||
// Non-critical error, continue anyway
|
}
|
||||||
} else {
|
|
||||||
l.Infow("Successfully cleared group history details")
|
l.Infow("Successfully cleared group history details")
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Delete all group config settings
|
// 7. Delete all group config settings
|
||||||
err = l.svcCtx.DB.Where("`category` = ?", "group").Delete(&system.System{}).Error
|
if err := tx.Where("`category` = ?", "group").Delete(&system.System{}).Error; err != nil {
|
||||||
|
l.Errorw("Failed to delete group config", logger.Field("error", err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
l.Infow("Successfully deleted all group config settings")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("Failed to delete group config", logger.Field("error", err.Error()))
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
l.Infow("Successfully deleted all group config settings")
|
|
||||||
|
|
||||||
l.Infow("Group reset completed successfully")
|
l.Infow("Group reset completed successfully")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package invite
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
|
|
||||||
modellog "github.com/perfect-panel/server/internal/model/log"
|
modellog "github.com/perfect-panel/server/internal/model/log"
|
||||||
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -113,6 +115,7 @@ func QueryBenefits(ctx context.Context, db *gorm.DB, relations []InviteRelation)
|
|||||||
for userId := range inviteeAndInviterSet {
|
for userId := range inviteeAndInviterSet {
|
||||||
inviteeAndInviterIds = append(inviteeAndInviterIds, userId)
|
inviteeAndInviterIds = append(inviteeAndInviterIds, userId)
|
||||||
}
|
}
|
||||||
|
slices.Sort(inviteeAndInviterIds)
|
||||||
|
|
||||||
if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil {
|
if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -186,26 +189,35 @@ func fillGiftBenefits(ctx context.Context, db *gorm.DB, benefits map[int64]Benef
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func QueryIdentifiers(ctx context.Context, db *gorm.DB, userIds []int64) (map[int64]string, error) {
|
type DeviceIdentifier struct {
|
||||||
identifiers := make(map[int64]string, len(userIds))
|
Identifier string
|
||||||
|
DeviceNo string
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueryDeviceIdentifiers(ctx context.Context, db *gorm.DB, userIds []int64) (map[int64]DeviceIdentifier, error) {
|
||||||
|
identifiers := make(map[int64]DeviceIdentifier, len(userIds))
|
||||||
if len(userIds) == 0 {
|
if len(userIds) == 0 {
|
||||||
return identifiers, nil
|
return identifiers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type identifierRow struct {
|
type identifierRow struct {
|
||||||
UserId int64 `gorm:"column:user_id"`
|
UserId int64 `gorm:"column:user_id"`
|
||||||
|
DeviceId int64 `gorm:"column:device_id"`
|
||||||
Identifier string `gorm:"column:identifier"`
|
Identifier string `gorm:"column:identifier"`
|
||||||
}
|
}
|
||||||
var rows []identifierRow
|
var rows []identifierRow
|
||||||
if err := db.WithContext(ctx).
|
if err := db.WithContext(ctx).
|
||||||
Table("user_auth_methods uam").
|
Table("user_device ud").
|
||||||
Select("uam.user_id, uam.auth_identifier as identifier").
|
Select("ud.user_id, ud.id as device_id, ud.identifier as identifier").
|
||||||
Joins("JOIN (SELECT user_id, MIN(id) AS id FROM user_auth_methods WHERE user_id IN ? GROUP BY user_id) first_uam ON first_uam.id = uam.id", userIds).
|
Joins("JOIN (SELECT user_id, MIN(id) AS id FROM user_device WHERE user_id IN ? GROUP BY user_id) first_ud ON first_ud.id = ud.id", userIds).
|
||||||
Scan(&rows).Error; err != nil {
|
Scan(&rows).Error; err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user identifiers failed: %v", err)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user device identifiers failed: %v", err)
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
identifiers[row.UserId] = row.Identifier
|
identifiers[row.UserId] = DeviceIdentifier{
|
||||||
|
Identifier: row.Identifier,
|
||||||
|
DeviceNo: tool.DeviceIdToHash(row.DeviceId),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return identifiers, nil
|
return identifiers, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/DATA-DOG/go-sqlmock"
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -25,7 +26,7 @@ func TestQueryBenefitsCountsFamilyOwnerGiftAsInviteeGift(t *testing.T) {
|
|||||||
WithArgs(33, int64(100), "family-order", 331, 332).
|
WithArgs(33, int64(100), "family-order", 331, 332).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
||||||
mock.ExpectQuery("object_id IN").
|
mock.ExpectQuery("object_id IN").
|
||||||
WithArgs(34, int64(900), int64(200), int64(100), "family-order").
|
WithArgs(34, int64(100), int64(200), int64(900), "family-order").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
||||||
AddRow(900, `{"type":341,"order_no":"family-order","amount":7,"balance":7,"remark":"邀请赠送"}`))
|
AddRow(900, `{"type":341,"order_no":"family-order","amount":7,"balance":7,"remark":"邀请赠送"}`))
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ func TestQueryBenefitsKeepsDirectInviteeGift(t *testing.T) {
|
|||||||
WithArgs(33, int64(100), "direct-order", 331, 332).
|
WithArgs(33, int64(100), "direct-order", 331, 332).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
||||||
mock.ExpectQuery("object_id IN").
|
mock.ExpectQuery("object_id IN").
|
||||||
WithArgs(34, int64(200), int64(100), "direct-order").
|
WithArgs(34, int64(100), int64(200), "direct-order").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
||||||
AddRow(200, `{"type":341,"order_no":"direct-order","amount":5,"balance":5,"remark":"邀请赠送"}`))
|
AddRow(200, `{"type":341,"order_no":"direct-order","amount":5,"balance":5,"remark":"邀请赠送"}`))
|
||||||
|
|
||||||
@@ -73,6 +74,39 @@ func TestQueryBenefitsKeepsDirectInviteeGift(t *testing.T) {
|
|||||||
assertBenefitsExpectations(t, mock)
|
assertBenefitsExpectations(t, mock)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueryDeviceIdentifiersReturnsDeviceIdentifierAndNo(t *testing.T) {
|
||||||
|
db, mock, cleanup := newBenefitsTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("FROM user_device ud").
|
||||||
|
WithArgs(int64(100), int64(200)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"user_id", "device_id", "identifier"}).
|
||||||
|
AddRow(100, 11, "inviter-hash").
|
||||||
|
AddRow(200, 22, "invitee-hash"))
|
||||||
|
|
||||||
|
identifiers, err := QueryDeviceIdentifiers(context.Background(), db, []int64{100, 200})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QueryDeviceIdentifiers returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inviter := identifiers[100]
|
||||||
|
if inviter.Identifier != "inviter-hash" {
|
||||||
|
t.Fatalf("inviter identifier = %q, want inviter-hash", inviter.Identifier)
|
||||||
|
}
|
||||||
|
if inviter.DeviceNo != tool.DeviceIdToHash(11) {
|
||||||
|
t.Fatalf("inviter device no = %q, want %q", inviter.DeviceNo, tool.DeviceIdToHash(11))
|
||||||
|
}
|
||||||
|
|
||||||
|
invitee := identifiers[200]
|
||||||
|
if invitee.Identifier != "invitee-hash" {
|
||||||
|
t.Fatalf("invitee identifier = %q, want invitee-hash", invitee.Identifier)
|
||||||
|
}
|
||||||
|
if invitee.DeviceNo != tool.DeviceIdToHash(22) {
|
||||||
|
t.Fatalf("invitee device no = %q, want %q", invitee.DeviceNo, tool.DeviceIdToHash(22))
|
||||||
|
}
|
||||||
|
assertBenefitsExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
func newBenefitsTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
func newBenefitsTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (l *GetInviteManageListLogic) GetInviteManageList(req *types.GetInviteManag
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
identifiers, err := QueryIdentifiers(l.ctx, l.svcCtx.DB, userIds)
|
devices, err := QueryDeviceIdentifiers(l.ctx, l.svcCtx.DB, userIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -78,9 +78,11 @@ func (l *GetInviteManageListLogic) GetInviteManageList(req *types.GetInviteManag
|
|||||||
benefit := benefits[row.InviteeId]
|
benefit := benefits[row.InviteeId]
|
||||||
list = append(list, types.InviteManageRecord{
|
list = append(list, types.InviteManageRecord{
|
||||||
InviterId: row.InviterId,
|
InviterId: row.InviterId,
|
||||||
InviterIdentifier: identifiers[row.InviterId],
|
InviterIdentifier: devices[row.InviterId].Identifier,
|
||||||
|
InviterDeviceNo: devices[row.InviterId].DeviceNo,
|
||||||
InviteeId: row.InviteeId,
|
InviteeId: row.InviteeId,
|
||||||
InviteeIdentifier: identifiers[row.InviteeId],
|
InviteeIdentifier: devices[row.InviteeId].Identifier,
|
||||||
|
InviteeDeviceNo: devices[row.InviteeId].DeviceNo,
|
||||||
InviteeAvatar: row.InviteeAvatar,
|
InviteeAvatar: row.InviteeAvatar,
|
||||||
InviteeEnable: row.InviteeEnable,
|
InviteeEnable: row.InviteeEnable,
|
||||||
InvitedAt: row.InvitedAt,
|
InvitedAt: row.InvitedAt,
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
// admin_claims.go 实现 Stage 2 后台工单接口:
|
||||||
|
//
|
||||||
|
// GET /v1/admin/lottery/claims — 分页列表
|
||||||
|
// POST /v1/admin/lottery/claims/approve — reviewing → paying
|
||||||
|
// POST /v1/admin/lottery/claims/reject — reviewing|paying → rejected
|
||||||
|
// POST /v1/admin/lottery/claims/mark-paid — paying → paid
|
||||||
|
// GET /v1/admin/lottery/claims/summary — 工作台状态计数
|
||||||
|
//
|
||||||
|
// 状态机严格 CAS:所有写路径都用 WHERE status IN (...) 做前置校验,
|
||||||
|
// RowsAffected==0 → 100011 claim_state_invalid(并发/竞态兜底)。
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/logic/audit"
|
||||||
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// audit action codes 供 admin_action_log 用(新增 Stage 2 三个)。
|
||||||
|
const (
|
||||||
|
ActionLotteryClaimApprove = "lottery.claim.approve"
|
||||||
|
ActionLotteryClaimReject = "lottery.claim.reject"
|
||||||
|
ActionLotteryClaimMarkPaid = "lottery.claim.mark_paid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- ListLotteryClaims ----------------------------------------------------
|
||||||
|
|
||||||
|
type ListLotteryClaimsLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListLotteryClaimsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryClaimsLogic {
|
||||||
|
return &ListLotteryClaimsLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLotteryClaims 按 type/status/activity_id/user_id/时间窗过滤。
|
||||||
|
// user_id / email 是"友好视图"字段,走 IN 查询批量拉一次 users 表拼上。
|
||||||
|
func (l *ListLotteryClaimsLogic) ListLotteryClaims(req *types.ListAdminLotteryClaimsRequest) (*types.ListAdminLotteryClaimsResponse, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
page, size := req.Page, req.Size
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size <= 0 || size > 200 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Claim{})
|
||||||
|
if t := strings.TrimSpace(req.Type); t != "" {
|
||||||
|
db = db.Where("prize_type = ?", t)
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(req.Status); s != "" {
|
||||||
|
db = db.Where("status = ?", s)
|
||||||
|
}
|
||||||
|
if req.ActivityId > 0 {
|
||||||
|
db = db.Where("activity_id = ?", req.ActivityId)
|
||||||
|
}
|
||||||
|
if req.UserId > 0 {
|
||||||
|
db = db.Where("user_id = ?", req.UserId)
|
||||||
|
}
|
||||||
|
if req.From > 0 {
|
||||||
|
db = db.Where("created_at >= ?", time.Unix(req.From, 0))
|
||||||
|
}
|
||||||
|
if req.To > 0 {
|
||||||
|
db = db.Where("created_at < ?", time.Unix(req.To, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
var rows []modelLottery.Claim
|
||||||
|
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附加:一次性拉快照 + 用户信息,避免 N+1。
|
||||||
|
drawIds := make([]int64, 0, len(rows))
|
||||||
|
userIds := make([]int64, 0, len(rows))
|
||||||
|
for _, c := range rows {
|
||||||
|
drawIds = append(drawIds, c.DrawId)
|
||||||
|
userIds = append(userIds, c.UserId)
|
||||||
|
}
|
||||||
|
snaps, err := l.loadSnapshots(drawIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users, err := l.loadUsers(userIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &types.ListAdminLotteryClaimsResponse{Total: total, Claims: make([]types.AdminLotteryClaim, 0, len(rows))}
|
||||||
|
for _, c := range rows {
|
||||||
|
resp.Claims = append(resp.Claims, claimToAdminView(c, snaps[c.DrawId], users[c.UserId]))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
func (l *ListLotteryClaimsLogic) loadSnapshots(drawIds []int64) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||||
|
if len(drawIds) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var snaps []modelLottery.PrizeSnapshot
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&snaps).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
|
||||||
|
for _, s := range snaps {
|
||||||
|
out[s.DrawId] = s
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListLotteryClaimsLogic) loadUsers(ids []int64) (map[int64]string, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// email 挂在 user_auth_methods 表;一次批量拉 auth_type='email' 的记录,
|
||||||
|
// 每人可能有多条 email(历史合并帐号),按 CreatedAt 排序取第一条即可。
|
||||||
|
type row struct {
|
||||||
|
UserId int64
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Table("user_auth_methods").
|
||||||
|
Select("user_id AS user_id, auth_identifier AS email").
|
||||||
|
Where("auth_type = ? AND user_id IN ?", "email", ids).
|
||||||
|
Order("created_at ASC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
out := make(map[int64]string, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if _, exists := out[r.UserId]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[r.UserId] = r.Email
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ApproveLotteryClaim --------------------------------------------------
|
||||||
|
|
||||||
|
type ApproveLotteryClaimLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApproveLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApproveLotteryClaimLogic {
|
||||||
|
return &ApproveLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveLotteryClaim reviewing → paying。CAS:命中 status='reviewing' 才推进。
|
||||||
|
func (l *ApproveLotteryClaimLogic) ApproveLotteryClaim(req *types.AdminApproveClaimRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
res := tx.Model(&modelLottery.Claim{}).
|
||||||
|
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusReviewing).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": modelLottery.ClaimStatusPaying,
|
||||||
|
"reviewed_by": actor,
|
||||||
|
"reviewed_at": now,
|
||||||
|
"reject_reason": "",
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: ActionLotteryClaimApprove,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RejectLotteryClaim ---------------------------------------------------
|
||||||
|
|
||||||
|
type RejectLotteryClaimLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRejectLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RejectLotteryClaimLogic {
|
||||||
|
return &RejectLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectLotteryClaim reviewing|paying → rejected;expires_at 不重置,用户在
|
||||||
|
// 剩余窗口内可再次提交。
|
||||||
|
func (l *RejectLotteryClaimLogic) RejectLotteryClaim(req *types.AdminRejectClaimRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(req.Reason)
|
||||||
|
if reason == "" {
|
||||||
|
return xerr.NewErrCode(xerr.InvalidParams)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
res := tx.Model(&modelLottery.Claim{}).
|
||||||
|
Where("id = ? AND status IN ?", req.Id,
|
||||||
|
[]string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": modelLottery.ClaimStatusRejected,
|
||||||
|
"reviewed_by": actor,
|
||||||
|
"reviewed_at": now,
|
||||||
|
"reject_reason": reason,
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: ActionLotteryClaimReject,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MarkPaidLotteryClaim -------------------------------------------------
|
||||||
|
|
||||||
|
type MarkPaidLotteryClaimLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMarkPaidLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkPaidLotteryClaimLogic {
|
||||||
|
return &MarkPaidLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPaidLotteryClaim paying → paid。
|
||||||
|
// 校验:crypto 必填 tx_hash / physical 必填 delivery_ref / manual_other 至少填一个。
|
||||||
|
// paid_at 缺省用服务端 now。同事务把 lottery_draw.dispatch_state 也推 paid。
|
||||||
|
func (l *MarkPaidLotteryClaimLogic) MarkPaidLotteryClaim(req *types.AdminMarkPaidClaimRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
txHash := strings.TrimSpace(req.TxHash)
|
||||||
|
deliveryRef := strings.TrimSpace(req.DeliveryRef)
|
||||||
|
now := time.Now()
|
||||||
|
paidAt := now
|
||||||
|
if req.PaidAt > 0 {
|
||||||
|
paidAt = time.Unix(req.PaidAt, 0)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 先取当前 claim 用于类型强校验
|
||||||
|
var claim modelLottery.Claim
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&claim).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
if err := validateMarkPaidByType(claim.PrizeType, txHash, deliveryRef); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAS 推进 status。
|
||||||
|
res := tx.Model(&modelLottery.Claim{}).
|
||||||
|
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusPaying).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": modelLottery.ClaimStatusPaid,
|
||||||
|
"reviewed_by": actor,
|
||||||
|
"reviewed_at": now,
|
||||||
|
"tx_hash": txHash,
|
||||||
|
"delivery_ref": deliveryRef,
|
||||||
|
"paid_at": paidAt,
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
// 同事务把 draw 的 dispatch_state 推到 paid,让 GET /records 与 admin 视图一致。
|
||||||
|
if err := tx.Model(&modelLottery.Draw{}).
|
||||||
|
Where("id = ? AND dispatch_state = ?", claim.DrawId, modelLottery.DispatchStatePendingClaim).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"dispatch_state": modelLottery.DispatchStatePaid,
|
||||||
|
"dispatched_at": paidAt,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: ActionLotteryClaimMarkPaid,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateMarkPaidByType 强制不同奖品类型的最少凭证:
|
||||||
|
// - crypto: tx_hash 必填
|
||||||
|
// - physical: delivery_ref 必填
|
||||||
|
// - manual_other: tx_hash 或 delivery_ref 至少一个
|
||||||
|
//
|
||||||
|
// 校验失败返回 InvalidParams(带具体原因,前端展示给运营)。
|
||||||
|
func validateMarkPaidByType(prizeType, txHash, deliveryRef string) error {
|
||||||
|
switch prizeType {
|
||||||
|
case modelLottery.PrizeTypeCrypto:
|
||||||
|
if txHash == "" {
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, "crypto 奖品必须填写 tx_hash")
|
||||||
|
}
|
||||||
|
case modelLottery.PrizeTypePhysical:
|
||||||
|
if deliveryRef == "" {
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, "physical 奖品必须填写 delivery_ref")
|
||||||
|
}
|
||||||
|
case modelLottery.PrizeTypeManualOther:
|
||||||
|
if txHash == "" && deliveryRef == "" {
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, "manual_other 奖品必须至少填写 tx_hash 或 delivery_ref")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ClaimsSummary --------------------------------------------------------
|
||||||
|
|
||||||
|
type LotteryClaimsSummaryLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLotteryClaimsSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LotteryClaimsSummaryLogic {
|
||||||
|
return &LotteryClaimsSummaryLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotteryClaimsSummary 一次 GROUP BY 拉齐 reviewing/paying 计数 + 单独查 overdue。
|
||||||
|
func (l *LotteryClaimsSummaryLogic) LotteryClaimsSummary() (*types.AdminLotteryClaimsSummary, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
PrizeType string
|
||||||
|
Status string
|
||||||
|
Cnt int64
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&modelLottery.Claim{}).
|
||||||
|
Select("prize_type, status, COUNT(*) AS cnt").
|
||||||
|
Where("status IN ?", []string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||||||
|
Group("prize_type, status").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
summary := &types.AdminLotteryClaimsSummary{}
|
||||||
|
for _, r := range rows {
|
||||||
|
bucket := bucketByType(summary, r.PrizeType)
|
||||||
|
if bucket == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch r.Status {
|
||||||
|
case modelLottery.ClaimStatusReviewing:
|
||||||
|
bucket.Reviewing = r.Cnt
|
||||||
|
case modelLottery.ClaimStatusPaying:
|
||||||
|
bucket.Paying = r.Cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// overdue:pending_claim 且 expires_at 已过(还没转 expired 的边缘时刻)。
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&modelLottery.Claim{}).
|
||||||
|
Where("status = ? AND expires_at < ?", modelLottery.ClaimStatusPendingClaim, time.Now()).
|
||||||
|
Count(&summary.Overdue).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketByType(s *types.AdminLotteryClaimsSummary, prizeType string) *types.AdminLotteryClaimsStatusCount {
|
||||||
|
switch prizeType {
|
||||||
|
case modelLottery.PrizeTypeCrypto:
|
||||||
|
return &s.Crypto
|
||||||
|
case modelLottery.PrizeTypePhysical:
|
||||||
|
return &s.Physical
|
||||||
|
case modelLottery.PrizeTypeManualOther:
|
||||||
|
return &s.ManualOther
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// claimToAdminView 把 model.Claim 组装成后台视图,附带快照 + 用户信息。
|
||||||
|
func claimToAdminView(c modelLottery.Claim, snap modelLottery.PrizeSnapshot, email string) types.AdminLotteryClaim {
|
||||||
|
view := types.AdminLotteryClaim{
|
||||||
|
Id: c.Id,
|
||||||
|
DrawId: c.DrawId,
|
||||||
|
ActivityId: c.ActivityId,
|
||||||
|
Status: c.Status,
|
||||||
|
ExpiresAt: c.ExpiresAt.Unix(),
|
||||||
|
ReviewedBy: c.ReviewedBy,
|
||||||
|
RejectReason: c.RejectReason,
|
||||||
|
TxHash: c.TxHash,
|
||||||
|
DeliveryRef: c.DeliveryRef,
|
||||||
|
CreatedAt: c.CreatedAt.Unix(),
|
||||||
|
User: types.AdminLotteryClaimUser{
|
||||||
|
Id: c.UserId,
|
||||||
|
Email: email,
|
||||||
|
},
|
||||||
|
Prize: types.AdminLotteryClaimPrize{
|
||||||
|
Type: snap.Type,
|
||||||
|
Name: snap.Name,
|
||||||
|
Config: json.RawMessage(defaultRawIfEmpty(snap.Config, "{}")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if snap.Type == "" {
|
||||||
|
// snapshot 未命中,用 claim.prize_type 兜底
|
||||||
|
view.Prize.Type = c.PrizeType
|
||||||
|
}
|
||||||
|
if c.ClaimData != "" {
|
||||||
|
view.ClaimData = json.RawMessage(c.ClaimData)
|
||||||
|
}
|
||||||
|
if c.SubmittedAt != nil {
|
||||||
|
view.SubmittedAt = c.SubmittedAt.Unix()
|
||||||
|
}
|
||||||
|
if c.ReviewedAt != nil {
|
||||||
|
view.ReviewedAt = c.ReviewedAt.Unix()
|
||||||
|
}
|
||||||
|
if c.PaidAt != nil {
|
||||||
|
view.PaidAt = c.PaidAt.Unix()
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// admin_claims_test.go — 单元测试 Stage 2 claim 状态机的纯函数校验。
|
||||||
|
// 数据库集成留给 stage2 QA curl 脚本;单测只覆盖纯逻辑分支:
|
||||||
|
// - validateMarkPaidByType 的三个奖品类型 x 凭证字段组合
|
||||||
|
// - bucketByType 的类型 → 状态桶映射
|
||||||
|
// - claimToAdminView 的 nullable 字段渲染
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateMarkPaidByType(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
prizeType string
|
||||||
|
txHash string
|
||||||
|
deliveryRef string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"crypto with tx_hash", modelLottery.PrizeTypeCrypto, "0xdeadbeef", "", false},
|
||||||
|
{"crypto missing tx_hash", modelLottery.PrizeTypeCrypto, "", "", true},
|
||||||
|
{"crypto ignores delivery_ref alone", modelLottery.PrizeTypeCrypto, "", "SF123", true},
|
||||||
|
{"physical with delivery_ref", modelLottery.PrizeTypePhysical, "", "SF123456", false},
|
||||||
|
{"physical missing delivery_ref", modelLottery.PrizeTypePhysical, "", "", true},
|
||||||
|
{"manual_other with tx_hash", modelLottery.PrizeTypeManualOther, "0xabc", "", false},
|
||||||
|
{"manual_other with delivery_ref", modelLottery.PrizeTypeManualOther, "", "SF00", false},
|
||||||
|
{"manual_other with both", modelLottery.PrizeTypeManualOther, "0xabc", "SF00", false},
|
||||||
|
{"manual_other with none", modelLottery.PrizeTypeManualOther, "", "", true},
|
||||||
|
{"unknown type", "auto_hallucinated", "", "", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateMarkPaidByType(tc.prizeType, tc.txHash, tc.deliveryRef)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketByType(t *testing.T) {
|
||||||
|
s := &types.AdminLotteryClaimsSummary{}
|
||||||
|
if bucketByType(s, modelLottery.PrizeTypeCrypto) != &s.Crypto {
|
||||||
|
t.Fatal("crypto bucket mismatch")
|
||||||
|
}
|
||||||
|
if bucketByType(s, modelLottery.PrizeTypePhysical) != &s.Physical {
|
||||||
|
t.Fatal("physical bucket mismatch")
|
||||||
|
}
|
||||||
|
if bucketByType(s, modelLottery.PrizeTypeManualOther) != &s.ManualOther {
|
||||||
|
t.Fatal("manual_other bucket mismatch")
|
||||||
|
}
|
||||||
|
if bucketByType(s, "unknown") != nil {
|
||||||
|
t.Fatal("unknown type must return nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimToAdminView_RendersNullableFields(t *testing.T) {
|
||||||
|
submittedAt := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
paidAt := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||||
|
claim := modelLottery.Claim{
|
||||||
|
Id: 42,
|
||||||
|
DrawId: 1234,
|
||||||
|
UserId: 88,
|
||||||
|
ActivityId: 100,
|
||||||
|
PrizeType: modelLottery.PrizeTypeCrypto,
|
||||||
|
Status: modelLottery.ClaimStatusPaid,
|
||||||
|
ClaimData: `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`,
|
||||||
|
SubmittedAt: &submittedAt,
|
||||||
|
ExpiresAt: submittedAt.Add(24 * time.Hour),
|
||||||
|
TxHash: "0xabcdef",
|
||||||
|
PaidAt: &paidAt,
|
||||||
|
}
|
||||||
|
snap := modelLottery.PrizeSnapshot{
|
||||||
|
Type: modelLottery.PrizeTypeCrypto,
|
||||||
|
Name: "1 BTC",
|
||||||
|
Config: `{"amount":"1","currency":"BTC","networks":["BTC"]}`,
|
||||||
|
}
|
||||||
|
view := claimToAdminView(claim, snap, "user@example.com")
|
||||||
|
|
||||||
|
if view.Id != 42 || view.DrawId != 1234 || view.User.Id != 88 {
|
||||||
|
t.Fatalf("view IDs wrong: %+v", view)
|
||||||
|
}
|
||||||
|
if view.User.Email != "user@example.com" {
|
||||||
|
t.Fatalf("Email = %q", view.User.Email)
|
||||||
|
}
|
||||||
|
if view.Status != modelLottery.ClaimStatusPaid {
|
||||||
|
t.Fatalf("Status = %q", view.Status)
|
||||||
|
}
|
||||||
|
if view.SubmittedAt != submittedAt.Unix() {
|
||||||
|
t.Fatalf("SubmittedAt = %d, want %d", view.SubmittedAt, submittedAt.Unix())
|
||||||
|
}
|
||||||
|
if view.PaidAt != paidAt.Unix() {
|
||||||
|
t.Fatalf("PaidAt = %d, want %d", view.PaidAt, paidAt.Unix())
|
||||||
|
}
|
||||||
|
if view.Prize.Type != modelLottery.PrizeTypeCrypto {
|
||||||
|
t.Fatalf("Prize.Type = %q", view.Prize.Type)
|
||||||
|
}
|
||||||
|
if len(view.ClaimData) == 0 {
|
||||||
|
t.Fatal("ClaimData must be included when non-empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimToAdminView_NoSnapshotFallsBackToClaimPrizeType(t *testing.T) {
|
||||||
|
claim := modelLottery.Claim{
|
||||||
|
Id: 1,
|
||||||
|
DrawId: 2,
|
||||||
|
PrizeType: modelLottery.PrizeTypePhysical,
|
||||||
|
Status: modelLottery.ClaimStatusPendingClaim,
|
||||||
|
ExpiresAt: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
view := claimToAdminView(claim, modelLottery.PrizeSnapshot{}, "")
|
||||||
|
if view.Prize.Type != modelLottery.PrizeTypePhysical {
|
||||||
|
t.Fatalf("Prize.Type fallback = %q", view.Prize.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
// Package lottery contains the admin-facing lottery HTTP logic. Every write
|
||||||
|
// endpoint runs its user-visible mutation inside a tx that ALSO writes an
|
||||||
|
// admin_action_log row via audit.WriteAdminAction, so a rollback leaves no
|
||||||
|
// dangling audit entries. Rules PUT is gated by rulecaps.ValidateEligibilityJSON.
|
||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/logic/audit"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/lottery/rulecaps"
|
||||||
|
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||||
|
userModel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// currentAdminId retrieves the actor's user.id from ctx (populated by
|
||||||
|
// AuthMiddleware). Zero → treated as unauthorized. All admin endpoints below
|
||||||
|
// short-circuit if the caller is not admin.
|
||||||
|
func currentAdminId(ctx context.Context) int64 {
|
||||||
|
u, ok := ctx.Value(constant.CtxKeyUser).(*userModel.User)
|
||||||
|
if !ok || u == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return u.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestMeta(ctx context.Context) (ip, ua string) {
|
||||||
|
// AdminMetaMiddleware populates these keys on the request context after
|
||||||
|
// AuthMiddleware runs. Absent middleware (unit tests, non-admin paths)
|
||||||
|
// → empty strings, which is the intended defensive default.
|
||||||
|
if v, ok := ctx.Value(constant.CtxKeyIP).(string); ok {
|
||||||
|
ip = v
|
||||||
|
}
|
||||||
|
if v, ok := ctx.Value(constant.CtxKeyUserAgent).(string); ok {
|
||||||
|
ua = v
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonOrDefault(raw json.RawMessage, def string) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CreateLotteryActivity -------------------------------------------------
|
||||||
|
|
||||||
|
type CreateLotteryActivityLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateLotteryActivityLogic {
|
||||||
|
return &CreateLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *CreateLotteryActivityLogic) CreateLotteryActivity(req *types.CreateAdminLotteryActivityRequest) (*types.AdminLotteryActivity, error) {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
if err := rulecaps.ValidateEligibilityJSON(req.Eligibility); err != nil {
|
||||||
|
return nil, ruleCapsToXerr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activity := modelLottery.Activity{
|
||||||
|
Title: strings.TrimSpace(req.Title),
|
||||||
|
Description: req.Description,
|
||||||
|
StartAt: time.Unix(req.StartAt, 0),
|
||||||
|
EndAt: time.Unix(req.EndAt, 0),
|
||||||
|
Status: modelLottery.ActivityStatusDraft,
|
||||||
|
GridSize: req.GridSize,
|
||||||
|
Eligibility: jsonOrDefault(req.Eligibility, "{}"),
|
||||||
|
ChanceSources: jsonOrDefault(req.ChanceSources, "[]"),
|
||||||
|
UnmetAction: defaultString(req.UnmetAction, modelLottery.UnmetActionBlock),
|
||||||
|
}
|
||||||
|
if activity.GridSize <= 0 {
|
||||||
|
activity.GridSize = 9
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&activity).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryActivityCreate,
|
||||||
|
TargetIds: int64ToStr(activity.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return activityToAdminView(activity), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UpdateLotteryActivity -------------------------------------------------
|
||||||
|
|
||||||
|
type UpdateLotteryActivityLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryActivityLogic {
|
||||||
|
return &UpdateLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *UpdateLotteryActivityLogic) UpdateLotteryActivity(req *types.UpdateAdminLotteryActivityRequest) (*types.AdminLotteryActivity, error) {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
var updated modelLottery.Activity
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
fields := map[string]any{}
|
||||||
|
if req.Title != "" {
|
||||||
|
fields["title"] = req.Title
|
||||||
|
}
|
||||||
|
if req.Description != "" {
|
||||||
|
fields["description"] = req.Description
|
||||||
|
}
|
||||||
|
if req.StartAt > 0 {
|
||||||
|
fields["start_at"] = time.Unix(req.StartAt, 0)
|
||||||
|
}
|
||||||
|
if req.EndAt > 0 {
|
||||||
|
fields["end_at"] = time.Unix(req.EndAt, 0)
|
||||||
|
}
|
||||||
|
if req.GridSize > 0 {
|
||||||
|
fields["grid_size"] = req.GridSize
|
||||||
|
}
|
||||||
|
if req.UnmetAction != "" {
|
||||||
|
fields["unmet_action"] = req.UnmetAction
|
||||||
|
}
|
||||||
|
if len(fields) > 0 {
|
||||||
|
if err := tx.Model(&modelLottery.Activity{}).Where("id = ?", req.Id).Updates(fields).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||||
|
}
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryActivityUpdate,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return activityToAdminView(updated), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ListLotteryActivities -------------------------------------------------
|
||||||
|
|
||||||
|
type ListLotteryActivitiesLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListLotteryActivitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryActivitiesLogic {
|
||||||
|
return &ListLotteryActivitiesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListLotteryActivitiesLogic) ListLotteryActivities(req *types.ListAdminLotteryActivitiesRequest) (*types.ListAdminLotteryActivitiesResponse, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
page, size := req.Page, req.Size
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size <= 0 || size > 200 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Activity{})
|
||||||
|
if req.Status != "" {
|
||||||
|
db = db.Where("status = ?", req.Status)
|
||||||
|
}
|
||||||
|
if req.Search != "" {
|
||||||
|
db = db.Where("title LIKE ?", "%"+req.Search+"%")
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
var rows []modelLottery.Activity
|
||||||
|
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
resp := &types.ListAdminLotteryActivitiesResponse{Total: total, List: make([]types.AdminLotteryActivity, 0, len(rows))}
|
||||||
|
for _, a := range rows {
|
||||||
|
resp.List = append(resp.List, *activityToAdminView(a))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GetLotteryActivity ---------------------------------------------------
|
||||||
|
|
||||||
|
type GetLotteryActivityLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGetLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetLotteryActivityLogic {
|
||||||
|
return &GetLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GetLotteryActivityLogic) GetLotteryActivity(req *types.AdminActivityIdRequest) (*types.AdminLotteryActivity, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
var a modelLottery.Activity
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", req.Id).First(&a).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
return activityToAdminView(a), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Publish / Pause -------------------------------------------------------
|
||||||
|
|
||||||
|
type toggleActivityStatusLogic struct {
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
action string
|
||||||
|
next string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *toggleActivityStatusLogic) run(id int64) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var a modelLottery.Activity
|
||||||
|
if err := tx.Where("id = ?", id).First(&a).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
if err := tx.Model(&modelLottery.Activity{}).Where("id = ?", id).UpdateColumn("status", l.next).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: l.action,
|
||||||
|
TargetIds: int64ToStr(id),
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublishLotteryActivityLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPublishLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishLotteryActivityLogic {
|
||||||
|
return &PublishLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *PublishLotteryActivityLogic) PublishLotteryActivity(req *types.AdminActivityIdRequest) error {
|
||||||
|
t := &toggleActivityStatusLogic{ctx: l.ctx, svcCtx: l.svcCtx, action: audit.ActionLotteryActivityPublish, next: modelLottery.ActivityStatusRunning}
|
||||||
|
return t.run(req.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PauseLotteryActivityLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPauseLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PauseLotteryActivityLogic {
|
||||||
|
return &PauseLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *PauseLotteryActivityLogic) PauseLotteryActivity(req *types.AdminActivityIdRequest) error {
|
||||||
|
t := &toggleActivityStatusLogic{ctx: l.ctx, svcCtx: l.svcCtx, action: audit.ActionLotteryActivityPause, next: modelLottery.ActivityStatusPaused}
|
||||||
|
return t.run(req.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UpdateLotteryRules (with caps) ----------------------------------------
|
||||||
|
|
||||||
|
type UpdateLotteryRulesLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateLotteryRulesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryRulesLogic {
|
||||||
|
return &UpdateLotteryRulesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *UpdateLotteryRulesLogic) UpdateLotteryRules(req *types.UpdateAdminLotteryRulesRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
if err := rulecaps.ValidateEligibilityJSON(req.Eligibility); err != nil {
|
||||||
|
return ruleCapsToXerr(err)
|
||||||
|
}
|
||||||
|
// Pre-collect the field diff outside the tx so an empty update rejects
|
||||||
|
// without opening one (cheaper on the happy path + easier to test).
|
||||||
|
fields := map[string]any{}
|
||||||
|
if len(req.Eligibility) > 0 {
|
||||||
|
fields["eligibility"] = string(req.Eligibility)
|
||||||
|
}
|
||||||
|
if len(req.ChanceSources) > 0 {
|
||||||
|
fields["chance_sources"] = string(req.ChanceSources)
|
||||||
|
}
|
||||||
|
if req.UnmetAction != "" {
|
||||||
|
fields["unmet_action"] = req.UnmetAction
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.InvalidParams)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
res := tx.Model(&modelLottery.Activity{}).Where("id = ?", req.Id).Updates(fields)
|
||||||
|
if res.Error != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryRulesPut,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleCapsToXerr(err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, rulecaps.ErrRuleTreeTooDeep):
|
||||||
|
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooDeep, err.Error())
|
||||||
|
case errors.Is(err, rulecaps.ErrRuleTreeTooManyNodes):
|
||||||
|
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooMany, err.Error())
|
||||||
|
case errors.Is(err, rulecaps.ErrRuleTreeTooLarge):
|
||||||
|
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooLarge, err.Error())
|
||||||
|
default:
|
||||||
|
return xerr.NewErrCodeMsg(xerr.InvalidParams, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CreatePrize / UpdatePrize / DeletePrize / ListPrizes -----------------
|
||||||
|
|
||||||
|
type CreateLotteryPrizeLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateLotteryPrizeLogic {
|
||||||
|
return &CreateLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *CreateLotteryPrizeLogic) CreateLotteryPrize(req *types.CreateAdminLotteryPrizeRequest) (*types.AdminLotteryPrize, error) {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
prize := modelLottery.Prize{
|
||||||
|
ActivityId: req.ActivityId,
|
||||||
|
Slot: req.Slot,
|
||||||
|
Type: req.Type,
|
||||||
|
Name: req.Name,
|
||||||
|
IconURL: req.IconUrl,
|
||||||
|
Config: jsonOrDefault(req.Config, "{}"),
|
||||||
|
Weight: req.Weight,
|
||||||
|
IsFallback: req.IsFallback,
|
||||||
|
}
|
||||||
|
if req.TotalStock != nil {
|
||||||
|
prize.TotalStock.Int64 = *req.TotalStock
|
||||||
|
prize.TotalStock.Valid = true
|
||||||
|
prize.RemainingStock.Int64 = *req.TotalStock
|
||||||
|
prize.RemainingStock.Valid = true
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&prize).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryPrizeCreate,
|
||||||
|
TargetIds: int64ToStr(prize.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return prizeToAdminView(prize), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateLotteryPrizeLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryPrizeLogic {
|
||||||
|
return &UpdateLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *UpdateLotteryPrizeLogic) UpdateLotteryPrize(req *types.UpdateAdminLotteryPrizeRequest) (*types.AdminLotteryPrize, error) {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
var updated modelLottery.Prize
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return xerr.NewErrCode(xerr.DatabaseQueryError)
|
||||||
|
}
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
fields := map[string]any{}
|
||||||
|
if req.Slot != nil {
|
||||||
|
fields["slot"] = *req.Slot
|
||||||
|
}
|
||||||
|
if req.Name != "" {
|
||||||
|
fields["name"] = req.Name
|
||||||
|
}
|
||||||
|
if req.IconUrl != "" {
|
||||||
|
fields["icon_url"] = req.IconUrl
|
||||||
|
}
|
||||||
|
if len(req.Config) > 0 {
|
||||||
|
fields["config"] = string(req.Config)
|
||||||
|
}
|
||||||
|
if req.Weight != nil {
|
||||||
|
fields["weight"] = *req.Weight
|
||||||
|
}
|
||||||
|
if req.TotalStock != nil {
|
||||||
|
fields["total_stock"] = *req.TotalStock
|
||||||
|
fields["remaining_stock"] = *req.TotalStock
|
||||||
|
}
|
||||||
|
if req.IsFallback != nil {
|
||||||
|
fields["is_fallback"] = *req.IsFallback
|
||||||
|
}
|
||||||
|
if len(fields) > 0 {
|
||||||
|
if err := tx.Model(&modelLottery.Prize{}).Where("id = ?", req.Id).Updates(fields).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||||
|
}
|
||||||
|
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryPrizeUpdate,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return prizeToAdminView(updated), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteLotteryPrizeLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeleteLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteLotteryPrizeLogic {
|
||||||
|
return &DeleteLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *DeleteLotteryPrizeLogic) DeleteLotteryPrize(req *types.AdminPrizeIdRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("id = ?", req.Id).Delete(&modelLottery.Prize{}).Error; err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
||||||
|
}
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryPrizeDelete,
|
||||||
|
TargetIds: int64ToStr(req.Id),
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListLotteryPrizesLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListLotteryPrizesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryPrizesLogic {
|
||||||
|
return &ListLotteryPrizesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListLotteryPrizesLogic) ListLotteryPrizes(req *types.ListAdminLotteryPrizesRequest) (*types.ListAdminLotteryPrizesResponse, error) {
|
||||||
|
if currentAdminId(l.ctx) == 0 {
|
||||||
|
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
var rows []modelLottery.Prize
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).Where("activity_id = ?", req.ActivityId).Order("slot ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||||
|
}
|
||||||
|
resp := &types.ListAdminLotteryPrizesResponse{List: make([]types.AdminLotteryPrize, 0, len(rows))}
|
||||||
|
for _, p := range rows {
|
||||||
|
resp.List = append(resp.List, *prizeToAdminView(p))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GrantLotteryChance ----------------------------------------------------
|
||||||
|
|
||||||
|
type GrantLotteryChanceLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGrantLotteryChanceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GrantLotteryChanceLogic {
|
||||||
|
return &GrantLotteryChanceLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GrantLotteryChanceLogic) GrantLotteryChance(req *types.GrantAdminLotteryChanceRequest) error {
|
||||||
|
actor := currentAdminId(l.ctx)
|
||||||
|
if actor == 0 {
|
||||||
|
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||||
|
}
|
||||||
|
// Prefix sourceRef with "manual:{actor}:" so admin-granted chances are
|
||||||
|
// distinguishable in ChanceGrant flow (audit trail + admin-scoped
|
||||||
|
// idempotency).
|
||||||
|
ref := "manual:" + int64ToStr(actor) + ":" + req.SourceRef
|
||||||
|
if err := l.svcCtx.LotteryChance.Grant(l.ctx, req.UserId, req.ActivityId, modelLottery.ChanceSourceManualGrant, ref, req.Amount); err != nil {
|
||||||
|
return errors.Wrap(xerr.NewErrCode(xerr.LotteryInternalError), err.Error())
|
||||||
|
}
|
||||||
|
// Audit outside the ChanceService tx — the Grant is idempotent so a
|
||||||
|
// duplicated audit row is preferable to a lost one.
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
ip, ua := requestMeta(l.ctx)
|
||||||
|
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||||
|
ActorUserId: actor,
|
||||||
|
Action: audit.ActionLotteryChancesGrant,
|
||||||
|
TargetIds: int64ToStr(req.UserId) + "," + int64ToStr(req.ActivityId),
|
||||||
|
RequestBody: body,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: ua,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func activityToAdminView(a modelLottery.Activity) *types.AdminLotteryActivity {
|
||||||
|
return &types.AdminLotteryActivity{
|
||||||
|
Id: a.Id,
|
||||||
|
Title: a.Title,
|
||||||
|
Description: a.Description,
|
||||||
|
StartAt: a.StartAt.Unix(),
|
||||||
|
EndAt: a.EndAt.Unix(),
|
||||||
|
Status: a.Status,
|
||||||
|
GridSize: a.GridSize,
|
||||||
|
Eligibility: json.RawMessage(defaultRawIfEmpty(a.Eligibility, "{}")),
|
||||||
|
ChanceSources: json.RawMessage(defaultRawIfEmpty(a.ChanceSources, "[]")),
|
||||||
|
UnmetAction: a.UnmetAction,
|
||||||
|
CreatedAt: a.CreatedAt.Unix(),
|
||||||
|
UpdatedAt: a.UpdatedAt.Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prizeToAdminView(p modelLottery.Prize) *types.AdminLotteryPrize {
|
||||||
|
view := &types.AdminLotteryPrize{
|
||||||
|
Id: p.Id,
|
||||||
|
ActivityId: p.ActivityId,
|
||||||
|
Slot: p.Slot,
|
||||||
|
Type: p.Type,
|
||||||
|
Name: p.Name,
|
||||||
|
IconUrl: p.IconURL,
|
||||||
|
Config: json.RawMessage(defaultRawIfEmpty(p.Config, "{}")),
|
||||||
|
Weight: p.Weight,
|
||||||
|
IsFallback: p.IsFallback,
|
||||||
|
CreatedAt: p.CreatedAt.Unix(),
|
||||||
|
UpdatedAt: p.UpdatedAt.Unix(),
|
||||||
|
}
|
||||||
|
if p.TotalStock.Valid {
|
||||||
|
v := p.TotalStock.Int64
|
||||||
|
view.TotalStock = &v
|
||||||
|
}
|
||||||
|
if p.RemainingStock.Valid {
|
||||||
|
v := p.RemainingStock.Int64
|
||||||
|
view.RemainingStock = &v
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRawIfEmpty(s, fallback string) string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultString(s, fallback string) string {
|
||||||
|
if s == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64ToStr(v int64) string {
|
||||||
|
// small buffer avoids strconv import here.
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := false
|
||||||
|
if v < 0 {
|
||||||
|
neg = true
|
||||||
|
v = -v
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAdminLotteryDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||||
|
if strings.Contains(actual, expected) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("actual sql does not contain expected: " + expected)
|
||||||
|
})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
t.Fatalf("gorm: %v", err)
|
||||||
|
}
|
||||||
|
return db, mock, func() { _ = sqlDB.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminCtx() context.Context {
|
||||||
|
return context.WithValue(context.Background(), constant.CtxKeyUser, &user.User{Id: 7})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLotteryRules_RejectsOversizeEligibility(t *testing.T) {
|
||||||
|
db, _, cleanup := newAdminLotteryDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// build oversized JSON
|
||||||
|
blob := strings.Repeat("a", 9000)
|
||||||
|
req := &types.UpdateAdminLotteryRulesRequest{
|
||||||
|
Id: 1,
|
||||||
|
Eligibility: json.RawMessage(`{"op":"AND","payload":"` + blob + `"}`),
|
||||||
|
}
|
||||||
|
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||||
|
err := logic.UpdateLotteryRules(req)
|
||||||
|
var ce *xerr.CodeError
|
||||||
|
if !errors.As(err, &ce) {
|
||||||
|
t.Fatalf("expected CodeError, got %v", err)
|
||||||
|
}
|
||||||
|
if ce.GetErrCode() != xerr.LotteryRuleTooLarge {
|
||||||
|
t.Fatalf("expected LotteryRuleTooLarge, got %d", ce.GetErrCode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLotteryRules_RejectsDeepTree(t *testing.T) {
|
||||||
|
db, _, cleanup := newAdminLotteryDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// build depth 9 tree
|
||||||
|
tree := map[string]any{"op": "OR", "children": []any{}}
|
||||||
|
cur := tree
|
||||||
|
for i := 1; i < 9; i++ {
|
||||||
|
next := map[string]any{"op": "OR", "children": []any{}}
|
||||||
|
cur["children"] = []any{next}
|
||||||
|
cur = next
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(tree)
|
||||||
|
req := &types.UpdateAdminLotteryRulesRequest{Id: 1, Eligibility: raw}
|
||||||
|
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||||
|
err := logic.UpdateLotteryRules(req)
|
||||||
|
var ce *xerr.CodeError
|
||||||
|
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.LotteryRuleTooDeep {
|
||||||
|
t.Fatalf("expected LotteryRuleTooDeep, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLotteryRules_RejectsAnonymousCaller(t *testing.T) {
|
||||||
|
db, _, cleanup := newAdminLotteryDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
logic := NewUpdateLotteryRulesLogic(context.Background(), &svc.ServiceContext{DB: db})
|
||||||
|
err := logic.UpdateLotteryRules(&types.UpdateAdminLotteryRulesRequest{Id: 1, Eligibility: json.RawMessage("{}")})
|
||||||
|
var ce *xerr.CodeError
|
||||||
|
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.ErrorTokenInvalid {
|
||||||
|
t.Fatalf("expected token invalid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLotteryRules_ValidTreePersists(t *testing.T) {
|
||||||
|
db, mock, cleanup := newAdminLotteryDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("UPDATE `lottery_activity`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectExec("INSERT INTO `admin_action_log`").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
req := &types.UpdateAdminLotteryRulesRequest{
|
||||||
|
Id: 1,
|
||||||
|
Eligibility: json.RawMessage(`{"type":"has_subscription"}`),
|
||||||
|
ChanceSources: json.RawMessage(`[{"source":"daily_signin","amount":1}]`),
|
||||||
|
}
|
||||||
|
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||||
|
if err := logic.UpdateLotteryRules(req); err != nil {
|
||||||
|
t.Fatalf("UpdateLotteryRules: %v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateLotteryRules_MissingBothFieldsRejects(t *testing.T) {
|
||||||
|
db, _, cleanup := newAdminLotteryDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
req := &types.UpdateAdminLotteryRulesRequest{Id: 1}
|
||||||
|
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||||
|
err := logic.UpdateLotteryRules(req)
|
||||||
|
var ce *xerr.CodeError
|
||||||
|
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.InvalidParams {
|
||||||
|
t.Fatalf("expected InvalidParams, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package lottery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRequestMeta_EmptyWhenCtxUnset asserts requestMeta returns empty strings
|
||||||
|
// when neither typed context key is populated (unit tests, non-admin paths).
|
||||||
|
func TestRequestMeta_EmptyWhenCtxUnset(t *testing.T) {
|
||||||
|
ip, ua := requestMeta(context.Background())
|
||||||
|
if ip != "" || ua != "" {
|
||||||
|
t.Fatalf("expected empty, got ip=%q ua=%q", ip, ua)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestMeta_ReadsTypedKeys asserts requestMeta picks up the values
|
||||||
|
// AdminMetaMiddleware pins onto ctx via the typed CtxKey constants.
|
||||||
|
func TestRequestMeta_ReadsTypedKeys(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
ctx = context.WithValue(ctx, constant.CtxKeyIP, "10.99.99.7")
|
||||||
|
ctx = context.WithValue(ctx, constant.CtxKeyUserAgent, "qa-audit-probe")
|
||||||
|
|
||||||
|
ip, ua := requestMeta(ctx)
|
||||||
|
if ip != "10.99.99.7" {
|
||||||
|
t.Fatalf("ip = %q, want %q", ip, "10.99.99.7")
|
||||||
|
}
|
||||||
|
if ua != "qa-audit-probe" {
|
||||||
|
t.Fatalf("ua = %q, want %q", ua, "qa-audit-probe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestMeta_IgnoresBareStringKeys guards against the F2 root cause:
|
||||||
|
// pre-fix, the writer used bare-string keys "ip" / "user_agent" which never
|
||||||
|
// collided with anyone's typed reader — so audit rows always saw empty
|
||||||
|
// strings. The test proves the reader now IGNORES bare-string writes: only
|
||||||
|
// the typed CtxKey path counts.
|
||||||
|
func TestRequestMeta_IgnoresBareStringKeys(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
//nolint:staticcheck // intentional bare-string key to prove reader ignores it
|
||||||
|
ctx = context.WithValue(ctx, "ip", "should-be-ignored")
|
||||||
|
//nolint:staticcheck // intentional bare-string key to prove reader ignores it
|
||||||
|
ctx = context.WithValue(ctx, "user_agent", "should-be-ignored")
|
||||||
|
|
||||||
|
ip, ua := requestMeta(ctx)
|
||||||
|
if ip != "" || ua != "" {
|
||||||
|
t.Fatalf("bare-string keys must be ignored; got ip=%q ua=%q", ip, ua)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user