Compare commits

..

1 Commits

Author SHA1 Message Date
架构师 c2d0db5875 修复(#143): 邀请权益查询排序 inviteeAndInviterIds 消除 map 迭代序 flake
inviteeAndInviterSet 是 map,按 range 收集到 slice 后顺序不固定,
传给 fillGiftBenefits 的 IN (?,?) 参数随之乱序,导致 sqlmock 按位置
匹配的单测 TestQueryBenefitsKeepsDirectInviteeGift 偶现失败(本地 10
次复现约 2-4 次)。

在收集后追加 slices.Sort,让生产代码本身的下游 SQL 参数稳定;同步把
测试期望改为升序。50 次重复 + race 全绿。

Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 21:00:52 -07:00
223 changed files with 9022 additions and 15736 deletions
@@ -0,0 +1,25 @@
package main
import (
"fmt"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/conf"
)
func main() {
var c config.Config
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
ctx := svc.NewServiceContext(c)
const key = "cache:auth:method:device"
before, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result()
fmt.Printf("cache before=%q\n", before)
m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
fmt.Printf("model err=%v enabled_nil=%v", err, m == nil || m.Enabled == nil)
if m != nil && m.Enabled != nil { fmt.Printf(" enabled=%v", *m.Enabled) }
if m != nil { fmt.Printf(" config=%s", m.Config) }
fmt.Println()
after, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result()
fmt.Printf("cache after=%q\n", after)
}
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"fmt"
initpkg "github.com/perfect-panel/server/initialize"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/conf"
)
func main() {
var c config.Config
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
ctx := svc.NewServiceContext(c)
method, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
if err != nil {
panic(err)
}
fmt.Printf("db auth_method.enabled=%v config=%s\n", *method.Enabled, method.Config)
initpkg.Device(ctx)
fmt.Printf("ctx.Config.Device.Enable=%v SecuritySecret=%q EnableSecurity=%v\n", ctx.Config.Device.Enable, ctx.Config.Device.SecuritySecret, ctx.Config.Device.EnableSecurity)
}
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"fmt"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/conf"
)
type row struct {
ID int64
Method string
Enabled int
Config string
}
func main() {
var c config.Config
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
ctx := svc.NewServiceContext(c)
var rows []row
if err := ctx.DB.Raw("SELECT id, method, enabled, config FROM auth_method WHERE method = ?", "device").Scan(&rows).Error; err != nil {
panic(err)
}
fmt.Printf("raw rows: %+v\n", rows)
m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
fmt.Printf("model err=%v\n", err)
if err == nil && m != nil && m.Enabled != nil {
fmt.Printf("model row: id=%d method=%s enabled=%v config=%s\n", m.Id, m.Method, *m.Enabled, m.Config)
} else {
fmt.Printf("model row nil or no enabled ptr: %#v\n", m)
}
}
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"fmt"
"github.com/perfect-panel/server/internal/config"
authmodel "github.com/perfect-panel/server/internal/model/auth"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/conf"
)
func main() {
var c config.Config
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
ctx := svc.NewServiceContext(c)
var a1 authmodel.Auth
err1 := ctx.DB.Model(&authmodel.Auth{}).Where("method = ?", "device").First(&a1).Error
fmt.Printf("gorm direct err=%v enabled_nil=%v", err1, a1.Enabled == nil)
if a1.Enabled != nil { fmt.Printf(" enabled=%v", *a1.Enabled) }
fmt.Printf(" config=%s\n", a1.Config)
var a2 authmodel.Auth
err2 := ctx.DB.Table("auth_method").Where("method = ?", "device").First(&a2).Error
fmt.Printf("gorm table err=%v enabled_nil=%v", err2, a2.Enabled == nil)
if a2.Enabled != nil { fmt.Printf(" enabled=%v", *a2.Enabled) }
fmt.Printf(" config=%s\n", a2.Config)
}
+13
View File
@@ -1,5 +1,18 @@
# 复制此文件为 .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
+337
View File
@@ -0,0 +1,337 @@
name: Build docker and publish
run-name: 简化的Docker构建和部署流程
on:
push:
branches:
- main
- internal
pull_request:
branches:
- main
- internal
env:
# Docker镜像仓库
REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }}
# SSH连接信息 (根据分支自动选择服务器和用户)
SSH_HOST: ${{ github.ref_name == 'main' && vars.SSH_HOST || vars.DEV_SSH_HOST }}
SSH_PORT: ${{ vars.SSH_PORT }}
SSH_USER: ${{ github.ref_name == 'main' && 'ubuntu' || 'root' }}
# SSH私钥(Gitea Secret 名称:AWS
SSH_KEY: ${{ secrets.AWS }}
# TG通知
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
# Go构建变量
SERVICE: vpn
SERVICE_STYLE: vpn
VERSION: ${{ github.sha }}
BUILDTIME: ${{ github.event.head_commit.timestamp }}
GOARCH: amd64
jobs:
build:
runs-on: ario-server
container:
image: node:20
strategy:
matrix:
# 只有node支持版本号别名
node: ['20.15.1']
steps:
# 步骤1: 下载代码
- name: 📥 下载代码
uses: actions/checkout@v4
# 步骤2: 设置动态环境变量
- name: ⚙️ 设置动态环境变量
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV
echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV
echo "DEPLOY_PATH=/opt/ppanel" >> $GITHUB_ENV
echo "DEPLOY_ENV_LABEL=🚀 服务已成功部署到生产环境" >> $GITHUB_ENV
echo "为 main 分支设置生产环境变量"
elif [ "${{ github.ref_name }}" = "internal" ]; then
echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV
echo "CONTAINER_NAME=ppanel-server-internal" >> $GITHUB_ENV
echo "DEPLOY_PATH=/root/bindbox" >> $GITHUB_ENV
echo "DEPLOY_ENV_LABEL=🧪 服务已成功部署到测试环境" >> $GITHUB_ENV
echo "为 internal 分支设置开发环境变量"
else
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
echo "CONTAINER_NAME=ppanel-server-${{ github.ref_name }}" >> $GITHUB_ENV
echo "DEPLOY_PATH=/root/vpn_server_other" >> $GITHUB_ENV
echo "DEPLOY_ENV_LABEL=🔧 服务已成功部署到其他环境" >> $GITHUB_ENV
echo "为其他分支 (${{ github.ref_name }}) 设置环境变量"
fi
# 步骤3: 安装系统工具 (curl, jq) 并升级 Docker CLI 到 1.44+
- name: 🔧 安装系统工具并升级 Docker CLI
run: |
set -e
export DEBIAN_FRONTEND=noninteractive
echo "等待 apt/dpkg 锁释放 (unattended-upgrades)..."
end=$((SECONDS+300))
while true; do
LOCKS_BUSY=0
if pgrep -x unattended-upgrades >/dev/null 2>&1; then LOCKS_BUSY=1; fi
if command -v fuser >/dev/null 2>&1; then
if fuser /var/lib/dpkg/lock >/dev/null 2>&1 \
|| fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
|| fuser /var/lib/apt/lists/lock >/dev/null 2>&1; then
LOCKS_BUSY=1
fi
fi
if [ "$LOCKS_BUSY" -eq 0 ]; then break; fi
if [ $SECONDS -ge $end ]; then
echo "等待 apt/dpkg 锁超时,使用 Dpkg::Lock::Timeout 继续..."
break
fi
echo "仍在等待锁释放..."; sleep 5
done
# 基础工具
apt-get update -y -o Dpkg::Lock::Timeout=600
apt-get install -y -o Dpkg::Lock::Timeout=600 jq curl ca-certificates gnupg lsb-release
# 移除旧版 docker.io,避免客户端过旧 (API 1.41)
if dpkg -s docker.io >/dev/null 2>&1; then
apt-get remove -y docker.io || true
fi
# 安装 Docker 官方仓库的 CLI (确保 API >= 1.44)
distro_codename=$(. /etc/os-release && echo "$VERSION_CODENAME")
install_repo="deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${distro_codename} stable"
mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "$install_repo" > /etc/apt/sources.list.d/docker.list
apt-get update -y -o Dpkg::Lock::Timeout=600
apt-get install -y -o Dpkg::Lock::Timeout=600 docker-ce-cli docker-buildx-plugin
# 版本检查
docker --version || true
docker version || true
echo "客户端 API 版本:" $(docker version --format '{{.Client.APIVersion}}')
# 步骤4: 构建镜像
- name: 🏗️ 构建镜像
run: |
echo "开始构建镜像..."
echo "仓库: ${{ env.REPO }}"
echo "版本标签: ${{ env.VERSION }}"
echo "分支标签: ${{ env.DOCKER_TAG_SUFFIX }}"
BUILD_TAG_ARGS="-t ${{ env.REPO }}:${{ env.VERSION }}"
if [ "${{ github.event_name }}" = "push" ]; then
BUILD_TAG_ARGS="$BUILD_TAG_ARGS -t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
else
echo "PR事件仅构建版本标签,不推送镜像、不部署"
fi
docker build -f Dockerfile \
--platform linux/amd64 \
--build-arg TARGETARCH=amd64 \
--build-arg VERSION=${{ env.VERSION }} \
--build-arg BUILDTIME=${{ env.BUILDTIME }} \
$BUILD_TAG_ARGS \
.
echo "镜像构建完成"
# 步骤5: 发布到镜像仓库
- name: 📤 发布到镜像仓库
if: github.event_name == 'push'
run: |
echo "开始推送镜像..."
echo "推送版本标签镜像: ${{ env.REPO }}:${{ env.VERSION }}"
docker push ${{ env.REPO }}:${{ env.VERSION }}
echo "推送分支标签镜像: ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
docker push ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}
echo "镜像推送完成"
# 步骤6: 调试 - 打印部署目标(不输出敏感信息)
- name: 🔍 调试 - 打印部署目标
if: github.event_name == 'push'
run: |
echo "========== 部署目标调试 =========="
echo "当前分支: ${{ github.ref_name }}"
echo "SSH_HOST: ${{ env.SSH_HOST }}"
echo "SSH_PORT: ${{ env.SSH_PORT }}"
echo "SSH_USER: ${{ env.SSH_USER }}"
echo "SSH认证方式: 私钥 (AWS)"
echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}"
echo "====================================="
# 步骤7: 传输配置文件
- name: 📂 传输配置文件
if: github.event_name == 'push'
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ env.SSH_HOST }}
username: ${{ env.SSH_USER }}
key: ${{ env.SSH_KEY }}
port: ${{ env.SSH_PORT }}
source: "docker-compose.cloud.yml"
target: "/tmp/ppanel-deploy/"
# 步骤8: 连接服务器更新、健康检查并按需回滚
- name: 🚀 连接服务器更新并启动
if: github.event_name == 'push'
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ env.SSH_HOST }}
username: ${{ env.SSH_USER }}
key: ${{ env.SSH_KEY }}
port: ${{ env.SSH_PORT }}
timeout: 300s
command_timeout: 600s
script: |
set -e
echo "连接服务器成功,开始部署..."
echo "部署目录: ${{ env.DEPLOY_PATH }}"
echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}"
echo "登录用户: ${{ env.SSH_USER }}"
HEALTHCHECK_URL="http://127.0.0.1:8080/v1/common/heartbeat"
NEW_TAG="${{ env.DOCKER_TAG_SUFFIX }}"
ROLLBACK_TAG="rollback-${{ env.VERSION }}"
SUDO=""
if [ "${{ github.ref_name }}" = "main" ]; then
SUDO="sudo"
fi
docker_cmd() {
if [ -n "$SUDO" ]; then
sudo docker "$@"
else
docker "$@"
fi
}
compose_with_tag() {
tag="$1"
shift
if [ -n "$SUDO" ]; then
sudo env PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
else
PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
fi
}
write_previous_tag() {
if [ -n "$SUDO" ]; then
printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" | sudo tee .previous-ppanel-image-tag >/dev/null
else
printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" > .previous-ppanel-image-tag
fi
}
health_check() {
attempt=1
while [ "$attempt" -le 3 ]; do
if curl -sf "$HEALTHCHECK_URL"; then
echo
return 0
fi
echo "健康检查第 ${attempt}/3 次失败,10s 后重试..."
attempt=$((attempt + 1))
sleep 10
done
return 1
}
if [ "${{ github.ref_name }}" = "main" ]; then
sudo mkdir -p ${{ env.DEPLOY_PATH }}
sudo cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
else
mkdir -p ${{ env.DEPLOY_PATH }}
cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
fi
cd ${{ env.DEPLOY_PATH }}
PREVIOUS_IMAGE_TAG="$(docker_cmd inspect --format '{{.Config.Image}}' ppanel-server 2>/dev/null || true)"
PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)"
echo "上一版本镜像tag: ${PREVIOUS_IMAGE_TAG:-未发现}"
echo "上一版本镜像ID: ${PREVIOUS_IMAGE_ID:-未发现}"
write_previous_tag
echo "📥 拉取镜像: ${{ env.REPO }}:${NEW_TAG}"
compose_with_tag "$NEW_TAG" pull ppanel-server
echo "🚀 启动服务..."
compose_with_tag "$NEW_TAG" up -d ppanel-server
echo "🩺 部署后健康检查: ${HEALTHCHECK_URL}"
if health_check; then
docker_cmd image prune -f || true
echo "✅ 部署后健康检查通过"
echo "✅ 部署命令执行完成"
exit 0
fi
echo "❌ 部署后健康检查连续 3 次失败,开始回滚..."
if [ -n "$PREVIOUS_IMAGE_ID" ]; then
docker_cmd tag "$PREVIOUS_IMAGE_ID" "${{ env.REPO }}:${ROLLBACK_TAG}"
echo "回滚镜像tag: ${{ env.REPO }}:${ROLLBACK_TAG}"
compose_with_tag "$ROLLBACK_TAG" up -d ppanel-server
echo "🩺 回滚后健康检查: ${HEALTHCHECK_URL}"
if health_check; then
echo "✅ 回滚后健康检查通过"
else
echo "❌ 回滚后健康检查仍失败"
fi
else
echo "未找到上一版本镜像ID,无法自动回滚"
fi
docker_cmd image prune -f || true
exit 1
# 步骤9: TG通知 (成功)
- name: 📱 发送成功通知到Telegram
if: success() && github.event_name == 'push'
uses: appleboy/telegram-action@master
with:
token: ${{ env.TG_BOT_TOKEN }}
to: ${{ env.TG_CHAT_ID }}
message: |
✅ 部署成功!
📦 项目: ${{ github.repository }}
🌿 分支: ${{ github.ref_name }}
📝 提交: ${{ github.sha }}
👤 提交者: ${{ github.actor }}
🕐 时间: ${{ github.event.head_commit.timestamp }}
${{ env.DEPLOY_ENV_LABEL }}
🩺 健康检查: 通过 (http://127.0.0.1:8080/v1/common/heartbeat)
parse_mode: Markdown
# 步骤10: TG通知 (失败)
- name: 📱 发送失败通知到Telegram
if: failure() && github.event_name == 'push'
uses: appleboy/telegram-action@master
with:
token: ${{ env.TG_BOT_TOKEN }}
to: ${{ env.TG_CHAT_ID }}
message: |
❌ 部署失败!
📦 项目: ${{ github.repository }}
🌿 分支: ${{ github.ref_name }}
📝 提交: ${{ github.sha }}
👤 提交者: ${{ github.actor }}
🕐 时间: ${{ github.event.head_commit.timestamp }}
🩺 健康检查: 失败或未完成;若新版本健康检查连续 3 次失败,已自动尝试回滚并重新检查
⚠️ 请检查构建日志获取详细信息
parse_mode: Markdown
-24
View File
@@ -1,24 +0,0 @@
# Code owners — 自动 request review
#
# 仓库私有 + 当前 plan 不支持 branch protection(详见 doc/development-workflow-zh.md
# 「平台层约束的现状」一节),CODEOWNERS 在此用作"自动 request review + 显性责任划分"
# 而非强制门禁。
#
# 任何 PR 默认 request 给 @shanshanzhong147 (owner) review。若后续引入团队
# handle(例如 @TawCorp/backend),把对应 path 改成 team handle 即可。
# 全部路径 — owner 默认 reviewer
* @shanshanzhong147
# 部署/CI/Docker — 改这些要再确认一次(涉及生产部署链路)
/.github/ @shanshanzhong147
/Dockerfile @shanshanzhong147
/docker-compose.*.yml @shanshanzhong147
/scripts/ @shanshanzhong147
/Makefile @shanshanzhong147
# 流程文档自身 — 改这里就是改流程
/CONTRIBUTING.md @shanshanzhong147
/CONTRIBUTING_ZH.md @shanshanzhong147
/doc/development-workflow-zh.md @shanshanzhong147
/.github/CODEOWNERS @shanshanzhong147
-51
View File
@@ -1,51 +0,0 @@
<!--
完整流程见 doc/development-workflow-zh.md
-->
## 关联 Issue
Closes HIF-XXX
<!-- 如关联多个:Closes HIF-XXX, Closes HIF-YYY -->
## 改动摘要
<!-- 1-3 句话说清楚做了什么、为什么 -->
## 改动细节
<!-- 按文件/模块逐条列;引用代码用 `file.go:行号` 格式 -->
-
-
## 测试计划
- [ ] `go build ./...` 通过
- [ ] `go vet ./...` 通过
- [ ] `go test -race ./... -count=1` 通过
- [ ] golangci-lint 通过
- [ ] 新增/修改的逻辑有对应单测覆盖
- [ ] (如涉及 DB 变更)migration up/down 双向验证
- [ ] (如涉及 APIcurl / Postman 验证命令贴在下面
<!-- 贴 curl 或测试输出 -->
```
```
## 风险 / 回滚
<!-- 这次改动失败时怎么回滚;是否影响线上数据;是否需要 feature flag -->
-
## Reviewer 自检清单
- [ ] PR 标题符合 commitlint 规范(`修复/新功能/重构/文档/配置(#<num>): ...`
- [ ] 分支命名 `fix/<num>-…` / `feat/<num>-…` / `chore/…`
- [ ] 目标分支 = `internal`
- [ ] 改动 scope 与 Issue 描述一致,无 scope creep
- [ ] **无无关代码改动**(架构师红线)
- [ ] 无密钥/凭证泄露
- [ ] CI 全绿
- [ ] 测试工程师已验收(如涉及业务逻辑)
+27
View File
@@ -0,0 +1,27 @@
# Production Environment Configuration for GitHub Actions
# This file defines production-specific deployment settings
environment:
name: production
url: https://api.ppanel.example.com
protection_rules:
- type: wait_timer
minutes: 5
- type: reviewers
reviewers:
- "@admin-team"
- "@devops-team"
variables:
ENVIRONMENT: production
LOG_LEVEL: info
DEPLOY_TIMEOUT: 300
# Environment-specific secrets required:
# PRODUCTION_HOST - Production server hostname/IP
# PRODUCTION_USER - SSH username for production server
# PRODUCTION_SSH_KEY - SSH private key for production server
# PRODUCTION_PORT - SSH port (default: 22)
# PRODUCTION_URL - Application URL for health checks
# DATABASE_PASSWORD - Production database password
# REDIS_PASSWORD - Production Redis password
# JWT_SECRET - JWT secret key for production
+23
View File
@@ -0,0 +1,23 @@
# Staging Environment Configuration for GitHub Actions
# This file defines staging-specific deployment settings
environment:
name: staging
url: https://staging-api.ppanel.example.com
protection_rules:
- type: wait_timer
minutes: 2
variables:
ENVIRONMENT: staging
LOG_LEVEL: debug
DEPLOY_TIMEOUT: 180
# Environment-specific secrets required:
# STAGING_HOST - Staging server hostname/IP
# STAGING_USER - SSH username for staging server
# STAGING_SSH_KEY - SSH private key for staging server
# STAGING_PORT - SSH port (default: 22)
# STAGING_URL - Application URL for health checks
# DATABASE_PASSWORD - Staging database password
# REDIS_PASSWORD - Staging Redis password
# JWT_SECRET - JWT secret key for staging
-72
View File
@@ -1,72 +0,0 @@
name: 持续集成
on:
pull_request:
branches:
- internal
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
name: 构建/Vet/测试
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: 检出代码
uses: actions/checkout@v6
- name: 配置 Go 环境
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: 下载依赖模块
run: go mod download
- name: 构建
run: go build ./...
- name: 运行 go vet
run: go vet ./...
- name: 运行测试
run: go test -race -count=1 ./...
lint:
name: golangci-lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: 检出代码
uses: actions/checkout@v6
with:
# Fetch base ref so golangci-lint can diff against it for only-new-issues.
fetch-depth: 0
- name: 配置 Go 环境
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: latest
args: --timeout=5m
# Legacy codebase has ~47 pre-existing lint issues (errcheck / unused
# carried over from upstream perfect-panel/server). Only flag NEW
# issues introduced by this PR so CI stays useful without forcing a
# mass cleanup. Backlog cleanup tracked separately.
only-new-issues: true
+79
View File
@@ -0,0 +1,79 @@
name: Build Linux Binary
on:
push:
branches: [ main, master ]
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to build (leave empty for auto)'
required: false
type: string
permissions:
contents: write
jobs:
build:
name: Build Linux Binary
runs-on: ario-server
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23.3'
cache: true
- name: Build
env:
CGO_ENABLED: 0
GOOS: linux
GOARCH: amd64
run: |
VERSION=${{ github.event.inputs.version }}
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --always --dirty)
fi
echo "Building ppanel-server $VERSION"
BUILD_TIME=$(date +"%Y-%m-%d_%H:%M:%S")
go build -ldflags="-w -s -X github.com/perfect-panel/server/pkg/constant.Version=$VERSION -X github.com/perfect-panel/server/pkg/constant.BuildTime=$BUILD_TIME" -o ppanel-server ./ppanel.go
tar -czf ppanel-server-${VERSION}-linux-amd64.tar.gz ppanel-server
sha256sum ppanel-server ppanel-server-${VERSION}-linux-amd64.tar.gz > checksum.txt
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ppanel-server-linux-amd64
path: |
ppanel-server
ppanel-server-*-linux-amd64.tar.gz
checksum.txt
- name: Create Release
if: startsWith(github.ref, 'refs/tags/')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${GITHUB_REF#refs/tags/}
# Check if release exists
if gh release view $VERSION >/dev/null 2>&1; then
echo "Release $VERSION already exists, deleting old assets..."
# Delete existing assets if they exist
gh release delete-asset $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz --yes 2>/dev/null || true
gh release delete-asset $VERSION checksum.txt --yes 2>/dev/null || true
else
echo "Creating new release $VERSION..."
gh release create $VERSION --title "PPanel Server $VERSION" --notes "Release $VERSION"
fi
# Upload assets (will overwrite if --clobber is supported, otherwise will fail gracefully)
echo "Uploading assets..."
gh release upload $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz checksum.txt --clobber
-253
View File
@@ -1,253 +0,0 @@
name: 测试环境部署
on:
push:
branches:
- main
- internal
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-staging-${{ github.ref }}
cancel-in-progress: false
env:
REGISTRY_HOST: ${{ vars.REGISTRY_HOST || 'registry.kxsw.us' }}
IMAGE_NAME: ${{ vars.REGISTRY_IMAGE || 'registry.kxsw.us/vpn-server' }}
STAGING_HOST: ${{ vars.STAGING_HOST || '154.12.35.103' }}
STAGING_DEPLOY_PATH: ${{ vars.STAGING_DEPLOY_PATH || '/opt/hifast-server' }}
STAGING_HEALTHCHECK_URL: ${{ vars.STAGING_HEALTHCHECK_URL || 'http://127.0.0.1:8080/v1/common/heartbeat' }}
# 关闭 docker/build-push-action 自动生成的英文 job summary
# 我们用 Telegram 通知传递部署结果,不需要 GitHub Actions 页面上那段英文
DOCKER_BUILD_SUMMARY: false
jobs:
build-and-deploy:
name: 构建镜像并部署到测试环境
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: 检出代码
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: 收集发布说明
id: release-notes
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
RANGE="${{ github.event.before }}..${{ github.sha }}"
else
RANGE="-5"
fi
NOTES="$(git log "$RANGE" --pretty=format:'- %h %s' --no-merges | head -20)"
if [ -z "$NOTES" ]; then
NOTES="$(git log -1 --pretty=format:'- %h %s')"
fi
{
echo "notes<<EOF"
echo "$NOTES"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: 配置 Go 环境
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: 运行测试
run: go test ./...
- name: 配置 Docker Buildx
uses: docker/setup-buildx-action@v4
- name: 构建并推送镜像
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
build-args: |
TARGETARCH=amd64
VERSION=${{ github.sha }}
tags: |
${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.IMAGE_NAME }}:staging
- name: 上传 compose 配置
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ env.STAGING_HOST }}
username: ${{ secrets.STAGING_SSH_USER }}
password: ${{ secrets.STAGING_SSH_PASSWORD }}
port: ${{ secrets.STAGING_SSH_PORT || 22 }}
source: docker-compose.cloud.yml
target: /tmp/hifast-server-deploy/
- name: 在测试服务器上部署
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ env.STAGING_HOST }}
username: ${{ secrets.STAGING_SSH_USER }}
password: ${{ secrets.STAGING_SSH_PASSWORD }}
port: ${{ secrets.STAGING_SSH_PORT || 22 }}
timeout: 300s
command_timeout: 600s
script: |
set -euo pipefail
IMAGE_NAME="${{ env.IMAGE_NAME }}"
NEW_TAG="${{ github.sha }}"
DEPLOY_PATH="${{ env.STAGING_DEPLOY_PATH }}"
HEALTHCHECK_URL="${{ env.STAGING_HEALTHCHECK_URL }}"
ROLLBACK_TAG="rollback-${NEW_TAG}"
if command -v sudo >/dev/null 2>&1 && ! docker ps >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
docker_cmd() {
if [ -n "$SUDO" ]; then
sudo docker "$@"
else
docker "$@"
fi
}
compose_cmd() {
tag="$1"
shift
if docker compose version >/dev/null 2>&1; then
if [ -n "$SUDO" ]; then
sudo env PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker compose -f docker-compose.cloud.yml "$@"
else
PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker compose -f docker-compose.cloud.yml "$@"
fi
else
if [ -n "$SUDO" ]; then
sudo env PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
else
PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
fi
fi
}
health_check() {
attempt=1
while [ "$attempt" -le 6 ]; do
if curl -fsS "$HEALTHCHECK_URL"; then
echo
return 0
fi
echo "Health check ${attempt}/6 failed; retrying in 10s..."
attempt=$((attempt + 1))
sleep 10
done
return 1
}
if [ -n "$SUDO" ]; then
sudo mkdir -p "$DEPLOY_PATH"
sudo cp /tmp/hifast-server-deploy/docker-compose.cloud.yml "$DEPLOY_PATH/docker-compose.cloud.yml"
else
mkdir -p "$DEPLOY_PATH"
cp /tmp/hifast-server-deploy/docker-compose.cloud.yml "$DEPLOY_PATH/docker-compose.cloud.yml"
fi
cd "$DEPLOY_PATH"
PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)"
echo "Previous image ID: ${PREVIOUS_IMAGE_ID:-none}"
echo "Pulling ${IMAGE_NAME}:${NEW_TAG}"
compose_cmd "$NEW_TAG" pull ppanel-server
echo "Starting ppanel-server"
compose_cmd "$NEW_TAG" up -d ppanel-server
echo "Checking ${HEALTHCHECK_URL}"
if health_check; then
docker_cmd image prune -f || true
echo "Staging deployment succeeded"
exit 0
fi
echo "Health check failed; attempting rollback"
if [ -n "$PREVIOUS_IMAGE_ID" ]; then
docker_cmd tag "$PREVIOUS_IMAGE_ID" "${IMAGE_NAME}:${ROLLBACK_TAG}"
compose_cmd "$ROLLBACK_TAG" up -d ppanel-server
health_check || true
else
echo "No previous image found; rollback skipped"
fi
docker_cmd image prune -f || true
exit 1
- name: Telegram 成功通知
if: success()
env:
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
run: |
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
echo "Telegram 密钥未配置,跳过通知。"
exit 0
fi
MESSAGE="$(cat <<'EOF'
✅ 测试环境发布成功
仓库:${{ github.repository }}
分支:${{ github.ref_name }}
提交:${{ github.sha }}
操作人:${{ github.actor }}
镜像:${{ env.IMAGE_NAME }}:${{ github.sha }}
本次更新:
${{ steps.release-notes.outputs.notes }}
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EOF
)"
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TG_CHAT_ID}" \
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,但发布结果不受影响。"
- name: Telegram 失败通知
if: failure()
env:
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
run: |
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
echo "Telegram 密钥未配置,跳过通知。"
exit 0
fi
MESSAGE="$(cat <<'EOF'
❌ 测试环境发布失败
仓库:${{ github.repository }}
分支:${{ github.ref_name }}
提交:${{ github.sha }}
操作人:${{ github.actor }}
镜像:${{ env.IMAGE_NAME }}:${{ github.sha }}
本次更新:
${{ steps.release-notes.outputs.notes }}
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EOF
)"
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TG_CHAT_ID}" \
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,工作流失败状态已记录。"
-266
View File
@@ -1,266 +0,0 @@
name: 发布验收测试
on:
workflow_run:
workflows:
- 测试环境部署
types:
- completed
branches:
- internal
- main
workflow_dispatch:
permissions:
contents: read
actions: read
concurrency:
group: release-acceptance
cancel-in-progress: false
env:
ACCEPTANCE_ARTIFACT_NAME: release-acceptance-${{ github.run_id }}-${{ github.run_attempt }}
ACCEPTANCE_REPORT_DIR: ${{ github.workspace }}/acceptance-artifacts
ACCEPTANCE_REPORT_PATH: ${{ github.workspace }}/acceptance-artifacts/acceptance-report.json
ACCEPTANCE_TEST_JSON: ${{ github.workspace }}/acceptance-artifacts/go-test.json
ACCEPTANCE_FAILURE_LOG: ${{ github.workspace }}/acceptance-artifacts/failure.log
ACCEPTANCE_NODE_SERVER_ID: ${{ vars.ACCEPTANCE_NODE_SERVER_ID || '31' }}
ACCEPTANCE_NODE_PROTOCOL: ${{ vars.ACCEPTANCE_NODE_PROTOCOL || 'trojan' }}
jobs:
acceptance:
name: Staging acceptance
runs-on: ubuntu-latest
timeout-minutes: 20
env:
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }}
if: >-
${{
github.event_name == 'workflow_dispatch' ||
(
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push'
)
}}
steps:
- name: 检出代码
uses: actions/checkout@v6
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: 配置 Go 环境
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: 准备报告目录
run: mkdir -p "$ACCEPTANCE_REPORT_DIR"
- name: 校验必需配置
env:
ACCEPTANCE_ADMIN_EMAIL: ${{ secrets.ACCEPTANCE_ADMIN_EMAIL }}
ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }}
ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }}
ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }}
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }}
STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }}
STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
STAGING_DB_NAME: ${{ secrets.STAGING_DB_NAME }}
STAGING_REDIS_ADDR: ${{ secrets.STAGING_REDIS_ADDR }}
STAGING_REDIS_PASSWORD: ${{ secrets.STAGING_REDIS_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${STAGING_BASE_URL:-}" ]; then
echo "STAGING_BASE_URL 未配置且默认值不可用" | tee "$ACCEPTANCE_FAILURE_LOG"
{
echo "## 发布验收测试"
echo
echo "状态:配置错误"
echo
echo "- `STAGING_BASE_URL` 不能为空。"
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
missing_optional=()
optional=(
ACCEPTANCE_ADMIN_EMAIL
ACCEPTANCE_ADMIN_PASSWORD
ACCEPTANCE_USER_EMAIL
ACCEPTANCE_USER_PASSWORD
STAGING_DB_HOST
STAGING_DB_USER
STAGING_DB_PASSWORD
STAGING_DB_NAME
STAGING_REDIS_ADDR
STAGING_REDIS_PASSWORD
)
for key in "${optional[@]}"; do
if [ -z "${!key:-}" ]; then
missing_optional+=("$key")
fi
done
: > "$ACCEPTANCE_FAILURE_LOG"
if [ "${#missing_optional[@]}" -gt 0 ]; then
printf '缺少可选 GitHub Actions secrets/vars,部分验收用例将被跳过:\n' | tee -a "$ACCEPTANCE_FAILURE_LOG"
printf -- '- %s\n' "${missing_optional[@]}" | tee -a "$ACCEPTANCE_FAILURE_LOG"
{
echo "## 发布验收测试"
echo
echo "状态:部分配置缺失"
echo
echo "缺少以下可选 secrets/vars,对应验收用例会在测试阶段自动跳过:"
printf -- '- `%s`\n' "${missing_optional[@]}"
echo
echo "- `STAGING_BASE_URL`${STAGING_BASE_URL}"
} >> "$GITHUB_STEP_SUMMARY"
else
{
echo "## 发布验收测试"
echo
echo "状态:配置检查通过"
echo
echo "- `STAGING_BASE_URL`${STAGING_BASE_URL}"
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: 下载依赖模块
run: go mod download
- name: 运行 acceptance 测试
env:
ACCEPTANCE_ADMIN_EMAIL: ${{ secrets.ACCEPTANCE_ADMIN_EMAIL }}
ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }}
ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }}
ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }}
ACCEPTANCE_NODE_SECRET: ${{ secrets.ACCEPTANCE_NODE_SECRET }}
ACCEPTANCE_RUN_ID: qa_${{ github.run_id }}_${{ github.run_attempt }}
STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }}
STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
STAGING_DB_NAME: ${{ secrets.STAGING_DB_NAME }}
STAGING_REDIS_ADDR: ${{ secrets.STAGING_REDIS_ADDR }}
STAGING_REDIS_PASSWORD: ${{ secrets.STAGING_REDIS_PASSWORD }}
STAGING_REDIS_DB: ${{ vars.STAGING_REDIS_DB || '0' }}
run: |
set -o pipefail
go test -json ./tests/acceptance/... \
-staging-url="$STAGING_BASE_URL" \
-report-path="$ACCEPTANCE_REPORT_PATH" \
> >(tee "$ACCEPTANCE_TEST_JSON") \
2> >(tee "$ACCEPTANCE_FAILURE_LOG" >&2)
- name: 生成测试摘要
if: always()
run: |
set -euo pipefail
{
echo "## 发布验收测试"
echo
echo "- 状态:${{ job.status }}"
echo "- Staging URL${STAGING_BASE_URL}"
echo "- Artifact${ACCEPTANCE_ARTIFACT_NAME}"
echo "- Run URL${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [ "${{ github.event_name }}" = "workflow_run" ]; then
echo "- Deploy run${{ github.event.workflow_run.html_url }}"
echo "- Deploy SHA${{ github.event.workflow_run.head_sha }}"
else
echo "- 手动触发 SHA${{ github.sha }}"
fi
echo
} | tee "$ACCEPTANCE_REPORT_DIR/summary.md" >> "$GITHUB_STEP_SUMMARY"
if [ "${{ job.status }}" = "failure" ]; then
if [ -s "$ACCEPTANCE_FAILURE_LOG" ]; then
{
echo
echo "Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "Artifact: ${ACCEPTANCE_ARTIFACT_NAME}"
} >> "$ACCEPTANCE_FAILURE_LOG"
else
{
echo "Acceptance 测试失败。"
echo "Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "Artifact: ${ACCEPTANCE_ARTIFACT_NAME}"
echo
tail -100 "$ACCEPTANCE_TEST_JSON" 2>/dev/null || true
} > "$ACCEPTANCE_FAILURE_LOG"
fi
elif [ ! -f "$ACCEPTANCE_FAILURE_LOG" ]; then
: > "$ACCEPTANCE_FAILURE_LOG"
fi
- name: 上传 acceptance artifact
if: always()
uses: actions/upload-artifact@v6
with:
name: ${{ env.ACCEPTANCE_ARTIFACT_NAME }}
path: |
${{ env.ACCEPTANCE_REPORT_PATH }}
${{ env.ACCEPTANCE_TEST_JSON }}
${{ env.ACCEPTANCE_FAILURE_LOG }}
${{ env.ACCEPTANCE_REPORT_DIR }}/summary.md
if-no-files-found: warn
retention-days: 14
- name: Telegram 成功通知
if: success()
env:
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
run: |
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
echo "Telegram 密钥未配置,跳过通知。"
exit 0
fi
MESSAGE="$(cat <<'EOF'
✅ 发布验收测试通过
仓库:${{ github.repository }}
分支:${{ github.event.workflow_run.head_branch || github.ref_name }}
提交:${{ github.event.workflow_run.head_sha || github.sha }}
Staging${{ env.STAGING_BASE_URL }}
Artifact${{ env.ACCEPTANCE_ARTIFACT_NAME }}
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EOF
)"
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TG_CHAT_ID}" \
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,但验收结果不受影响。"
- name: Telegram 失败通知
if: failure()
env:
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
run: |
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
echo "Telegram 密钥未配置,跳过通知。"
exit 0
fi
MESSAGE="$(cat <<'EOF'
❌ 发布验收测试失败
仓库:${{ github.repository }}
分支:${{ github.event.workflow_run.head_branch || github.ref_name }}
提交:${{ github.event.workflow_run.head_sha || github.sha }}
Staging${{ env.STAGING_BASE_URL }}
Artifact${{ env.ACCEPTANCE_ARTIFACT_NAME }}
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EOF
)"
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TG_CHAT_ID}" \
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,工作流失败状态已记录。"
-10
View File
@@ -26,13 +26,6 @@ Thumbs.db
*.crt *.crt
*.key *.key
*.pem *.pem
*.pub
*id_rsa*
*id_ed25519*
*_bak
*.go_bak
deploy/**/keys/
deliverables/
# ==================== 日志 ==================== # ==================== 日志 ====================
*.log *.log
@@ -42,8 +35,6 @@ 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
@@ -79,7 +70,6 @@ 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/
+66
View File
@@ -0,0 +1,66 @@
project_name: ppanel
version: 1
release:
prerelease: auto
builds:
- # If true, skip the build.
# Useful for library projects.
# Default is false
skip: true
changelog:
# Set it to true if you wish to skip the changelog generation.
# This may result in an empty release notes on GitHub/GitLab/Gitea.
disable: false
# Changelog generation implementation to use.
#
# Valid options are:
# - `git`: uses `git log`;
# - `github`: uses the compare GitHub API, appending the author login to the changelog.
# - `gitlab`: uses the compare GitLab API, appending the author name and email to the changelog.
# - `github-native`: uses the GitHub release notes generation API, disables the groups feature.
#
# Defaults to `git`.
use: github
# Sorts the changelog by the commit's messages.
# Could either be asc, desc or empty
# Default is empty
sort: asc
# Format to use for commit formatting.
# Only available when use is one of `github`, `gitea`, or `gitlab`.
#
# Default: '{{ .SHA }}: {{ .Message }} ({{ with .AuthorUsername }}@{{ . }}{{ else }}{{ .AuthorName }} <{{ .AuthorEmail }}>{{ end }})'.
# Extra template fields: `SHA`, `Message`, `AuthorName`, `AuthorEmail`, and
# `AuthorUsername`.
format: "{{ .Message }}"
# Group commits messages by given regex and title.
# Order value defines the order of the groups.
# Proving no regex means all commits will be grouped under the default group.
# Groups are disabled when using github-native, as it already groups things by itself.
#
# Default is no groups.
groups:
- title: "✨ Features"
regexp: "^.*feat[(\\w)]*:+.*$"
order: 0
- title: "🐛 Bug Fixes"
regexp: "^.*fix[(\\w)]*:+.*$"
order: 1
- title: "🎫 Chores"
regexp: "^.*chore[(\\w)]*:+.*$"
order: 2
- title: "🔨 Refactor"
regexp: "^.*refactor[(\\w)]*:+.*$"
order: 3
- title: "🔧 Build"
regexp: "^.*?(ci)(\\(.+\\))??!?:.+$"
order: 4
- title: "📝 Documentation"
regexp: "^.*?docs?(\\(.+\\))??!?:.+$"
order: 5
- title: "✨ Others"
order: 999
-2
View File
@@ -1,7 +1,5 @@
# 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
-2
View File
@@ -1,7 +1,5 @@
# 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 标题和描述
-18
View File
@@ -1,21 +1,3 @@
<!--
TawCorp internal fork header. Upstream PPanel content begins below.
Do not remove this header without updating Multica workspace context and doc/development-workflow-zh.md.
-->
> ### TawCorp hifast-server (internal fork)
>
> This repository is the **canonical** TawCorp fork of [perfect-panel/server](https://github.com/perfect-panel/server). Migrated from `git.kxsw.us/HI-VPN/hi-server` on **2026-06-03**; the old Gitea remote is deprecated — do not push or pull from it.
>
> - **Development workflow (required reading for internal contributors)**: [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md)
> - **Branch model**: `internal` (dev mainline, auto-deploys to staging) → `main` (release)
> - **Merge policy**: PR + architect review + GitHub UI "Squash and merge"; direct push to `internal`/`main` is blocked by `lefthook` pre-push and forbidden by policy
> - **Issue tracker**: Multica workspace `Hifast` (prefix `HIF-`)
>
> External contributors: read [`CONTRIBUTING.md`](CONTRIBUTING.md) for the upstream-compatible baseline.
---
# PPanel Server # PPanel Server
<div align="center"> <div align="center">
-2
View File
@@ -21,10 +21,8 @@ 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"`
-86
View File
@@ -1,86 +0,0 @@
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
View File
@@ -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 []TrafficLimit `json:"traffic_limit,omitempty"` TrafficLimit *string `json:"traffic_limit,omitempty"`
} }
GetUserLoginLogsRequest { GetUserLoginLogsRequest {
Page int `form:"page"` Page int `form:"page"`
-2
View File
@@ -64,8 +64,6 @@ type (
ServerUser { ServerUser {
Id int64 `json:"id"` Id int64 `json:"id"`
UUID string `json:"uuid"` UUID string `json:"uuid"`
// SpeedLimit 单位为 Mbps0 表示不限速。
// 节点端 (V2bX/XrayR 等) 按 Mbps 解释该值,服务端透传不做单位换算。
SpeedLimit int64 `json:"speed_limit"` SpeedLimit int64 `json:"speed_limit"`
DeviceLimit int64 `json:"device_limit"` DeviceLimit int64 `json:"device_limit"`
} }
-33
View File
@@ -1,33 +0,0 @@
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)
}
+4 -37
View File
@@ -117,7 +117,6 @@ 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"`
@@ -133,39 +132,12 @@ 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"`
} }
CommissionReturnLog { QueryWithdrawalLogListResponse {
Id int64 `json:"id"` List []WithdrawalLog `json:"list"`
UserId int64 `json:"user_id"` Total int64 `json:"total"`
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"`
@@ -410,14 +382,10 @@ service ppanel {
@handler CancelWithdrawal @handler CancelWithdrawal
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog) post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
@doc "Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log)" @doc "Query Withdrawal Log"
@handler QueryWithdrawalLog @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)
@@ -477,4 +445,3 @@ service ppanel {
@handler DeviceWsConnect @handler DeviceWsConnect
get /device_ws_connect get /device_ws_connect
} }
-1
View File
@@ -575,7 +575,6 @@ 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"`
+20
View File
@@ -0,0 +1,20 @@
EC2 SSH 连接资料
服务器名称: hifast-hk-app-01
公网 IP: 43.198.248.161
登录用户: ubuntu
私钥文件:
- hifast-hk-app-01-reset
公钥文件:
- hifast-hk-app-01-reset.pub
连接命令:
ssh -i hifast-hk-app-01-reset ubuntu@43.198.248.161
如果在 Mac / Linux 上使用,先执行:
chmod 600 hifast-hk-app-01-reset
如果要给别人使用,只需要把私钥文件 hifast-hk-app-01-reset 发给对方即可。
出于安全考虑,建议通过安全渠道传输,并在后续需要时重新轮换密钥。
@@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCgAAAKjy5KDa8uSg
2gAAAAtzc2gtZWQyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCg
AAAEDwSb0b/0S6Tw8Od5hAtIKqt1JvomqQQS44Ty3xL+FjPtvqawOqZIcu/SZaAI/i9+ND
cDnnaq/3m5B2wf3hGegKAAAAIWhpZmFzdC1oay1hcHAtMDEtcmVzZXQtMjAyNi0wNS0xMA
ECAwQ=
-----END OPENSSH PRIVATE KEY-----
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvqawOqZIcu/SZaAI/i9+NDcDnnaq/3m5B2wf3hGegK hifast-hk-app-01-reset-2026-05-10
+363
View File
@@ -0,0 +1,363 @@
# PPanel 香港区新 AWS 账号部署说明
本目录用于在 **新 AWS 账号** 中按 **香港区 `ap-east-1`** 重建一套全新空环境。
目标架构:
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL`
## 1. 资源清单
按下面顺序创建资源:
1. VPC
2. 2 个公有子网 + 2 个私有子网
3. Internet Gateway
4. 公有 / 私有路由表
5. 安全组
6. RDS MySQL
7. EC2 本机 Redis Docker
8. EC2
9. ACM 证书
10. ALB + Target Group
11. WAF Web ACL
12. 平行环境域名
建议命名:
- VPC: `ppanel-hk-prod`
- EC2: `ppanel-app-hk-01`
- RDS: `ppanel-mysql-hk`
- Redis container: `hifast-redis`
- ALB: `ppanel-alb-hk`
- WAF: `ppanel-waf-hk`
## 2. 默认规格
### EC2
- Region: `ap-east-1`
- OS: Ubuntu 24.04 LTS
- Instance type: `t4g.large` 起步
- Disk: `gp3 80GB`
- Public subnet: 是
- IAM Role: 允许读取 CloudWatch / SSM(如使用)
- 如果要在 AWS EC2 本机执行 S3 备份:额外允许写入专用备份桶
### RDS MySQL
- Engine: MySQL 8.0
- Class: `db.r7g.xlarge`
- Storage: `gp3 100GB`
- DB name: `hifast`
- Username: `admin`
- Public access: `No`
- Charset: `utf8mb4`
- Backup: `7-14 days`
### Redis
- 部署位置:业务 EC2 本机
- 部署方式:Docker
- 版本:`redis:8.2.1`
- 监听:`0.0.0.0:6379`
- 应用连接:`127.0.0.1:6379`
- 安全组:仅对白名单备用节点或同机应用开放
## 3. 网络与安全组
### 子网布局
- `public-a`, `public-b`: ALB / EC2
- `private-a`, `private-b`: RDS
### 安全组建议
#### `sg-alb`
- Inbound
- `80/tcp` from `0.0.0.0/0`
- `443/tcp` from `0.0.0.0/0`
- Outbound
- `80/tcp` to `sg-ec2`
#### `sg-ec2`
- Inbound
- `80/tcp` from `sg-alb`
- `22/tcp` from `你的固定运维 IP`
- Outbound
- all
说明:
- 应用容器监听 `127.0.0.1:8080`
- EC2 对外只让 Nginx 监听 `80`
- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1`
#### `sg-rds`
- Inbound
- `3306/tcp` from `sg-ec2`
#### `sg-ec2` 额外说明
- 如果需要外部备用节点复制 Redis,再额外放行:
- `6379/tcp` from `104.238.220.230/32`
## 4. ALB / Target Group / 健康检查
### Target Group
- Type: `Instance`
- Protocol: `HTTP`
- Port: `80`
- Health check path: `/v1/common/heartbeat`
- Success code: `200`
这个路径已由项目现有接口提供,无需额外改代码。
### ALB 监听器
- `80` -> redirect to `443`
- `443` -> forward 到 target group
### ACM
-`ap-east-1` 申请证书
- 先给平行环境域名,例如:
- `api-new.hifast.biz`
- `logs-new.hifast.biz`
## 5. WAF 规则
首版至少启用:
1. `AWSManagedRulesCommonRuleSet`
2. `AWSManagedRulesKnownBadInputsRuleSet`
3. `AWSManagedRulesAmazonIpReputationList`
4. 全站 rate-based rule
5. 针对高风险路径的 rate-based rule
建议的第一版限流:
- 全站:每 IP `2000 / 5 分钟`
- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟`
- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟`
节点上报接口建议后续补:
- `/v1/server/status`
- `/v1/server/online`
- `/v1/server/traffic`
优先用节点出口 IP 白名单;没有固定出口 IP 的节点暂时保留 `secret_key`,但不要把它当成唯一防线。
## 6. EC2 文件落地
在 EC2 上建议使用:
- 应用目录:`/opt/ppanel`
- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf`
需要上传这些文件 / 目录:
- `docker-compose.cloud.yml`
- `deploy/aws/ap-east-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml`
- `deploy/aws/ap-east-1/nginx/ppanel-api.conf`
- `grafana/`
- `loki/`
- `prometheus/`
- `tempo/`
- `.env.example` -> 重命名为 `.env`
目标目录示例:
```text
/opt/ppanel/
docker-compose.cloud.yml
.env
configs/ppanel.yaml
grafana/
loki/
prometheus/
tempo/
logs/
cache/
tempo_data/
```
## 7. 应用配置
基线模板见:
- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example)
- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf)
关键值必须替换:
- `MySQL.Addr`
- `MySQL.Password`
- `Redis.Host`
- `Redis.Pass`
- `JwtAuth.AccessSecret`
- `Administrator.Email`
- `Administrator.Password`
- `AppSignature.AppSecrets.*`
- `device.security_secret`
- `Site.Host`
- `Site.SiteName`
Redis 约定保持不变:
- 业务缓存:DB `0`
- AsynqDB `5`(代码内部已固定使用)
## 8. 部署步骤
### 8.1 初始化 EC2
把脚本上传到 EC2 后执行:
## 9. 104 灾备节点常用运维脚本
如果你要在 `104.238.220.230` 上执行数据迁移、主从重拉、主库提升,可以直接复用仓库里的这几份脚本:
- 数据导出 / 导入交互工具:
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
- MySQL 主从运维工具:
- [`deploy/scripts/mysql_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/mysql_replica_ops.sh)
- Redis 主从运维工具:
- [`deploy/scripts/redis_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/redis_replica_ops.sh)
- 统一总入口:
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
- 主从运维环境模板:
- [`deploy/aws/ap-east-1/configs/replica-ops.env.example`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/aws/ap-east-1/configs/replica-ops.env.example)
### 9.1 数据迁移工具
支持:
- 备份 MySQL 到 S3
- 备份 Redis 到 S3
- 从正式库导出 MySQL `sql.gz`
-`sql.gz` 导入 AWS RDS
- 从正式 Redis 导出 `RDB`
-`RDB` 导入 Docker Redis 或宿主机 Redis
- 查看 MySQL / Redis 当前主从状态
- 强制重拉 MySQL / Redis 主从
- 把 MySQL / Redis 从库提升为可写主库
示例:
```bash
bash deploy/scripts/hifast_data_sync_tool.sh /root/replica-ops.env
```
```bash
chmod +x deploy/scripts/bootstrap_aws_ec2.sh
sudo APP_DIR=/opt/ppanel deploy/scripts/bootstrap_aws_ec2.sh
```
### 8.2 安装 Nginx 配置
```bash
sudo cp deploy/aws/ap-east-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf
sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf
sudo nginx -t
sudo systemctl reload nginx
```
### 8.3 启动容器
```bash
cd /opt/ppanel
docker compose -f docker-compose.cloud.yml up -d
```
### 8.4 预检
```bash
chmod +x deploy/scripts/preflight_aws_hk.sh
APP_DIR=/opt/ppanel \
RDS_HOST=<new-rds-endpoint> \
REDIS_HOST=127.0.0.1 \
deploy/scripts/preflight_aws_hk.sh
```
## 9. 平行环境验证
先验证 `api-new.hifast.biz`,不要直接切正式域名。
必测项:
1. `ALB target` 为 healthy
2. `GET /v1/common/heartbeat` 返回 200
3. 管理员登录
4. 用户注册 / 登录
5. 订阅查询
6. 节点上报 `/v1/server/status`
7. 本机 Redis 可写缓存
8. Asynq 可入队并消费
## 10. 正式切换
切换前检查:
1. ALB 5xx 为 0
2. EC2 CPU / Memory 正常
3. RDS CPU / Connections 正常
4. 本机 Redis CPU / Connections / Memory 正常
5. WAF 已挂到 ALB
6. EC2 安全组没有对公网放 `8080/3333/9090/4317`
切换方式:
1. 保持新环境先跑平行域名
2. 正式域名切到新 ALB
3. 观察至少 1 小时
4. 确认无误后再处理旧环境
## 11. 监控建议
至少建这些 CloudWatch / Grafana 观测项:
- ALB `RequestCount`, `HTTPCode_ELB_5XX_Count`, `TargetResponseTime`
- EC2 `CPUUtilization`, `NetworkIn`, `NetworkOut`, `StatusCheckFailed`
- RDS `CPUUtilization`, `DatabaseConnections`, `ReadLatency`, `WriteLatency`
- Redis 容器 CPU / Memory / restart count
## 12. 这次方案的边界
本目录交付的是:
- 香港区新账号的部署模板
- 新空环境启动与验证流程
- ALB / WAF / EC2 / RDS / 本机 Redis 的落地约定
不包含:
- 旧数据迁移
- Terraform / CloudFormation 自动建资源
- Redis 托管版改造
- 多活 / 自动扩缩容
## 13. S3 备份补强
当前已落地的 S3 备份桶:
- `hifast-prod-backups-200810848252-ap-east-1`
建议与现网结合方式:
1. `RDS automated backup` 继续保留,作为第一层恢复能力
2. `104` 外部 MySQL 从库执行逻辑备份并上传到 S3,作为第二层可下载备份
3. `104` 外部 Redis 从库按需导出 `RDB` 到 S3,补齐缓存类灾备材料
仓库中已补充:
- 环境变量模板:[`configs/backup-to-s3.env.example`](./configs/backup-to-s3.env.example)
- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh)
- Redis 备份脚本:[`../../scripts/redis_rdb_backup_to_s3.sh`](../../scripts/redis_rdb_backup_to_s3.sh)
建议把 MySQL 备份脚本优先部署到 `104`,因为它直接连接本地只读从库,对 AWS 主库扰动最小。
@@ -0,0 +1,18 @@
AWS_REGION=ap-east-1
S3_BUCKET=hifast-prod-backups-200810848252-ap-east-1
S3_PREFIX=mysql
BACKUP_DIR=/var/backups/hifast
HOST_TAG=104-standby
KEEP_LOCAL_DAYS=3
CHECK_REPLICA=1
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=backup_reader
MYSQL_PASSWORD=CHANGE_ME
MYSQL_SOCKET=
MYSQL_DATABASE=hifast
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
@@ -0,0 +1,20 @@
PRIMARY_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
PRIMARY_PORT=3306
PRIMARY_USER=admin
PRIMARY_PASSWORD=CHANGE_ME
PRIMARY_DB=hifast
PRIMARY_REPL_USER=repl
PRIMARY_REPL_PASSWORD=CHANGE_ME
PRIMARY_REPL_HOST=104.238.220.230
PRIMARY_BINLOG_RETENTION_HOURS=24
REPLICA_HOST=127.0.0.1
REPLICA_PORT=3306
REPLICA_USER=root
REPLICA_PASSWORD=
REPLICA_SOCKET=/var/run/mysqld/mysqld.sock
REPLICA_DB=hifast
REPLICA_SOURCE_SSL=1
DUMP_FILE=
@@ -0,0 +1,111 @@
Host: 0.0.0.0
Port: 8080
Debug: false
JwtAuth:
AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET
AccessExpire: 604800
Logger:
ServiceName: PPanel
Mode: console
Encoding: plain
TimeFormat: "2006-01-02 15:04:05.000"
Path: logs
Level: info
MaxContentLength: 0
Compress: false
Stat: true
KeepDays: 7
StackCooldownMillis: 100
MaxBackups: 7
MaxSize: 100
Rotation: daily
FileTimeFormat: "2006-01-02T15:04:05.000Z07:00"
MySQL:
Addr: YOUR_RDS_ENDPOINT:3306
Dbname: hifast
Username: admin
Password: CHANGE_ME_TO_RDS_PASSWORD
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
MaxIdleConns: 10
MaxOpenConns: 100
SlowThreshold: 1000
Redis:
Host: 127.0.0.1:6379
Pass: CHANGE_ME_TO_REDIS_PASSWORD
DB: 0
PoolSize: 100
MinIdleConns: 10
MaxRetries: 3
PoolTimeout: 4
IdleTimeout: 300
MaxConnAge: 0
DialTimeout: 5
ReadTimeout: 3
WriteTimeout: 3
Trace:
Name: ppanel-server
Endpoint: 127.0.0.1:4317
Sampler: 0.1
Batcher: otlpgrpc
Site:
Host: api-new.hifast.biz
SiteName: HiFastVPN
Administrator:
Email: admin@example.com
Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD
Telegram:
Enable: false
BotID: 0
BotName: ""
BotToken: ""
GroupChatID: ""
EnableNotify: false
WebHookDomain: ""
Kutt:
Enable: false
ApiURL: ""
ApiKey: ""
TargetURL: ""
Domain: ""
OpenInstall:
Enable: false
AppKey: ""
ApiKey: ""
Loki:
Enable: true
URL: "http://localhost:3100"
AppSignature:
AppSecrets:
android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET
ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET
web-client: CHANGE_ME_WEB_SIGNATURE_SECRET
ValidWindowSeconds: 300
SkipPrefixes:
- /v1/notify/
- /v1/iap/notifications
- /v1/telegram/webhook
- /v1/subscribe/config
Signature:
EnableSignature: false
device:
enable: true
security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET
Register:
EnableTrial: true
EnableTrialEmailWhitelist: true
TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com"
@@ -0,0 +1,23 @@
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=CHANGE_ME
MYSQL_SOCKET=
REPL_SOURCE_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
REPL_SOURCE_PORT=3306
REPL_SOURCE_USER=repl
REPL_SOURCE_PASSWORD=CHANGE_ME
REPL_SOURCE_SSL=1
REPL_SOURCE_LOG_FILE=
REPL_SOURCE_LOG_POS=
REPL_SOURCE_AUTO_POSITION=1
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
REDIS_SOURCE_HOST=18.163.33.75
REDIS_SOURCE_PORT=6379
REDIS_SOURCE_USER=
REDIS_SOURCE_PASSWORD=CHANGE_ME
@@ -0,0 +1,33 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
client_max_body_size 20m;
access_log /var/log/nginx/ppanel-access.log;
error_log /var/log/nginx/ppanel-error.log warn;
location / {
proxy_http_version 1.1;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location = /nginx_status {
stub_status;
access_log off;
allow 127.0.0.1;
deny all;
}
}
+358
View File
@@ -0,0 +1,358 @@
# PPanel 日本东京区 AWS 部署说明
本目录用于在 **AWS 日本东京区 `ap-northeast-1`** 重建一套全新生产环境,并承接当前香港区 `ap-east-1` 的正式迁移。
如果你要看“当前已经真实跑起来的东京架构”,优先看:
- [`ops/hifast-current-architecture-zh.md`](/Users/Apple/code_vpn/vpn/ppanel-server/ops/hifast-current-architecture-zh.md)
- [`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md)
这份 README 更偏向:
- 目标架构
- 资源规划
- 部署方法
- 后续待完成项
目标架构:
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL`
灾备链路:
`RDS MySQL / EC2 Redis -> 104.238.220.230 外部灾备`
当前仓库内已补充:
- 东京基础设施参数模板:[`configs/aws-jp-infra.env.example`](./configs/aws-jp-infra.env.example)
- 东京真实实施状态登记:[`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md)
- 东京底座资源创建脚本:[`../../scripts/aws_jp_create_base_infra.sh`](../../scripts/aws_jp_create_base_infra.sh)
- 东京资源状态检查脚本:[`../../scripts/aws_jp_describe_state.sh`](../../scripts/aws_jp_describe_state.sh)
- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh)
- 10 分钟 MySQL 备份定时器安装脚本:[`../../scripts/install_mysql_backup_timer.sh`](../../scripts/install_mysql_backup_timer.sh)
## 1. 资源清单
按下面顺序创建资源:
1. VPC
2. 2 个公有子网 + 2 个私有子网
3. Internet Gateway
4. 公有 / 私有路由表
5. 安全组
6. RDS MySQL
7. EC2 本机 Redis Docker
8. EC2
9. ACM 证书
10. ALB + Target Group
11. WAF Web ACL
12. 东京平行环境域名
13. 东京 S3 备份桶
建议命名:
- VPC: `ppanel-jp-prod`
- EC2: `ppanel-app-jp-01`
- RDS: `ppanel-mysql-jp`
- Redis container: `hifast-redis`
- ALB: `ppanel-alb-jp`
- WAF: `ppanel-waf-jp`
- S3: `hifast-prod-backups-200810848252-ap-northeast-1`
## 2. 默认规格
### EC2
- Region: `ap-northeast-1`
- OS: Ubuntu 24.04 LTS
- Instance type: `t4g.large`
- Disk: `gp3 80GB`
- Public subnet: 是
- IAM Role:
- 允许读取 CloudWatch / SSM(如使用)
- 如果要在东京 EC2 上执行 S3 备份:额外允许写入东京备份桶
### RDS MySQL
- Engine: `MySQL 8.4`
- Class: `db.r7g.xlarge`
- Storage: `gp3 100GB`
- DB name: `hifast`
- Username: `admin`
- Public access: `Yes`
- Charset: `utf8mb4`
- Backup retention: `7-14 days`
- Deletion protection: `On`
- Multi-AZ: `Yes`(当前按 2 实例 Multi-AZ 创建)
说明:
- 当前东京 RDS 需要允许 `104.238.220.230` 从公网直连 `3306`,用于外部 MySQL 从库复制
- 因此本阶段 RDS 使用 `public subnet group + Publicly accessible = Yes`
- 访问面只通过 `sg-rds` 严格限制到业务 EC2 安全组和 `104.238.220.230/32`
### Redis
- 部署位置:业务 EC2 本机
- 部署方式:Docker
- 版本:`redis:8.2.1`
- 监听:`0.0.0.0:6379`
- 应用连接:`127.0.0.1:6379`
- 安全组:仅对白名单备用节点 `104.238.220.230/32` 或同机应用开放
## 3. 网络与安全组
### 子网布局
- `public-a`, `public-c`: ALB / EC2
- `private-a`, `private-c`: RDS
说明:
- 东京优先使用 `ap-northeast-1a``ap-northeast-1c`
- 如果账户映射不同,也可以用任意 2 个可用区,但公私网必须各 2 个子网
### 安全组建议
#### `sg-alb`
- Inbound
- `80/tcp` from `0.0.0.0/0`
- `443/tcp` from `0.0.0.0/0`
- Outbound
- `80/tcp` to `sg-ec2`
#### `sg-ec2`
- Inbound
- `80/tcp` from `sg-alb`
- `22/tcp` from `你的固定运维 IP`
- `6379/tcp` from `104.238.220.230/32`
- Outbound
- all
说明:
- 应用容器监听 `127.0.0.1:8080`
- EC2 对外只让 Nginx 监听 `80`
- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1`
#### `sg-rds`
- Inbound
- `3306/tcp` from `sg-ec2`
- `3306/tcp` from `104.238.220.230/32`
## 4. ALB / Target Group / 健康检查
### Target Group
- Type: `Instance`
- Protocol: `HTTP`
- Port: `80`
- Health check path: `/v1/common/heartbeat`
- Success code: `200`
### ALB 监听器
- `80` -> redirect to `443`
- `443` -> forward 到 target group
### ACM
-`ap-northeast-1` 重新申请证书
- 先给平行环境域名,例如:
- `api-jp.hifast.biz`
- `logs-jp.hifast.biz`
## 5. WAF 规则
首版至少启用:
1. `AWSManagedRulesCommonRuleSet`
2. `AWSManagedRulesKnownBadInputsRuleSet`
3. `AWSManagedRulesAmazonIpReputationList`
4. 全站 rate-based rule
5. 针对高风险路径的 rate-based rule
建议的第一版限流:
- 全站:每 IP `2000 / 5 分钟`
- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟`
- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟`
节点上报接口建议后续补:
- `/v1/server/status`
- `/v1/server/online`
- `/v1/server/traffic`
## 6. EC2 文件落地
在 EC2 上建议使用:
- 应用目录:`/opt/ppanel`
- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf`
需要上传这些文件 / 目录:
- `docker-compose.cloud.yml`
- `deploy/aws/ap-northeast-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml`
- `deploy/aws/ap-northeast-1/nginx/ppanel-api.conf`
- `grafana/`
- `loki/`
- `prometheus/`
- `tempo/`
- `.env.example` -> 重命名为 `.env`
目标目录示例:
```text
/opt/ppanel/
docker-compose.cloud.yml
.env
configs/ppanel.yaml
grafana/
loki/
prometheus/
tempo/
logs/
cache/
tempo_data/
```
## 7. 应用配置
基线模板见:
- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example)
- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf)
关键值必须替换:
- `MySQL.Addr`
- `MySQL.Password`
- `Redis.Host`
- `Redis.Pass`
- `JwtAuth.AccessSecret`
- `Administrator.Email`
- `Administrator.Password`
- `AppSignature.AppSecrets.*`
- `device.security_secret`
- `Site.Host`
- `Site.SiteName`
Redis 约定保持不变:
- 业务缓存:DB `0`
- AsynqDB `5`
## 8. 部署步骤
### 8.0 创建东京基础设施
如果本机或跳板机已经配置好 AWS CLI 凭据,可以先直接执行:
```bash
cp deploy/aws/ap-northeast-1/configs/aws-jp-infra.env.example /root/aws-jp-infra.env
chmod 600 /root/aws-jp-infra.env
vim /root/aws-jp-infra.env
chmod +x deploy/scripts/aws_jp_create_base_infra.sh
bash deploy/scripts/aws_jp_create_base_infra.sh /root/aws-jp-infra.env
```
执行后可用下面命令随时核对东京底座状态:
```bash
chmod +x deploy/scripts/aws_jp_describe_state.sh
bash deploy/scripts/aws_jp_describe_state.sh /root/aws-jp-infra.env
```
### 8.1 初始化 EC2
把脚本上传到东京 EC2 后执行:
```bash
chmod +x deploy/scripts/bootstrap_aws_ec2.sh
sudo APP_DIR=/opt/ppanel APP_USER=ubuntu deploy/scripts/bootstrap_aws_ec2.sh
```
### 8.2 安装 Nginx 配置
```bash
sudo cp deploy/aws/ap-northeast-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf
sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf
sudo nginx -t
sudo systemctl reload nginx
```
### 8.3 启动容器
```bash
cd /opt/ppanel
docker compose -f docker-compose.cloud.yml up -d
```
### 8.4 预检
```bash
chmod +x deploy/scripts/preflight_aws_jp.sh
APP_DIR=/opt/ppanel \
RDS_HOST=<TOKYO_RDS_ENDPOINT> \
REDIS_HOST=127.0.0.1 \
deploy/scripts/preflight_aws_jp.sh
```
## 9. 数据迁移与切换
正式迁移请按:
- [`ops/hifast-aws-jp-migration-runbook-zh.md`](/Users/Apple/code_vpn/vpn/ppanel-server/ops/hifast-aws-jp-migration-runbook-zh.md)
执行。
核心原则:
- 先搭平行环境
- 停机后再导出香港主数据
- 东京验收通过后再切正式域名
- 切换后再重挂 `104` 灾备
## 10. 104 灾备节点常用模板
如果迁移完成后要把 `104.238.220.230` 重挂为东京主站从库,可复用:
- [`deploy/scripts/hifast_mysql_seed_primary_and_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_mysql_seed_primary_and_replica.sh)
- [`deploy/scripts/hifast_mysql_attach_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_mysql_attach_replica.sh)
- [`deploy/scripts/hifast_redis_attach_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_redis_attach_replica.sh)
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
- [`configs/replica-ops.env.example`](./configs/replica-ops.env.example)
## 11. 东京资源创建前置检查
在 AWS 控制台里至少先确认:
- 东京区已启用
- `ap-northeast-1` 可创建 `t4g.large`
- `ap-northeast-1` RDS 可创建 `db.r7g.xlarge`
- ACM / ALB / WAF / S3 服务在东京区可正常使用
- Tokyo 对应配额满足:
- On-Demand Standard vCPU
- ALB 数量
- Elastic IP(如需)
- RDS 实例数
## 12. 当前已知真实进度
截至 `2026-05-20`,已知状态如下:
- 东京 VPC `ppanel-jp-prod` 已创建
- VPC ID: `vpc-0846b23b4a7d64eac`
- VPC CIDR: `10.20.0.0/16`
- 4 个子网在 AWS 控制台里曾填写完成,但提交时控制台 session 失效
- 因此:
- 子网是否真正创建成功,需要重新核实
- IGW / 路由表 / 安全组 / RDS / EC2 / ALB / WAF 都应按“未完成”处理,重新复核
实时状态请以后续更新的 [`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md) 为准。
@@ -0,0 +1,55 @@
AWS_REGION=ap-northeast-1
AWS_ACCOUNT_ID=200810848252
VPC_NAME=ppanel-jp-prod
VPC_ID=
VPC_CIDR=10.20.0.0/16
PUBLIC_SUBNET_A_NAME=ppanel-jp-public-a
PUBLIC_SUBNET_A_AZ=ap-northeast-1a
PUBLIC_SUBNET_A_CIDR=10.20.0.0/24
PUBLIC_SUBNET_C_NAME=ppanel-jp-public-c
PUBLIC_SUBNET_C_AZ=ap-northeast-1c
PUBLIC_SUBNET_C_CIDR=10.20.1.0/24
PRIVATE_SUBNET_A_NAME=ppanel-jp-private-a
PRIVATE_SUBNET_A_AZ=ap-northeast-1a
PRIVATE_SUBNET_A_CIDR=10.20.10.0/24
PRIVATE_SUBNET_C_NAME=ppanel-jp-private-c
PRIVATE_SUBNET_C_AZ=ap-northeast-1c
PRIVATE_SUBNET_C_CIDR=10.20.11.0/24
IGW_NAME=ppanel-jp-igw
PUBLIC_ROUTE_TABLE_NAME=ppanel-jp-public-rt
PRIVATE_ROUTE_TABLE_NAME=ppanel-jp-private-rt
SG_ALB_NAME=ppanel-jp-sg-alb
SG_EC2_NAME=ppanel-jp-sg-ec2
SG_RDS_NAME=ppanel-jp-sg-rds
OPS_SSH_CIDR=CHANGE_ME_TO_YOUR_FIXED_PUBLIC_IP_OR_CIDR
DR_REPLICA_IP=104.238.220.230/32
EC2_NAME=ppanel-app-jp-01
EC2_AMI_FAMILY=ubuntu-24.04
EC2_INSTANCE_TYPE=t4g.large
EC2_DISK_GB=80
EC2_KEY_PAIR=CHANGE_ME
RDS_IDENTIFIER=ppanel-mysql-jp
RDS_DB_NAME=hifast
RDS_ADMIN_USER=admin
RDS_INSTANCE_CLASS=db.r7g.xlarge
RDS_STORAGE_GB=100
ALB_NAME=ppanel-alb-jp
TARGET_GROUP_NAME=ppanel-tg-jp
WAF_NAME=ppanel-waf-jp
PARALLEL_API_DOMAIN=api-jp.hifast.biz
PARALLEL_LOGS_DOMAIN=logs-jp.hifast.biz
PRODUCTION_API_DOMAIN=CHANGE_ME
S3_BACKUP_BUCKET=hifast-prod-backups-200810848252-ap-northeast-1
@@ -0,0 +1,19 @@
AWS_REGION=ap-northeast-1
S3_BUCKET=hifast-prod-backups-200810848252-ap-northeast-1
S3_PREFIX=mysql
BACKUP_DIR=/var/backups/hifast
HOST_TAG=104-standby-for-jp
KEEP_LOCAL_DAYS=3
CHECK_REPLICA=1
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=backup_reader
MYSQL_PASSWORD=CHANGE_ME
MYSQL_SOCKET=
MYSQL_DATABASE=hifast
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
@@ -0,0 +1,21 @@
PRIMARY_HOST=ppanel-mysql-jp.<CHANGE_ME>.ap-northeast-1.rds.amazonaws.com
PRIMARY_PORT=3306
PRIMARY_USER=admin
PRIMARY_PASSWORD=CHANGE_ME
PRIMARY_DB=hifast
PRIMARY_REPL_USER=repl
PRIMARY_REPL_PASSWORD=CHANGE_ME
PRIMARY_REPL_HOST=104.238.220.230
PRIMARY_BINLOG_RETENTION_HOURS=24
REPLICA_HOST=127.0.0.1
REPLICA_PORT=3306
REPLICA_USER=root
REPLICA_PASSWORD=
REPLICA_SOCKET=/var/run/mysqld/mysqld.sock
REPLICA_DB=hifast
REPLICA_SOURCE_SSL=1
DUMP_FILE=
@@ -0,0 +1,112 @@
Host: 0.0.0.0
Port: 8080
Debug: false
JwtAuth:
AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET
AccessExpire: 604800
Logger:
ServiceName: PPanel
Mode: console
Encoding: plain
TimeFormat: "2006-01-02 15:04:05.000"
Path: logs
Level: info
MaxContentLength: 0
Compress: false
Stat: true
KeepDays: 7
StackCooldownMillis: 100
MaxBackups: 7
MaxSize: 100
Rotation: daily
FileTimeFormat: "2006-01-02T15:04:05.000Z07:00"
MySQL:
Addr: YOUR_TOKYO_RDS_ENDPOINT:3306
Dbname: hifast
Username: admin
Password: CHANGE_ME_TO_TOKYO_RDS_PASSWORD
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FTokyo
MaxIdleConns: 10
MaxOpenConns: 100
SlowThreshold: 1000
Redis:
Host: 127.0.0.1:6379
Pass: CHANGE_ME_TO_TOKYO_REDIS_PASSWORD
DB: 0
PoolSize: 100
MinIdleConns: 10
MaxRetries: 3
PoolTimeout: 4
IdleTimeout: 300
MaxConnAge: 0
DialTimeout: 5
ReadTimeout: 3
WriteTimeout: 3
Trace:
Name: ppanel-server
Endpoint: 127.0.0.1:4317
Sampler: 0.1
Batcher: otlpgrpc
Site:
Host: api-jp.hifast.biz
SiteName: HiFastVPN
Administrator:
Email: admin@example.com
Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD
Telegram:
Enable: false
BotID: 0
BotName: ""
BotToken: ""
GroupChatID: ""
EnableNotify: false
WebHookDomain: ""
Kutt:
Enable: false
ApiURL: ""
ApiKey: ""
TargetURL: ""
Domain: ""
OpenInstall:
Enable: false
AppKey: ""
ApiKey: ""
Loki:
Enable: true
URL: "http://localhost:3100"
AppSignature:
AppSecrets:
android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET
ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET
web-client: CHANGE_ME_WEB_SIGNATURE_SECRET
ValidWindowSeconds: 300
SkipPrefixes:
- /v1/notify/
- /v1/iap/notifications
- /v1/telegram/webhook
- /v1/subscribe/config
Signature:
EnableSignature: false
device:
enable: true
security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET
Register:
EnableTrial: true
EnableTrialEmailWhitelist: true
TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com"
@@ -0,0 +1,23 @@
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=CHANGE_ME
MYSQL_SOCKET=
REPL_SOURCE_HOST=ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com
REPL_SOURCE_PORT=3306
REPL_SOURCE_USER=repl
REPL_SOURCE_PASSWORD=XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer
REPL_SOURCE_SSL=1
REPL_SOURCE_LOG_FILE=mysql-bin-changelog.000189
REPL_SOURCE_LOG_POS=185053
REPL_SOURCE_AUTO_POSITION=1
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
REDIS_SOURCE_HOST=3.114.29.208
REDIS_SOURCE_PORT=6379
REDIS_SOURCE_USER=
REDIS_SOURCE_PASSWORD=hifast67yj
@@ -0,0 +1,187 @@
# Tokyo Resource Inventory
最后更新:`2026-05-21`
这个文件记录当前东京迁移的真实实施状态,不是示例。
## Region
- AWS account: `hifastvpn (200810848252)`
- Region: `ap-northeast-1`
## Current Status
- 东京迁移方案已在仓库内落地为执行资产
- 东京 VPC 已创建
- 东京子网、IGW、路由表已在 AWS 控制台创建并复核
- 东京三层安全组已在 AWS 控制台创建并复核
- 东京 VPC DNS 开关已开启,可支持公网可访问 RDS
- 东京 S3 备份桶已在 AWS 控制台创建并复核
- 东京 ACM 证书请求已创建,等待 DNS 验证
- 东京 RDS MySQL 已创建完成并可用
- 东京业务 EC2 已创建完成并绑定固定 EIP
- 因当前本机没有可用 AWS CLI 凭据,云上资源状态仍需在 AWS 控制台或已登录环境中复查
## Networking
- VPC
- Name: `ppanel-jp-prod`
- VPC ID: `vpc-0846b23b4a7d64eac`
- CIDR: `10.20.0.0/16`
- Status: `created`
- Public subnet A
- Name: `ppanel-jp-public-a`
- AZ: `ap-northeast-1a`
- CIDR: `10.20.0.0/24`
- Subnet ID: `subnet-091232bdb53e71490`
- Status: `created`
- Public subnet C
- Name: `ppanel-jp-public-c`
- AZ: `ap-northeast-1c`
- CIDR: `10.20.1.0/24`
- Subnet ID: `subnet-01ba0975c525ce8cf`
- Status: `created`
- Private subnet A
- Name: `ppanel-jp-private-a`
- AZ: `ap-northeast-1a`
- CIDR: `10.20.10.0/24`
- Subnet ID: `subnet-0bd13111c02f0edbe`
- Status: `created`
- Private subnet C
- Name: `ppanel-jp-private-c`
- AZ: `ap-northeast-1c`
- CIDR: `10.20.11.0/24`
- Subnet ID: `subnet-0d86c5c756dbc84b2`
- Status: `created`
- Internet Gateway
- Name: `ppanel-jp-igw`
- IGW ID: `igw-028041bcbf63b672c`
- Status: `created`
- Public route table
- Name: `ppanel-jp-public-rt`
- Route Table ID: `rtb-061b101080e4800e5`
- Default route: `0.0.0.0/0 -> igw-028041bcbf63b672c`
- Status: `created`
- Private route table
- Name: `ppanel-jp-private-rt`
- Route Table ID: `rtb-0d7a191a515031c45`
- Status: `created`
## Security
- `sg-alb`
- Name: `ppanel-jp-sg-alb`
- Security Group ID: `sg-0b3a23c31041a5a5a`
- Inbound:
- `80/tcp <- 0.0.0.0/0`
- `443/tcp <- 0.0.0.0/0`
- Status: `created`
- `sg-ec2`
- Name: `ppanel-jp-sg-ec2`
- Security Group ID: `sg-01f2a5a81e7505c91`
- Inbound:
- `80/tcp <- sg-0b3a23c31041a5a5a`
- `22/tcp <- 64.118.144.142/32`
- `6379/tcp <- 104.238.220.230/32`
- Status: `created`
- `sg-rds`
- Name: `ppanel-jp-sg-rds`
- Security Group ID: `sg-0b71db1e2c18b57c0`
- Inbound:
- `3306/tcp <- sg-01f2a5a81e7505c91`
- `3306/tcp <- 104.238.220.230/32`
- Status: `created`
## Compute / Database / Edge
- EC2 `ppanel-app-jp-01`:
- Instance ID: `i-07839130074cd7ed9`
- Type: `c7i.xlarge`
- Platform: `Ubuntu 26.04 / Linux`
- AZ: `ap-northeast-1c`
- VPC: `ppanel-jp-prod (vpc-0846b23b4a7d64eac)`
- Subnet: `ppanel-jp-public-c (subnet-01ba0975c525ce8cf)`
- Private IP: `10.20.1.168`
- Public IP / Elastic IP: `3.114.29.208`
- Public DNS: `ec2-3-114-29-208.ap-northeast-1.compute.amazonaws.com`
- Security group: `ppanel-jp-sg-ec2 (sg-01f2a5a81e7505c91)`
- Key pair: `ppanel-jp-key-20260521`
- Root volume: `gp3 100GiB`
- ENI: `eni-08accb427a470c9f7`
- EIP allocation ID: `eipalloc-038b32d5c0119accf`
- EIP association ID: `eipassoc-09184aa9161b6a4d9`
- Status: `running`
- SSH recovery private key: `deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery`
- SSH recovery public key: `deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery.pub`
- RDS subnet group:
- Name: `ppanel-jp-rds-subnet-group`
- VPC: `vpc-0846b23b4a7d64eac`
- Subnets:
- `subnet-0bd13111c02f0edbe` / `ppanel-jp-private-a`
- `subnet-0d86c5c756dbc84b2` / `ppanel-jp-private-c`
- Status: `created`
- RDS public subnet group:
- Name: `ppanel-jp-rds-public-subnet-group`
- VPC: `vpc-0846b23b4a7d64eac`
- Subnets:
- `subnet-091232bdb53e71490` / `ppanel-jp-public-a`
- `subnet-01ba0975c525ce8cf` / `ppanel-jp-public-c`
- Status: `created`
- RDS `ppanel-mysql-jp`:
- Engine: `MySQL Community 8.4.8`
- Class: `db.r7g.xlarge`
- Storage: `gp3 100GiB`
- Deployment: `Single instance (current actual state)`
- VPC: `ppanel-jp-prod (vpc-0846b23b4a7d64eac)`
- Subnet group: `ppanel-jp-rds-public-subnet-group`
- Security group: `ppanel-jp-sg-rds (sg-0b71db1e2c18b57c0)`
- Master username: `admin`
- Credential management: `self-managed`
- Secrets Manager managed password: `disabled`
- Current master password visibility: `not retrievable from AWS console; reset only`
- Public access: `enabled (set at creation time for external replication)`
- Status: `available`
- Endpoint: `ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com`
- Public IP (resolved via public DNS): `52.196.204.186`
- Connection test from Tokyo EC2:
- `mysql -h ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com -u admin -e "select 1"`
- Result: `ERROR 1045 (28000): Access denied for user 'admin'@'ip-10-20-1-168.ap-northeast-1.compute.internal' (using password: NO)`
- Meaning: `network path and security group are working; only the password is missing`
- Current admin password: `TkyRds20260521!N9mQ8sKe2vLp7Xa`
- External replica prep for `104.238.220.230`:
- binlog retention hours: `24`
- replication user: `repl@104.238.220.230`
- replication password: `XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer`
- current binlog file: `mysql-bin-changelog.000189`
- current binlog position: `185053`
- ALB `ppanel-alb-jp`: `not created`
- WAF `ppanel-waf-jp`: `not created`
- ACM certificate in `ap-northeast-1`:
- Certificate ID: `29d0b9b6-ab37-44d9-ad9e-18fa7e9aae3a`
- Domains:
- `api-jp.hifast.biz`
- `logs-jp.hifast.biz`
- Status: `pending_validation`
- Route 53 hosted zone in current AWS account: `not found`
- S3 backup bucket `hifast-prod-backups-200810848252-ap-northeast-1`: `created`
## Domains
- Parallel API domain: `api-jp.hifast.biz`
- Parallel logs domain: `logs-jp.hifast.biz`
- Production API domain: `pending user final confirmation`
- ACM DNS validation records pending external DNS add:
- `api-jp.hifast.biz`
- Name: `_0de5970dfbadaf46759447b2ea627a10.api-jp.hifast.biz.`
- Type: `CNAME`
- Value: `_6a632a5b85c5b3f304cc492090b741b3.jkddzztszm.acm-validations.aws.`
- `logs-jp.hifast.biz`
- Name: `_349a2b2bc4678d76c3ab341ccf73db61.logs-jp.hifast.biz.`
- Type: `CNAME`
- Value: `_74ced20dc96aa39070188605cf0ced18.jkddzztszm.acm-validations.aws.`
## DR
- DR host: `104.238.220.230`
- Planned MySQL upstream after cutover: `Tokyo RDS`
- Planned Redis upstream after cutover: `Tokyo EC2 public IP`
@@ -0,0 +1,69 @@
# Tokyo Resource Inventory Example
Use this file as the single source of truth while building the Tokyo environment.
## Region
- AWS account: `hifastvpn (200810848252)`
- Region: `ap-northeast-1`
## DNS
- Production API domain: `CHANGE_ME`
- Parallel API domain: `api-jp.hifast.biz`
- Parallel logs domain: `logs-jp.hifast.biz`
## Networking
- VPC name: `ppanel-jp-prod`
- VPC CIDR: `10.20.0.0/16`
- Public subnet A: `10.20.0.0/24`
- Public subnet C: `10.20.1.0/24`
- Private subnet A: `10.20.10.0/24`
- Private subnet C: `10.20.11.0/24`
- Ops CIDR for SSH: `CHANGE_ME`
## Compute
- EC2 name: `ppanel-app-jp-01`
- EC2 type: `t4g.large`
- EC2 disk: `gp3 80GB`
- SSH key pair: `CHANGE_ME`
## Database
- RDS identifier: `ppanel-mysql-jp`
- RDS engine: `MySQL 8.4`
- RDS class: `db.r7g.xlarge`
- RDS storage: `gp3 100GB`
- DB name: `hifast`
- DB admin user: `admin`
## Cache
- Redis container: `hifast-redis`
- Redis port: `6379`
- Redis password: `CHANGE_ME`
## Security / Secrets
- JWT secret: `CHANGE_ME`
- Admin email: `CHANGE_ME`
- Admin password: `CHANGE_ME`
- Android app signature secret: `CHANGE_ME`
- iOS app signature secret: `CHANGE_ME`
- Web app signature secret: `CHANGE_ME`
- Device security secret: `CHANGE_ME`
## Backup
- S3 backup bucket: `hifast-prod-backups-200810848252-ap-northeast-1`
- Versioning: `Enabled`
## DR
- DR host: `104.238.220.230`
- MySQL repl user: `repl`
- MySQL repl password: `CHANGE_ME`
- Redis source password: `CHANGE_ME`
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1YwAAAKBaQXT3WkF0
9wAAAAtzc2gtZWQyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1Yw
AAAEArWQWWwPoJp+za5JhSnrE0Pw1/TtqWRrdY5e8hULqYDGwnFkOMq9oh1WN6vdAwdFjD
jfCRWfMe6sDZNCoeWvVjAAAAF0FwcGxlQE1hY0Jvb2stUHJvLmxvY2FsAQIDBAUG
-----END OPENSSH PRIVATE KEY-----
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGwnFkOMq9oh1WN6vdAwdFjDjfCRWfMe6sDZNCoeWvVj Apple@MacBook-Pro.local
@@ -0,0 +1,34 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
client_max_body_size 20m;
access_log /var/log/nginx/ppanel-access.log;
error_log /var/log/nginx/ppanel-error.log warn;
location / {
proxy_http_version 1.1;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location = /nginx_status {
stub_status;
access_log off;
allow 127.0.0.1;
deny all;
}
}
@@ -0,0 +1,17 @@
[Unit]
Description=Hifast MySQL backup to S3
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=root
Group=root
EnvironmentFile=/root/backup-to-s3.env
ExecStart=/usr/bin/env bash -lc 'exec /opt/ppanel/deploy/scripts/mysql_backup_to_s3.sh'
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
[Install]
WantedBy=multi-user.target
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Run Hifast MySQL backup to S3 every 10 minutes
[Timer]
OnCalendar=*:0/10
Persistent=true
RandomizedDelaySec=30
Unit=hifast-mysql-backup.service
[Install]
WantedBy=timers.target
-254
View File
@@ -1,254 +0,0 @@
# 开发流程(迁移到 GitHub 后)
> 适用:`github.com/TawCorp/hifast-server`canonical 仓库)及配套的 Multica agent 工作区。
> 历史背景:本仓库于 2026-06-03 从 `git.kxsw.us/HI-VPN/hi-server` 迁移到 GitHub。**`git.kxsw.us` 已废弃**,所有新开发只走本仓库。
---
## 0. TL;DR
```
Multica issue → 分支 fix/<num>-中文简述 → 推 github → 开 PR → CI 绿 → 架构师 approve
→ GitHub UI Squash and merge 进 internal → 测试环境部署 (deploy-staging.yml) 自动跑
→ QA 在 staging 验收 → Multica issue → done
发版时:架构师把 internal squash 进 main → 对外正式版本
```
不要在 GitHub UI 之外 squash 后直接 push `internal` / `main`。不要往 `git.kxsw.us` 推。
---
## 1. 分支模型
| 分支 | 角色 | 写入方式 |
|---|---|---|
| `main` | 对外正式版本(生产) | **只**由架构师 squash merge `internal``main` |
| `internal` | 日常开发主干,staging 触发分支 | **只**由架构师在 GitHub UI 上 Squash and merge PR |
| `fix/<num>-…` | bug 修复分支 | 工程师自己开自己删(PR 合并后 GitHub 自动删) |
| `feat/<num>-…` | 新功能分支 | 同上 |
| `chore/…` `refactor/…` `hotfix/…` | 杂项 / 重构 / 紧急修复 | 同上 |
**分支命名约定**(严格遵守,便于 CI / CODEOWNERS / 历史追溯):
| 前缀 | 用途 | 示例 |
|---|---|---|
| `fix/<num>-` | 关联 Multica issue 编号的 bug 修复 | `fix/143-邀请权益单测flake` |
| `feat/<num>-` | 关联 Multica issue 编号的新功能 | `feat/77-套餐列表促销信息` |
| `chore/` | 配置 / 文档 / 工具链(无关联 issue 可不带编号) | `chore/dev-workflow-docs` |
| `hotfix/<num>-` | 生产紧急修复 | `hotfix/151-payment-callback-503` |
| `refactor/<num>-` | 重构(行为不变) | `refactor/138-promo-eligibility` |
**单分支存活上限:3 天**(架构师 CLAUDE.md 约定)。超时未合并的分支由架构师在 issue 上 ping 持有者收尾或重切。
---
## 2. 标准 PR 生命周期
### 2.1 立项
- Multica 上有对应 issueHIF-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 CICI 红时不要 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 自己分支 | 合 PRpush `internal` / `main`;改 CODEOWNERS;自己 approve 自己 PR |
| 架构师(Squad Leader | reviewapproveGitHub 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 APIHTTP 403)。
这意味着 "禁止直接 push internal" / "require CI pass" / "require approval" 这些**在 GitHub 平台层面没法硬强制**。当前依赖三层软约束:
1. **客户端层**`lefthook.yml``pre-push` 钩子会在尝试直接 push `internal` / `main` 时报错。绕过去要明确加 `--no-verify`,会留 git trailers。
2. **流程层**:本文档 + `CODEOWNERS` 自动 request review + 架构师作为单一合并执行人。
3. **审计层**:所有 merge 都对应 Multica issue,事后任何"非 PR 路径上去的 commit"都能在 git log + Multica 对照查出来。
如果未来组织规模扩大、人类协作者增多,建议升级 GitHub Team$4/user/月)拿到 branch protection 平台保障。**目前规模下软约束足够。**
---
## 6. 常见场景
### Hotfix(生产紧急修复)
走和 fix 同样的流程,只是分支前缀 `hotfix/`PR 标题前加 `[HOTFIX]`。架构师可酌情把 review 等待时间压缩到 30 分钟以内。Hotfix 通常直接合 `internal` 后立即由架构师再开 `internal → main` 的 PR 一起发版。
### Revert
- 在 GitHub UI 上找到要 revert 的 PR,点 "Revert"。
- GitHub 会生成 revert PR,按正常流程过 review + merge。
- Revert PR 标题用 `修复(#<原 issue 编号>): revert <原 PR 标题>`
### 文档 / 配置改动
`chore/` 分支。CI 一样跑(保险),review 可走 fast-track。
### 跨多个 issue 的大改动
- 优先拆成多个独立 PR,每个 PR 对应一个 issue。
- 如果实在拆不开,PR title 用 `<类型>(#XXX/#YYY/#ZZZ): ...`PR body 里 `Closes` 三个。
---
## 7. FAQ
**Q: 为什么不允许在本地 squash 再推 internal**
A: 绕过 GitHub PR review 流程 + CI gate + 审计记录。即使你确定改动 100% 正确,也走 PR——这是文化约束,避免架构师红线被逐渐侵蚀。
**Q: CI 跑得慢,PR 一直 yellow,可以先 merge 吗?**
A: 不可以。CI 通常 5 分钟内出结果;如果超过 15 分钟仍 pending,检查是否有 stuck job,必要时 re-run。
**Q: 如果架构师不在,怎么办?**
A: 备份 reviewer = 仓库 owner@shanshanzhong147)。CODEOWNERS 已经把 owner 列为兜底 reviewer。
**Q: lefthook pre-push 不让我 push internal,但我确实需要紧急修一行小字?**
A: 没有"紧急修一行小字"这种例外。开 hotfix 分支 + 开 PR + 走 fast-track review。
**Q: Multica issue 状态没流转到 done,是不是要手动推?**
A: 不要直接推。先确认 PR merged + deploy 绿 + QA 通过三个条件全满足。任一项没满足就维持 `in_review` 并加评论说卡在哪。
**Q: 我能不能用 Rebase / Merge commit 模式合 PR**
A: 不行。必须用 Squash and merge。`internal` 必须保持 linear history(一个 PR = 一个 commit),方便回滚和审计。
---
## 8. 紧急联系
- Deploy 红 / staging 挂:运维工程师(Multica agent `071d94d9-38bb-43cc-8c58-29e3b52d7bd4`
- 流程问题 / branch protection 决策:仓库 owner @shanshanzhong147
- 架构 / API 设计:架构师(Multica agent `0de10589-f101-49ef-8dfa-0e9e992fe27c`
+231 -2
View File
@@ -1,12 +1,34 @@
# PPanel 服务部署 (云端/无源码版)
# 使用方法:
# 1. 确保已将 docker-compose.cloud.yml, configs/, loki/, grafana/, prometheus/, tempo/ 目录上传到服务器同一目录
# 2. 确保 configs/ 目录下有 ppanel.yaml 配置文件(参考 etc/ppanel.yaml
# 3. 确保 logs/ cache/ tempo_data/ 目录存在 (mkdir -p logs cache tempo_data)
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
#
# 网络说明:
# ppanel-server 使用 host 网络(可出外网,直接访问 AWS RDS / 本机 Redis
# 监控服务(Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中
# Tempo(4317) 将端口映射到 127.0.0.1ppanel-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: ${PPANEL_SERVER_IMAGE:-registry.kxsw.us/vpn-server}:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag} image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag}
container_name: ppanel-server 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 - ./cache:/app/cache # GeoLite2-City.mmdb IP 地理位置数据库
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
network_mode: host network_mode: host
@@ -15,8 +37,215 @@ 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 gRPCppanel-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
+2
View File
@@ -1,3 +1,5 @@
version: '3'
services: services:
ppanel: ppanel:
container_name: ppanel-server container_name: ppanel-server
+8 -8
View File
@@ -15,10 +15,10 @@ Logger: # 日志配置
Level: debug # 日志级别: debug, info, warn, error, panic, fatal Level: debug # 日志级别: debug, info, warn, error, panic, fatal
MySQL: MySQL:
Addr: 127.0.0.1:3306 # 本地开发默认;Docker bridge 模式改为 mysql:3306 Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
Username: root # MySQL用户名 Username: root # MySQL用户名
Password: CHANGE_ME_TO_DB_PASSWORD # MySQL密码 Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
Dbname: ppanel # MySQL数据库名 Dbname: hifast # 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: CHANGE_ME_ANDROID_APP_SECRET # Android 客户端签名密钥 android-client: uB4G,XxL2{7b # Android 客户端签名密钥
ios-client: CHANGE_ME_IOS_APP_SECRET # iOS 客户端签名密钥 ios-client: uB4G,XxL2{7b # iOS 客户端签名密钥
web-client: CHANGE_ME_WEB_APP_SECRET # Web 客户端签名密钥 web-client: uB4G,XxL2{7b # 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: "" # 本地开发留空;生产如需链路追踪再配置 exporter Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc
Endpoint: "" Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317
S3: S3:
Enable: false Enable: false
-2
View File
@@ -82,7 +82,6 @@ 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
@@ -146,7 +145,6 @@ 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
-4
View File
@@ -54,8 +54,6 @@ 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=
@@ -397,8 +395,6 @@ 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=
@@ -0,0 +1,272 @@
apiVersion: 1
groups:
- orgId: 1
name: ppanel-core
folder: PPanel
interval: 1m
rules:
- uid: ppanel-target-down
title: PPanel monitoring target down
condition: C
for: 2m
noDataState: Alerting
execErrState: Error
annotations:
summary: "Monitoring target is down"
description: "{{ $labels.job }} on {{ $labels.instance }} has been down for more than 2 minutes."
labels:
severity: critical
service: ppanel
data:
- refId: A
relativeTimeRange:
from: 300
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: 'up{job=~"grafana|prometheus|node-exporter|cadvisor|nginx-exporter|loki|tempo"}'
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
- refId: C
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 1
type: lt
operator:
type: and
query:
params:
- A
reducer:
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
- uid: ppanel-host-disk-high
title: PPanel host disk usage high
condition: C
for: 10m
noDataState: NoData
execErrState: Error
annotations:
summary: "Host disk usage is high"
description: "{{ $labels.instance }} {{ $labels.mountpoint }} disk usage is above 85% for 10 minutes."
labels:
severity: warning
service: ppanel
data:
- refId: A
relativeTimeRange:
from: 900
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: '100 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} * 100)'
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
- refId: C
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 85
type: gt
operator:
type: and
query:
params:
- A
reducer:
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
- uid: ppanel-host-memory-high
title: PPanel host memory usage high
condition: C
for: 10m
noDataState: NoData
execErrState: Error
annotations:
summary: "Host memory usage is high"
description: "{{ $labels.instance }} memory usage is above 90% for 10 minutes."
labels:
severity: warning
service: ppanel
data:
- refId: A
relativeTimeRange:
from: 900
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100'
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
- refId: C
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 90
type: gt
operator:
type: and
query:
params:
- A
reducer:
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
- uid: ppanel-host-cpu-high
title: PPanel host CPU usage high
condition: C
for: 10m
noDataState: NoData
execErrState: Error
annotations:
summary: "Host CPU usage is high"
description: "{{ $labels.instance }} CPU usage is above 90% for 10 minutes."
labels:
severity: warning
service: ppanel
data:
- refId: A
relativeTimeRange:
from: 900
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
- refId: C
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 90
type: gt
operator:
type: and
query:
params:
- A
reducer:
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
- uid: ppanel-container-restarts
title: PPanel container restarted
condition: C
for: 1m
noDataState: NoData
execErrState: Error
annotations:
summary: "Container restarted"
description: "{{ $labels.name }} restarted or changed start time in the last hour."
labels:
severity: warning
service: ppanel
data:
- refId: A
relativeTimeRange:
from: 3600
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: 'sum by (name) (changes(container_start_time_seconds{name!=""}[1h]))'
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
- refId: C
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 0
type: gt
operator:
type: and
query:
params:
- A
reducer:
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
@@ -0,0 +1,14 @@
apiVersion: 1
providers:
- name: PPanel
orgId: 1
folder: PPanel
folderUid: ppanel
type: file
disableDeletion: false
allowUiUpdates: true
updateIntervalSeconds: 30
options:
path: /etc/grafana/provisioning/dashboards/json
foldersFromFilesStructure: false
@@ -0,0 +1,520 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"gridPos": {
"h": 3,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"content": "<b>AWS CloudWatch overview</b><br/>Region: ap-east-1 (Hong Kong)<br/>RDS DBInstanceIdentifier: hifast-mysql-prod-v2<br/>Redis: current production uses a local Docker Redis container (<code>hifast-redis</code>) on EC2 rather than AWS ElastiCache.<br/><br/>This dashboard keeps the RDS CloudWatch panels. Redis should be observed from the local ops dashboard via Prometheus/cAdvisor instead of ElastiCache metrics.",
"mode": "html"
},
"pluginVersion": "11.0.0",
"title": "Read Me",
"type": "text"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 3
},
"id": 2,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "CPUUtilization",
"namespace": "AWS/RDS",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "RDS CPU",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 3
},
"id": 3,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "DatabaseConnections",
"namespace": "AWS/RDS",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "RDS Connections",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 3
},
"id": 4,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "FreeStorageSpace",
"namespace": "AWS/RDS",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Minimum"
}
],
"title": "RDS Free Storage",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 11
},
"id": 5,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "ReadLatency",
"namespace": "AWS/RDS",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "WriteLatency",
"namespace": "AWS/RDS",
"period": "",
"refId": "B",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "RDS Read / Write Latency",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "iops"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 11
},
"id": 6,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "ReadIOPS",
"namespace": "AWS/RDS",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "WriteIOPS",
"namespace": "AWS/RDS",
"period": "",
"refId": "B",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "RDS Read / Write IOPS",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 19
},
"id": 7,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"ReplicationGroupId": "hifastapp-redis"
},
"metricName": "CPUUtilization",
"namespace": "AWS/ElastiCache",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "Redis Host CPU (Legacy ElastiCache)",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 6,
"y": 19
},
"id": 8,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"ReplicationGroupId": "hifastapp-redis"
},
"metricName": "EngineCPUUtilization",
"namespace": "AWS/ElastiCache",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "Redis Engine CPU (Legacy ElastiCache)",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 12,
"y": 19
},
"id": 9,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"ReplicationGroupId": "hifastapp-redis"
},
"metricName": "CurrConnections",
"namespace": "AWS/ElastiCache",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "Redis Connections (Legacy ElastiCache)",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 18,
"y": 19
},
"id": 10,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"ReplicationGroupId": "hifastapp-redis"
},
"metricName": "DatabaseMemoryUsagePercentage",
"namespace": "AWS/ElastiCache",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "Redis Memory Usage % (Legacy ElastiCache)",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 39,
"style": "dark",
"tags": [
"aws",
"cloudwatch",
"rds",
"redis"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "AWS RDS & Redis Overview",
"uid": "aws-rds-redis-overview",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,565 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [
{
"options": {
"0": {
"text": "DOWN"
},
"1": {
"text": "UP"
}
},
"type": "value"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "13.0.1",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "up{job=~\"prometheus|grafana|node-exporter|cadvisor|nginx-exporter|loki|tempo\"}",
"instant": true,
"legendFormat": "{{job}}",
"range": false,
"refId": "A"
}
],
"title": "Service Availability",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 75
},
{
"color": "red",
"value": 90
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 4
},
"id": 2,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"legendFormat": "{{instance}} CPU",
"range": true,
"refId": "A"
}
],
"title": "Host CPU Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 80
},
{
"color": "red",
"value": 90
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 4
},
"id": 3,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
"legendFormat": "{{instance}} memory",
"range": true,
"refId": "A"
}
],
"title": "Host Memory Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 80
},
{
"color": "red",
"value": 90
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 4
},
"id": 4,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "100 - (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} * 100)",
"legendFormat": "{{mountpoint}}",
"range": true,
"refId": "A"
}
],
"title": "Host Disk Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 12
},
"id": 5,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name!=\"\"}[5m]))",
"legendFormat": "{{name}}",
"range": true,
"refId": "A"
}
],
"title": "Container CPU",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 12
},
"id": 6,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (name) (container_memory_working_set_bytes{name!=\"\"})",
"legendFormat": "{{name}}",
"range": true,
"refId": "A"
}
],
"title": "Container Memory",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 12
},
"id": 7,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (name) (changes(container_start_time_seconds{name!=\"\"}[1h]))",
"legendFormat": "{{name}}",
"range": true,
"refId": "A"
}
],
"title": "Container Restarts / Changes",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 20
},
"id": 8,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "rate(nginx_http_requests_total[5m])",
"legendFormat": "requests",
"range": true,
"refId": "A"
}
],
"title": "Nginx Requests",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 20
},
"id": 9,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": true,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "{job=~\"ppanel-server|nginx|docker\"} |~ \"(?i)(error|panic|fatal|timeout|exception|failed)\"",
"queryType": "range",
"refId": "A"
}
],
"title": "Recent Errors",
"type": "logs"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 20
},
"id": 10,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (service_name) (rate(traces_spanmetrics_calls_total[5m]))",
"legendFormat": "{{service_name}}",
"range": true,
"refId": "A"
}
],
"title": "Trace Span Calls",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 42,
"tags": [
"ppanel",
"ops",
"prometheus",
"loki",
"tempo"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "PPanel Ops Overview",
"uid": "ppanel-ops-overview",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,330 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "sum(count_over_time({compose_service=\"ppanel-server\"}[5m]))",
"queryType": "range",
"refId": "A"
}
],
"title": "Matched Log Volume",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "sum(count_over_time({compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\" [5m]))",
"queryType": "range",
"refId": "A"
}
],
"title": "Matched Error Volume",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"gridPos": {
"h": 12,
"w": 24,
"x": 0,
"y": 7
},
"id": 3,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": true,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "{compose_service=\"ppanel-server\"}",
"queryType": "range",
"refId": "A"
}
],
"title": "Filtered Server Logs",
"type": "logs"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 19
},
"id": 4,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": true,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "{compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\"",
"queryType": "range",
"refId": "A"
}
],
"title": "Filtered Server Errors",
"type": "logs"
}
],
"refresh": "30s",
"schemaVersion": 42,
"tags": [
"ppanel",
"logs",
"server",
"loki"
],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": ".",
"value": "."
},
"description": "输入用户 ID;默认 . 表示不过滤",
"hide": 0,
"label": "用户ID",
"name": "user_id",
"options": [],
"query": ".",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"selected": false,
"text": ".",
"value": "."
},
"description": "输入邮箱或邮箱片段;默认 . 表示不过滤",
"hide": 0,
"label": "邮箱",
"name": "email",
"options": [],
"query": ".",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"selected": false,
"text": ".",
"value": "."
},
"description": "输入订单号/支付单号/交易号片段;默认 . 表示不过滤",
"hide": 0,
"label": "订单",
"name": "order",
"options": [],
"query": ".",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"selected": true,
"text": "All",
"value": "."
},
"description": "日志等级",
"hide": 0,
"includeAll": false,
"label": "等级",
"multi": false,
"name": "level",
"options": [
{
"selected": true,
"text": "All",
"value": "."
},
{
"selected": false,
"text": "debug",
"value": "debug"
},
{
"selected": false,
"text": "info",
"value": "info"
},
{
"selected": false,
"text": "warn",
"value": "warn"
},
{
"selected": false,
"text": "error",
"value": "error"
},
{
"selected": false,
"text": "slow",
"value": "slow"
},
{
"selected": false,
"text": "panic",
"value": "panic"
},
{
"selected": false,
"text": "fatal",
"value": "fatal"
}
],
"query": "All : .,debug,info,warn,error,slow,panic,fatal",
"queryValue": "",
"skipUrlSync": false,
"type": "custom"
},
{
"current": {
"selected": false,
"text": ".",
"value": "."
},
"description": "任意关键字;默认 . 表示不过滤",
"hide": 0,
"label": "关键字",
"name": "keyword",
"options": [],
"query": ".",
"skipUrlSync": false,
"type": "textbox"
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "PPanel Server Logs",
"uid": "ppanel-server-logs",
"version": 5,
"weekStart": ""
}
@@ -0,0 +1,57 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
jsonData:
httpMethod: POST
manageAlerts: true
prometheusType: Prometheus
prometheusVersion: 2.50.0
timeInterval: 15s
- name: Loki
uid: loki
type: loki
access: proxy
url: http://loki:3100
editable: true
jsonData:
derivedFields:
- datasourceUid: tempo
matcherRegex: '"(?:trace|traceID|trace_id)"\s*:\s*"([a-f0-9]{32})"'
name: TraceID
url: '$${__value.raw}'
- name: Tempo
uid: tempo
type: tempo
access: proxy
url: http://tempo:3200
editable: true
jsonData:
tracesToLogsV2:
datasourceUid: loki
filterByTraceID: true
filterBySpanID: false
tags:
- key: service.name
value: service_name
tracesToMetrics:
datasourceUid: prometheus
serviceMap:
datasourceUid: prometheus
- name: CloudWatch
uid: cloudwatch
type: cloudwatch
access: proxy
editable: true
jsonData:
authType: default
defaultRegion: ap-east-1
@@ -1,11 +0,0 @@
-- 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`;
@@ -1,125 +0,0 @@
-- 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='抽奖时刻的门槛评估快照(对账/申诉用)';
@@ -1,2 +0,0 @@
-- 02157 抽奖发奖账本回滚
DROP TABLE IF EXISTS `lottery_grant_ledger`;
@@ -1,25 +0,0 @@
-- 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';
@@ -1,2 +0,0 @@
-- 02158 admin_action_log 回滚
DROP TABLE IF EXISTS `admin_action_log`;
@@ -1,21 +0,0 @@
-- 02158 admin_action_log —— 管理端写操作审计(PR C 起要求)
--
-- 每一条 admin CRUD/rules 更新都在同事务内插入一行审计流水,方便后续追责
-- 与合规审查。actor_user_id 是操作者的 user.idaction 是操作动作
-- 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='后台写操作审计流水';
@@ -1,3 +0,0 @@
-- 02159 抽奖活动 Stage 2 down migration
-- Stage 2 只新增 1 张表,回滚直接 drop 即可。
DROP TABLE IF EXISTS `lottery_claim`;
@@ -1,47 +0,0 @@
-- 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='抽奖人工奖领奖工单';
-3
View File
@@ -70,6 +70,3 @@ 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:"
-8
View File
@@ -39,7 +39,6 @@ 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"`
@@ -251,13 +250,6 @@ 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 短链接
@@ -1,74 +0,0 @@
// 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)
}
}
@@ -1,169 +0,0 @@
// 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,6 +1,9 @@
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"
@@ -12,18 +15,31 @@ 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
if err := c.ShouldBind(&req); err != nil { _ = c.ShouldBind(&req)
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 type", name: "invalid traffic limit json",
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"traffic_limit":"not-json"}`, body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"traffic_limit":"not-json"}`,
}, },
} }
-51
View File
@@ -1,51 +0,0 @@
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))
}
}
@@ -1,69 +0,0 @@
// 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)
}
}
@@ -1,26 +0,0 @@
package user
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Query Commission Return Log
func QueryCommissionReturnLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.QueryCommissionReturnLogRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := user.NewQueryCommissionReturnLogLogic(c.Request.Context(), svcCtx)
resp, err := l.QueryCommissionReturnLog(&req)
result.HttpResult(c, resp, err)
}
}
@@ -1,141 +0,0 @@
package user
import (
"context"
"database/sql/driver"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
logmodel "github.com/perfect-panel/server/internal/model/log"
usermodel "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/constant"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestCommissionReturnLogHandler_HTTPResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
db, mock, cleanup := newCommissionReturnHandlerTestDB(t)
defer cleanup()
expectCommissionReturnHTTPQueries(mock, 42, 3, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, time.Unix(1700000000, 0)).
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, time.Unix(1700000000, 0)).
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0)))
router := gin.New()
svcCtx := &svc.ServiceContext{DB: db}
router.Use(injectTestUser(42))
router.GET("/v1/public/user/commission_return_log", QueryCommissionReturnLogHandler(svcCtx))
req := httptest.NewRequest(http.MethodGet, "/v1/public/user/commission_return_log?page=1&size=10", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := strings.TrimSpace(rec.Body.String())
t.Logf("commission_return_log response: %s", body)
if !strings.Contains(body, `"event_type":338`) || !strings.Contains(body, `"event_type":337`) || !strings.Contains(body, `"event_type":333`) {
t.Fatalf("response body = %s", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func TestWithdrawalLogHandler_CommissionRefundHTTPResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
db, mock, cleanup := newCommissionReturnHandlerTestDB(t)
defer cleanup()
expectCommissionReturnHTTPQueries(mock, 42, 1, logmodel.CommissionTypeRefund)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0)))
router := gin.New()
svcCtx := &svc.ServiceContext{DB: db}
router.Use(injectTestUser(42))
router.GET("/v1/public/user/withdrawal_log", QueryWithdrawalLogHandler(svcCtx))
req := httptest.NewRequest(http.MethodGet, "/v1/public/user/withdrawal_log?page=1&size=10&biz_type=commission_refund", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := strings.TrimSpace(rec.Body.String())
t.Logf("withdrawal_log commission_refund response: %s", body)
if !strings.Contains(body, `"biz_type":"commission_refund"`) || !strings.Contains(body, `"amount":2500`) || strings.Contains(body, `"amount":1500`) || strings.Contains(body, `"amount":2000`) {
t.Fatalf("response body = %s", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func newCommissionReturnHandlerTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
if strings.Contains(actualSQL, expectedSQL) {
return nil
}
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}
func injectTestUser(userID int64) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), constant.CtxKeyUser, &usermodel.User{Id: userID})
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func expectCommissionReturnHTTPQueries(mock sqlmock.Sqlmock, userID int64, total int64, eventTypes ...uint16) {
query := "SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ?"
args := []driver.Value{logmodel.TypeCommission.Uint8(), userID}
if len(eventTypes) > 0 {
clauses := make([]string, 0, len(eventTypes))
for _, eventType := range eventTypes {
clauses = append(clauses, "`content` LIKE ?")
args = append(args, fmt.Sprintf("%%\"type\":%d%%", eventType))
}
query += " AND (" + strings.Join(clauses, " OR ") + ")"
}
mock.ExpectQuery(query).
WithArgs(args...).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total))
}
@@ -8,7 +8,7 @@ import (
"github.com/perfect-panel/server/pkg/result" "github.com/perfect-panel/server/pkg/result"
) )
// Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log) // Query Withdrawal Log
func QueryWithdrawalLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { 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
-6
View File
@@ -1180,9 +1180,6 @@ 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))
} }
@@ -1221,7 +1218,4 @@ 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)
} }
+1 -2
View File
@@ -9,7 +9,6 @@ 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"
) )
@@ -85,7 +84,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 {
result.HttpResult(c, nil, err) c.String(http.StatusInternalServerError, "Internal Server")
return return
} }
c.Header("subscription-userinfo", resp.Header) c.Header("subscription-userinfo", resp.Header)
-332
View File
@@ -1,332 +0,0 @@
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,7 +7,6 @@ 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"
@@ -49,33 +48,6 @@ func (l *DeleteNodeGroupLogic) DeleteNodeGroup(req *types.DeleteNodeGroupRequest
return fmt.Errorf("cannot delete group with %d associated nodes, please migrate nodes first", nodeCount) 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,7 +4,6 @@ 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"
@@ -53,9 +52,10 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
Email string `json:"email"` Email string `json:"email"`
} }
var users []UserInfo var users []UserInfo
if err := json.Unmarshal([]byte(detail.UserData), &users); err != nil { if err := l.svcCtx.DB.Raw("SELECT * FROM JSON_ARRAY(?)", detail.UserData).Scan(&users).Error; err != nil {
// 如果解析失败,尝试用标准 JSON 解析
logger.Errorf("failed to parse user data: %v", err) logger.Errorf("failed to parse user data: %v", err)
return nil, "", fmt.Errorf("parse group history user_data failed: %w", err) continue
} }
// 查询节点组名称 // 查询节点组名称
@@ -123,10 +123,7 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
result = append(result, csvData...) result = append(result, csvData...)
// 生成文件名 // 生成文件名
filename := "group_result_current.csv" filename := fmt.Sprintf("group_result_%d.csv", req.HistoryId)
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 读取) // 获取配置快照(从 system_config 读取)
var configValue string var configValue string
if history.GroupMode == "average" { if history.GroupMode == "average" {
l.svcCtx.DB.Table("system"). l.svcCtx.DB.Table("system_config").
Where("`category` = ? AND `key` = ?", "group", "average_config"). Where("`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"). l.svcCtx.DB.Table("system_config").
Where("`category` = ? AND `key` = ?", "group", "traffic_config"). Where("`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 := (page - 1) * size offset := (req.Page - 1) * req.Size
if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&histories).Error; err != nil { if err := query.Order("id DESC").Offset(offset).Limit(req.Size).Find(&histories).Error; err != nil {
logger.Errorf("failed to find group histories: %v", err) 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 := (page - 1) * size offset := (req.Page - 1) * req.Size
if err := query.Order("sort ASC").Offset(offset).Limit(size).Find(&nodeGroups).Error; err != nil { if err := query.Order("sort ASC").Offset(offset).Limit(req.Size).Find(&nodeGroups).Error; err != nil {
logger.Errorf("failed to find node groups: %v", err) logger.Errorf("failed to find node groups: %v", err)
return nil, err return nil, err
} }
@@ -1,346 +0,0 @@
package group
import (
"context"
"encoding/csv"
"fmt"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
modelgroup "github.com/perfect-panel/server/internal/model/group"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestGetGroupHistoryDetailReadsConfigFromSystem(t *testing.T) {
db, mock, cleanup := newGroupTestDB(t)
defer cleanup()
now := time.Unix(1710000000, 0)
mock.ExpectQuery("FROM `group_history`").
WithArgs(int64(9), 1).
WillReturnRows(sqlmock.NewRows([]string{
"id", "group_mode", "trigger_type", "state", "total_users", "success_count", "failed_count", "start_time", "end_time", "error_message", "created_at",
}).AddRow(int64(9), "traffic", "manual", "completed", 1, 1, 0, now, now, "", now))
mock.ExpectQuery("FROM `group_history_detail`").
WithArgs(int64(9)).
WillReturnRows(sqlmock.NewRows([]string{
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
}).AddRow(int64(1), int64(9), int64(3), 1, 2, `[{"id":7,"email":"u@example.com"}]`, now))
mock.ExpectQuery("FROM `system`").
WithArgs("group", "traffic_config").
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(`{"strategy":"closed"}`))
logic := newTestGroupHistoryDetailLogic(db)
resp, err := logic.GetGroupHistoryDetail(&types.GetGroupHistoryDetailRequest{Id: 9})
if err != nil {
t.Fatalf("GetGroupHistoryDetail error: %v", err)
}
if got := resp.ConfigSnapshot["config"].(map[string]interface{})["strategy"]; got != "closed" {
t.Fatalf("config snapshot strategy = %v, want closed", got)
}
assertGroupExpectations(t, mock)
}
func TestExportGroupResultParsesHistoryUserDataJSON(t *testing.T) {
db, mock, cleanup := newGroupTestDB(t)
defer cleanup()
historyID := int64(11)
now := time.Unix(1710000000, 0)
mock.ExpectQuery("FROM `group_history_detail`").
WithArgs(historyID).
WillReturnRows(sqlmock.NewRows([]string{
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
}).AddRow(int64(1), historyID, int64(8), 1, 2, `[{"id":42,"email":"u@example.com"}]`, now))
mock.ExpectQuery("FROM `node_group`").
WithArgs(int64(8), 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(8), "VIP"))
logic := newTestExportGroupResultLogic(db)
data, filename, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID})
if err != nil {
t.Fatalf("ExportGroupResult error: %v", err)
}
if filename != "group_result_11.csv" {
t.Fatalf("filename = %q, want group_result_11.csv", filename)
}
records, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(string(data), "\ufeff"))).ReadAll()
if err != nil {
t.Fatalf("read csv: %v", err)
}
if len(records) != 2 || strings.Join(records[1], ",") != "42,8,VIP" {
t.Fatalf("csv records = %#v, want user/group row", records)
}
assertGroupExpectations(t, mock)
}
func TestExportGroupResultRejectsInvalidHistoryUserDataJSON(t *testing.T) {
db, mock, cleanup := newGroupTestDB(t)
defer cleanup()
historyID := int64(12)
now := time.Unix(1710000000, 0)
mock.ExpectQuery("FROM `group_history_detail`").
WithArgs(historyID).
WillReturnRows(sqlmock.NewRows([]string{
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
}).AddRow(int64(1), historyID, int64(8), 1, 2, `{bad-json`, now))
logic := newTestExportGroupResultLogic(db)
_, _, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID})
if err == nil || !strings.Contains(err.Error(), "parse group history user_data failed") {
t.Fatalf("ExportGroupResult error = %v, want parse error", err)
}
assertGroupExpectations(t, mock)
}
func TestDeleteNodeGroupRejectsSubscribeReferences(t *testing.T) {
tests := []struct {
name string
defaultSubscribeCount int64
subscribeGroupCount int64
userSubscribeCount int64
wantErr string
}{
{name: "subscribe default group", defaultSubscribeCount: 1, wantErr: "default node group"},
{name: "subscribe group list", subscribeGroupCount: 1, wantErr: "node group list"},
{name: "user subscribe group", userSubscribeCount: 1, wantErr: "user subscribes"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, mock, cleanup := newGroupTestDB(t)
defer cleanup()
expectDeleteNodeGroupBaseChecks(mock, 5)
mock.ExpectQuery("FROM `subscribe`").
WithArgs(int64(5)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.defaultSubscribeCount))
if tt.defaultSubscribeCount == 0 {
mock.ExpectQuery("FROM `subscribe`").
WithArgs("[5]").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.subscribeGroupCount))
}
if tt.defaultSubscribeCount == 0 && tt.subscribeGroupCount == 0 {
mock.ExpectQuery("FROM `user_subscribe`").
WithArgs(int64(5)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.userSubscribeCount))
}
logic := newTestDeleteNodeGroupLogic(db)
err := logic.DeleteNodeGroup(&types.DeleteNodeGroupRequest{Id: 5})
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("DeleteNodeGroup error = %v, want contains %q", err, tt.wantErr)
}
assertGroupExpectations(t, mock)
})
}
}
func TestResetGroupsRollsBackOnFailure(t *testing.T) {
db, mock, cleanup := newGroupTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectExec("DELETE FROM `node_group`").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE `subscribe`").
WithArgs(int64(0), "[]").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE `nodes`").
WithArgs("[]").
WillReturnError(fmt.Errorf("node update failed"))
mock.ExpectRollback()
logic := newTestResetGroupsLogic(db)
err := logic.ResetGroups()
if err == nil || !strings.Contains(err.Error(), "node update failed") {
t.Fatalf("ResetGroups error = %v, want node update failure", err)
}
assertGroupExpectations(t, mock)
}
func TestPreviewUserNodesIncludesPublicNodesInGroupMode(t *testing.T) {
db, mock, cleanup := newGroupTestDB(t)
defer cleanup()
now := time.Unix(1710000000, 0)
mock.ExpectQuery("FROM `user_subscribe`").
WithArgs(int64(100), int8(0), int8(1)).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subscribe_id", "node_group_id"}).
AddRow(int64(1), int64(100), int64(10), int64(5)))
mock.ExpectQuery("FROM `subscribe`").
WithArgs(int64(10)).
WillReturnRows(sqlmock.NewRows([]string{"id", "node_group_id", "node_group_ids", "nodes", "node_tags"}).
AddRow(int64(10), int64(0), "[]", "", ""))
mock.ExpectQuery("FROM `system`").
WithArgs("group", "enabled").
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("true"))
mock.ExpectQuery("FROM `nodes`").
WithArgs(true).
WillReturnRows(sqlmock.NewRows([]string{
"id", "name", "tags", "port", "address", "server_id", "protocol", "enabled", "sort", "node_group_ids", "created_at", "updated_at",
}).
AddRow(int64(1), "group-node", "", uint16(443), "g.example.com", int64(1), "vless", true, 1, "[5]", now, now).
AddRow(int64(2), "public-node", "", uint16(443), "p.example.com", int64(1), "vless", true, 2, "[]", now, now))
mock.ExpectQuery("FROM `node_group`").
WithArgs(int64(5)).
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(5), "Group A"))
logic := newTestPreviewUserNodesLogic(db)
resp, err := logic.PreviewUserNodes(&types.PreviewUserNodesRequest{UserId: 100})
if err != nil {
t.Fatalf("PreviewUserNodes error: %v", err)
}
if !hasNodeGroup(resp.NodeGroups, 0, "public-node") {
t.Fatalf("PreviewUserNodes node groups = %#v, want public node group", resp.NodeGroups)
}
assertGroupExpectations(t, mock)
}
func TestTrafficRangeMatchingUsesClosedBounds(t *testing.T) {
min0, max10 := int64(0), int64(10)
min10, max20 := int64(10), int64(20)
nodeGroups := []modelgroup.NodeGroup{
{Id: 1, MinTrafficGB: &min0, MaxTrafficGB: &max10},
{Id: 2, MinTrafficGB: &min10, MaxTrafficGB: &max20},
}
tests := map[float64]int64{
0: 1,
10: 1,
15: 2,
21: 0,
}
for used, want := range tests {
if got := matchTrafficNodeGroup(used, nodeGroups); got != want {
t.Fatalf("matchTrafficNodeGroup(%v) = %d, want %d", used, got, want)
}
}
}
func TestNormalizePaginationDefaultsAndMax(t *testing.T) {
tests := []struct {
page, size int
wantPage int
wantSize int
}{
{page: 0, size: 0, wantPage: 1, wantSize: defaultPageSize},
{page: -1, size: -5, wantPage: 1, wantSize: defaultPageSize},
{page: 2, size: maxPageSize + 1, wantPage: 2, wantSize: maxPageSize},
}
for _, tt := range tests {
gotPage, gotSize := normalizePagination(tt.page, tt.size)
if gotPage != tt.wantPage || gotSize != tt.wantSize {
t.Fatalf("normalizePagination(%d, %d) = (%d, %d), want (%d, %d)", tt.page, tt.size, gotPage, gotSize, tt.wantPage, tt.wantSize)
}
}
}
func newGroupTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
if strings.Contains(actualSQL, expectedSQL) {
return nil
}
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}
func newTestGroupHistoryDetailLogic(db *gorm.DB) *GetGroupHistoryDetailLogic {
ctx := context.Background()
return &GetGroupHistoryDetailLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
}
func newTestExportGroupResultLogic(db *gorm.DB) *ExportGroupResultLogic {
ctx := context.Background()
return &ExportGroupResultLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
}
func newTestDeleteNodeGroupLogic(db *gorm.DB) *DeleteNodeGroupLogic {
ctx := context.Background()
return &DeleteNodeGroupLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
}
func newTestResetGroupsLogic(db *gorm.DB) *ResetGroupsLogic {
ctx := context.Background()
return &ResetGroupsLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
}
func newTestPreviewUserNodesLogic(db *gorm.DB) *PreviewUserNodesLogic {
ctx := context.Background()
return &PreviewUserNodesLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
}
func expectDeleteNodeGroupBaseChecks(mock sqlmock.Sqlmock, nodeGroupId int64) {
now := time.Unix(1710000000, 0)
mock.ExpectQuery("FROM `node_group`").
WithArgs(nodeGroupId, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at"}).
AddRow(nodeGroupId, "Group", now, now))
mock.ExpectQuery("FROM `nodes`").
WithArgs(fmt.Sprintf("[%d]", nodeGroupId)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
}
func hasNodeGroup(items []types.NodeGroupItem, id int64, nodeName string) bool {
for _, item := range items {
if item.Id != id {
continue
}
for _, n := range item.Nodes {
if n.Name == nodeName {
return true
}
}
}
return false
}
func assertGroupExpectations(t *testing.T, mock sqlmock.Sqlmock) {
t.Helper()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
-37
View File
@@ -1,37 +0,0 @@
package group
import (
"fmt"
"github.com/perfect-panel/server/internal/model/node"
"gorm.io/gorm"
)
const (
defaultPageSize = 20
maxPageSize = 100
)
func normalizePagination(page, size int) (int, int) {
if page <= 0 {
page = 1
}
if size <= 0 {
size = defaultPageSize
}
if size > maxPageSize {
size = maxPageSize
}
return page, size
}
func countNodesInGroup(db *gorm.DB, nodeGroupId int64) (int, error) {
var count int64
err := db.Model(&node.Node{}).
Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroupId)).
Count(&count).Error
if err != nil {
return 0, err
}
return int(count), nil
}
@@ -196,10 +196,10 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
return nil, err 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,13 +450,9 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ
} }
} }
// 预览模式不显示公共节点(node_group_ids 为空的节点),只展示用户真正所在分组的节点
if len(publicNodes) > 0 { if len(publicNodes) > 0 {
nodeGroupItems = append(nodeGroupItems, types.NodeGroupItem{ logger.Infof("[PreviewUserNodes] skipping %d public nodes (not in user's assigned group)", len(publicNodes))
Id: 0,
Name: "公共节点",
Nodes: publicNodes,
})
logger.Infof("[PreviewUserNodes] adding public nodes group: nodes=%d", len(publicNodes))
} }
} else { } else {
@@ -679,7 +679,21 @@ 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)
targetNodeGroupId := matchTrafficNodeGroup(usedTrafficGB, nodeGroups) // 查找匹配的流量范围(使用左闭右开区间 [Min, Max))
var targetNodeGroupId int64 = 0
for _, ng := range nodeGroups {
if ng.MinTrafficGB == nil || ng.MaxTrafficGB == nil {
continue
}
minTraffic := float64(*ng.MinTrafficGB)
maxTraffic := float64(*ng.MaxTrafficGB)
// 检查是否在区间内 [min, max)
if usedTrafficGB >= minTraffic && usedTrafficGB < maxTraffic {
targetNodeGroupId = ng.Id
break
}
}
// 如果没有匹配到任何范围,targetNodeGroupId 保持为 0(不分配节点组) // 如果没有匹配到任何范围,targetNodeGroupId 保持为 0(不分配节点组)
@@ -720,14 +734,7 @@ 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 {
count, err := countNodesInGroup(tx, ng.Id) nodeGroupCount[ng.Id] = 1 // 每个节点组计为1
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 {
@@ -757,20 +764,6 @@ 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 {
+46 -57
View File
@@ -7,10 +7,8 @@ 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 {
@@ -29,64 +27,55 @@ func NewResetGroupsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Reset
} }
func (l *ResetGroupsLogic) ResetGroups() error { func (l *ResetGroupsLogic) ResetGroups() error {
err := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error { // 1. Delete all node groups
// 1. Delete all node groups err := l.svcCtx.DB.Where("1 = 1").Delete(&group.NodeGroup{}).Error
if err := tx.Where("1 = 1").Delete(&group.NodeGroup{}).Error; err != nil {
l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully deleted all node groups")
// 2. Clear node_group_id/node_group_ids for all subscribes (products)
if err := tx.Table((&subscribe.Subscribe{}).TableName()).Where("1 = 1").Updates(map[string]interface{}{
"node_group_id": 0,
"node_group_ids": "[]",
}).Error; err != nil {
l.Errorw("Failed to clear subscribes' node groups", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully cleared all subscribes' node groups")
// 3. Clear node_group_ids for all nodes
if err := tx.Table((&node.Node{}).TableName()).Where("1 = 1").Update("node_group_ids", "[]").Error; err != nil {
l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully cleared all nodes' node_group_ids")
// 4. Clear user_subscribe node_group_id
if err := tx.Table((&user.Subscribe{}).TableName()).Where("1 = 1").Update("node_group_id", 0).Error; err != nil {
l.Errorw("Failed to clear user subscribes' node_group_id", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully cleared all user subscribes' node_group_id")
// 5. Clear group history
if err := tx.Where("1 = 1").Delete(&group.GroupHistory{}).Error; err != nil {
l.Errorw("Failed to clear group history", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully cleared group history")
// 6. Clear group history details
if err := tx.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error; err != nil {
l.Errorw("Failed to clear group history details", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully cleared group history details")
// 7. Delete all group config settings
if err := tx.Where("`category` = ?", "group").Delete(&system.System{}).Error; err != nil {
l.Errorw("Failed to delete group config", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully deleted all group config settings")
return nil
})
if err != nil { if err != nil {
l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error()))
return err return err
} }
l.Infow("Successfully deleted all node groups")
// 2. Clear node_group_ids for all subscribes (products)
err = l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("1 = 1").Update("node_group_ids", "[]").Error
if err != nil {
l.Errorw("Failed to clear subscribes' node_group_ids", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully cleared all subscribes' node_group_ids")
// 3. Clear node_group_ids for all nodes
err = l.svcCtx.DB.Model(&node.Node{}).Where("1 = 1").Update("node_group_ids", "[]").Error
if err != nil {
l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully cleared all nodes' node_group_ids")
// 4. Clear group history
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistory{}).Error
if err != nil {
l.Errorw("Failed to clear group history", logger.Field("error", err.Error()))
// Non-critical error, continue anyway
} else {
l.Infow("Successfully cleared group history")
}
// 7. Clear group history details
err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error
if err != nil {
l.Errorw("Failed to clear group history details", logger.Field("error", err.Error()))
// Non-critical error, continue anyway
} else {
l.Infow("Successfully cleared group history details")
}
// 5. Delete all group config settings
err = l.svcCtx.DB.Where("`category` = ?", "group").Delete(&system.System{}).Error
if err != nil {
l.Errorw("Failed to delete group config", logger.Field("error", err.Error()))
return err
}
l.Infow("Successfully deleted all group config settings")
l.Infow("Group reset completed successfully") l.Infow("Group reset completed successfully")
return nil return nil
+7 -17
View File
@@ -5,7 +5,6 @@ import (
"slices" "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"
@@ -189,35 +188,26 @@ func fillGiftBenefits(ctx context.Context, db *gorm.DB, benefits map[int64]Benef
return nil return nil
} }
type DeviceIdentifier struct { func QueryIdentifiers(ctx context.Context, db *gorm.DB, userIds []int64) (map[int64]string, error) {
Identifier string identifiers := make(map[int64]string, len(userIds))
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_device ud"). Table("user_auth_methods uam").
Select("ud.user_id, ud.id as device_id, ud.identifier as identifier"). Select("uam.user_id, uam.auth_identifier as identifier").
Joins("JOIN (SELECT user_id, MIN(id) AS id FROM user_device WHERE user_id IN ? GROUP BY user_id) first_ud ON first_ud.id = ud.id", userIds). Joins("JOIN (SELECT user_id, MIN(id) AS id FROM user_auth_methods WHERE user_id IN ? GROUP BY user_id) first_uam ON first_uam.id = uam.id", userIds).
Scan(&rows).Error; err != nil { Scan(&rows).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user device identifiers failed: %v", err) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user identifiers failed: %v", err)
} }
for _, row := range rows { for _, row := range rows {
identifiers[row.UserId] = DeviceIdentifier{ identifiers[row.UserId] = row.Identifier
Identifier: row.Identifier,
DeviceNo: tool.DeviceIdToHash(row.DeviceId),
}
} }
return identifiers, nil return identifiers, nil
} }
@@ -7,7 +7,6 @@ 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"
) )
@@ -74,39 +73,6 @@ 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
} }
devices, err := QueryDeviceIdentifiers(l.ctx, l.svcCtx.DB, userIds) identifiers, err := QueryIdentifiers(l.ctx, l.svcCtx.DB, userIds)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -78,11 +78,9 @@ 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: devices[row.InviterId].Identifier, InviterIdentifier: identifiers[row.InviterId],
InviterDeviceNo: devices[row.InviterId].DeviceNo,
InviteeId: row.InviteeId, InviteeId: row.InviteeId,
InviteeIdentifier: devices[row.InviteeId].Identifier, InviteeIdentifier: identifiers[row.InviteeId],
InviteeDeviceNo: devices[row.InviteeId].DeviceNo,
InviteeAvatar: row.InviteeAvatar, InviteeAvatar: row.InviteeAvatar,
InviteeEnable: row.InviteeEnable, InviteeEnable: row.InviteeEnable,
InvitedAt: row.InvitedAt, InvitedAt: row.InvitedAt,
@@ -1,471 +0,0 @@
// 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 → rejectedexpires_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
}
}
// overduepending_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
}
@@ -1,122 +0,0 @@
// 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)
}
}
-688
View File
@@ -1,688 +0,0 @@
// 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:])
}
@@ -1,134 +0,0 @@
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)
}
}
@@ -1,51 +0,0 @@
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