Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de388ac1ef | |||
| 6157b2c571 | |||
| 268ae1ebf8 | |||
| f5ad26b943 | |||
| 9b5ff89ee8 | |||
| e74958e17f | |||
| 21811f4d63 | |||
| 24caf58987 | |||
| b98d718f3c | |||
| bcb8cd222c | |||
| 34cd1c524e | |||
| 377f13da48 | |||
| 5e4cc33ff6 | |||
| 53fb541846 | |||
| f6f2ca9a29 | |||
| 19d28a8f89 | |||
| 8ff992e74c | |||
| 84d222576a | |||
| cfc9cf790b | |||
| c540091ef9 | |||
| c837999573 | |||
| e33af1450b | |||
| e567821e07 | |||
| 5e30794db1 | |||
| ccbdab55aa | |||
| ed181886dd | |||
| 9ddd8257c5 | |||
| fc6f193479 | |||
| 3df59ef345 | |||
| 87ebfa1fac | |||
| ac25eb4d91 | |||
| 54379976ec | |||
| 750b7be424 | |||
| aa11588c8f | |||
| c3050821d5 | |||
| 5b9f384f81 | |||
| 7236ca4cf2 | |||
| ae126296e3 | |||
| 1e99cfb83c | |||
| 0659a930f8 | |||
| c2d1b5a0d8 | |||
| 3644e9ce3f | |||
| e5d6539d79 | |||
| e17dc4a273 | |||
| b162022d39 | |||
| 075c1215ca | |||
| 8ba4471791 | |||
| 3e265bd837 | |||
| fefbd4f56a | |||
| 197fed7d12 | |||
| f452f80100 | |||
| 1022160ff8 | |||
| 82eff47f38 | |||
| d351b50066 | |||
| 4366a9be8b | |||
| b5e50d1ee5 | |||
| 02b41e7a2c | |||
| d12c340743 | |||
| c90edac630 | |||
| b9192db042 | |||
| 48e507783e | |||
| d6efcb8e0b | |||
| d2f9289338 | |||
| 92e303aaa7 | |||
| 54328b197d | |||
| 5ebfe0a981 | |||
| 22d9773f1b | |||
| d89798f001 | |||
| 81c3059892 | |||
| f9fa4756e9 | |||
| 27f1203282 | |||
| f7f890c990 | |||
| 8b4e7561f4 | |||
| a6e9e2bdb8 | |||
| b798f520c8 | |||
| bae234fe15 | |||
| 79ab4460bc | |||
| 0169a16ada | |||
| b6b5bccde6 | |||
| eba256bdc9 | |||
| 72f2b94263 | |||
| bb67ebcb79 | |||
| 0bd7560b64 | |||
| a0f2d8a7b8 | |||
| 30221232c9 | |||
| 80751eb8ef | |||
| 3bbce5ce84 | |||
| 1726a584fa |
@@ -1,25 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var c config.Config
|
||||
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||
ctx := svc.NewServiceContext(c)
|
||||
const key = "cache:auth:method:device"
|
||||
before, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result()
|
||||
fmt.Printf("cache before=%q\n", before)
|
||||
m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
|
||||
fmt.Printf("model err=%v enabled_nil=%v", err, m == nil || m.Enabled == nil)
|
||||
if m != nil && m.Enabled != nil { fmt.Printf(" enabled=%v", *m.Enabled) }
|
||||
if m != nil { fmt.Printf(" config=%s", m.Config) }
|
||||
fmt.Println()
|
||||
after, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result()
|
||||
fmt.Printf("cache after=%q\n", after)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
initpkg "github.com/perfect-panel/server/initialize"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var c config.Config
|
||||
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||
ctx := svc.NewServiceContext(c)
|
||||
method, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("db auth_method.enabled=%v config=%s\n", *method.Enabled, method.Config)
|
||||
initpkg.Device(ctx)
|
||||
fmt.Printf("ctx.Config.Device.Enable=%v SecuritySecret=%q EnableSecurity=%v\n", ctx.Config.Device.Enable, ctx.Config.Device.SecuritySecret, ctx.Config.Device.EnableSecurity)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
ID int64
|
||||
Method string
|
||||
Enabled int
|
||||
Config string
|
||||
}
|
||||
|
||||
func main() {
|
||||
var c config.Config
|
||||
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||
ctx := svc.NewServiceContext(c)
|
||||
|
||||
var rows []row
|
||||
if err := ctx.DB.Raw("SELECT id, method, enabled, config FROM auth_method WHERE method = ?", "device").Scan(&rows).Error; err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("raw rows: %+v\n", rows)
|
||||
|
||||
m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
|
||||
fmt.Printf("model err=%v\n", err)
|
||||
if err == nil && m != nil && m.Enabled != nil {
|
||||
fmt.Printf("model row: id=%d method=%s enabled=%v config=%s\n", m.Id, m.Method, *m.Enabled, m.Config)
|
||||
} else {
|
||||
fmt.Printf("model row nil or no enabled ptr: %#v\n", m)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
authmodel "github.com/perfect-panel/server/internal/model/auth"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var c config.Config
|
||||
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||
ctx := svc.NewServiceContext(c)
|
||||
|
||||
var a1 authmodel.Auth
|
||||
err1 := ctx.DB.Model(&authmodel.Auth{}).Where("method = ?", "device").First(&a1).Error
|
||||
fmt.Printf("gorm direct err=%v enabled_nil=%v", err1, a1.Enabled == nil)
|
||||
if a1.Enabled != nil { fmt.Printf(" enabled=%v", *a1.Enabled) }
|
||||
fmt.Printf(" config=%s\n", a1.Config)
|
||||
|
||||
var a2 authmodel.Auth
|
||||
err2 := ctx.DB.Table("auth_method").Where("method = ?", "device").First(&a2).Error
|
||||
fmt.Printf("gorm table err=%v enabled_nil=%v", err2, a2.Enabled == nil)
|
||||
if a2.Enabled != nil { fmt.Printf(" enabled=%v", *a2.Enabled) }
|
||||
fmt.Printf(" config=%s\n", a2.Config)
|
||||
}
|
||||
+2
-15
@@ -1,18 +1,5 @@
|
||||
# 复制此文件为 .env 并填写真实值
|
||||
# cp .env.example .env
|
||||
|
||||
# MySQL root 密码(同时需要在 configs/ppanel.yaml 的 MySQL.Password 中填写相同的值)
|
||||
MYSQL_ROOT_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||
|
||||
# Grafana 管理员密码
|
||||
GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||
|
||||
# PPanel Server 镜像标签(留空使用 latest)
|
||||
PPANEL_SERVER_TAG=latest
|
||||
|
||||
# AWS 区域(香港)
|
||||
AWS_REGION=ap-east-1
|
||||
|
||||
# Grafana 公开域名(如需反代)
|
||||
GRAFANA_DOMAIN=logs-new.hifast.biz
|
||||
GRAFANA_ROOT_URL=https://logs-new.hifast.biz
|
||||
# PPanel Server 镜像标签(由 CI/CD 传入不可变 tag,如 git SHA)
|
||||
PPANEL_SERVER_TAG=CHANGE_ME_TO_GIT_SHA
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
name: Build docker and publish
|
||||
run-name: 简化的Docker构建和部署流程
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- internal
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- internal
|
||||
|
||||
env:
|
||||
# Docker镜像仓库
|
||||
REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }}
|
||||
# SSH连接信息 (根据分支自动选择服务器和用户)
|
||||
SSH_HOST: ${{ github.ref_name == 'main' && vars.SSH_HOST || vars.DEV_SSH_HOST }}
|
||||
SSH_PORT: ${{ vars.SSH_PORT }}
|
||||
SSH_USER: ${{ github.ref_name == 'main' && 'ubuntu' || 'root' }}
|
||||
# SSH私钥(Gitea Secret 名称:AWS)
|
||||
SSH_KEY: ${{ secrets.AWS }}
|
||||
# TG通知
|
||||
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
||||
TG_CHAT_ID: "-4940243803"
|
||||
# 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 "为 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 "为 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 "为其他分支 (${{ 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 }}"
|
||||
|
||||
# 构建镜像,同时打上版本和分支两个标签
|
||||
docker build -f Dockerfile \
|
||||
--platform linux/amd64 \
|
||||
--build-arg TARGETARCH=amd64 \
|
||||
--build-arg VERSION=${{ env.VERSION }} \
|
||||
--build-arg BUILDTIME=${{ env.BUILDTIME }} \
|
||||
-t ${{ env.REPO }}:${{ env.VERSION }} \
|
||||
-t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }} \
|
||||
.
|
||||
|
||||
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 "镜像推送完成"
|
||||
|
||||
# 调试: 打印部署目标(不输出敏感信息)
|
||||
- name: 🔍 调试 - 打印部署目标
|
||||
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 "====================================="
|
||||
|
||||
# 步骤5: 传输配置文件
|
||||
- name: 📂 传输配置文件
|
||||
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/"
|
||||
|
||||
# 步骤6: 连接服务器更新并启动
|
||||
- name: 🚀 连接服务器更新并启动
|
||||
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: |
|
||||
echo "连接服务器成功,开始部署..."
|
||||
echo "部署目录: ${{ env.DEPLOY_PATH }}"
|
||||
echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
||||
echo "登录用户: ${{ env.SSH_USER }}"
|
||||
|
||||
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
|
||||
cd ${{ env.DEPLOY_PATH }}
|
||||
echo "📥 拉取镜像..."
|
||||
sudo docker-compose -f docker-compose.cloud.yml pull ppanel-server
|
||||
echo "🚀 启动服务..."
|
||||
sudo docker-compose -f docker-compose.cloud.yml up -d ppanel-server
|
||||
sudo docker image prune -f || true
|
||||
else
|
||||
mkdir -p ${{ env.DEPLOY_PATH }}
|
||||
cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
|
||||
cd ${{ env.DEPLOY_PATH }}
|
||||
echo "📥 拉取镜像..."
|
||||
docker-compose -f docker-compose.cloud.yml pull ppanel-server
|
||||
echo "🚀 启动服务..."
|
||||
docker-compose -f docker-compose.cloud.yml up -d ppanel-server
|
||||
docker image prune -f || true
|
||||
fi
|
||||
|
||||
echo "✅ 部署命令执行完成"
|
||||
|
||||
# 步骤6: TG通知 (成功)
|
||||
- name: 📱 发送成功通知到Telegram
|
||||
if: success()
|
||||
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 }}
|
||||
|
||||
🚀 服务已成功部署到生产环境
|
||||
parse_mode: Markdown
|
||||
|
||||
# 步骤5: TG通知 (失败)
|
||||
- name: 📱 发送失败通知到Telegram
|
||||
if: failure()
|
||||
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 }}
|
||||
|
||||
⚠️ 请检查构建日志获取详细信息
|
||||
parse_mode: Markdown
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Code owners — 自动 request review
|
||||
#
|
||||
# 仓库私有 + 当前 plan 不支持 branch protection(详见 doc/development-workflow-zh.md
|
||||
# 「平台层约束的现状」一节),CODEOWNERS 在此用作"自动 request review + 显性责任划分",
|
||||
# 而非强制门禁。
|
||||
#
|
||||
# 任何 PR 默认 request 给 @shanshanzhong147 (owner) review。若后续引入团队
|
||||
# handle(例如 @TawCorp/backend),把对应 path 改成 team handle 即可。
|
||||
|
||||
# 全部路径 — owner 默认 reviewer
|
||||
* @shanshanzhong147
|
||||
|
||||
# 部署/CI/Docker — 改这些要再确认一次(涉及生产部署链路)
|
||||
/.github/ @shanshanzhong147
|
||||
/Dockerfile @shanshanzhong147
|
||||
/docker-compose.*.yml @shanshanzhong147
|
||||
/scripts/ @shanshanzhong147
|
||||
/Makefile @shanshanzhong147
|
||||
|
||||
# 流程文档自身 — 改这里就是改流程
|
||||
/CONTRIBUTING.md @shanshanzhong147
|
||||
/CONTRIBUTING_ZH.md @shanshanzhong147
|
||||
/doc/development-workflow-zh.md @shanshanzhong147
|
||||
/.github/CODEOWNERS @shanshanzhong147
|
||||
@@ -0,0 +1,51 @@
|
||||
<!--
|
||||
完整流程见 doc/development-workflow-zh.md
|
||||
-->
|
||||
|
||||
## 关联 Issue
|
||||
|
||||
Closes HIF-XXX
|
||||
<!-- 如关联多个:Closes HIF-XXX, Closes HIF-YYY -->
|
||||
|
||||
## 改动摘要
|
||||
|
||||
<!-- 1-3 句话说清楚做了什么、为什么 -->
|
||||
|
||||
## 改动细节
|
||||
|
||||
<!-- 按文件/模块逐条列;引用代码用 `file.go:行号` 格式 -->
|
||||
|
||||
-
|
||||
-
|
||||
|
||||
## 测试计划
|
||||
|
||||
- [ ] `go build ./...` 通过
|
||||
- [ ] `go vet ./...` 通过
|
||||
- [ ] `go test -race ./... -count=1` 通过
|
||||
- [ ] golangci-lint 通过
|
||||
- [ ] 新增/修改的逻辑有对应单测覆盖
|
||||
- [ ] (如涉及 DB 变更)migration up/down 双向验证
|
||||
- [ ] (如涉及 API)curl / Postman 验证命令贴在下面
|
||||
|
||||
<!-- 贴 curl 或测试输出 -->
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
## 风险 / 回滚
|
||||
|
||||
<!-- 这次改动失败时怎么回滚;是否影响线上数据;是否需要 feature flag -->
|
||||
|
||||
-
|
||||
|
||||
## Reviewer 自检清单
|
||||
|
||||
- [ ] PR 标题符合 commitlint 规范(`修复/新功能/重构/文档/配置(#<num>): ...`)
|
||||
- [ ] 分支命名 `fix/<num>-…` / `feat/<num>-…` / `chore/…`
|
||||
- [ ] 目标分支 = `internal`
|
||||
- [ ] 改动 scope 与 Issue 描述一致,无 scope creep
|
||||
- [ ] **无无关代码改动**(架构师红线)
|
||||
- [ ] 无密钥/凭证泄露
|
||||
- [ ] CI 全绿
|
||||
- [ ] 测试工程师已验收(如涉及业务逻辑)
|
||||
@@ -1,27 +0,0 @@
|
||||
# Production Environment Configuration for GitHub Actions
|
||||
# This file defines production-specific deployment settings
|
||||
|
||||
environment:
|
||||
name: production
|
||||
url: https://api.ppanel.example.com
|
||||
protection_rules:
|
||||
- type: wait_timer
|
||||
minutes: 5
|
||||
- type: reviewers
|
||||
reviewers:
|
||||
- "@admin-team"
|
||||
- "@devops-team"
|
||||
variables:
|
||||
ENVIRONMENT: production
|
||||
LOG_LEVEL: info
|
||||
DEPLOY_TIMEOUT: 300
|
||||
|
||||
# Environment-specific secrets required:
|
||||
# PRODUCTION_HOST - Production server hostname/IP
|
||||
# PRODUCTION_USER - SSH username for production server
|
||||
# PRODUCTION_SSH_KEY - SSH private key for production server
|
||||
# PRODUCTION_PORT - SSH port (default: 22)
|
||||
# PRODUCTION_URL - Application URL for health checks
|
||||
# DATABASE_PASSWORD - Production database password
|
||||
# REDIS_PASSWORD - Production Redis password
|
||||
# JWT_SECRET - JWT secret key for production
|
||||
@@ -1,23 +0,0 @@
|
||||
# Staging Environment Configuration for GitHub Actions
|
||||
# This file defines staging-specific deployment settings
|
||||
|
||||
environment:
|
||||
name: staging
|
||||
url: https://staging-api.ppanel.example.com
|
||||
protection_rules:
|
||||
- type: wait_timer
|
||||
minutes: 2
|
||||
variables:
|
||||
ENVIRONMENT: staging
|
||||
LOG_LEVEL: debug
|
||||
DEPLOY_TIMEOUT: 180
|
||||
|
||||
# Environment-specific secrets required:
|
||||
# STAGING_HOST - Staging server hostname/IP
|
||||
# STAGING_USER - SSH username for staging server
|
||||
# STAGING_SSH_KEY - SSH private key for staging server
|
||||
# STAGING_PORT - SSH port (default: 22)
|
||||
# STAGING_URL - Application URL for health checks
|
||||
# DATABASE_PASSWORD - Staging database password
|
||||
# REDIS_PASSWORD - Staging Redis password
|
||||
# JWT_SECRET - JWT secret key for staging
|
||||
@@ -0,0 +1,72 @@
|
||||
name: 持续集成
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- internal
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: 构建/Vet/测试
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: 配置 Go 环境
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: 下载依赖模块
|
||||
run: go mod download
|
||||
|
||||
- name: 构建
|
||||
run: go build ./...
|
||||
|
||||
- name: 运行 go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: 运行测试
|
||||
run: go test -race -count=1 ./...
|
||||
|
||||
lint:
|
||||
name: golangci-lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Fetch base ref so golangci-lint can diff against it for only-new-issues.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 配置 Go 环境
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout=5m
|
||||
# Legacy codebase has ~47 pre-existing lint issues (errcheck / unused
|
||||
# carried over from upstream perfect-panel/server). Only flag NEW
|
||||
# issues introduced by this PR so CI stays useful without forcing a
|
||||
# mass cleanup. Backlog cleanup tracked separately.
|
||||
only-new-issues: true
|
||||
@@ -1,79 +0,0 @@
|
||||
name: Build Linux Binary
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to build (leave empty for auto)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Linux Binary
|
||||
runs-on: ario-server
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23.3'
|
||||
cache: true
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: linux
|
||||
GOARCH: amd64
|
||||
run: |
|
||||
VERSION=${{ github.event.inputs.version }}
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
fi
|
||||
|
||||
echo "Building ppanel-server $VERSION"
|
||||
BUILD_TIME=$(date +"%Y-%m-%d_%H:%M:%S")
|
||||
go build -ldflags="-w -s -X github.com/perfect-panel/server/pkg/constant.Version=$VERSION -X github.com/perfect-panel/server/pkg/constant.BuildTime=$BUILD_TIME" -o ppanel-server ./ppanel.go
|
||||
tar -czf ppanel-server-${VERSION}-linux-amd64.tar.gz ppanel-server
|
||||
sha256sum ppanel-server ppanel-server-${VERSION}-linux-amd64.tar.gz > checksum.txt
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ppanel-server-linux-amd64
|
||||
path: |
|
||||
ppanel-server
|
||||
ppanel-server-*-linux-amd64.tar.gz
|
||||
checksum.txt
|
||||
|
||||
- name: Create Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
|
||||
# Check if release exists
|
||||
if gh release view $VERSION >/dev/null 2>&1; then
|
||||
echo "Release $VERSION already exists, deleting old assets..."
|
||||
# Delete existing assets if they exist
|
||||
gh release delete-asset $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz --yes 2>/dev/null || true
|
||||
gh release delete-asset $VERSION checksum.txt --yes 2>/dev/null || true
|
||||
else
|
||||
echo "Creating new release $VERSION..."
|
||||
gh release create $VERSION --title "PPanel Server $VERSION" --notes "Release $VERSION"
|
||||
fi
|
||||
|
||||
# Upload assets (will overwrite if --clobber is supported, otherwise will fail gracefully)
|
||||
echo "Uploading assets..."
|
||||
gh release upload $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz checksum.txt --clobber
|
||||
@@ -0,0 +1,253 @@
|
||||
name: 测试环境部署
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- internal
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-staging-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY_HOST: ${{ vars.REGISTRY_HOST || 'registry.kxsw.us' }}
|
||||
IMAGE_NAME: ${{ vars.REGISTRY_IMAGE || 'registry.kxsw.us/vpn-server' }}
|
||||
STAGING_HOST: ${{ vars.STAGING_HOST || '154.12.35.103' }}
|
||||
STAGING_DEPLOY_PATH: ${{ vars.STAGING_DEPLOY_PATH || '/opt/hifast-server' }}
|
||||
STAGING_HEALTHCHECK_URL: ${{ vars.STAGING_HEALTHCHECK_URL || 'http://127.0.0.1:8080/v1/common/heartbeat' }}
|
||||
# 关闭 docker/build-push-action 自动生成的英文 job summary
|
||||
# 我们用 Telegram 通知传递部署结果,不需要 GitHub Actions 页面上那段英文
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
name: 构建镜像并部署到测试环境
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 收集发布说明
|
||||
id: release-notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
|
||||
RANGE="${{ github.event.before }}..${{ github.sha }}"
|
||||
else
|
||||
RANGE="-5"
|
||||
fi
|
||||
|
||||
NOTES="$(git log "$RANGE" --pretty=format:'- %h %s' --no-merges | head -20)"
|
||||
if [ -z "$NOTES" ]; then
|
||||
NOTES="$(git log -1 --pretty=format:'- %h %s')"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "notes<<EOF"
|
||||
echo "$NOTES"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 配置 Go 环境
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: 运行测试
|
||||
run: go test ./...
|
||||
|
||||
- name: 配置 Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: 构建并推送镜像
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
build-args: |
|
||||
TARGETARCH=amd64
|
||||
VERSION=${{ github.sha }}
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
${{ env.IMAGE_NAME }}:staging
|
||||
|
||||
- name: 上传 compose 配置
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
host: ${{ env.STAGING_HOST }}
|
||||
username: ${{ secrets.STAGING_SSH_USER }}
|
||||
password: ${{ secrets.STAGING_SSH_PASSWORD }}
|
||||
port: ${{ secrets.STAGING_SSH_PORT || 22 }}
|
||||
source: docker-compose.cloud.yml
|
||||
target: /tmp/hifast-server-deploy/
|
||||
|
||||
- name: 在测试服务器上部署
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
with:
|
||||
host: ${{ env.STAGING_HOST }}
|
||||
username: ${{ secrets.STAGING_SSH_USER }}
|
||||
password: ${{ secrets.STAGING_SSH_PASSWORD }}
|
||||
port: ${{ secrets.STAGING_SSH_PORT || 22 }}
|
||||
timeout: 300s
|
||||
command_timeout: 600s
|
||||
script: |
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_NAME="${{ env.IMAGE_NAME }}"
|
||||
NEW_TAG="${{ github.sha }}"
|
||||
DEPLOY_PATH="${{ env.STAGING_DEPLOY_PATH }}"
|
||||
HEALTHCHECK_URL="${{ env.STAGING_HEALTHCHECK_URL }}"
|
||||
ROLLBACK_TAG="rollback-${NEW_TAG}"
|
||||
|
||||
if command -v sudo >/dev/null 2>&1 && ! docker ps >/dev/null 2>&1; then
|
||||
SUDO="sudo"
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
docker_cmd() {
|
||||
if [ -n "$SUDO" ]; then
|
||||
sudo docker "$@"
|
||||
else
|
||||
docker "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
compose_cmd() {
|
||||
tag="$1"
|
||||
shift
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
if [ -n "$SUDO" ]; then
|
||||
sudo env PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker compose -f docker-compose.cloud.yml "$@"
|
||||
else
|
||||
PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker compose -f docker-compose.cloud.yml "$@"
|
||||
fi
|
||||
else
|
||||
if [ -n "$SUDO" ]; then
|
||||
sudo env PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
|
||||
else
|
||||
PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
health_check() {
|
||||
attempt=1
|
||||
while [ "$attempt" -le 6 ]; do
|
||||
if curl -fsS "$HEALTHCHECK_URL"; then
|
||||
echo
|
||||
return 0
|
||||
fi
|
||||
echo "Health check ${attempt}/6 failed; retrying in 10s..."
|
||||
attempt=$((attempt + 1))
|
||||
sleep 10
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ -n "$SUDO" ]; then
|
||||
sudo mkdir -p "$DEPLOY_PATH"
|
||||
sudo cp /tmp/hifast-server-deploy/docker-compose.cloud.yml "$DEPLOY_PATH/docker-compose.cloud.yml"
|
||||
else
|
||||
mkdir -p "$DEPLOY_PATH"
|
||||
cp /tmp/hifast-server-deploy/docker-compose.cloud.yml "$DEPLOY_PATH/docker-compose.cloud.yml"
|
||||
fi
|
||||
|
||||
cd "$DEPLOY_PATH"
|
||||
|
||||
PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)"
|
||||
echo "Previous image ID: ${PREVIOUS_IMAGE_ID:-none}"
|
||||
|
||||
echo "Pulling ${IMAGE_NAME}:${NEW_TAG}"
|
||||
compose_cmd "$NEW_TAG" pull ppanel-server
|
||||
|
||||
echo "Starting ppanel-server"
|
||||
compose_cmd "$NEW_TAG" up -d ppanel-server
|
||||
|
||||
echo "Checking ${HEALTHCHECK_URL}"
|
||||
if health_check; then
|
||||
docker_cmd image prune -f || true
|
||||
echo "Staging deployment succeeded"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Health check failed; attempting rollback"
|
||||
if [ -n "$PREVIOUS_IMAGE_ID" ]; then
|
||||
docker_cmd tag "$PREVIOUS_IMAGE_ID" "${IMAGE_NAME}:${ROLLBACK_TAG}"
|
||||
compose_cmd "$ROLLBACK_TAG" up -d ppanel-server
|
||||
health_check || true
|
||||
else
|
||||
echo "No previous image found; rollback skipped"
|
||||
fi
|
||||
|
||||
docker_cmd image prune -f || true
|
||||
exit 1
|
||||
|
||||
- name: Telegram 成功通知
|
||||
if: success()
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram 密钥未配置,跳过通知。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MESSAGE="$(cat <<'EOF'
|
||||
✅ 测试环境发布成功
|
||||
仓库:${{ github.repository }}
|
||||
分支:${{ github.ref_name }}
|
||||
提交:${{ github.sha }}
|
||||
操作人:${{ github.actor }}
|
||||
镜像:${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
本次更新:
|
||||
${{ steps.release-notes.outputs.notes }}
|
||||
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EOF
|
||||
)"
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,但发布结果不受影响。"
|
||||
|
||||
- name: Telegram 失败通知
|
||||
if: failure()
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram 密钥未配置,跳过通知。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MESSAGE="$(cat <<'EOF'
|
||||
❌ 测试环境发布失败
|
||||
仓库:${{ github.repository }}
|
||||
分支:${{ github.ref_name }}
|
||||
提交:${{ github.sha }}
|
||||
操作人:${{ github.actor }}
|
||||
镜像:${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
本次更新:
|
||||
${{ steps.release-notes.outputs.notes }}
|
||||
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EOF
|
||||
)"
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,工作流失败状态已记录。"
|
||||
@@ -0,0 +1,244 @@
|
||||
name: 发布验收测试
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- 测试环境部署
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- internal
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: release-acceptance
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
ACCEPTANCE_ARTIFACT_NAME: release-acceptance-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
ACCEPTANCE_REPORT_DIR: acceptance-artifacts
|
||||
ACCEPTANCE_REPORT_PATH: acceptance-artifacts/acceptance-report.json
|
||||
ACCEPTANCE_TEST_JSON: acceptance-artifacts/go-test.json
|
||||
ACCEPTANCE_FAILURE_LOG: acceptance-artifacts/failure.log
|
||||
ACCEPTANCE_NODE_SERVER_ID: ${{ vars.ACCEPTANCE_NODE_SERVER_ID || '31' }}
|
||||
ACCEPTANCE_NODE_PROTOCOL: ${{ vars.ACCEPTANCE_NODE_PROTOCOL || 'trojan' }}
|
||||
|
||||
jobs:
|
||||
acceptance:
|
||||
name: Staging acceptance
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL }}
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
)
|
||||
}}
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: 配置 Go 环境
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: 准备报告目录
|
||||
run: mkdir -p "$ACCEPTANCE_REPORT_DIR"
|
||||
|
||||
- name: 校验必需配置
|
||||
env:
|
||||
ACCEPTANCE_ADMIN_EMAIL: ${{ secrets.ACCEPTANCE_ADMIN_EMAIL }}
|
||||
ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }}
|
||||
ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }}
|
||||
ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }}
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL }}
|
||||
STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }}
|
||||
STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
|
||||
STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
|
||||
STAGING_DB_NAME: ${{ secrets.STAGING_DB_NAME }}
|
||||
STAGING_REDIS_ADDR: ${{ secrets.STAGING_REDIS_ADDR }}
|
||||
STAGING_REDIS_PASSWORD: ${{ secrets.STAGING_REDIS_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
missing=()
|
||||
required=(
|
||||
ACCEPTANCE_ADMIN_EMAIL
|
||||
ACCEPTANCE_ADMIN_PASSWORD
|
||||
ACCEPTANCE_USER_EMAIL
|
||||
ACCEPTANCE_USER_PASSWORD
|
||||
STAGING_BASE_URL
|
||||
STAGING_DB_HOST
|
||||
STAGING_DB_USER
|
||||
STAGING_DB_PASSWORD
|
||||
STAGING_DB_NAME
|
||||
STAGING_REDIS_ADDR
|
||||
STAGING_REDIS_PASSWORD
|
||||
)
|
||||
|
||||
for key in "${required[@]}"; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
missing+=("$key")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
printf '缺少必需 GitHub Actions secrets/vars:\n' | tee "$ACCEPTANCE_FAILURE_LOG"
|
||||
printf -- '- %s\n' "${missing[@]}" | tee -a "$ACCEPTANCE_FAILURE_LOG"
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "状态:配置缺失"
|
||||
echo
|
||||
echo "缺少以下 secrets/vars:"
|
||||
printf -- '- `%s`\n' "${missing[@]}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 下载依赖模块
|
||||
run: go mod download
|
||||
|
||||
- name: 运行 acceptance 测试
|
||||
env:
|
||||
ACCEPTANCE_ADMIN_EMAIL: ${{ secrets.ACCEPTANCE_ADMIN_EMAIL }}
|
||||
ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }}
|
||||
ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }}
|
||||
ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }}
|
||||
ACCEPTANCE_NODE_SECRET: ${{ secrets.ACCEPTANCE_NODE_SECRET }}
|
||||
ACCEPTANCE_RUN_ID: qa_${{ github.run_id }}_${{ github.run_attempt }}
|
||||
STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }}
|
||||
STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
|
||||
STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
|
||||
STAGING_DB_NAME: ${{ secrets.STAGING_DB_NAME }}
|
||||
STAGING_REDIS_ADDR: ${{ secrets.STAGING_REDIS_ADDR }}
|
||||
STAGING_REDIS_PASSWORD: ${{ secrets.STAGING_REDIS_PASSWORD }}
|
||||
STAGING_REDIS_DB: ${{ vars.STAGING_REDIS_DB || '0' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
|
||||
go test -json ./tests/acceptance/... \
|
||||
-staging-url="$STAGING_BASE_URL" \
|
||||
-report-path="$ACCEPTANCE_REPORT_PATH" \
|
||||
> >(tee "$ACCEPTANCE_TEST_JSON") \
|
||||
2> >(tee "$ACCEPTANCE_FAILURE_LOG" >&2)
|
||||
|
||||
- name: 生成测试摘要
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
{
|
||||
echo "## 发布验收测试"
|
||||
echo
|
||||
echo "- 状态:${{ job.status }}"
|
||||
echo "- Staging URL:${STAGING_BASE_URL}"
|
||||
echo "- Artifact:${ACCEPTANCE_ARTIFACT_NAME}"
|
||||
echo "- Run URL:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
if [ "${{ github.event_name }}" = "workflow_run" ]; then
|
||||
echo "- Deploy run:${{ github.event.workflow_run.html_url }}"
|
||||
echo "- Deploy SHA:${{ github.event.workflow_run.head_sha }}"
|
||||
else
|
||||
echo "- 手动触发 SHA:${{ github.sha }}"
|
||||
fi
|
||||
echo
|
||||
} | tee "$ACCEPTANCE_REPORT_DIR/summary.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "${{ job.status }}" = "failure" ]; then
|
||||
if [ -s "$ACCEPTANCE_FAILURE_LOG" ]; then
|
||||
{
|
||||
echo
|
||||
echo "Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "Artifact: ${ACCEPTANCE_ARTIFACT_NAME}"
|
||||
} >> "$ACCEPTANCE_FAILURE_LOG"
|
||||
else
|
||||
{
|
||||
echo "Acceptance 测试失败。"
|
||||
echo "Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "Artifact: ${ACCEPTANCE_ARTIFACT_NAME}"
|
||||
echo
|
||||
tail -100 "$ACCEPTANCE_TEST_JSON" 2>/dev/null || true
|
||||
} > "$ACCEPTANCE_FAILURE_LOG"
|
||||
fi
|
||||
elif [ ! -f "$ACCEPTANCE_FAILURE_LOG" ]; then
|
||||
: > "$ACCEPTANCE_FAILURE_LOG"
|
||||
fi
|
||||
|
||||
- name: 上传 acceptance artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ env.ACCEPTANCE_ARTIFACT_NAME }}
|
||||
path: |
|
||||
${{ env.ACCEPTANCE_REPORT_PATH }}
|
||||
${{ env.ACCEPTANCE_TEST_JSON }}
|
||||
${{ env.ACCEPTANCE_FAILURE_LOG }}
|
||||
${{ env.ACCEPTANCE_REPORT_DIR }}/summary.md
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Telegram 成功通知
|
||||
if: success()
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram 密钥未配置,跳过通知。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MESSAGE="$(cat <<'EOF'
|
||||
✅ 发布验收测试通过
|
||||
仓库:${{ github.repository }}
|
||||
分支:${{ github.event.workflow_run.head_branch || github.ref_name }}
|
||||
提交:${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
Staging:${{ env.STAGING_BASE_URL }}
|
||||
Artifact:${{ env.ACCEPTANCE_ARTIFACT_NAME }}
|
||||
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EOF
|
||||
)"
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,但验收结果不受影响。"
|
||||
|
||||
- name: Telegram 失败通知
|
||||
if: failure()
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then
|
||||
echo "Telegram 密钥未配置,跳过通知。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MESSAGE="$(cat <<'EOF'
|
||||
❌ 发布验收测试失败
|
||||
仓库:${{ github.repository }}
|
||||
分支:${{ github.event.workflow_run.head_branch || github.ref_name }}
|
||||
提交:${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
Staging:${{ env.STAGING_BASE_URL }}
|
||||
Artifact:${{ env.ACCEPTANCE_ARTIFACT_NAME }}
|
||||
运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EOF
|
||||
)"
|
||||
|
||||
curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${TG_CHAT_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,工作流失败状态已记录。"
|
||||
@@ -26,6 +26,13 @@ Thumbs.db
|
||||
*.crt
|
||||
*.key
|
||||
*.pem
|
||||
*.pub
|
||||
*id_rsa*
|
||||
*id_ed25519*
|
||||
*_bak
|
||||
*.go_bak
|
||||
deploy/**/keys/
|
||||
deliverables/
|
||||
|
||||
# ==================== 日志 ====================
|
||||
*.log
|
||||
@@ -35,6 +42,7 @@ logs/
|
||||
# ==================== 测试 ====================
|
||||
/test/
|
||||
*_test.go
|
||||
!tests/acceptance/*_test.go
|
||||
*_test_config.go
|
||||
**/logtest/
|
||||
*_test.yaml
|
||||
@@ -70,6 +78,7 @@ script/*.sh
|
||||
|
||||
# Codex local configuration
|
||||
.codex/
|
||||
.codex-tmp/
|
||||
|
||||
# Claude Flow runtime data
|
||||
.claude-flow/data/
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
project_name: ppanel
|
||||
version: 1
|
||||
release:
|
||||
prerelease: auto
|
||||
builds:
|
||||
- # If true, skip the build.
|
||||
# Useful for library projects.
|
||||
# Default is false
|
||||
skip: true
|
||||
|
||||
changelog:
|
||||
# Set it to true if you wish to skip the changelog generation.
|
||||
# This may result in an empty release notes on GitHub/GitLab/Gitea.
|
||||
disable: false
|
||||
|
||||
# Changelog generation implementation to use.
|
||||
#
|
||||
# Valid options are:
|
||||
# - `git`: uses `git log`;
|
||||
# - `github`: uses the compare GitHub API, appending the author login to the changelog.
|
||||
# - `gitlab`: uses the compare GitLab API, appending the author name and email to the changelog.
|
||||
# - `github-native`: uses the GitHub release notes generation API, disables the groups feature.
|
||||
#
|
||||
# Defaults to `git`.
|
||||
use: github
|
||||
|
||||
# Sorts the changelog by the commit's messages.
|
||||
# Could either be asc, desc or empty
|
||||
# Default is empty
|
||||
sort: asc
|
||||
|
||||
# Format to use for commit formatting.
|
||||
# Only available when use is one of `github`, `gitea`, or `gitlab`.
|
||||
#
|
||||
# Default: '{{ .SHA }}: {{ .Message }} ({{ with .AuthorUsername }}@{{ . }}{{ else }}{{ .AuthorName }} <{{ .AuthorEmail }}>{{ end }})'.
|
||||
# Extra template fields: `SHA`, `Message`, `AuthorName`, `AuthorEmail`, and
|
||||
# `AuthorUsername`.
|
||||
format: "{{ .Message }}"
|
||||
|
||||
# Group commits messages by given regex and title.
|
||||
# Order value defines the order of the groups.
|
||||
# Proving no regex means all commits will be grouped under the default group.
|
||||
# Groups are disabled when using github-native, as it already groups things by itself.
|
||||
#
|
||||
# Default is no groups.
|
||||
groups:
|
||||
- title: "✨ Features"
|
||||
regexp: "^.*feat[(\\w)]*:+.*$"
|
||||
order: 0
|
||||
- title: "🐛 Bug Fixes"
|
||||
regexp: "^.*fix[(\\w)]*:+.*$"
|
||||
order: 1
|
||||
- title: "🎫 Chores"
|
||||
regexp: "^.*chore[(\\w)]*:+.*$"
|
||||
order: 2
|
||||
- title: "🔨 Refactor"
|
||||
regexp: "^.*refactor[(\\w)]*:+.*$"
|
||||
order: 3
|
||||
- title: "🔧 Build"
|
||||
regexp: "^.*?(ci)(\\(.+\\))??!?:.+$"
|
||||
order: 4
|
||||
- title: "📝 Documentation"
|
||||
regexp: "^.*?docs?(\\(.+\\))??!?:.+$"
|
||||
order: 5
|
||||
- title: "✨ Others"
|
||||
order: 999
|
||||
@@ -1,5 +1,7 @@
|
||||
# Pull Request Submission Guidelines
|
||||
|
||||
> **TawCorp internal contributors**: read [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md) first. It documents the canonical end-to-end flow (Multica issue → branch → PR → CI → review → squash merge via GitHub UI → Deploy Staging → QA), agent role boundaries, branch / commit conventions, and the soft-constraint model used in place of branch protection. The guidelines below apply to all contributors (internal and external) as the baseline.
|
||||
|
||||
To ensure the quality of the codebase and maintainability of the project, please follow these guidelines before submitting a Pull Request (PR):
|
||||
|
||||
## 1. PR Title and Description
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Pull Request 提交须知
|
||||
|
||||
> **TawCorp 内部协作者**:请先阅读 [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md)。该文档定义了从 Multica issue 到 PR 合并到 Deploy Staging 到 QA 验收的端到端流程,以及 agent 角色边界、分支/commit 约定、和不依赖 GitHub branch protection 的软约束模型。下面的通用指南仍然适用,但内部协作以 `doc/development-workflow-zh.md` 为准。
|
||||
|
||||
为了确保代码库的质量和项目的可维护性,在提交 Pull Request(PR)之前,请务必遵循以下准则:
|
||||
|
||||
## 1. PR 标题和描述
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
<!--
|
||||
TawCorp internal fork header. Upstream PPanel content begins below.
|
||||
Do not remove this header without updating Multica workspace context and doc/development-workflow-zh.md.
|
||||
-->
|
||||
|
||||
> ### TawCorp hifast-server (internal fork)
|
||||
>
|
||||
> This repository is the **canonical** TawCorp fork of [perfect-panel/server](https://github.com/perfect-panel/server). Migrated from `git.kxsw.us/HI-VPN/hi-server` on **2026-06-03**; the old Gitea remote is deprecated — do not push or pull from it.
|
||||
>
|
||||
> - **Development workflow (required reading for internal contributors)**: [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md)
|
||||
> - **Branch model**: `internal` (dev mainline, auto-deploys to staging) → `main` (release)
|
||||
> - **Merge policy**: PR + architect review + GitHub UI "Squash and merge"; direct push to `internal`/`main` is blocked by `lefthook` pre-push and forbidden by policy
|
||||
> - **Issue tracker**: Multica workspace `Hifast` (prefix `HIF-`)
|
||||
>
|
||||
> External contributors: read [`CONTRIBUTING.md`](CONTRIBUTING.md) for the upstream-compatible baseline.
|
||||
|
||||
---
|
||||
|
||||
# PPanel Server
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
upstream api_backend {
|
||||
server 127.0.0.1:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
|
||||
listen 80;
|
||||
server_name api.hifast.biz 4d3vsw888xgaen.hifast.biz;
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
allow all;
|
||||
}
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.hifast.biz;
|
||||
client_max_body_size 150M;
|
||||
ssl_certificate /etc/nginx/ssl/hifast.biz/_.hifast.biz.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/nginx/ssl/hifast.biz/_.hifast.biz.key; # managed by Certbot
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "DENY";
|
||||
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
if ($http_user_agent ~* '(9999|91\.78)') {
|
||||
return 444;
|
||||
}
|
||||
location ~ ^/v1/common/client/download/file/(?<download_name>Hi快VPN-(?<os>windows|mac|android)-1.0.0-ic-.*\.(?<ext>exe|dmg|apk))$ {
|
||||
alias /var/www/download/Hi快VPN-$os-1.0.0.$ext;
|
||||
charset utf-8;
|
||||
add_header Content-Disposition 'attachment; filename="$download_name"';
|
||||
add_header Content-Type application/octet-stream;
|
||||
}
|
||||
|
||||
location /v1/common/client/download/file/ {
|
||||
alias /var/www/download/;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://api_backend;
|
||||
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_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name 4d3vsw888xgaen.hifast.biz;
|
||||
client_max_body_size 150M;
|
||||
ssl_certificate /etc/nginx/ssl/hifast.biz/_.hifast.biz.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/nginx/ssl/hifast.biz/_.hifast.biz.key; # managed by Certbot
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json image/svg+xml;
|
||||
root /var/www/admin;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "Invite API"
|
||||
desc: "API for ppanel"
|
||||
author: "Tension"
|
||||
email: "tension@ppanel.com"
|
||||
version: "0.0.1"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
type (
|
||||
GetInviteManageListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Search string `form:"search"`
|
||||
InviterId int64 `form:"inviter_id"`
|
||||
InviteeId int64 `form:"invitee_id"`
|
||||
}
|
||||
InviteManageRecord {
|
||||
InviterId int64 `json:"inviter_id"`
|
||||
InviterIdentifier string `json:"inviter_identifier"`
|
||||
InviterDeviceNo string `json:"inviter_device_no"`
|
||||
InviteeId int64 `json:"invitee_id"`
|
||||
InviteeIdentifier string `json:"invitee_identifier"`
|
||||
InviteeDeviceNo string `json:"invitee_device_no"`
|
||||
InviteeAvatar string `json:"invitee_avatar"`
|
||||
InviteeEnable bool `json:"invitee_enable"`
|
||||
InvitedAt int64 `json:"invited_at"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
HasPurchased bool `json:"has_purchased"`
|
||||
InviterCommission int64 `json:"inviter_commission"`
|
||||
InviterGiftDays int64 `json:"inviter_gift_days"`
|
||||
InviteeGiftDays int64 `json:"invitee_gift_days"`
|
||||
}
|
||||
GetInviteManageListResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []InviteManageRecord `json:"list"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
prefix: v1/admin/invite
|
||||
group: admin/invite
|
||||
middleware: AuthMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Get invite manage list"
|
||||
@handler GetInviteManageList
|
||||
get /list (GetInviteManageListRequest) returns (GetInviteManageListResponse)
|
||||
}
|
||||
@@ -149,6 +149,35 @@ type (
|
||||
Total int64 `json:"total"`
|
||||
List []CommissionLog `json:"list"`
|
||||
}
|
||||
OrderRefundLog {
|
||||
OrderId int64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
OperatorUserId int64 `json:"operator_user_id"`
|
||||
OperatorAuthIdentifier string `json:"operator_auth_identifier,omitempty"`
|
||||
TargetUserId int64 `json:"target_user_id"`
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
RefererUserId int64 `json:"referer_user_id,omitempty"`
|
||||
CommissionAmount int64 `json:"commission_amount"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OrderStatusBefore uint8 `json:"order_status_before"`
|
||||
OrderStatusAfter uint8 `json:"order_status_after"`
|
||||
SubscribeStatusBefore uint8 `json:"subscribe_status_before"`
|
||||
SubscribeStatusAfter uint8 `json:"subscribe_status_after"`
|
||||
SubscribeExpireBefore int64 `json:"subscribe_expire_before"`
|
||||
SubscribeExpireAfter int64 `json:"subscribe_expire_after"`
|
||||
CommissionBefore int64 `json:"commission_before"`
|
||||
CommissionAfter int64 `json:"commission_after"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
FilterOrderRefundLogRequest {
|
||||
FilterLogParams
|
||||
OrderId int64 `form:"order_id,optional"`
|
||||
UserId int64 `form:"user_id,optional"`
|
||||
}
|
||||
FilterOrderRefundLogResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []OrderRefundLog `json:"list"`
|
||||
}
|
||||
GiftLog {
|
||||
Type uint16 `json:"type"`
|
||||
userId int64 `json:"user_id"`
|
||||
@@ -239,6 +268,30 @@ type (
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
GetLogMessageRawRequest {
|
||||
Id int64 `form:"id" validate:"required"`
|
||||
}
|
||||
GetLogMessageRawResponse {
|
||||
Id int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
AppVersion string `json:"app_version"`
|
||||
OsName string `json:"os_name"`
|
||||
OsVersion string `json:"os_version"`
|
||||
DeviceId string `json:"device_id"`
|
||||
UserId *int64 `json:"user_id"`
|
||||
SessionId string `json:"session_id"`
|
||||
Level uint8 `json:"level"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
Message string `json:"message"`
|
||||
Stack string `json:"stack"`
|
||||
Context interface{} `json:"context"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Locale string `json:"locale"`
|
||||
Digest string `json:"digest"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@@ -291,6 +344,10 @@ service ppanel {
|
||||
@handler FilterCommissionLog
|
||||
get /commission/list (FilterCommissionLogRequest) returns (FilterCommissionLogResponse)
|
||||
|
||||
@doc "Filter order refund log"
|
||||
@handler FilterOrderRefundLog
|
||||
get /order/refund/list (FilterOrderRefundLogRequest) returns (FilterOrderRefundLogResponse)
|
||||
|
||||
@doc "Filter gift log"
|
||||
@handler FilterGiftLog
|
||||
get /gift/list (FilterGiftLogRequest) returns (FilterGiftLogResponse)
|
||||
@@ -314,5 +371,9 @@ service ppanel {
|
||||
@doc "Get error log message detail"
|
||||
@handler GetErrorLogMessageDetail
|
||||
get /error_message/detail returns (GetErrorLogMessageDetailResponse)
|
||||
|
||||
@doc "Get log message raw detail (temporary)"
|
||||
@handler GetLogMessageRaw
|
||||
get /message/detail (GetLogMessageRawRequest) returns (GetLogMessageRawResponse)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ type (
|
||||
PaymentId int64 `json:"payment_id,omitempty"`
|
||||
TradeNo string `json:"trade_no,omitempty"`
|
||||
}
|
||||
RefundOrderRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Reason string `json:"reason,omitempty" validate:"omitempty,max=500"`
|
||||
}
|
||||
ActivateOrderRequest {
|
||||
OrderNo string `json:"order_no" validate:"required"`
|
||||
}
|
||||
@@ -68,6 +72,10 @@ service ppanel {
|
||||
@handler UpdateOrderStatus
|
||||
put /status (UpdateOrderStatusRequest)
|
||||
|
||||
@doc "Refund order"
|
||||
@handler RefundOrder
|
||||
post /refund (RefundOrderRequest)
|
||||
|
||||
@doc "Manually activate order"
|
||||
@handler ActivateOrder
|
||||
post /activate (ActivateOrderRequest)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "promo admin API"
|
||||
desc: "API for ppanel"
|
||||
author: "Tension"
|
||||
email: "tension@ppanel.com"
|
||||
version: "0.0.1"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
type (
|
||||
CreatePromoRuleRequest {
|
||||
Name string `json:"name" validate:"required,max=100"`
|
||||
Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Priority int64 `json:"priority" validate:"gte=0"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
StartTime *int64 `json:"start_time"`
|
||||
EndTime *int64 `json:"end_time"`
|
||||
}
|
||||
UpdatePromoRuleRequest {
|
||||
Id int64 `uri:"id" validate:"required,gt=0"`
|
||||
Name string `json:"name" validate:"required,max=100"`
|
||||
Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Priority int64 `json:"priority" validate:"gte=0"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
StartTime *int64 `json:"start_time"`
|
||||
EndTime *int64 `json:"end_time"`
|
||||
}
|
||||
GetPromoRuleDetailRequest {
|
||||
Id int64 `uri:"id" validate:"required,gt=0"`
|
||||
}
|
||||
DeletePromoRuleRequest {
|
||||
Id int64 `uri:"id" validate:"required,gt=0"`
|
||||
}
|
||||
GetPromoRuleListRequest {
|
||||
Page int64 `form:"page" validate:"required,gt=0"`
|
||||
Size int64 `form:"size" validate:"required,gt=0,lte=200"`
|
||||
Type string `form:"type" validate:"omitempty,oneof=new_user inactive_user campaign"`
|
||||
Enabled *bool `form:"enabled"`
|
||||
Search string `form:"search,omitempty"`
|
||||
}
|
||||
GetPromoRuleListResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []PromoRule `json:"list"`
|
||||
}
|
||||
SetPromoPriceRequest {
|
||||
PromoRuleId int64 `json:"promo_rule_id" validate:"required,gt=0"`
|
||||
Items []PromoPriceItem `json:"items" validate:"required,min=1,dive"`
|
||||
}
|
||||
GetPromoPriceListRequest {
|
||||
Page int64 `form:"page" validate:"required,gt=0"`
|
||||
Size int64 `form:"size" validate:"required,gt=0,lte=200"`
|
||||
RuleId int64 `form:"rule_id,omitempty"`
|
||||
SubscribeId int64 `form:"subscribe_id,omitempty"`
|
||||
}
|
||||
GetPromoPriceListResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []PromoPrice `json:"list"`
|
||||
}
|
||||
DeletePromoPriceRequest {
|
||||
Id int64 `uri:"id" validate:"required,gt=0"`
|
||||
}
|
||||
GetPromoUsageListRequest {
|
||||
Page int64 `form:"page" validate:"required,gt=0"`
|
||||
Size int64 `form:"size" validate:"required,gt=0,lte=200"`
|
||||
RuleId int64 `form:"rule_id,omitempty"`
|
||||
UserId int64 `form:"user_id,omitempty"`
|
||||
SubscribeId int64 `form:"subscribe_id,omitempty"`
|
||||
OrderNo string `form:"order_no,omitempty"`
|
||||
}
|
||||
GetPromoUsageListResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []PromoUsage `json:"list"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
prefix: v1/admin/promo
|
||||
group: admin/promo
|
||||
middleware: AuthMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Create promo rule"
|
||||
@handler CreateRule
|
||||
post /rule (CreatePromoRuleRequest) returns (PromoRule)
|
||||
|
||||
@doc "Get promo rule list"
|
||||
@handler GetRuleList
|
||||
get /rule/list (GetPromoRuleListRequest) returns (GetPromoRuleListResponse)
|
||||
|
||||
@doc "Get promo rule detail"
|
||||
@handler GetRuleDetail
|
||||
get /rule/:id (GetPromoRuleDetailRequest) returns (PromoRule)
|
||||
|
||||
@doc "Update promo rule"
|
||||
@handler UpdateRule
|
||||
put /rule/:id (UpdatePromoRuleRequest) returns (PromoRule)
|
||||
|
||||
@doc "Delete promo rule"
|
||||
@handler DeleteRule
|
||||
delete /rule/:id (DeletePromoRuleRequest)
|
||||
|
||||
@doc "Set promo prices"
|
||||
@handler SetPrice
|
||||
post /price (SetPromoPriceRequest)
|
||||
|
||||
@doc "Get promo price list"
|
||||
@handler GetPriceList
|
||||
get /price/list (GetPromoPriceListRequest) returns (GetPromoPriceListResponse)
|
||||
|
||||
@doc "Delete promo price"
|
||||
@handler DeletePrice
|
||||
delete /price/:id (DeletePromoPriceRequest)
|
||||
|
||||
@doc "Get promo usage list"
|
||||
@handler GetUsageList
|
||||
get /usage/list (GetPromoUsageListRequest) returns (GetPromoUsageListResponse)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ type (
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
NewUserOnly *bool `json:"new_user_only"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
@@ -74,6 +75,7 @@ type (
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
NewUserOnly *bool `json:"new_user_only"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
@@ -175,4 +177,3 @@ service ppanel {
|
||||
@handler ResetAllSubscribeToken
|
||||
post /reset_all_token returns (ResetAllSubscribeTokenResponse)
|
||||
}
|
||||
|
||||
|
||||
+73
-47
@@ -23,10 +23,12 @@ type (
|
||||
SubscribeId *int64 `form:"subscribe_id,omitempty"`
|
||||
UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"`
|
||||
ShortCode string `form:"short_code,omitempty"`
|
||||
DeviceId *int64 `form:"device_id,omitempty"`
|
||||
FamilyJoined *bool `form:"family_joined,omitempty"`
|
||||
FamilyStatus string `form:"family_status,omitempty"`
|
||||
FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"`
|
||||
FamilyId *int64 `form:"family_id,omitempty"`
|
||||
SortOrder string `form:"sort_order,omitempty"`
|
||||
}
|
||||
// GetUserListResponse
|
||||
GetUserListResponse {
|
||||
@@ -38,20 +40,20 @@ type (
|
||||
Id int64 `form:"id" validate:"required"`
|
||||
}
|
||||
UpdateUserBasiceInfoRequest {
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
Password string `json:"password"`
|
||||
Avatar string `json:"avatar"`
|
||||
Balance int64 `json:"balance"`
|
||||
Commission int64 `json:"commission"`
|
||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
ReferCode string `json:"refer_code"`
|
||||
RefererId int64 `json:"referer_id"`
|
||||
Enable *bool `json:"enable"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Remark string `json:"remark"`
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
Password string `json:"password"`
|
||||
Avatar string `json:"avatar"`
|
||||
Balance *int64 `json:"balance"`
|
||||
Commission *int64 `json:"commission"`
|
||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||
GiftAmount *int64 `json:"gift_amount"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
ReferCode string `json:"refer_code"`
|
||||
RefererId *int64 `json:"referer_id"`
|
||||
Enable *bool `json:"enable"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Remark *string `json:"remark"`
|
||||
}
|
||||
UpdateUserNotifySettingRequest {
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
@@ -76,29 +78,6 @@ type (
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
UserSubscribeDetail {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
User User `json:"user"`
|
||||
OrderId int64 `json:"order_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Subscribe Subscribe `json:"subscribe"`
|
||||
NodeGroupId int64 `json:"node_group_id"`
|
||||
GroupLocked bool `json:"group_locked"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
ResetTime int64 `json:"reset_time"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
EffectiveSpeed int64 `json:"effective_speed"`
|
||||
IsThrottled bool `json:"is_throttled"`
|
||||
ThrottleRule string `json:"throttle_rule,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
BatchDeleteUserRequest {
|
||||
Ids []int64 `json:"ids" validate:"required"`
|
||||
}
|
||||
@@ -158,18 +137,22 @@ type (
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
CreateUserSubscribeRequest {
|
||||
UserId int64 `json:"user_id"`
|
||||
ExpiredAt int64 `json:"expired_at"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
ExpiredAt int64 `json:"expired_at"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
SpeedLimit int64 `json:"speed_limit,optional"`
|
||||
TrafficLimit string `json:"traffic_limit,optional"`
|
||||
}
|
||||
UpdateUserSubscribeRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
ExpiredAt int64 `json:"expired_at"`
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
ExpiredAt int64 `json:"expired_at"`
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
|
||||
TrafficLimit []TrafficLimit `json:"traffic_limit,omitempty"`
|
||||
}
|
||||
GetUserLoginLogsRequest {
|
||||
Page int `form:"page"`
|
||||
@@ -235,6 +218,7 @@ type (
|
||||
Size int `form:"size"`
|
||||
UserId *int64 `form:"user_id,omitempty"`
|
||||
Status *uint8 `form:"status,omitempty"`
|
||||
Method *uint8 `form:"method,omitempty"`
|
||||
}
|
||||
GetWithdrawalListResponse {
|
||||
List []WithdrawalLog `json:"list"`
|
||||
@@ -247,6 +231,40 @@ type (
|
||||
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||
Reason string `json:"reason" validate:"required,max=500"`
|
||||
}
|
||||
GetAdminUserInviteStatsRequest {
|
||||
UserId int64 `form:"user_id" validate:"required"`
|
||||
}
|
||||
GetAdminUserInviteStatsResponse {
|
||||
InviteCount int64 `json:"invite_count"`
|
||||
TotalCommission int64 `json:"total_commission"`
|
||||
CurrentCommission int64 `json:"current_commission"`
|
||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase bool `json:"only_first_purchase"`
|
||||
}
|
||||
GetAdminUserInviteListRequest {
|
||||
UserId int64 `form:"user_id" validate:"required"`
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Search string `form:"search"`
|
||||
Enable *int `form:"enable"`
|
||||
UserIdSearch int64 `form:"user_id_search"`
|
||||
}
|
||||
AdminInvitedUser {
|
||||
Id int64 `json:"id"`
|
||||
Avatar string `json:"avatar"`
|
||||
Identifier string `json:"identifier"`
|
||||
Enable bool `json:"enable"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
HasPurchased bool `json:"has_purchased"`
|
||||
InviterCommission int64 `json:"inviter_commission"`
|
||||
InviterGiftDays int64 `json:"inviter_gift_days"`
|
||||
InviteeGiftDays int64 `json:"invitee_gift_days"`
|
||||
}
|
||||
GetAdminUserInviteListResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []AdminInvitedUser `json:"list"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@@ -399,4 +417,12 @@ service ppanel {
|
||||
@doc "Reject withdrawal"
|
||||
@handler RejectWithdrawal
|
||||
post /withdrawal/reject (RejectWithdrawalRequest)
|
||||
|
||||
@doc "Get admin user invite stats"
|
||||
@handler GetAdminUserInviteStats
|
||||
get /invite/stats (GetAdminUserInviteStatsRequest) returns (GetAdminUserInviteStatsResponse)
|
||||
|
||||
@doc "Get admin user invite list"
|
||||
@handler GetAdminUserInviteList
|
||||
get /invite/list (GetAdminUserInviteListRequest) returns (GetAdminUserInviteListResponse)
|
||||
}
|
||||
|
||||
+2
-3
@@ -111,7 +111,7 @@ type (
|
||||
|
||||
@server (
|
||||
prefix: v1/server
|
||||
group: server
|
||||
group: node/server
|
||||
middleware: ServerMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@@ -138,11 +138,10 @@ service ppanel {
|
||||
|
||||
@server (
|
||||
prefix: v2/server
|
||||
group: server
|
||||
group: node/server
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Get Server Protocol Config"
|
||||
@handler QueryServerProtocolConfig
|
||||
get /:server_id (QueryServerConfigRequest) returns (QueryServerConfigResponse)
|
||||
}
|
||||
|
||||
|
||||
+2
-13
@@ -16,13 +16,7 @@ type (
|
||||
}
|
||||
|
||||
FileUploadResponse {
|
||||
FileId string `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"content_type"`
|
||||
Etag string `json:"etag"`
|
||||
Status string `json:"status"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
FileUploadInitRequest {
|
||||
@@ -47,12 +41,7 @@ type (
|
||||
}
|
||||
|
||||
FileUploadCompleteResponse {
|
||||
FileId string `json:"file_id"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"content_type"`
|
||||
Etag string `json:"etag"`
|
||||
Status string `json:"status"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+40
-5
@@ -109,22 +109,33 @@ type (
|
||||
Rules []string `json:"rules" validate:"required"`
|
||||
}
|
||||
CommissionWithdrawRequest {
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
Method uint8 `json:"method" validate:"oneof=0 1 2 3"`
|
||||
Account string `json:"account,omitempty"`
|
||||
QrCodeUrl string `json:"qr_code_url,omitempty"`
|
||||
}
|
||||
WithdrawalLog {
|
||||
Id int64 `json:"id"`
|
||||
BizType string `json:"biz_type"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
Status uint8 `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Method uint8 `json:"method"`
|
||||
Account string `json:"account"`
|
||||
QrCodeUrl string `json:"qr_code_url"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
CancelWithdrawalRequest {
|
||||
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||
}
|
||||
QueryWithdrawalLogListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
|
||||
}
|
||||
QueryWithdrawalLogListResponse {
|
||||
List []WithdrawalLog `json:"list"`
|
||||
@@ -192,6 +203,23 @@ type (
|
||||
GrowthRate string `json:"growth_rate"`
|
||||
PaidGrowthRate string `json:"paid_growth_rate"`
|
||||
}
|
||||
GetInviteRecordsRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
StartTime int64 `form:"start_time"`
|
||||
EndTime int64 `form:"end_time"`
|
||||
}
|
||||
InviteRecord {
|
||||
Role string `json:"role"`
|
||||
PeerHash string `json:"peer_hash"`
|
||||
GiftDays int64 `json:"gift_days"`
|
||||
OrderNo string `json:"order_no"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
GetInviteRecordsResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []InviteRecord `json:"list"`
|
||||
}
|
||||
GetInviteSalesRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
@@ -352,6 +380,10 @@ service ppanel {
|
||||
@handler CommissionWithdraw
|
||||
post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog)
|
||||
|
||||
@doc "Cancel pending withdrawal"
|
||||
@handler CancelWithdrawal
|
||||
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
|
||||
|
||||
@doc "Query Withdrawal Log"
|
||||
@handler QueryWithdrawalLog
|
||||
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
|
||||
@@ -384,6 +416,10 @@ service ppanel {
|
||||
@handler GetAgentRealtime
|
||||
get /agent_realtime (GetAgentRealtimeRequest) returns (GetAgentRealtimeResponse)
|
||||
|
||||
@doc "Get Invite Records"
|
||||
@handler GetInviteRecords
|
||||
get /invite_records (GetInviteRecordsRequest) returns (GetInviteRecordsResponse)
|
||||
|
||||
@doc "Get Invite Sales"
|
||||
@handler GetInviteSales
|
||||
get /invite_sales (GetInviteSalesRequest) returns (GetInviteSalesResponse)
|
||||
@@ -411,4 +447,3 @@ service ppanel {
|
||||
@handler DeviceWsConnect
|
||||
get /device_ws_connect
|
||||
}
|
||||
|
||||
|
||||
+104
-21
@@ -160,24 +160,26 @@ type (
|
||||
OnlyRealDevice bool `json:"only_real_device"`
|
||||
}
|
||||
RegisterConfig {
|
||||
StopRegister bool `json:"stop_register"`
|
||||
EnableTrial bool `json:"enable_trial"`
|
||||
TrialSubscribe int64 `json:"trial_subscribe"`
|
||||
TrialTime int64 `json:"trial_time"`
|
||||
TrialTimeUnit string `json:"trial_time_unit"`
|
||||
EnableIpRegisterLimit bool `json:"enable_ip_register_limit"`
|
||||
IpRegisterLimit int64 `json:"ip_register_limit"`
|
||||
IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
StopRegister bool `json:"stop_register"`
|
||||
EnableTrial bool `json:"enable_trial"`
|
||||
EnableTrialEmailWhitelist bool `json:"enable_trial_email_whitelist"`
|
||||
TrialSubscribe int64 `json:"trial_subscribe"`
|
||||
TrialTime int64 `json:"trial_time"`
|
||||
TrialTimeUnit string `json:"trial_time_unit"`
|
||||
TrialEmailDomainWhitelist string `json:"trial_email_domain_whitelist"`
|
||||
EnableIpRegisterLimit bool `json:"enable_ip_register_limit"`
|
||||
IpRegisterLimit int64 `json:"ip_register_limit"`
|
||||
IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
}
|
||||
VerifyConfig {
|
||||
CaptchaType string `json:"captcha_type"` // local or turnstile
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
TurnstileSecret string `json:"turnstile_secret"`
|
||||
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` // User login captcha
|
||||
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` // User register captcha
|
||||
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` // Admin login captcha
|
||||
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` // User reset password captcha
|
||||
CaptchaType string `json:"captcha_type"` // local or turnstile
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
TurnstileSecret string `json:"turnstile_secret"`
|
||||
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` // User login captcha
|
||||
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` // User register captcha
|
||||
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` // Admin login captcha
|
||||
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` // User reset password captcha
|
||||
}
|
||||
NodeConfig {
|
||||
NodeSecret string `json:"node_secret"`
|
||||
@@ -226,9 +228,52 @@ type (
|
||||
CurrencySymbol string `json:"currency_symbol"`
|
||||
}
|
||||
SubscribeDiscount {
|
||||
Quantity int64 `json:"quantity"`
|
||||
Discount float64 `json:"discount"`
|
||||
MapApple string `json:"map_apple"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
Discount float64 `json:"discount"`
|
||||
NewUserOnly bool `json:"new_user_only"`
|
||||
MapApple string `json:"map_apple"`
|
||||
Promo *SubscribePromo `json:"promo"`
|
||||
}
|
||||
PromoPrice {
|
||||
Id int64 `json:"id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
PromoRuleId int64 `json:"promo_rule_id"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
PromoPrice int64 `json:"promo_price"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
PromoPriceItem {
|
||||
SubscribeId int64 `json:"subscribe_id" validate:"required,gt=0"`
|
||||
Quantity int64 `json:"quantity" validate:"required,gt=0,lte=1000"`
|
||||
PromoPrice int64 `json:"promo_price" validate:"required,gt=0"`
|
||||
}
|
||||
PromoRule {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Priority int64 `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
StartTime *int64 `json:"start_time"`
|
||||
EndTime *int64 `json:"end_time"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
PromoUsage {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
PromoRuleId int64 `json:"promo_rule_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
PromoPrice int64 `json:"promo_price"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
SubscribePromo {
|
||||
RuleName string `json:"rule_name"`
|
||||
RuleType string `json:"rule_type"`
|
||||
PromoPrice int64 `json:"promo_price"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
TrafficLimit {
|
||||
StatType string `json:"stat_type"`
|
||||
@@ -251,6 +296,7 @@ type (
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
NewUserOnly bool `json:"new_user_only"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
|
||||
@@ -427,6 +473,7 @@ type (
|
||||
FeeAmount int64 `json:"fee_amount"`
|
||||
TradeNo string `json:"trade_no"`
|
||||
Status uint8 `json:"status"`
|
||||
StatusName string `json:"status_name,omitempty"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
@@ -449,6 +496,7 @@ type (
|
||||
FeeAmount int64 `json:"fee_amount"`
|
||||
TradeNo string `json:"trade_no"`
|
||||
Status uint8 `json:"status"`
|
||||
StatusName string `json:"status_name,omitempty"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Subscribe Subscribe `json:"subscribe"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
@@ -513,10 +561,13 @@ type (
|
||||
}
|
||||
UserSubscribe {
|
||||
Id int64 `json:"id"`
|
||||
IdStr string `json:"id_str"`
|
||||
UserId int64 `json:"user_id"`
|
||||
OrderId int64 `json:"order_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Subscribe Subscribe `json:"subscribe"`
|
||||
NodeGroupId int64 `json:"node_group_id"`
|
||||
NodeGroupName string `json:"node_group_name"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
FinishedAt int64 `json:"finished_at"`
|
||||
@@ -524,6 +575,7 @@ type (
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
EntitlementSource string `json:"entitlement_source"`
|
||||
@@ -534,6 +586,35 @@ type (
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
UserSubscribeDetail {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
User User `json:"user"`
|
||||
OrderId int64 `json:"order_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Subscribe Subscribe `json:"subscribe"`
|
||||
NodeGroupId int64 `json:"node_group_id"`
|
||||
NodeGroupName string `json:"node_group_name"`
|
||||
GroupLocked bool `json:"group_locked"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
ResetTime int64 `json:"reset_time"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
|
||||
PlanSpeedLimit int64 `json:"plan_speed_limit"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
EffectiveSpeed int64 `json:"effective_speed"`
|
||||
IsThrottled bool `json:"is_throttled"`
|
||||
ThrottleRule string `json:"throttle_rule,omitempty"`
|
||||
ThrottleStart int64 `json:"throttle_start,omitempty"`
|
||||
ThrottleEnd int64 `json:"throttle_end,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
UserAffiliate {
|
||||
Avatar string `json:"avatar"`
|
||||
Identifier string `json:"identifier"`
|
||||
@@ -677,6 +758,7 @@ type (
|
||||
Price int64 `json:"price"`
|
||||
Amount int64 `json:"amount"`
|
||||
Discount int64 `json:"discount"`
|
||||
PromoDiscount int64 `json:"promo_discount"`
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
Coupon string `json:"coupon"`
|
||||
CouponDiscount int64 `json:"coupon_discount"`
|
||||
@@ -832,8 +914,9 @@ type (
|
||||
Sandbox *bool `json:"sandbox,omitempty"`
|
||||
}
|
||||
AttachAppleTransactionResponse {
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Tier string `json:"tier"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Tier string `json:"tier"`
|
||||
ExistingOrderNo string `json:"existing_order_no,omitempty"`
|
||||
}
|
||||
RestoreAppleTransactionsRequest {
|
||||
Transactions []string `json:"transactions" validate:"required"`
|
||||
|
||||
@@ -4316,6 +4316,9 @@
|
||||
"discount": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"promo": {
|
||||
"$ref": "#/definitions/SubscribePromo"
|
||||
}
|
||||
},
|
||||
"title": "SubscribeDiscount",
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
EC2 SSH 连接资料
|
||||
|
||||
服务器名称: hifast-hk-app-01
|
||||
公网 IP: 43.198.248.161
|
||||
登录用户: ubuntu
|
||||
|
||||
私钥文件:
|
||||
- hifast-hk-app-01-reset
|
||||
|
||||
公钥文件:
|
||||
- hifast-hk-app-01-reset.pub
|
||||
|
||||
连接命令:
|
||||
ssh -i hifast-hk-app-01-reset ubuntu@43.198.248.161
|
||||
|
||||
如果在 Mac / Linux 上使用,先执行:
|
||||
chmod 600 hifast-hk-app-01-reset
|
||||
|
||||
如果要给别人使用,只需要把私钥文件 hifast-hk-app-01-reset 发给对方即可。
|
||||
出于安全考虑,建议通过安全渠道传输,并在后续需要时重新轮换密钥。
|
||||
@@ -1,8 +0,0 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCgAAAKjy5KDa8uSg
|
||||
2gAAAAtzc2gtZWQyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCg
|
||||
AAAEDwSb0b/0S6Tw8Od5hAtIKqt1JvomqQQS44Ty3xL+FjPtvqawOqZIcu/SZaAI/i9+ND
|
||||
cDnnaq/3m5B2wf3hGegKAAAAIWhpZmFzdC1oay1hcHAtMDEtcmVzZXQtMjAyNi0wNS0xMA
|
||||
ECAwQ=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1 +0,0 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvqawOqZIcu/SZaAI/i9+NDcDnnaq/3m5B2wf3hGegK hifast-hk-app-01-reset-2026-05-10
|
||||
@@ -1,363 +0,0 @@
|
||||
# PPanel 香港区新 AWS 账号部署说明
|
||||
|
||||
本目录用于在 **新 AWS 账号** 中按 **香港区 `ap-east-1`** 重建一套全新空环境。
|
||||
|
||||
目标架构:
|
||||
|
||||
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL`
|
||||
|
||||
## 1. 资源清单
|
||||
|
||||
按下面顺序创建资源:
|
||||
|
||||
1. VPC
|
||||
2. 2 个公有子网 + 2 个私有子网
|
||||
3. Internet Gateway
|
||||
4. 公有 / 私有路由表
|
||||
5. 安全组
|
||||
6. RDS MySQL
|
||||
7. EC2 本机 Redis Docker
|
||||
8. EC2
|
||||
9. ACM 证书
|
||||
10. ALB + Target Group
|
||||
11. WAF Web ACL
|
||||
12. 平行环境域名
|
||||
|
||||
建议命名:
|
||||
|
||||
- VPC: `ppanel-hk-prod`
|
||||
- EC2: `ppanel-app-hk-01`
|
||||
- RDS: `ppanel-mysql-hk`
|
||||
- Redis container: `hifast-redis`
|
||||
- ALB: `ppanel-alb-hk`
|
||||
- WAF: `ppanel-waf-hk`
|
||||
|
||||
## 2. 默认规格
|
||||
|
||||
### EC2
|
||||
|
||||
- Region: `ap-east-1`
|
||||
- OS: Ubuntu 24.04 LTS
|
||||
- Instance type: `t4g.large` 起步
|
||||
- Disk: `gp3 80GB`
|
||||
- Public subnet: 是
|
||||
- IAM Role: 允许读取 CloudWatch / SSM(如使用)
|
||||
- 如果要在 AWS EC2 本机执行 S3 备份:额外允许写入专用备份桶
|
||||
|
||||
### RDS MySQL
|
||||
|
||||
- Engine: MySQL 8.0
|
||||
- Class: `db.r7g.xlarge`
|
||||
- Storage: `gp3 100GB`
|
||||
- DB name: `hifast`
|
||||
- Username: `admin`
|
||||
- Public access: `No`
|
||||
- Charset: `utf8mb4`
|
||||
- Backup: `7-14 days`
|
||||
|
||||
### Redis
|
||||
|
||||
- 部署位置:业务 EC2 本机
|
||||
- 部署方式:Docker
|
||||
- 版本:`redis:8.2.1`
|
||||
- 监听:`0.0.0.0:6379`
|
||||
- 应用连接:`127.0.0.1:6379`
|
||||
- 安全组:仅对白名单备用节点或同机应用开放
|
||||
|
||||
## 3. 网络与安全组
|
||||
|
||||
### 子网布局
|
||||
|
||||
- `public-a`, `public-b`: ALB / EC2
|
||||
- `private-a`, `private-b`: RDS
|
||||
|
||||
### 安全组建议
|
||||
|
||||
#### `sg-alb`
|
||||
|
||||
- Inbound
|
||||
- `80/tcp` from `0.0.0.0/0`
|
||||
- `443/tcp` from `0.0.0.0/0`
|
||||
- Outbound
|
||||
- `80/tcp` to `sg-ec2`
|
||||
|
||||
#### `sg-ec2`
|
||||
|
||||
- Inbound
|
||||
- `80/tcp` from `sg-alb`
|
||||
- `22/tcp` from `你的固定运维 IP`
|
||||
- Outbound
|
||||
- all
|
||||
|
||||
说明:
|
||||
|
||||
- 应用容器监听 `127.0.0.1:8080`
|
||||
- EC2 对外只让 Nginx 监听 `80`
|
||||
- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1`
|
||||
|
||||
#### `sg-rds`
|
||||
|
||||
- Inbound
|
||||
- `3306/tcp` from `sg-ec2`
|
||||
|
||||
#### `sg-ec2` 额外说明
|
||||
|
||||
- 如果需要外部备用节点复制 Redis,再额外放行:
|
||||
- `6379/tcp` from `104.238.220.230/32`
|
||||
|
||||
## 4. ALB / Target Group / 健康检查
|
||||
|
||||
### Target Group
|
||||
|
||||
- Type: `Instance`
|
||||
- Protocol: `HTTP`
|
||||
- Port: `80`
|
||||
- Health check path: `/v1/common/heartbeat`
|
||||
- Success code: `200`
|
||||
|
||||
这个路径已由项目现有接口提供,无需额外改代码。
|
||||
|
||||
### ALB 监听器
|
||||
|
||||
- `80` -> redirect to `443`
|
||||
- `443` -> forward 到 target group
|
||||
|
||||
### ACM
|
||||
|
||||
- 在 `ap-east-1` 申请证书
|
||||
- 先给平行环境域名,例如:
|
||||
- `api-new.hifast.biz`
|
||||
- `logs-new.hifast.biz`
|
||||
|
||||
## 5. WAF 规则
|
||||
|
||||
首版至少启用:
|
||||
|
||||
1. `AWSManagedRulesCommonRuleSet`
|
||||
2. `AWSManagedRulesKnownBadInputsRuleSet`
|
||||
3. `AWSManagedRulesAmazonIpReputationList`
|
||||
4. 全站 rate-based rule
|
||||
5. 针对高风险路径的 rate-based rule
|
||||
|
||||
建议的第一版限流:
|
||||
|
||||
- 全站:每 IP `2000 / 5 分钟`
|
||||
- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟`
|
||||
- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟`
|
||||
|
||||
节点上报接口建议后续补:
|
||||
|
||||
- `/v1/server/status`
|
||||
- `/v1/server/online`
|
||||
- `/v1/server/traffic`
|
||||
|
||||
优先用节点出口 IP 白名单;没有固定出口 IP 的节点暂时保留 `secret_key`,但不要把它当成唯一防线。
|
||||
|
||||
## 6. EC2 文件落地
|
||||
|
||||
在 EC2 上建议使用:
|
||||
|
||||
- 应用目录:`/opt/ppanel`
|
||||
- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf`
|
||||
|
||||
需要上传这些文件 / 目录:
|
||||
|
||||
- `docker-compose.cloud.yml`
|
||||
- `deploy/aws/ap-east-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml`
|
||||
- `deploy/aws/ap-east-1/nginx/ppanel-api.conf`
|
||||
- `grafana/`
|
||||
- `loki/`
|
||||
- `prometheus/`
|
||||
- `tempo/`
|
||||
- `.env.example` -> 重命名为 `.env`
|
||||
|
||||
目标目录示例:
|
||||
|
||||
```text
|
||||
/opt/ppanel/
|
||||
docker-compose.cloud.yml
|
||||
.env
|
||||
configs/ppanel.yaml
|
||||
grafana/
|
||||
loki/
|
||||
prometheus/
|
||||
tempo/
|
||||
logs/
|
||||
cache/
|
||||
tempo_data/
|
||||
```
|
||||
|
||||
## 7. 应用配置
|
||||
|
||||
基线模板见:
|
||||
|
||||
- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example)
|
||||
- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf)
|
||||
|
||||
关键值必须替换:
|
||||
|
||||
- `MySQL.Addr`
|
||||
- `MySQL.Password`
|
||||
- `Redis.Host`
|
||||
- `Redis.Pass`
|
||||
- `JwtAuth.AccessSecret`
|
||||
- `Administrator.Email`
|
||||
- `Administrator.Password`
|
||||
- `AppSignature.AppSecrets.*`
|
||||
- `device.security_secret`
|
||||
- `Site.Host`
|
||||
- `Site.SiteName`
|
||||
|
||||
Redis 约定保持不变:
|
||||
|
||||
- 业务缓存:DB `0`
|
||||
- Asynq:DB `5`(代码内部已固定使用)
|
||||
|
||||
## 8. 部署步骤
|
||||
|
||||
### 8.1 初始化 EC2
|
||||
|
||||
把脚本上传到 EC2 后执行:
|
||||
|
||||
## 9. 104 灾备节点常用运维脚本
|
||||
|
||||
如果你要在 `104.238.220.230` 上执行数据迁移、主从重拉、主库提升,可以直接复用仓库里的这几份脚本:
|
||||
|
||||
- 数据导出 / 导入交互工具:
|
||||
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
|
||||
- MySQL 主从运维工具:
|
||||
- [`deploy/scripts/mysql_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/mysql_replica_ops.sh)
|
||||
- Redis 主从运维工具:
|
||||
- [`deploy/scripts/redis_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/redis_replica_ops.sh)
|
||||
- 统一总入口:
|
||||
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
|
||||
- 主从运维环境模板:
|
||||
- [`deploy/aws/ap-east-1/configs/replica-ops.env.example`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/aws/ap-east-1/configs/replica-ops.env.example)
|
||||
|
||||
### 9.1 数据迁移工具
|
||||
|
||||
支持:
|
||||
|
||||
- 备份 MySQL 到 S3
|
||||
- 备份 Redis 到 S3
|
||||
- 从正式库导出 MySQL `sql.gz`
|
||||
- 把 `sql.gz` 导入 AWS RDS
|
||||
- 从正式 Redis 导出 `RDB`
|
||||
- 将 `RDB` 导入 Docker Redis 或宿主机 Redis
|
||||
- 查看 MySQL / Redis 当前主从状态
|
||||
- 强制重拉 MySQL / Redis 主从
|
||||
- 把 MySQL / Redis 从库提升为可写主库
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
bash deploy/scripts/hifast_data_sync_tool.sh /root/replica-ops.env
|
||||
```
|
||||
|
||||
```bash
|
||||
chmod +x deploy/scripts/bootstrap_aws_ec2.sh
|
||||
sudo APP_DIR=/opt/ppanel deploy/scripts/bootstrap_aws_ec2.sh
|
||||
```
|
||||
|
||||
### 8.2 安装 Nginx 配置
|
||||
|
||||
```bash
|
||||
sudo cp deploy/aws/ap-east-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf
|
||||
sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 8.3 启动容器
|
||||
|
||||
```bash
|
||||
cd /opt/ppanel
|
||||
docker compose -f docker-compose.cloud.yml up -d
|
||||
```
|
||||
|
||||
### 8.4 预检
|
||||
|
||||
```bash
|
||||
chmod +x deploy/scripts/preflight_aws_hk.sh
|
||||
APP_DIR=/opt/ppanel \
|
||||
RDS_HOST=<new-rds-endpoint> \
|
||||
REDIS_HOST=127.0.0.1 \
|
||||
deploy/scripts/preflight_aws_hk.sh
|
||||
```
|
||||
|
||||
## 9. 平行环境验证
|
||||
|
||||
先验证 `api-new.hifast.biz`,不要直接切正式域名。
|
||||
|
||||
必测项:
|
||||
|
||||
1. `ALB target` 为 healthy
|
||||
2. `GET /v1/common/heartbeat` 返回 200
|
||||
3. 管理员登录
|
||||
4. 用户注册 / 登录
|
||||
5. 订阅查询
|
||||
6. 节点上报 `/v1/server/status`
|
||||
7. 本机 Redis 可写缓存
|
||||
8. Asynq 可入队并消费
|
||||
|
||||
## 10. 正式切换
|
||||
|
||||
切换前检查:
|
||||
|
||||
1. ALB 5xx 为 0
|
||||
2. EC2 CPU / Memory 正常
|
||||
3. RDS CPU / Connections 正常
|
||||
4. 本机 Redis CPU / Connections / Memory 正常
|
||||
5. WAF 已挂到 ALB
|
||||
6. EC2 安全组没有对公网放 `8080/3333/9090/4317`
|
||||
|
||||
切换方式:
|
||||
|
||||
1. 保持新环境先跑平行域名
|
||||
2. 正式域名切到新 ALB
|
||||
3. 观察至少 1 小时
|
||||
4. 确认无误后再处理旧环境
|
||||
|
||||
## 11. 监控建议
|
||||
|
||||
至少建这些 CloudWatch / Grafana 观测项:
|
||||
|
||||
- ALB `RequestCount`, `HTTPCode_ELB_5XX_Count`, `TargetResponseTime`
|
||||
- EC2 `CPUUtilization`, `NetworkIn`, `NetworkOut`, `StatusCheckFailed`
|
||||
- RDS `CPUUtilization`, `DatabaseConnections`, `ReadLatency`, `WriteLatency`
|
||||
- Redis 容器 CPU / Memory / restart count
|
||||
|
||||
## 12. 这次方案的边界
|
||||
|
||||
本目录交付的是:
|
||||
|
||||
- 香港区新账号的部署模板
|
||||
- 新空环境启动与验证流程
|
||||
- ALB / WAF / EC2 / RDS / 本机 Redis 的落地约定
|
||||
|
||||
不包含:
|
||||
|
||||
- 旧数据迁移
|
||||
- Terraform / CloudFormation 自动建资源
|
||||
- Redis 托管版改造
|
||||
- 多活 / 自动扩缩容
|
||||
|
||||
## 13. S3 备份补强
|
||||
|
||||
当前已落地的 S3 备份桶:
|
||||
|
||||
- `hifast-prod-backups-200810848252-ap-east-1`
|
||||
|
||||
建议与现网结合方式:
|
||||
|
||||
1. `RDS automated backup` 继续保留,作为第一层恢复能力
|
||||
2. `104` 外部 MySQL 从库执行逻辑备份并上传到 S3,作为第二层可下载备份
|
||||
3. `104` 外部 Redis 从库按需导出 `RDB` 到 S3,补齐缓存类灾备材料
|
||||
|
||||
仓库中已补充:
|
||||
|
||||
- 环境变量模板:[`configs/backup-to-s3.env.example`](./configs/backup-to-s3.env.example)
|
||||
- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh)
|
||||
- Redis 备份脚本:[`../../scripts/redis_rdb_backup_to_s3.sh`](../../scripts/redis_rdb_backup_to_s3.sh)
|
||||
|
||||
建议把 MySQL 备份脚本优先部署到 `104`,因为它直接连接本地只读从库,对 AWS 主库扰动最小。
|
||||
@@ -1,18 +0,0 @@
|
||||
AWS_REGION=ap-east-1
|
||||
S3_BUCKET=hifast-prod-backups-200810848252-ap-east-1
|
||||
S3_PREFIX=mysql
|
||||
BACKUP_DIR=/var/backups/hifast
|
||||
HOST_TAG=104-standby
|
||||
KEEP_LOCAL_DAYS=3
|
||||
CHECK_REPLICA=1
|
||||
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=backup_reader
|
||||
MYSQL_PASSWORD=CHANGE_ME
|
||||
MYSQL_SOCKET=
|
||||
MYSQL_DATABASE=hifast
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=CHANGE_ME
|
||||
@@ -1,20 +0,0 @@
|
||||
PRIMARY_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
|
||||
PRIMARY_PORT=3306
|
||||
PRIMARY_USER=admin
|
||||
PRIMARY_PASSWORD=CHANGE_ME
|
||||
PRIMARY_DB=hifast
|
||||
|
||||
PRIMARY_REPL_USER=repl
|
||||
PRIMARY_REPL_PASSWORD=CHANGE_ME
|
||||
PRIMARY_REPL_HOST=104.238.220.230
|
||||
PRIMARY_BINLOG_RETENTION_HOURS=24
|
||||
|
||||
REPLICA_HOST=127.0.0.1
|
||||
REPLICA_PORT=3306
|
||||
REPLICA_USER=root
|
||||
REPLICA_PASSWORD=
|
||||
REPLICA_SOCKET=/var/run/mysqld/mysqld.sock
|
||||
REPLICA_DB=hifast
|
||||
REPLICA_SOURCE_SSL=1
|
||||
|
||||
DUMP_FILE=
|
||||
@@ -1,111 +0,0 @@
|
||||
Host: 0.0.0.0
|
||||
Port: 8080
|
||||
Debug: false
|
||||
|
||||
JwtAuth:
|
||||
AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET
|
||||
AccessExpire: 604800
|
||||
|
||||
Logger:
|
||||
ServiceName: PPanel
|
||||
Mode: console
|
||||
Encoding: plain
|
||||
TimeFormat: "2006-01-02 15:04:05.000"
|
||||
Path: logs
|
||||
Level: info
|
||||
MaxContentLength: 0
|
||||
Compress: false
|
||||
Stat: true
|
||||
KeepDays: 7
|
||||
StackCooldownMillis: 100
|
||||
MaxBackups: 7
|
||||
MaxSize: 100
|
||||
Rotation: daily
|
||||
FileTimeFormat: "2006-01-02T15:04:05.000Z07:00"
|
||||
|
||||
MySQL:
|
||||
Addr: YOUR_RDS_ENDPOINT:3306
|
||||
Dbname: hifast
|
||||
Username: admin
|
||||
Password: CHANGE_ME_TO_RDS_PASSWORD
|
||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||
MaxIdleConns: 10
|
||||
MaxOpenConns: 100
|
||||
SlowThreshold: 1000
|
||||
|
||||
Redis:
|
||||
Host: 127.0.0.1:6379
|
||||
Pass: CHANGE_ME_TO_REDIS_PASSWORD
|
||||
DB: 0
|
||||
PoolSize: 100
|
||||
MinIdleConns: 10
|
||||
MaxRetries: 3
|
||||
PoolTimeout: 4
|
||||
IdleTimeout: 300
|
||||
MaxConnAge: 0
|
||||
DialTimeout: 5
|
||||
ReadTimeout: 3
|
||||
WriteTimeout: 3
|
||||
|
||||
Trace:
|
||||
Name: ppanel-server
|
||||
Endpoint: 127.0.0.1:4317
|
||||
Sampler: 0.1
|
||||
Batcher: otlpgrpc
|
||||
|
||||
Site:
|
||||
Host: api-new.hifast.biz
|
||||
SiteName: HiFastVPN
|
||||
|
||||
Administrator:
|
||||
Email: admin@example.com
|
||||
Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD
|
||||
|
||||
Telegram:
|
||||
Enable: false
|
||||
BotID: 0
|
||||
BotName: ""
|
||||
BotToken: ""
|
||||
GroupChatID: ""
|
||||
EnableNotify: false
|
||||
WebHookDomain: ""
|
||||
|
||||
Kutt:
|
||||
Enable: false
|
||||
ApiURL: ""
|
||||
ApiKey: ""
|
||||
TargetURL: ""
|
||||
Domain: ""
|
||||
|
||||
OpenInstall:
|
||||
Enable: false
|
||||
AppKey: ""
|
||||
ApiKey: ""
|
||||
|
||||
Loki:
|
||||
Enable: true
|
||||
URL: "http://localhost:3100"
|
||||
|
||||
AppSignature:
|
||||
AppSecrets:
|
||||
android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET
|
||||
ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET
|
||||
web-client: CHANGE_ME_WEB_SIGNATURE_SECRET
|
||||
ValidWindowSeconds: 300
|
||||
SkipPrefixes:
|
||||
- /v1/notify/
|
||||
- /v1/iap/notifications
|
||||
- /v1/telegram/webhook
|
||||
- /v1/subscribe/config
|
||||
|
||||
Signature:
|
||||
EnableSignature: false
|
||||
|
||||
device:
|
||||
enable: true
|
||||
security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET
|
||||
|
||||
Register:
|
||||
EnableTrial: true
|
||||
EnableTrialEmailWhitelist: true
|
||||
TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com"
|
||||
@@ -1,23 +0,0 @@
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=CHANGE_ME
|
||||
MYSQL_SOCKET=
|
||||
|
||||
REPL_SOURCE_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
|
||||
REPL_SOURCE_PORT=3306
|
||||
REPL_SOURCE_USER=repl
|
||||
REPL_SOURCE_PASSWORD=CHANGE_ME
|
||||
REPL_SOURCE_SSL=1
|
||||
REPL_SOURCE_LOG_FILE=
|
||||
REPL_SOURCE_LOG_POS=
|
||||
REPL_SOURCE_AUTO_POSITION=1
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=CHANGE_ME
|
||||
|
||||
REDIS_SOURCE_HOST=18.163.33.75
|
||||
REDIS_SOURCE_PORT=6379
|
||||
REDIS_SOURCE_USER=
|
||||
REDIS_SOURCE_PASSWORD=CHANGE_ME
|
||||
@@ -1,33 +0,0 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
access_log /var/log/nginx/ppanel-access.log;
|
||||
error_log /var/log/nginx/ppanel-error.log warn;
|
||||
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location = /nginx_status {
|
||||
stub_status;
|
||||
access_log off;
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
# 提现 & 文件上传 & 日志上报 — 用户端 API 接口文档
|
||||
|
||||
> 基于 ppanel-server 源码整理,所有时间戳均为**秒级 Unix**。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [一、提现接口](#一提现接口)
|
||||
- [1.1 申请提现](#11-申请提现)
|
||||
- [1.2 取消提现](#12-取消提现)
|
||||
- [1.3 查询提现记录](#13-查询提现记录)
|
||||
- [二、枚举值与状态流转](#二枚举值与状态流转)
|
||||
- [三、文件上传接口](#三文件上传接口)
|
||||
- [3.1 直传文件(小文件)](#31-直传文件小文件)
|
||||
- [3.2 初始化上传(大文件 — 预签名)](#32-初始化上传大文件--预签名)
|
||||
- [3.3 确认上传完成](#33-确认上传完成)
|
||||
- [四、日志查询接口 (Admin)](#四日志查询接口-admin)
|
||||
- [4.1 错误日志列表](#41-错误日志列表)
|
||||
- [4.2 错误日志详情](#42-错误日志详情)
|
||||
- [4.3 日志消息原始详情](#43-日志消息原始详情)
|
||||
|
||||
---
|
||||
|
||||
## 一、提现接口
|
||||
|
||||
> 认证方式: JWT(用户登录态)
|
||||
>
|
||||
> 路由前缀: `/v1/public/user`
|
||||
|
||||
### 1.1 申请提现
|
||||
|
||||
提交佣金提现申请,创建一条待审核的提现记录。
|
||||
|
||||
```
|
||||
POST /v1/public/user/commission_withdraw
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
| 字段 | 类型 | 必填 | 校验 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `amount` | int64 | 是 | — | 提现金额(分) |
|
||||
| `method` | uint8 | 是 | `oneof=0 1 2 3` | 收款方式(见枚举表) |
|
||||
| `content` | string | 否 | — | 提现备注 |
|
||||
| `account` | string | 条件必填 | — | 收款账号 |
|
||||
| `qr_code_url` | string | 条件必填 | — | 收款码图片 URL |
|
||||
|
||||
**各收款方式的必填字段**
|
||||
|
||||
| method | 收款方式 | 必填字段 |
|
||||
|--------|---------|---------|
|
||||
| `1` 支付宝 | `qr_code_url` | 收款码图片 |
|
||||
| `2` 微信 | `qr_code_url` | 收款码图片 |
|
||||
| `3` 银行卡 | `account` | 收款账号 |
|
||||
| `0` 其他 | `account` 必填 |
|
||||
|
||||
**Request 示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"amount": 5000,
|
||||
"content": "提现到支付宝",
|
||||
"method": 1,
|
||||
"account": "user@example.com",
|
||||
"qr_code_url": "https://cdn.example.com/qrcode/alipay.png"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**: [`WithdrawalLog`](#withdrawallog-对象)
|
||||
|
||||
---
|
||||
|
||||
### 1.2 取消提现
|
||||
|
||||
用户取消自己的待审核提现申请,佣金退回账户。
|
||||
|
||||
```
|
||||
POST /v1/public/user/withdrawal_cancel
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
| 字段 | 类型 | 必填 | 校验 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `withdrawal_id` | int64 | 是 | `required,gt=0` | 提现记录 ID |
|
||||
|
||||
**Request 示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"withdrawal_id": 123
|
||||
}
|
||||
```
|
||||
|
||||
**Response**: [`WithdrawalLog`](#withdrawallog-对象)(状态已变为 `3=已取消`)
|
||||
|
||||
---
|
||||
|
||||
### 1.3 查询提现记录
|
||||
|
||||
分页查询当前用户的提现记录(自动按 JWT 中的 userId 过滤)。
|
||||
|
||||
```
|
||||
GET /v1/public/user/withdrawal_log
|
||||
```
|
||||
|
||||
**Query 参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `page` | int | 否 | 页码,默认 1 |
|
||||
| `size` | int | 否 | 每页条数,默认 10 |
|
||||
|
||||
**Request 示例**
|
||||
|
||||
```
|
||||
GET /v1/public/user/withdrawal_log?page=1&size=10
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"list": [WithdrawalLog, ...],
|
||||
"total": 25
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、枚举值与状态流转
|
||||
|
||||
### 提现状态 (`status`)
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| 0 | 待审核 |
|
||||
| 1 | 已通过 |
|
||||
| 2 | 已拒绝 |
|
||||
| 3 | 已取消 |
|
||||
|
||||
### 收款方式 (`method`)
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| 0 | 其他 |
|
||||
| 1 | 支付宝 |
|
||||
| 2 | 微信 |
|
||||
| 3 | 银行卡 |
|
||||
|
||||
### 状态流转
|
||||
|
||||
```
|
||||
┌── 管理员通过 ──▶ 已通过 (1)
|
||||
│
|
||||
待审核 (0) ──────┼── 管理员拒绝 ──▶ 已拒绝 (2)
|
||||
│
|
||||
└── 用户取消 ───▶ 已取消 (3)
|
||||
```
|
||||
|
||||
### WithdrawalLog 对象
|
||||
|
||||
所有提现接口共用的响应结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 100,
|
||||
"amount": 5000,
|
||||
"content": "提现备注",
|
||||
"status": 0,
|
||||
"reason": "",
|
||||
"method": 1,
|
||||
"account": "user@example.com",
|
||||
"qr_code_url": "https://cdn.example.com/qrcode/alipay.png",
|
||||
"created_at": 1716700000,
|
||||
"updated_at": 1716700000
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | int64 | 提现记录 ID |
|
||||
| `user_id` | int64 | 用户 ID |
|
||||
| `amount` | int64 | 提现金额(分) |
|
||||
| `content` | string | 提现备注 |
|
||||
| `status` | uint8 | 状态(见枚举表) |
|
||||
| `reason` | string | 拒绝原因(仅 status=2 时有值,其余 omitempty) |
|
||||
| `method` | uint8 | 收款方式(见枚举表) |
|
||||
| `account` | string | 收款账号 |
|
||||
| `qr_code_url` | string | 收款码图片 URL |
|
||||
| `created_at` | int64 | 创建时间(秒级 Unix) |
|
||||
| `updated_at` | int64 | 更新时间(秒级 Unix) |
|
||||
|
||||
---
|
||||
|
||||
## 三、文件上传接口
|
||||
|
||||
> 认证方式: JWT + DeviceMiddleware(用户登录态 + 设备认证)
|
||||
>
|
||||
> 路由前缀: `/v1/public/file`
|
||||
>
|
||||
> 存储后端: S3 兼容(RustFS)
|
||||
|
||||
提供两种上传方式:
|
||||
|
||||
| 方式 | 适用场景 | 流程 |
|
||||
|------|---------|------|
|
||||
| **直传** | 小文件(收款码等) | 1 次请求,`multipart/form-data` 直接上传 |
|
||||
| **预签名** | 大文件 / 客户端直传 S3 | init → 客户端 PUT 到预签名 URL → complete 确认 |
|
||||
|
||||
---
|
||||
|
||||
### 3.1 直传文件(小文件)
|
||||
|
||||
通过 `multipart/form-data` 直接上传文件到服务端,服务端转存至 S3。
|
||||
|
||||
```
|
||||
POST /v1/public/file/upload
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Form 参数**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `biz_type` | string | 是 | 业务类型(如 `withdrawal_qrcode`、`avatar` 等) |
|
||||
| `file` | file | 是 | 上传的文件(multipart) |
|
||||
|
||||
**cURL 示例**
|
||||
|
||||
```bash
|
||||
curl -X POST /v1/public/file/upload \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-F "biz_type=withdrawal_qrcode" \
|
||||
-F "file=@/path/to/alipay_qr.png"
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "a1b2c3d4e5f678901234",
|
||||
"file_name": "alipay_qr.png",
|
||||
"object_key": "app-upload/2026/05/27/100/alipay_qr.png__a1b2c3d4e5f678901234",
|
||||
"size": 52480,
|
||||
"content_type": "image/png",
|
||||
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
|
||||
"status": "completed"
|
||||
}
|
||||
```
|
||||
|
||||
**FileUploadResponse 字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `file_id` | string | 文件唯一 ID(24 字符 hex) |
|
||||
| `file_name` | string | 原始文件名 |
|
||||
| `object_key` | string | S3 对象路径 |
|
||||
| `size` | int64 | 文件大小(字节) |
|
||||
| `content_type` | string | MIME 类型 |
|
||||
| `etag` | string | S3 ETag |
|
||||
| `status` | string | 状态,直传成功即 `completed` |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 初始化上传(大文件 — 预签名)
|
||||
|
||||
获取 S3 预签名 URL,客户端直接 PUT 到 S3,避免文件经过服务端。
|
||||
|
||||
```
|
||||
POST /v1/public/file/upload/init
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
| 字段 | 类型 | 必填 | 校验 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `biz_type` | string | 是 | `required` | 业务类型 |
|
||||
| `file_name` | string | 是 | `required` | 文件名 |
|
||||
| `content_type` | string | 是 | `required` | MIME 类型(如 `image/png`) |
|
||||
| `size` | int64 | 是 | `required` | 文件大小(字节) |
|
||||
| `sha256` | string | 否 | — | 文件 SHA256(可选校验) |
|
||||
|
||||
**Request 示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"biz_type": "withdrawal_qrcode",
|
||||
"file_name": "wechat_qr.png",
|
||||
"content_type": "image/png",
|
||||
"size": 102400,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb924..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "b2c3d4e5f6789012345a",
|
||||
"object_key": "app-upload/2026/05/27/100/wechat_qr.png__b2c3d4e5f6789012345a",
|
||||
"upload_url": "https://s3.example.com/bucket/app-upload/...?X-Amz-Signature=...",
|
||||
"method": "PUT",
|
||||
"headers": {
|
||||
"Content-Type": "image/png"
|
||||
},
|
||||
"expired_at": 1716700300
|
||||
}
|
||||
```
|
||||
|
||||
**FileUploadInitResponse 字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `file_id` | string | 文件唯一 ID |
|
||||
| `object_key` | string | S3 对象路径 |
|
||||
| `upload_url` | string | 预签名上传 URL |
|
||||
| `method` | string | HTTP 方法(`PUT`) |
|
||||
| `headers` | map | 上传时需携带的请求头 |
|
||||
| `expired_at` | int64 | 预签名过期时间(秒级 Unix,默认 300 秒) |
|
||||
|
||||
**客户端上传流程**
|
||||
|
||||
```
|
||||
1. 调用 /upload/init 获取 upload_url
|
||||
2. 用返回的 method + headers 直接上传文件到 upload_url
|
||||
3. 上传成功后调用 /upload/complete 确认
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 确认上传完成
|
||||
|
||||
客户端通过预签名 URL 上传完成后,调用此接口确认文件状态。
|
||||
|
||||
```
|
||||
POST /v1/public/file/upload/complete
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
|
||||
| 字段 | 类型 | 必填 | 校验 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| `file_id` | string | 是 | `required` | init 返回的 file_id |
|
||||
|
||||
**Request 示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "b2c3d4e5f6789012345a"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_id": "b2c3d4e5f6789012345a",
|
||||
"object_key": "app-upload/2026/05/27/100/wechat_qr.png__b2c3d4e5f6789012345a",
|
||||
"size": 102400,
|
||||
"content_type": "image/png",
|
||||
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
|
||||
"status": "completed"
|
||||
}
|
||||
```
|
||||
|
||||
**FileUploadCompleteResponse 字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `file_id` | string | 文件唯一 ID |
|
||||
| `object_key` | string | S3 对象路径 |
|
||||
| `size` | int64 | 实际文件大小(S3 HeadObject 获取) |
|
||||
| `content_type` | string | MIME 类型 |
|
||||
| `etag` | string | S3 ETag |
|
||||
| `status` | string | `completed` |
|
||||
|
||||
**校验规则**
|
||||
|
||||
- 文件大小不能超过配置的 `S3.MaxUploadSize`
|
||||
- Content-Type 必须在配置的 `S3.AllowedContentTypes` 白名单内(若配置了)
|
||||
- complete 时会校验 S3 上的实际文件大小是否与 init 声明的一致
|
||||
- 只能确认自己发起的上传(userId 校验)
|
||||
|
||||
---
|
||||
|
||||
## 四、日志查询接口 (Admin)
|
||||
|
||||
> 认证方式: AuthMiddleware(管理员权限)
|
||||
>
|
||||
> 路由前缀: `/v1/admin/log`
|
||||
>
|
||||
> 数据来源: `log_message` 表(客户端上报的错误/崩溃日志)
|
||||
|
||||
---
|
||||
|
||||
### 4.1 错误日志列表
|
||||
|
||||
分页查询客户端上报的错误日志,支持多维度筛选。
|
||||
|
||||
```
|
||||
GET /v1/admin/log/error_message/list
|
||||
```
|
||||
|
||||
**Query 参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `page` | int | 是 | 页码 |
|
||||
| `size` | int | 是 | 每页条数 |
|
||||
| `platform` | string | 否 | 平台筛选(ios / android / windows / mac / harmony) |
|
||||
| `level` | uint8 | 否 | 日志级别 |
|
||||
| `user_id` | int64 | 否 | 用户 ID |
|
||||
| `device_id` | string | 否 | 设备 ID |
|
||||
| `error_code` | string | 否 | 错误码 |
|
||||
| `keyword` | string | 否 | 关键字搜索(匹配 message) |
|
||||
| `start` | int64 | 否 | 开始时间(秒级 Unix) |
|
||||
| `end` | int64 | 否 | 结束时间(秒级 Unix) |
|
||||
|
||||
**Request 示例**
|
||||
|
||||
```
|
||||
GET /v1/admin/log/error_message/list?page=1&size=20&platform=ios&start=1716600000&end=1716700000
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 50,
|
||||
"list": [
|
||||
{
|
||||
"id": 1,
|
||||
"platform": "ios",
|
||||
"app_version": "2.1.0",
|
||||
"os_name": "iOS",
|
||||
"os_version": "17.5",
|
||||
"device_id": "A1B2C3D4",
|
||||
"user_id": 100,
|
||||
"session_id": "sess_xxx",
|
||||
"level": 3,
|
||||
"error_code": "VPN_CONNECT_FAIL",
|
||||
"message": "Failed to establish VPN tunnel",
|
||||
"created_at": 1716700000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**ErrorLogMessage 字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | int64 | 日志 ID |
|
||||
| `platform` | string | 平台 |
|
||||
| `app_version` | string | 客户端版本 |
|
||||
| `os_name` | string | 操作系统名称 |
|
||||
| `os_version` | string | 操作系统版本 |
|
||||
| `device_id` | string | 设备 ID |
|
||||
| `user_id` | int64 | 用户 ID |
|
||||
| `session_id` | string | 会话 ID |
|
||||
| `level` | uint8 | 日志级别 |
|
||||
| `error_code` | string | 错误码 |
|
||||
| `message` | string | 错误消息 |
|
||||
| `created_at` | int64 | 创建时间(秒级 Unix) |
|
||||
|
||||
---
|
||||
|
||||
### 4.2 错误日志详情
|
||||
|
||||
获取单条错误日志的完整详情(列表字段 + 堆栈/IP/UA 等扩展信息)。
|
||||
|
||||
```
|
||||
GET /v1/admin/log/error_message/detail
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"platform": "ios",
|
||||
"app_version": "2.1.0",
|
||||
"os_name": "iOS",
|
||||
"os_version": "17.5",
|
||||
"device_id": "A1B2C3D4",
|
||||
"user_id": 100,
|
||||
"session_id": "sess_xxx",
|
||||
"level": 3,
|
||||
"error_code": "VPN_CONNECT_FAIL",
|
||||
"message": "Failed to establish VPN tunnel",
|
||||
"stack": "at VPNManager.connect() line 42\nat ...",
|
||||
"client_ip": "1.2.3.4",
|
||||
"user_agent": "PPanel/2.1.0 iOS/17.5",
|
||||
"locale": "zh-CN",
|
||||
"occurred_at": 1716700000,
|
||||
"created_at": 1716700000
|
||||
}
|
||||
```
|
||||
|
||||
**相比列表额外返回的字段**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `stack` | string | 堆栈信息 |
|
||||
| `client_ip` | string | 客户端 IP |
|
||||
| `user_agent` | string | User-Agent |
|
||||
| `locale` | string | 客户端语言/地区 |
|
||||
| `occurred_at` | int64 | 错误发生时间(秒级 Unix) |
|
||||
|
||||
---
|
||||
|
||||
### 4.3 日志消息原始详情
|
||||
|
||||
获取单条 `log_message` 的完整原始数据(含 context、digest 等全量字段)。
|
||||
|
||||
```
|
||||
GET /v1/admin/log/message/detail
|
||||
```
|
||||
|
||||
**Query 参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | int64 | 是 | 日志消息 ID |
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"platform": "ios",
|
||||
"app_version": "2.1.0",
|
||||
"os_name": "iOS",
|
||||
"os_version": "17.5",
|
||||
"device_id": "A1B2C3D4",
|
||||
"user_id": 100,
|
||||
"session_id": "sess_xxx",
|
||||
"level": 3,
|
||||
"error_code": "VPN_CONNECT_FAIL",
|
||||
"message": "Failed to establish VPN tunnel",
|
||||
"stack": "at VPNManager.connect() line 42\nat ...",
|
||||
"context": { "server_id": 5, "protocol": "vmess" },
|
||||
"client_ip": "1.2.3.4",
|
||||
"user_agent": "PPanel/2.1.0 iOS/17.5",
|
||||
"locale": "zh-CN",
|
||||
"digest": "sha256_abc123...",
|
||||
"occurred_at": 1716700000,
|
||||
"created_at": 1716700000
|
||||
}
|
||||
```
|
||||
|
||||
**相比详情额外返回的字段**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `context` | any | 附加上下文(原始 JSON) |
|
||||
| `digest` | string | 内容摘要(用于去重) |
|
||||
@@ -0,0 +1,559 @@
|
||||
# App 邀请列表与商品套餐折扣需求梳理
|
||||
|
||||
本文基于当前 `ppanel-server` 代码现状,对以下两个需求做整理:
|
||||
|
||||
1. App 需要一个“邀请列表/邀请记录”接口。
|
||||
2. 商品套餐需要补充“折扣信息”,当存在折扣时,App 需要做样式展示,并支持用户继续下单购买。
|
||||
|
||||
目标是帮助产品、前端、后端快速统一口径,明确:
|
||||
|
||||
- 现在已有哪些接口可以复用
|
||||
- 哪些地方确实需要新增
|
||||
- “接口增加三个字段”更适合加在哪一层
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求结论
|
||||
|
||||
### 1.1 邀请列表
|
||||
|
||||
当前公开侧已经有一部分邀请能力,但**没有一个完全匹配“App 邀请记录列表”语义的公开接口**。
|
||||
|
||||
现状:
|
||||
|
||||
- 已有 `GET /v1/public/user/affiliate/list`
|
||||
- 能返回“我邀请了哪些用户”
|
||||
- 但字段较少,只包含基础信息
|
||||
- 已有 `GET /v1/public/user/invite_sales`
|
||||
- 返回的是“被邀请用户的成交订单记录”
|
||||
- 不是“邀请用户记录列表”
|
||||
- 已有 `GET /v1/public/user/invite_stats`
|
||||
- 返回邀请统计
|
||||
- 不是列表
|
||||
|
||||
结论:
|
||||
|
||||
- 如果 App 只是要展示“我邀请了哪些人”,`/affiliate/list` 可以复用。
|
||||
- 如果 App 需要展示“邀请时间、是否购买、购买次数、带来的佣金/赠送天数”等完整邀请记录,则**建议新增一个公开接口**。
|
||||
|
||||
### 1.2 商品套餐折扣
|
||||
|
||||
当前公开侧套餐列表接口 `GET /v1/public/subscribe/list` **已经返回 `discount` 字段**,下单预览接口 `POST /v1/public/order/pre` 也已经支持折扣计算。
|
||||
|
||||
现状:
|
||||
|
||||
- 套餐列表已有折扣规则数组 `discount`
|
||||
- 预下单接口已有:
|
||||
- 原价 `price`
|
||||
- 实付 `amount`
|
||||
- 折扣金额 `discount`
|
||||
- 活动优惠 `promo_discount`
|
||||
- 优惠券减免 `coupon_discount`
|
||||
- 手续费 `fee_amount`
|
||||
|
||||
结论:
|
||||
|
||||
- 后端**不是完全没有折扣能力**,而是“已经有计算能力,但 App 展示层使用起来不够直接”。
|
||||
- 如果需求明确要求“接口增加三个字段”,**更推荐补在套餐列表返回的 `discount[]` 子项里**,而不是直接加在下单接口里。
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前代码现状
|
||||
|
||||
## 2.1 邀请相关
|
||||
|
||||
### 2.1.1 已有公开接口
|
||||
|
||||
#### A. 邀请基础列表
|
||||
|
||||
接口:
|
||||
|
||||
- `GET /v1/public/user/affiliate/list`
|
||||
|
||||
请求:
|
||||
|
||||
- `page`
|
||||
- `size`
|
||||
|
||||
返回结构:
|
||||
|
||||
- `total`
|
||||
- `list[]`
|
||||
- `identifier`
|
||||
- `avatar`
|
||||
- `registered_at`
|
||||
- `enable`
|
||||
|
||||
特点:
|
||||
|
||||
- 能表达“我邀请了谁”
|
||||
- 不能表达“是否购买 / 购买次数 / 给我带来多少收益”
|
||||
|
||||
对应代码:
|
||||
|
||||
- `apis/public/user.api`
|
||||
- `internal/logic/public/user/queryUserAffiliateListLogic.go`
|
||||
|
||||
#### B. 邀请成交记录
|
||||
|
||||
接口:
|
||||
|
||||
- `GET /v1/public/user/invite_sales`
|
||||
|
||||
返回结构:
|
||||
|
||||
- `total`
|
||||
- `list[]`
|
||||
- `amount`
|
||||
- `updated_at`
|
||||
- `user_hash`
|
||||
- `product_name`
|
||||
|
||||
特点:
|
||||
|
||||
- 更像“邀请带来的订单流水”
|
||||
- 不是邀请用户列表
|
||||
|
||||
对应代码:
|
||||
|
||||
- `internal/logic/public/user/getInviteSalesLogic.go`
|
||||
|
||||
#### C. 邀请统计
|
||||
|
||||
接口:
|
||||
|
||||
- `GET /v1/public/user/invite_stats`
|
||||
|
||||
返回结构:
|
||||
|
||||
- `friendly_count`
|
||||
- `history_count`
|
||||
|
||||
特点:
|
||||
|
||||
- 只适合头部统计卡片
|
||||
- 不适合列表页
|
||||
|
||||
对应代码:
|
||||
|
||||
- `internal/logic/public/user/getUserInviteStatsLogic.go`
|
||||
|
||||
### 2.1.2 已有后台接口
|
||||
|
||||
后台已经有更完整的邀请记录能力,可以直接参考:
|
||||
|
||||
- `GetAdminUserInviteList`
|
||||
- `GetInviteManageList`
|
||||
|
||||
这些接口已经能返回:
|
||||
|
||||
- 邀请时间
|
||||
- 是否购买
|
||||
- 购买次数
|
||||
- 邀请人佣金
|
||||
- 邀请人赠送天数
|
||||
- 被邀请人赠送天数
|
||||
|
||||
对应代码:
|
||||
|
||||
- `internal/logic/admin/user/getAdminUserInviteListLogic.go`
|
||||
- `internal/logic/admin/invite/getInviteManageListLogic.go`
|
||||
|
||||
结论:
|
||||
|
||||
- 邀请记录的统计逻辑后端已经有现成实现思路。
|
||||
- 新增 App 公开接口时,建议复用这部分逻辑,不要从零再写一套。
|
||||
|
||||
---
|
||||
|
||||
## 2.2 套餐折扣相关
|
||||
|
||||
### 2.2.1 套餐列表接口已有折扣规则
|
||||
|
||||
接口:
|
||||
|
||||
- `GET /v1/public/subscribe/list`
|
||||
|
||||
当前返回的套餐结构 `Subscribe` 中已包含:
|
||||
|
||||
- `unit_price`
|
||||
- `discount []SubscribeDiscount`
|
||||
|
||||
其中 `SubscribeDiscount` 当前字段为:
|
||||
|
||||
- `quantity`
|
||||
- `discount`
|
||||
- `new_user_only`
|
||||
- `map_apple`
|
||||
- `promo`
|
||||
|
||||
对应代码:
|
||||
|
||||
- `apis/public/subscribe.api`
|
||||
- `internal/logic/public/subscribe/querySubscribeListLogic.go`
|
||||
- `internal/types/types.go`
|
||||
|
||||
说明:
|
||||
|
||||
- `discount` 是按购买数量 `quantity` 生效的阶梯折扣
|
||||
- 不是单个套餐固定只有一个折扣值
|
||||
|
||||
### 2.2.2 预下单接口已有价格计算结果
|
||||
|
||||
接口:
|
||||
|
||||
- `POST /v1/public/order/pre`
|
||||
|
||||
当前已返回:
|
||||
|
||||
- `price`:原价
|
||||
- `amount`:最终应付
|
||||
- `discount`:折扣减免金额
|
||||
- `promo_discount`:活动优惠金额
|
||||
- `gift_amount`:礼品余额抵扣
|
||||
- `coupon_discount`:优惠券减免
|
||||
- `fee_amount`:手续费
|
||||
|
||||
对应代码:
|
||||
|
||||
- `apis/public/order.api`
|
||||
- `internal/logic/public/order/preCreateOrderLogic.go`
|
||||
|
||||
说明:
|
||||
|
||||
- 只要 App 知道 `subscribe_id + quantity [+ coupon] [+ payment]`,就已经能拿到准确的下单金额
|
||||
- 所以“下单购买”这件事本身,后端主链路已经具备
|
||||
|
||||
---
|
||||
|
||||
## 3. 差距分析
|
||||
|
||||
## 3.1 邀请列表的真实缺口
|
||||
|
||||
如果产品要的是“邀请记录页”,通常至少会关心以下内容:
|
||||
|
||||
- 被邀请用户
|
||||
- 邀请时间
|
||||
- 是否已购买
|
||||
- 购买次数
|
||||
- 给邀请人带来的佣金
|
||||
- 双方赠送天数
|
||||
|
||||
而当前:
|
||||
|
||||
- `/affiliate/list` 只有基础用户列表
|
||||
- `/invite_sales` 是订单成交记录
|
||||
- `/invite_stats` 是统计值
|
||||
|
||||
所以当前公开侧缺一个“**邀请关系维度的邀请记录列表**”。
|
||||
|
||||
## 3.2 套餐折扣的真实缺口
|
||||
|
||||
后端目前的主要问题不是“不会算折扣”,而是:
|
||||
|
||||
- `discount[]` 更偏规则定义
|
||||
- App 如果只想直接展示“折后价 / 优惠金额 / 折扣标签”,还需要自己再算一层
|
||||
- 这会增加前端理解成本,也容易和后端口径不一致
|
||||
|
||||
所以当前更合理的改法是:
|
||||
|
||||
- 保留现有折扣规则
|
||||
- 再额外补充几个**面向展示的字段**
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐方案
|
||||
|
||||
## 4.1 邀请记录接口
|
||||
|
||||
### 方案建议
|
||||
|
||||
新增一个公开接口,例如:
|
||||
|
||||
- `GET /v1/public/user/invite_records`
|
||||
|
||||
说明:
|
||||
|
||||
- 从登录态中取当前用户 ID
|
||||
- 不从前端传 `user_id`
|
||||
- 只查“当前用户邀请的记录”
|
||||
|
||||
### 请求参数建议
|
||||
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"size": 10
|
||||
}
|
||||
```
|
||||
|
||||
### 返回字段建议
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 2,
|
||||
"list": [
|
||||
{
|
||||
"invitee_id": 1001,
|
||||
"invitee_identifier": "138****8888",
|
||||
"invitee_avatar": "https://...",
|
||||
"invitee_enable": true,
|
||||
"invited_at": 1716800000,
|
||||
"order_count": 3,
|
||||
"has_purchased": true,
|
||||
"inviter_commission": 1200,
|
||||
"inviter_gift_days": 30,
|
||||
"invitee_gift_days": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
- `invitee_id`:被邀请用户 ID
|
||||
- `invitee_identifier`:被邀请用户展示账号
|
||||
- `invitee_avatar`:头像
|
||||
- `invitee_enable`:是否启用
|
||||
- `invited_at`:邀请时间
|
||||
- `order_count`:该被邀请用户产生的有效订单数
|
||||
- `has_purchased`:是否已购买
|
||||
- `inviter_commission`:给邀请人带来的佣金,单位建议继续沿用分
|
||||
- `inviter_gift_days`:邀请人获赠天数
|
||||
- `invitee_gift_days`:被邀请人获赠天数
|
||||
|
||||
### 实现建议
|
||||
|
||||
优先复用现有后台逻辑思路:
|
||||
|
||||
- 参考 `internal/logic/admin/user/getAdminUserInviteListLogic.go`
|
||||
- 或参考 `internal/logic/admin/invite/getInviteManageListLogic.go`
|
||||
|
||||
公开接口与后台接口的主要差异只有两点:
|
||||
|
||||
- 公开接口不允许前端指定 `user_id`
|
||||
- 公开接口按当前登录用户本人维度返回
|
||||
|
||||
### 是否可以不新增接口
|
||||
|
||||
可以,但前提是 App 接受以下拆分:
|
||||
|
||||
- 列表页用 `/affiliate/list`
|
||||
- 顶部统计用 `/invite_stats`
|
||||
- 订单流水页用 `/invite_sales`
|
||||
|
||||
如果产品要的是一个完整“邀请记录页”,不建议这样拆三次请求,前端维护成本偏高。
|
||||
|
||||
---
|
||||
|
||||
## 4.2 套餐折扣字段建议
|
||||
|
||||
### 核心建议
|
||||
|
||||
“接口增加三个字段”建议**加在 `SubscribeDiscount` 子项里**,不要直接加在 `Subscribe` 顶层。
|
||||
|
||||
原因:
|
||||
|
||||
- 折扣是按 `quantity` 生效的
|
||||
- 一个套餐可能有多个折扣档位
|
||||
- 如果加在套餐顶层,很难表达“买 1 个月”和“买 12 个月”对应不同折扣
|
||||
|
||||
### 推荐新增字段
|
||||
|
||||
建议在 `SubscribeDiscount` 中增加以下三个展示字段:
|
||||
|
||||
- `discount_price`
|
||||
- `discount_amount`
|
||||
- `discount_desc`
|
||||
|
||||
推荐结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"quantity": 12,
|
||||
"discount": 80,
|
||||
"new_user_only": false,
|
||||
"map_apple": "",
|
||||
"promo": null,
|
||||
"discount_price": 9600,
|
||||
"discount_amount": 2400,
|
||||
"discount_desc": "年付8折"
|
||||
}
|
||||
```
|
||||
|
||||
### 三个字段的含义
|
||||
|
||||
#### 1. `discount_price`
|
||||
|
||||
- 含义:该档位折后总价
|
||||
- 计算建议:`unit_price * quantity * discount / 100`
|
||||
- 单位:分
|
||||
|
||||
作用:
|
||||
|
||||
- App 可直接展示“折后价”
|
||||
- 下单时直接把该项的 `quantity` 带入 `/order/pre` 或 `/order/purchase`
|
||||
|
||||
#### 2. `discount_amount`
|
||||
|
||||
- 含义:该档位比原价便宜多少钱
|
||||
- 计算建议:`unit_price * quantity - discount_price`
|
||||
- 单位:分
|
||||
|
||||
作用:
|
||||
|
||||
- App 可直接展示“立省 xx”
|
||||
|
||||
#### 3. `discount_desc`
|
||||
|
||||
- 含义:折扣展示文案
|
||||
- 示例:
|
||||
- `年付8折`
|
||||
- `季付9折`
|
||||
- `新用户首单8折`
|
||||
|
||||
作用:
|
||||
|
||||
- App 可直接做角标、标签、促销文案展示
|
||||
|
||||
### 为什么不推荐这三个字段加在下单接口
|
||||
|
||||
因为下单接口本来就是“结果型接口”,它已经能返回:
|
||||
|
||||
- 原价
|
||||
- 折扣金额
|
||||
- 实付金额
|
||||
|
||||
如果只是为了 App 卡片展示,再去每个套餐都调一次 `/order/pre`,成本会比较高:
|
||||
|
||||
- 请求次数多
|
||||
- 页面首屏会更慢
|
||||
- 前端链路更复杂
|
||||
|
||||
更合适的做法是:
|
||||
|
||||
- 套餐列表接口负责“展示友好”
|
||||
- 预下单接口负责“结算准确”
|
||||
|
||||
---
|
||||
|
||||
## 5. 推荐改动清单
|
||||
|
||||
## 5.1 邀请列表
|
||||
|
||||
建议新增:
|
||||
|
||||
- 新接口:`GET /v1/public/user/invite_records`
|
||||
|
||||
建议新增类型:
|
||||
|
||||
- `GetUserInviteRecordsRequest`
|
||||
- `UserInviteRecord`
|
||||
- `GetUserInviteRecordsResponse`
|
||||
|
||||
建议实现位置:
|
||||
|
||||
- `apis/public/user.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/handler/public/user/`
|
||||
- `internal/logic/public/user/`
|
||||
|
||||
## 5.2 套餐折扣
|
||||
|
||||
建议调整:
|
||||
|
||||
- `SubscribeDiscount` 增加 3 个字段:
|
||||
- `discount_price`
|
||||
- `discount_amount`
|
||||
- `discount_desc`
|
||||
|
||||
建议实现位置:
|
||||
|
||||
- `apis/types.api`
|
||||
- `internal/types/types.go`
|
||||
- `internal/logic/public/subscribe/querySubscribeListLogic.go`
|
||||
|
||||
---
|
||||
|
||||
## 6. 前后端协作建议
|
||||
|
||||
## 6.1 App 侧调用建议
|
||||
|
||||
邀请页建议:
|
||||
|
||||
- 头部统计:`/v1/public/user/invite_stats`
|
||||
- 邀请记录列表:`/v1/public/user/invite_records`
|
||||
- 如果还要看成交流水:`/v1/public/user/invite_sales`
|
||||
|
||||
套餐页建议:
|
||||
|
||||
- 先调 `/v1/public/subscribe/list` 渲染套餐和折扣标签
|
||||
- 用户点某个折扣档位时,带 `subscribe_id + quantity` 调 `/v1/public/order/pre`
|
||||
- 用户确认后再调 `/v1/public/order/purchase`
|
||||
|
||||
## 6.2 单位口径建议
|
||||
|
||||
建议继续保持后端金额统一为“分”:
|
||||
|
||||
- `unit_price`
|
||||
- `discount_price`
|
||||
- `discount_amount`
|
||||
- `amount`
|
||||
- `coupon_discount`
|
||||
|
||||
这样可以避免前后端出现小数精度问题。
|
||||
|
||||
---
|
||||
|
||||
## 7. 最终建议
|
||||
|
||||
### 建议一
|
||||
|
||||
如果你们只是要“邀请用户名单”,可直接复用:
|
||||
|
||||
- `GET /v1/public/user/affiliate/list`
|
||||
|
||||
### 建议二
|
||||
|
||||
如果你们要的是完整“邀请记录”,建议新增:
|
||||
|
||||
- `GET /v1/public/user/invite_records`
|
||||
|
||||
这是本次需求里更合理的新增接口。
|
||||
|
||||
### 建议三
|
||||
|
||||
商品套餐“折扣信息”不建议重新设计一整套下单逻辑。
|
||||
|
||||
当前后端已经具备:
|
||||
|
||||
- 套餐折扣规则
|
||||
- 预下单价格计算
|
||||
- 正式下单购买
|
||||
|
||||
更推荐做法是:
|
||||
|
||||
- 在 `SubscribeDiscount` 里补 3 个展示字段:
|
||||
- `discount_price`
|
||||
- `discount_amount`
|
||||
- `discount_desc`
|
||||
|
||||
这样改动最小,也最贴近 App 展示场景。
|
||||
|
||||
---
|
||||
|
||||
## 8. 相关代码位置
|
||||
|
||||
- `internal/logic/public/user/queryUserAffiliateListLogic.go`
|
||||
- `internal/logic/public/user/getInviteSalesLogic.go`
|
||||
- `internal/logic/public/user/getUserInviteStatsLogic.go`
|
||||
- `internal/logic/admin/user/getAdminUserInviteListLogic.go`
|
||||
- `internal/logic/admin/invite/getInviteManageListLogic.go`
|
||||
- `internal/logic/public/subscribe/querySubscribeListLogic.go`
|
||||
- `internal/logic/public/order/preCreateOrderLogic.go`
|
||||
- `internal/logic/public/order/purchaseLogic.go`
|
||||
- `internal/types/types.go`
|
||||
- `apis/public/user.api`
|
||||
- `apis/public/subscribe.api`
|
||||
- `apis/public/order.api`
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
# App 端三个接口对接文档
|
||||
|
||||
> 适用:移动端 / 桌面端 App
|
||||
> 维护:基于当前 `internal/handler` + `internal/logic` 代码反向梳理
|
||||
> 时间:2026-05-27
|
||||
|
||||
涉及接口:
|
||||
|
||||
1. [文件上传](#1-文件上传) — `POST /v1/public/file/upload`
|
||||
2. [订阅列表(含促销 promo)](#2-订阅列表含促销-promo) — `GET /v1/public/subscribe/list`
|
||||
3. [邀请赠送记录](#3-邀请赠送记录) — `GET /v1/public/user/invite_records`
|
||||
|
||||
公共说明:
|
||||
|
||||
- BaseURL 示例:`https://tapi.hifast.biz`
|
||||
- 鉴权头:`Authorization: <JWT>`(注意:**不要**写 `Bearer ` 前缀,本项目 `AuthMiddleware` 直接取 token 值)
|
||||
- 业务码包在 `{ code, msg, data }` 信封中,`code = 200` 为成功
|
||||
- 默认 `Accept: application/json`,可选 `lang: zh_CN`
|
||||
- 经过 `AuthMiddleware` + `DeviceMiddleware` 的接口都需要登录态 + 设备绑定校验
|
||||
|
||||
---
|
||||
|
||||
## 1. 文件上传
|
||||
|
||||
### 1.1 Endpoint
|
||||
|
||||
```
|
||||
POST /v1/public/file/upload
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
- Handler: `internal/handler/public/file/fileUploadHandler.go`
|
||||
- Logic: `internal/logic/public/file/fileuploadlogic.go:33`
|
||||
- 路由: `internal/handler/routes.go:934`
|
||||
- 中间件: `AuthMiddleware` + `DeviceMiddleware`(必须登录)
|
||||
|
||||
### 1.2 请求
|
||||
|
||||
#### Form 参数
|
||||
|
||||
| 字段 | 位置 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `biz_type` | form | 是 | 业务分类标签,会作为对象 key 的一部分(如 `app-package`、`avatar`) |
|
||||
| `file` | form file | 是 | 待上传文件二进制 |
|
||||
|
||||
#### 文件约束(来自 `etc/ppanel.yaml` → `S3`,可调整)
|
||||
|
||||
| 项 | 默认值 |
|
||||
|---|---|
|
||||
| 单文件最大 | **104857600 字节(100 MiB)** |
|
||||
| 允许的 Content-Type | `application/zip, application/x-zip-compressed, application/gzip, application/x-gzip, application/octet-stream, text/plain, application/json, image/jpeg, image/jpg, image/png, image/webp, image/gif, image/heic, image/heif, image/bmp` |
|
||||
|
||||
> Content-Type 判定优先级:multipart 文件头里的 `Content-Type` → 文件嗅探(前 512 字节)→ 兜底 `application/octet-stream`。
|
||||
> **前端 form 上传时尽量带上 `Content-Type`**,否则被嗅探成 `application/octet-stream` 可能不在白名单里。
|
||||
|
||||
### 1.3 请求示例
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload' \
|
||||
-H 'Authorization: <JWT>' \
|
||||
-H 'Accept: application/json' \
|
||||
-F 'biz_type=app-package' \
|
||||
-F 'file=@"/Users/Apple/Documents/avatar.jpg";type=image/jpeg'
|
||||
```
|
||||
|
||||
### 1.4 响应
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `data.url` | string | 上传完成后的可访问 URL,规则:`{S3.PublicBaseURL or S3.Endpoint}/{bucket}/{prefix}/{YYYY}/{MM}/{DD}/{userId}/{safeFileName}__{fileId}` |
|
||||
|
||||
成功示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"url": "http://107.173.50.22:5016/hifastvpn/app-upload/2026/05/28/510/2026-05-27_20.03.55.jpg__226ad097c2ee4e3546e729c5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 错误码
|
||||
|
||||
| 业务码 | 触发场景 |
|
||||
|---|---|
|
||||
| `InvalidAccess` | 未登录 / JWT 无效 |
|
||||
| `ParamError` | 缺少 `biz_type` 或 `file` |
|
||||
| `InvalidParams` | `biz_type` 为空、文件名为空、size <= 0、超过 `MaxUploadSize`、`Content-Type` 不在白名单 |
|
||||
| `ERROR` | S3 未启用(`S3.Enable=false`) / S3 写入失败 |
|
||||
|
||||
### 1.6 前端易踩坑
|
||||
|
||||
1. `file` 字段名必须是 `file`,写 `image` / `upload` 都不行。
|
||||
2. `biz_type` 走 form 字段(`form:"biz_type"`),不要塞 query 里。
|
||||
3. 上传成功只返回 `url`,不返回 `file_id` / 大小等元数据;如需附加元数据,请走分片协议 `POST /upload/init` + `POST /upload/complete`。
|
||||
4. 想上传 PDF / DOC 不会成功——白名单里没有,需要后端调 `S3.AllowedContentTypes`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 订阅列表(含促销 promo)
|
||||
|
||||
### 2.1 Endpoint
|
||||
|
||||
```
|
||||
GET /v1/public/subscribe/list
|
||||
```
|
||||
|
||||
- Handler: `internal/handler/public/subscribe/querySubscribeListHandler.go`
|
||||
- Logic: `internal/logic/public/subscribe/querySubscribeListLogic.go:32`
|
||||
- Promo 合并逻辑: `internal/logic/public/subscribe/promo.go`
|
||||
- 路由: `internal/handler/routes.go:1026`
|
||||
- 中间件: **`OptionalAuthMiddleware` + `DeviceMiddleware`**(**未登录也能请求**,但未登录时只能拿到 `rule_type = campaign` 的促销)
|
||||
|
||||
### 2.2 请求
|
||||
|
||||
#### Query 参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `language` | string | 否 | 语言筛选,传值后返回该语言版本;不传则按系统默认语言返回 |
|
||||
|
||||
#### 头部说明(影响返回内容)
|
||||
|
||||
| Header | 影响 |
|
||||
|---|---|
|
||||
| `Authorization` | 传则识别为登录态,能拿到 `new_user` / `inactive_user` 类型的个性化促销;不传只返回 `campaign` 类型 |
|
||||
| `X-App-Id` | **不传**会被识别为"老版本客户端",每个套餐的 `discount` 列表会被**截掉最后一个元素**。新版 App 必须带 `X-App-Id` |
|
||||
|
||||
### 2.3 请求示例
|
||||
|
||||
```bash
|
||||
curl -X GET 'https://tapi.hifast.biz/v1/public/subscribe/list?language=zh-CN' \
|
||||
-H 'Authorization: <JWT>' \
|
||||
-H 'X-App-Id: hifast-ios' \
|
||||
-H 'Accept: application/json'
|
||||
```
|
||||
|
||||
### 2.4 响应
|
||||
|
||||
#### 顶层
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `data.total` | int64 | 返回的套餐数量(= `len(list)`,不是数据库总数) |
|
||||
| `data.list` | Subscribe[] | 套餐列表 |
|
||||
|
||||
#### `Subscribe` 关键字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int64 | 套餐 ID |
|
||||
| `name` | string | 套餐名 |
|
||||
| `language` | string | 当前返回的语言版本 |
|
||||
| `description` | string | 套餐描述(可能是富文本/Markdown) |
|
||||
| `unit_price` | int64 | 单时间单位**原价**,单位:**分** |
|
||||
| `unit_time` | string | 时间单位,枚举:`Day` / `Month` / `Year`(注意首字母大写) |
|
||||
| `discount` | SubscribeDiscount[] | 量级折扣 + 促销,按 `quantity` 升序 |
|
||||
| `node_count` | int64 | 节点数 |
|
||||
| `traffic` | int64 | 套餐总流量,单位:字节 |
|
||||
| `speed_limit` | int64 | 限速,单位见后端约定 |
|
||||
| `device_limit` | int64 | 同时在线设备数限制 |
|
||||
| `quota` | int64 | 总配额 |
|
||||
| `show` | bool | 是否在前端展示 |
|
||||
| `sell` | bool | 是否可售卖(本接口只返回 `sell=true`) |
|
||||
| `show_original_price` | bool | 是否展示划线原价 |
|
||||
| `reset_cycle` | int64 | 流量重置周期 |
|
||||
| `renewal_reset` | bool | 续费时是否重置流量 |
|
||||
| `created_at` / `updated_at` | int64 | 秒级 Unix 时间戳 |
|
||||
|
||||
#### `SubscribeDiscount` 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `quantity` | int64 | 购买的时间单位数量(如 1 = 1 个月,3 = 3 个月) |
|
||||
| `discount` | float64 | 量级折扣比例,0 表示无折扣,0.05 表示再优惠 5% |
|
||||
| `map_apple` | string | 对应 Apple IAP 商品 ID |
|
||||
| `promo` | SubscribePromo \| null | **#77 新增的促销对象**,命中促销规则时下发,否则为 `null` |
|
||||
|
||||
#### `SubscribePromo` 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `rule_name` | string | 促销规则名(运营在后台填写,可直接给用户展示,如"新人首单 8 折") |
|
||||
| `rule_type` | string | 规则类型枚举(见下表) |
|
||||
| `promo_price` | int64 | **促销价**,单位:**分**。优先级高于 `unit_price * discount`,前端命中促销时按此价显示 |
|
||||
| `expires_at` | int64 | 该促销对当前用户的失效时间(**秒级 Unix**),`0` 表示无明确截止 |
|
||||
|
||||
#### `rule_type` 枚举
|
||||
|
||||
| 值 | 含义 | 资格判定 |
|
||||
|---|---|---|
|
||||
| `campaign` | 全员/限时活动 | 仅看 `start_time` / `end_time` 是否在窗口内;**未登录也会下发** |
|
||||
| `new_user` | 新用户首单 | 登录用户,且 `now < user.created_at + params.window_hours`;`expires_at = user.created_at + window_hours` |
|
||||
| `inactive_user` | 老用户唤回 | 登录用户,且距离最近一个订阅过期已超过 `params.inactive_months` 个月;`expires_at = 规则 end_time` |
|
||||
|
||||
> 多条促销规则命中同一 `(subscribe_id, quantity)` 时,按 `priority DESC, id ASC` 取**首条**,不是合并。
|
||||
|
||||
### 2.5 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 1,
|
||||
"list": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "月付套餐",
|
||||
"language": "zh-CN",
|
||||
"description": "...",
|
||||
"unit_price": 1000,
|
||||
"unit_time": "Month",
|
||||
"show_original_price": true,
|
||||
"node_count": 30,
|
||||
"traffic": 107374182400,
|
||||
"device_limit": 3,
|
||||
"discount": [
|
||||
{
|
||||
"quantity": 1,
|
||||
"discount": 0,
|
||||
"map_apple": "ios.month1",
|
||||
"promo": {
|
||||
"rule_name": "新人首单 8 折",
|
||||
"rule_type": "new_user",
|
||||
"promo_price": 800,
|
||||
"expires_at": 1780500000
|
||||
}
|
||||
},
|
||||
{
|
||||
"quantity": 3,
|
||||
"discount": 0.05,
|
||||
"map_apple": "ios.month3",
|
||||
"promo": null
|
||||
}
|
||||
],
|
||||
"show": true,
|
||||
"sell": true,
|
||||
"created_at": 1764547200,
|
||||
"updated_at": 1779934580
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 价格计算建议(前端)
|
||||
|
||||
对每个 `discount` 元素:
|
||||
|
||||
```
|
||||
原价 = unit_price * quantity
|
||||
量级折后价 = round(原价 * (1 - discount))
|
||||
|
||||
if promo != null:
|
||||
实付 = promo.promo_price * quantity // 注意:promo_price 是「单价」,乘以 quantity
|
||||
划线价 = 原价 // 用于展示「省 XX」
|
||||
else:
|
||||
实付 = 量级折后价
|
||||
划线价 = 原价(show_original_price=true 时展示)
|
||||
```
|
||||
|
||||
> 注意:`promo_price` 设计为**单价**(与 `unit_price` 同级),不是总价。
|
||||
> 命中促销时建议同时显示 `rule_name`("新人首单 8 折")和倒计时(基于 `expires_at`)。
|
||||
|
||||
### 2.7 前端易踩坑
|
||||
|
||||
1. **必带 `X-App-Id`**——否则 `discount` 数组最后一个会被砍掉。
|
||||
2. **促销分登录态**:未登录时只能拿到 `campaign`;未拿到 `new_user`/`inactive_user` 时先检查是否传了 `Authorization`。
|
||||
3. **`unit_time` 是 PascalCase**:`Day` / `Month` / `Year`,别小写匹配。
|
||||
4. **金额单位都是分**(`unit_price`、`promo_price`),展示时除以 100。
|
||||
5. **`expires_at = 0`** 表示无截止,不要展示成 1970 年。
|
||||
6. `total` 是当前返回的条数,不是数据库总数(接口在 logic 里强制 `Size: 9999`,相当于不分页)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 邀请赠送记录
|
||||
|
||||
> 当前用户的"邀请赠送天数"流水。包含两类:
|
||||
> - 当前用户作为**邀请人**,被邀请的朋友下单触发的赠送;
|
||||
> - 当前用户作为**被邀请人**,自己下单触发的对应赠送(双向赠送)。
|
||||
>
|
||||
> 数据源:`system_logs` 表,`type = 33 (TypeGift)` 且 `content.remark = "邀请赠送"`。
|
||||
> 这里**只是赠送天数**,不包含邀请佣金(请走 affiliate 系列接口)。
|
||||
|
||||
### 3.1 Endpoint
|
||||
|
||||
```
|
||||
GET /v1/public/user/invite_records
|
||||
```
|
||||
|
||||
- Handler: `internal/handler/public/user/getInviteRecordsHandler.go`
|
||||
- Logic: `internal/logic/public/user/getInviteRecordsLogic.go:61`
|
||||
- 路由: `internal/handler/routes.go:1122`
|
||||
- 中间件: `AuthMiddleware` + `DeviceMiddleware`(必须登录)
|
||||
|
||||
### 3.2 请求
|
||||
|
||||
#### Query 参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `page` | int | 否 | `1` | 页码,<1 自动归一为 1 |
|
||||
| `size` | int | 否 | `10` | 每页条数,<1 归一为 10,**>100 截断为 100** |
|
||||
| `start_time` | int64 | 否 | `0` | 起始时间(**秒级 Unix**),`0` 表示不过滤下界 |
|
||||
| `end_time` | int64 | 否 | `0` | 截止时间(**秒级 Unix**),`0` 表示不过滤上界 |
|
||||
|
||||
> ⚠️ `start_time` / `end_time` 单位是**秒**(后端用 `FROM_UNIXTIME(?)`)。传毫秒会过滤掉所有记录。
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```bash
|
||||
# 不带时间过滤
|
||||
curl -X GET 'https://tapi.hifast.biz/v1/public/user/invite_records?page=1&size=20' \
|
||||
-H 'Authorization: <JWT>' \
|
||||
-H 'Accept: application/json'
|
||||
|
||||
# 带时间过滤
|
||||
curl -X GET 'https://tapi.hifast.biz/v1/public/user/invite_records?page=1&size=20&start_time=1764547200&end_time=1780099200' \
|
||||
-H 'Authorization: <JWT>'
|
||||
```
|
||||
|
||||
> 旧 curl 模板里的 `--data-urlencode 'page=1'` 等对 GET 是 form body,不会被读取,请用 query string。
|
||||
|
||||
### 3.3 响应
|
||||
|
||||
#### 顶层
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `data.total` | int64 | 当前过滤条件下的**记录总数**(用于分页) |
|
||||
| `data.list` | InviteRecord[] | 当前页列表,可能为空数组 `[]` |
|
||||
|
||||
#### `InviteRecord` 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `role` | string | 当前用户在该条记录中的角色:`inviter` 或 `invitee`(详见下表) |
|
||||
| `peer_hash` | string | 对端用户的脱敏哈希(10 位定长数字字符串),用于"匿名展示朋友"。订单已删 / 对端 id 缺失时为 `""` |
|
||||
| `gift_days` | int64 | 本次赠送天数(来源 `system_logs.content.amount`) |
|
||||
| `order_no` | string | 触发本次赠送的订单号 |
|
||||
| `created_at` | int64 | 赠送时间,**毫秒级 Unix**(SQL 端 `UNIX_TIMESTAMP(created_at) * 1000`) |
|
||||
|
||||
> ⚠️ **时间戳单位不一致**:请求里的 `start_time/end_time` 是**秒**,响应里的 `created_at` 是**毫秒**。前端请区分对待。
|
||||
> (与项目其它接口"统一秒级"约定不同,是该接口的当前实现。)
|
||||
|
||||
#### `role` 取值
|
||||
|
||||
| 值 | 含义 | `peer_hash` 来源 |
|
||||
|---|---|---|
|
||||
| `inviter` | 当前用户是**邀请人**,朋友下单触发的赠送 | 被邀请人(即订单的 `user_id`)的脱敏 hash |
|
||||
| `invitee` | 当前用户是**被邀请人**,自己下单触发的赠送 | 邀请人(`user.referer_id`)的脱敏 hash |
|
||||
|
||||
判定规则:默认 `inviter`;若 `order.user_id == 当前用户 id`,切换为 `invitee` 并改用 `referer_id` 计算 hash。
|
||||
|
||||
#### 排序与分页
|
||||
|
||||
- 排序:`created_at DESC, id DESC`(最近一条在最前)
|
||||
- 分页:`LIMIT size OFFSET (page-1)*size`
|
||||
- `total` **不**受 `LIMIT/OFFSET` 影响
|
||||
|
||||
### 3.4 响应示例
|
||||
|
||||
非空:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 2,
|
||||
"list": [
|
||||
{
|
||||
"role": "inviter",
|
||||
"peer_hash": "0382716459",
|
||||
"gift_days": 30,
|
||||
"order_no": "20260527123456789",
|
||||
"created_at": 1779934580000
|
||||
},
|
||||
{
|
||||
"role": "invitee",
|
||||
"peer_hash": "1745920031",
|
||||
"gift_days": 30,
|
||||
"order_no": "20260520112233445",
|
||||
"created_at": 1779329780000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
空:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 0,
|
||||
"list": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 错误码
|
||||
|
||||
| 业务码 | 触发场景 |
|
||||
|---|---|
|
||||
| `InvalidAccess` | 未登录 / JWT 无效 |
|
||||
| `ParamError` | 参数绑定失败 |
|
||||
| `DatabaseQueryError` | DB 查询失败(count / 日志 / 订单任一) |
|
||||
|
||||
### 3.6 前端易踩坑
|
||||
|
||||
1. 传**毫秒**给 `start_time/end_time` → 永远拿到空集。请传**秒**。
|
||||
2. 拿到的 `created_at` 是**毫秒**,**不要再 `*1000`**,直接 `new Date(created_at)` 即可。
|
||||
3. 空列表是 `[]` 不是 `null`,可直接 `.map`。
|
||||
4. `peer_hash` 可能为 `""`,UI 兜底展示"未知朋友"。
|
||||
5. `size` 上限 100,传 1000 会被截断。
|
||||
6. 本接口**只含赠送天数**,不含邀请佣金(佣金 → affiliate 接口)。
|
||||
|
||||
---
|
||||
|
||||
## 附录:业务码常量速查
|
||||
|
||||
| 名称 | HTTP 含义 | 出现场景 |
|
||||
|---|---|---|
|
||||
| `200` | 成功 | `{"code":200,"msg":"success",...}` |
|
||||
| `InvalidAccess` | 未授权 | 未登录 / JWT 无效 / 设备未绑定 |
|
||||
| `ParamError` | 参数错误 | 请求绑定失败、缺必填项 |
|
||||
| `InvalidParams` | 参数校验不通过 | 业务规则校验失败(文件超限、Content-Type 不合法等) |
|
||||
| `DatabaseQueryError` | DB 错 | SQL 查询失败 |
|
||||
| `ERROR` | 通用错 | 第三方/中间件失败(S3 未启用、S3 写入失败等) |
|
||||
@@ -0,0 +1,254 @@
|
||||
# 开发流程(迁移到 GitHub 后)
|
||||
|
||||
> 适用:`github.com/TawCorp/hifast-server`(canonical 仓库)及配套的 Multica agent 工作区。
|
||||
> 历史背景:本仓库于 2026-06-03 从 `git.kxsw.us/HI-VPN/hi-server` 迁移到 GitHub。**`git.kxsw.us` 已废弃**,所有新开发只走本仓库。
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
```
|
||||
Multica issue → 分支 fix/<num>-中文简述 → 推 github → 开 PR → CI 绿 → 架构师 approve
|
||||
→ GitHub UI Squash and merge 进 internal → 测试环境部署 (deploy-staging.yml) 自动跑
|
||||
→ QA 在 staging 验收 → Multica issue → done
|
||||
|
||||
发版时:架构师把 internal squash 进 main → 对外正式版本
|
||||
```
|
||||
|
||||
不要在 GitHub UI 之外 squash 后直接 push `internal` / `main`。不要往 `git.kxsw.us` 推。
|
||||
|
||||
---
|
||||
|
||||
## 1. 分支模型
|
||||
|
||||
| 分支 | 角色 | 写入方式 |
|
||||
|---|---|---|
|
||||
| `main` | 对外正式版本(生产) | **只**由架构师 squash merge `internal` → `main` |
|
||||
| `internal` | 日常开发主干,staging 触发分支 | **只**由架构师在 GitHub UI 上 Squash and merge PR |
|
||||
| `fix/<num>-…` | bug 修复分支 | 工程师自己开自己删(PR 合并后 GitHub 自动删) |
|
||||
| `feat/<num>-…` | 新功能分支 | 同上 |
|
||||
| `chore/…` `refactor/…` `hotfix/…` | 杂项 / 重构 / 紧急修复 | 同上 |
|
||||
|
||||
**分支命名约定**(严格遵守,便于 CI / CODEOWNERS / 历史追溯):
|
||||
|
||||
| 前缀 | 用途 | 示例 |
|
||||
|---|---|---|
|
||||
| `fix/<num>-` | 关联 Multica issue 编号的 bug 修复 | `fix/143-邀请权益单测flake` |
|
||||
| `feat/<num>-` | 关联 Multica issue 编号的新功能 | `feat/77-套餐列表促销信息` |
|
||||
| `chore/` | 配置 / 文档 / 工具链(无关联 issue 可不带编号) | `chore/dev-workflow-docs` |
|
||||
| `hotfix/<num>-` | 生产紧急修复 | `hotfix/151-payment-callback-503` |
|
||||
| `refactor/<num>-` | 重构(行为不变) | `refactor/138-promo-eligibility` |
|
||||
|
||||
**单分支存活上限:3 天**(架构师 CLAUDE.md 约定)。超时未合并的分支由架构师在 issue 上 ping 持有者收尾或重切。
|
||||
|
||||
---
|
||||
|
||||
## 2. 标准 PR 生命周期
|
||||
|
||||
### 2.1 立项
|
||||
|
||||
- Multica 上有对应 issue(HIF-XXX)。
|
||||
- issue 已 assigned,状态 `todo` 或 `in_progress`。
|
||||
|
||||
### 2.2 写代码
|
||||
|
||||
```bash
|
||||
# 在 worktree 里
|
||||
git fetch origin
|
||||
git checkout -B fix/<num>-中文简述 origin/internal
|
||||
|
||||
# 编码 + 写测试
|
||||
# lefthook pre-commit 会自动跑 fmt / imports / lint / vet / test
|
||||
git commit -m "修复(#<num>): 一句话说清楚做了什么"
|
||||
|
||||
# 推到 github(不再推 git.kxsw.us)
|
||||
git push origin fix/<num>-中文简述
|
||||
```
|
||||
|
||||
**commit message**(commitlint 强制):
|
||||
|
||||
```
|
||||
<类型>(#<num>): <一句话描述,<= 72 字符>
|
||||
|
||||
<可选正文,说明 why>
|
||||
```
|
||||
|
||||
类型只有这五个:**`修复` / `新功能` / `重构` / `文档` / `配置`**。其它一律不允许。
|
||||
|
||||
### 2.3 开 PR
|
||||
|
||||
```bash
|
||||
gh pr create --base internal --title '修复(#<num>): ...' --body-file <path>
|
||||
```
|
||||
|
||||
或 GitHub UI 开。PR 模板(`.github/PULL_REQUEST_TEMPLATE.md`)会自动填入,按指引补内容。
|
||||
|
||||
**PR 标题必须等于 squash 后的合入 commit 标题**(用 `<类型>(#<num>): ...` 形式)。
|
||||
|
||||
### 2.4 CI 自动跑
|
||||
|
||||
`.github/workflows/ci.yml` 在 PR 上触发:
|
||||
|
||||
| Job | 执行 |
|
||||
|---|---|
|
||||
| `build-and-test` | `go build ./...` + `go vet ./...` + `go test -race -count=1 ./...` |
|
||||
| `lint` | `golangci-lint run` |
|
||||
|
||||
红 → 工程师本地复现 + 修,反复直到全绿。
|
||||
|
||||
### 2.5 Code review
|
||||
|
||||
- `CODEOWNERS` 自动 request review。
|
||||
- 架构师(或被指派的 reviewer)逐 hunk 看,特别关注:
|
||||
- scope 与 issue 一致性(无 scope creep——架构师红线之一)
|
||||
- 错误处理 / SQL 注入 / N+1
|
||||
- 测试覆盖关键边界
|
||||
- 是否引入了无关的代码改动(架构师红线:"发现成员修改了无关代码 → 立即打回重做")
|
||||
- review 通过即在 PR 上点 Approve。
|
||||
|
||||
### 2.6 Merge to `internal`
|
||||
|
||||
- **必须用 GitHub UI 的 "Squash and merge"**。
|
||||
- squash 后 commit message 由架构师编辑确认:保持 `<类型>(#<num>): ...` 形式 + 必要正文。
|
||||
- 合并按钮按下后 PR 自动 close,分支由 GitHub 自动删("Automatically delete head branches" 应开启)。
|
||||
- 架构师在 Multica issue 上 `@运维工程师` 附 commit hash,通知可以部署(虽然测试环境部署 workflow 已自动跑,但运维需要确认部署状态)。
|
||||
|
||||
**禁止做的事**:
|
||||
- ❌ 在本地 squash 再 `git push` 到 `internal` / `main`
|
||||
- ❌ Rebase merge / Create a merge commit(保持 linear history)
|
||||
- ❌ Force push 到 `internal` / `main`
|
||||
- ❌ Bypass CI(CI 红时不要 merge)
|
||||
- ❌ 自己 approve 自己的 PR
|
||||
- ❌ 未经测试工程师验收的分支合并(架构师红线)
|
||||
|
||||
### 2.7 测试环境部署自动触发 (`.github/workflows/deploy-staging.yml`)
|
||||
|
||||
合并到 `internal` 后,`.github/workflows/deploy-staging.yml` 自动触发:
|
||||
|
||||
1. `go test ./...`(已被 CI 保证过,理论上不会再红)
|
||||
2. Build Docker image `registry.kxsw.us/vpn-server:<sha>` + `:staging`
|
||||
3. scp `docker-compose.cloud.yml` 到 staging host
|
||||
4. ssh 重启 `ppanel-server` 服务
|
||||
5. healthcheck + Telegram 通知
|
||||
|
||||
部署失败 → Telegram 告警 → 运维介入回滚。Deploy 不阻塞下一个 PR,但**修复 deploy 是当前 deploy 失败者的责任**。
|
||||
|
||||
### 2.8 QA 验收
|
||||
|
||||
- 测试工程师在 staging(`tapi.hifast.biz` / 配套前端)按 issue 描述的 acceptance criteria 验收。
|
||||
- 验收通过 → Multica issue 推 `done`。
|
||||
- 任何一项失败 → 单独开子 issue 指派对应工程师,**不要**回退 PR / revert(除非生产数据安全风险)。
|
||||
|
||||
### 2.9 发版到 `main`(对外正式版本)
|
||||
|
||||
由架构师在合适的时机(feature 集齐、staging 跑稳一段时间后)执行:
|
||||
|
||||
```bash
|
||||
gh pr create --base main --head internal --title '发版: <版本号>'
|
||||
```
|
||||
|
||||
走和普通 PR 一样的流程(CI 绿 + review + Squash and merge)。`main` 的 push 也可以触发后续生产部署 workflow(如未来增加 `deploy-production.yml`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. Multica issue 状态映射
|
||||
|
||||
| Multica 状态 | 对应阶段 |
|
||||
|---|---|
|
||||
| `backlog` | 还没排期 |
|
||||
| `todo` | 已排期,待 assigned agent 开始 |
|
||||
| `in_progress` | 分支已开始写,未 push |
|
||||
| `in_review` | PR 已开,等 review / CI / merge |
|
||||
| `done` | PR merged + 测试环境部署通过 + QA 验收通过 |
|
||||
|
||||
**三个条件没全满足就不要标 done**——否则验收链路看不到真问题。
|
||||
|
||||
Issue metadata 建议字段(与现有 agent CLAUDE.md 推荐一致):
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| `pr_url` | https://github.com/TawCorp/hifast-server/pull/XX |
|
||||
| `merge_commit` | merge 后的 squash commit SHA |
|
||||
| `deploy_url` | `tapi.hifast.biz` 或对应前端域名 |
|
||||
| `pipeline_status` | `coding` / `pr_open` / `merged` / `deployed` / `qa_passed` |
|
||||
| `waiting_on` | 当前阻塞点(如 `qa_e2e_access` / `architect_review`) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Agent 边界
|
||||
|
||||
| Agent | 可以做 | 不可以做 |
|
||||
|---|---|---|
|
||||
| 后端 / 前端 / 运维工程师 | push 自己的 fix/feat 分支;开 PR;回评 issue;本地 squash 自己分支 | 合 PR;push `internal` / `main`;改 CODEOWNERS;自己 approve 自己 PR |
|
||||
| 架构师(Squad Leader) | review;approve;GitHub UI squash merge;在 issue 上拆解需求 + 分派子任务;维护 doc/ 和 CLAUDE.md | 在本地 merge 后 push `internal`(绕过 CI gate);直接编写业务逻辑或 UI 代码 |
|
||||
| 测试工程师 | 在 staging 验收;回评 issue;开子 issue 报 bug | 改 prod 代码;改 CI workflow;自己合 PR |
|
||||
| 仓库 owner(人类) | 任何越界操作 + 流程治理决策(如 branch protection 升级) | — |
|
||||
|
||||
任何越界都需要在 issue 上声明 + 走另一个 PR。
|
||||
|
||||
---
|
||||
|
||||
## 5. 平台层约束的现状(重要)
|
||||
|
||||
> 私有仓库 + 当前 GitHub plan 不支持 branch protection / rulesets API(HTTP 403)。
|
||||
|
||||
这意味着 "禁止直接 push internal" / "require CI pass" / "require approval" 这些**在 GitHub 平台层面没法硬强制**。当前依赖三层软约束:
|
||||
|
||||
1. **客户端层**:`lefthook.yml` 的 `pre-push` 钩子会在尝试直接 push `internal` / `main` 时报错。绕过去要明确加 `--no-verify`,会留 git trailers。
|
||||
2. **流程层**:本文档 + `CODEOWNERS` 自动 request review + 架构师作为单一合并执行人。
|
||||
3. **审计层**:所有 merge 都对应 Multica issue,事后任何"非 PR 路径上去的 commit"都能在 git log + Multica 对照查出来。
|
||||
|
||||
如果未来组织规模扩大、人类协作者增多,建议升级 GitHub Team($4/user/月)拿到 branch protection 平台保障。**目前规模下软约束足够。**
|
||||
|
||||
---
|
||||
|
||||
## 6. 常见场景
|
||||
|
||||
### Hotfix(生产紧急修复)
|
||||
|
||||
走和 fix 同样的流程,只是分支前缀 `hotfix/`,PR 标题前加 `[HOTFIX]`。架构师可酌情把 review 等待时间压缩到 30 分钟以内。Hotfix 通常直接合 `internal` 后立即由架构师再开 `internal → main` 的 PR 一起发版。
|
||||
|
||||
### Revert
|
||||
|
||||
- 在 GitHub UI 上找到要 revert 的 PR,点 "Revert"。
|
||||
- GitHub 会生成 revert PR,按正常流程过 review + merge。
|
||||
- Revert PR 标题用 `修复(#<原 issue 编号>): revert <原 PR 标题>`。
|
||||
|
||||
### 文档 / 配置改动
|
||||
|
||||
走 `chore/` 分支。CI 一样跑(保险),review 可走 fast-track。
|
||||
|
||||
### 跨多个 issue 的大改动
|
||||
|
||||
- 优先拆成多个独立 PR,每个 PR 对应一个 issue。
|
||||
- 如果实在拆不开,PR title 用 `<类型>(#XXX/#YYY/#ZZZ): ...`,PR body 里 `Closes` 三个。
|
||||
|
||||
---
|
||||
|
||||
## 7. FAQ
|
||||
|
||||
**Q: 为什么不允许在本地 squash 再推 internal?**
|
||||
A: 绕过 GitHub PR review 流程 + CI gate + 审计记录。即使你确定改动 100% 正确,也走 PR——这是文化约束,避免架构师红线被逐渐侵蚀。
|
||||
|
||||
**Q: CI 跑得慢,PR 一直 yellow,可以先 merge 吗?**
|
||||
A: 不可以。CI 通常 5 分钟内出结果;如果超过 15 分钟仍 pending,检查是否有 stuck job,必要时 re-run。
|
||||
|
||||
**Q: 如果架构师不在,怎么办?**
|
||||
A: 备份 reviewer = 仓库 owner(@shanshanzhong147)。CODEOWNERS 已经把 owner 列为兜底 reviewer。
|
||||
|
||||
**Q: lefthook pre-push 不让我 push internal,但我确实需要紧急修一行小字?**
|
||||
A: 没有"紧急修一行小字"这种例外。开 hotfix 分支 + 开 PR + 走 fast-track review。
|
||||
|
||||
**Q: Multica issue 状态没流转到 done,是不是要手动推?**
|
||||
A: 不要直接推。先确认 PR merged + deploy 绿 + QA 通过三个条件全满足。任一项没满足就维持 `in_review` 并加评论说卡在哪。
|
||||
|
||||
**Q: 我能不能用 Rebase / Merge commit 模式合 PR?**
|
||||
A: 不行。必须用 Squash and merge。`internal` 必须保持 linear history(一个 PR = 一个 commit),方便回滚和审计。
|
||||
|
||||
---
|
||||
|
||||
## 8. 紧急联系
|
||||
|
||||
- Deploy 红 / staging 挂:运维工程师(Multica agent `071d94d9-38bb-43cc-8c58-29e3b52d7bd4`)
|
||||
- 流程问题 / branch protection 决策:仓库 owner @shanshanzhong147
|
||||
- 架构 / API 设计:架构师(Multica agent `0de10589-f101-49ef-8dfa-0e9e992fe27c`)
|
||||
@@ -0,0 +1,796 @@
|
||||
# 促销优惠价系统设计文档
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 业务需求
|
||||
|
||||
为套餐规格提供可配置的优惠价格能力,支持多种促销场景:
|
||||
|
||||
- **新客优惠**:注册 N 天内的用户享受优惠价
|
||||
- **回归用户**:N 个月未活跃的用户享受优惠价
|
||||
- **活动促销**:指定时间段内所有用户享受优惠价
|
||||
- **未来可扩展**:首充优惠、邀请用户专属价、指定地区优惠等
|
||||
|
||||
### 1.2 设计原则
|
||||
|
||||
1. **纯新增,不改老代码**:现有的 `new_user_only` + `discount.NewUserOnly` + 24h 窗口逻辑全部保留不动
|
||||
2. **固定价格,非百分比**:运营直接设定优惠价(如 $5.99),不再需要反算折扣百分比
|
||||
3. **后台可配置**:规则类型、参数、时间窗口、优先级均可在管理后台配置
|
||||
4. **促销价不叠加批量折扣**:促销价命中时即为最终基础单价,跳过 `getDiscount()` 的百分比折扣
|
||||
|
||||
### 1.3 与现有体系的关系
|
||||
|
||||
```
|
||||
现有体系(保留不动):
|
||||
subscribe.NewUserOnly → 套餐级新客限制
|
||||
discount[].NewUserOnly → 折扣档位级新客限制
|
||||
newUserEligibility.go → 24h 窗口 + 家庭组判定
|
||||
newUserDiscountEligibility.go → 新客折扣资格组装
|
||||
getDiscount() → 百分比折扣选择
|
||||
order.IsNew → 订单首购标记(统计/佣金用)
|
||||
|
||||
新增体系(本次设计):
|
||||
promo_rule 表 → 可配置的促销规则
|
||||
subscribe_promo 表 → 规格×规则 的优惠价
|
||||
promo_usage 表 → 使用记录(运营分析用)
|
||||
EvaluatePromo() → 促销资格判定
|
||||
```
|
||||
|
||||
**互斥规则**:促销价命中时,跳过老的百分比折扣逻辑(`getDiscount()`)。
|
||||
两套体系不叠加 — 用户要么走促销价,要么走原价+百分比折扣,不会同时生效。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### 2.1 新增表:`promo_rule`(促销规则)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `promo_rule` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '规则名称,如"新客7天优惠"',
|
||||
`type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '规则类型:new_user / inactive_user / campaign',
|
||||
`params` JSON NOT NULL COMMENT '类型专属参数',
|
||||
`priority` INT NOT NULL DEFAULT 0 COMMENT '优先级,数值越大越优先匹配',
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`start_time` DATETIME DEFAULT NULL COMMENT '生效开始时间,NULL=立即生效',
|
||||
`end_time` DATETIME DEFAULT NULL COMMENT '生效结束时间,NULL=永不过期',
|
||||
`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_enabled_priority` (`enabled`, `priority` DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表';
|
||||
```
|
||||
|
||||
### 2.2 新增表:`subscribe_promo`(规格优惠价)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `subscribe_promo` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID',
|
||||
`promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID',
|
||||
`promo_price` 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_subscribe_rule` (`subscribe_id`, `promo_rule_id`),
|
||||
KEY `idx_promo_rule_id` (`promo_rule_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表';
|
||||
```
|
||||
|
||||
### 2.3 新增表:`promo_usage`(促销使用记录)
|
||||
|
||||
用于运营分析,不做强制去重约束。
|
||||
|
||||
```sql
|
||||
CREATE TABLE `promo_usage` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '使用的规则 ID',
|
||||
`subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '购买的规格 ID',
|
||||
`order_no` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联订单号',
|
||||
`promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '使用时的促销单价(分)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_rule` (`user_id`, `promo_rule_id`),
|
||||
KEY `idx_order_no` (`order_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表';
|
||||
```
|
||||
|
||||
### 2.4 `order` 表新增字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE `order`
|
||||
ADD COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '促销规则ID, 0=未使用促销',
|
||||
ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT '促销优惠金额(分)';
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 订单字段 | 含义 | 促销命中时 | 未命中时 |
|
||||
|---------|------|-----------|---------|
|
||||
| `Price` | 原始总价 = `UnitPrice × Quantity` | 不变,始终记录原价 | 不变 |
|
||||
| `promo_rule_id` | 使用的促销规则 | 规则 ID | 0 |
|
||||
| `promo_discount` | 促销优惠金额 | `(UnitPrice - PromoPrice) × Quantity` | 0 |
|
||||
| `Discount` | 百分比折扣金额 | **0**(不叠加) | 正常计算 |
|
||||
| `Amount` | 最终支付金额 | 基于促销价计算 | 基于原价+折扣计算 |
|
||||
|
||||
**订单自证**:任何一笔订单都能独立还原其价格构成,不需要回查促销规则表:
|
||||
```
|
||||
Amount = Price - promo_discount - Discount - CouponDiscount + FeeAmount - GiftAmount
|
||||
```
|
||||
|
||||
### 2.5 ER 关系
|
||||
|
||||
```
|
||||
subscribe (1) ──── (*) subscribe_promo (*) ──── (1) promo_rule
|
||||
│
|
||||
│
|
||||
user (1) ──────── (*) promo_usage (*) ─────────── (1) promo_rule
|
||||
│
|
||||
(*) order ← 新增 promo_rule_id, promo_discount
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 规则类型定义
|
||||
|
||||
### 3.1 `new_user` — 新客优惠
|
||||
|
||||
**含义**:用户注册后 N 小时内可享受优惠价
|
||||
|
||||
**params 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"window_hours": 168
|
||||
}
|
||||
```
|
||||
|
||||
**判定逻辑**:
|
||||
|
||||
```
|
||||
eligible = (当前时间 - 用户注册时间) < window_hours
|
||||
expires_at = 用户注册时间 + window_hours
|
||||
```
|
||||
|
||||
**与老逻辑的区别**:
|
||||
|
||||
| | 老逻辑 | 新逻辑 |
|
||||
|--|--------|--------|
|
||||
| 窗口期 | 硬编码 24h | 配置化,后台可改 |
|
||||
| 判定基准 | 首台设备注册时间 + 家庭组 | 用户注册时间(`user.created_at`) |
|
||||
| 价格方式 | 百分比折扣 | 固定价格 |
|
||||
| 与折扣叠加 | 是(百分比折扣本身) | 否(替代原价,跳过折扣) |
|
||||
|
||||
### 3.2 `inactive_user` — 回归用户优惠
|
||||
|
||||
**含义**:最近 N 个月没有活跃订阅的用户可享受优惠价
|
||||
|
||||
**params 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"inactive_months": 3
|
||||
}
|
||||
```
|
||||
|
||||
**判定逻辑**:
|
||||
|
||||
```
|
||||
last_active = 用户最后一个订阅的 expire_time
|
||||
eligible = last_active 为空(从未购买过)
|
||||
OR (当前时间 - last_active) >= inactive_months 个月
|
||||
expires_at = 规则的 end_time(如有),否则无过期
|
||||
```
|
||||
|
||||
**查询依据**:`user_subscribe` 表中该用户最近一条记录的 `expire_time`
|
||||
|
||||
**注意**:「从未购买过」的用户同时满足 `new_user` 和 `inactive_user`,靠 `priority` 排序选择高优先级的那条。
|
||||
|
||||
### 3.3 `campaign` — 活动促销
|
||||
|
||||
**含义**:在指定时间段内,所有用户均可享受优惠价
|
||||
|
||||
**params 结构**:
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
活动促销不需要额外参数,完全靠 `promo_rule.start_time` 和 `end_time` 控制。
|
||||
|
||||
**判定逻辑**:
|
||||
|
||||
```
|
||||
eligible = start_time <= 当前时间 <= end_time
|
||||
expires_at = end_time
|
||||
```
|
||||
|
||||
### 3.4 扩展预留
|
||||
|
||||
未来新增规则类型只需:
|
||||
1. 定义新的 `type` 字符串(如 `first_purchase`、`referral`、`region`)
|
||||
2. 定义对应的 `params` 结构
|
||||
3. 在判定逻辑中增加一个 `case` 分支
|
||||
|
||||
不需要改表结构,不需要改 API 格式。
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心逻辑
|
||||
|
||||
### 4.1 促销资格判定
|
||||
|
||||
新增文件:`internal/logic/common/promoEligibility.go`
|
||||
|
||||
```go
|
||||
type PromoResult struct {
|
||||
Eligible bool
|
||||
RuleID int64
|
||||
RuleName string
|
||||
RuleType string
|
||||
PromoPrice int64 // 促销单价(分)
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64) (*PromoResult, error) {
|
||||
// 1. 查询该规格关联的所有已启用规则,按 priority DESC
|
||||
// 2. 遍历规则,按类型判定
|
||||
// 3. 首条命中即返回
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 各类型判定函数
|
||||
|
||||
```go
|
||||
func evaluateNewUser(user *User, params RuleParams) (bool, time.Time) {
|
||||
windowHours := params.WindowHours
|
||||
if windowHours <= 0 {
|
||||
return false, time.Time{}
|
||||
}
|
||||
expiresAt := user.CreatedAt.Add(time.Duration(windowHours) * time.Hour)
|
||||
eligible := time.Now().Before(expiresAt)
|
||||
return eligible, expiresAt
|
||||
}
|
||||
|
||||
func evaluateInactiveUser(ctx context.Context, userID int64, rule PromoRule) (bool, time.Time) {
|
||||
inactiveMonths := rule.Params.InactiveMonths
|
||||
if inactiveMonths <= 0 {
|
||||
return false, time.Time{}
|
||||
}
|
||||
lastExpire := getLastSubscriptionExpireTime(ctx, userID)
|
||||
if lastExpire.IsZero() {
|
||||
return true, rule.GetExpiresAt()
|
||||
}
|
||||
threshold := time.Now().AddDate(0, -inactiveMonths, 0)
|
||||
eligible := lastExpire.Before(threshold)
|
||||
return eligible, rule.GetExpiresAt()
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 下单流程集成(不叠加方案)
|
||||
|
||||
在 `purchaseLogic.go` 中 `sub.UnitPrice * req.Quantity` 之前,插入促销价判定:
|
||||
|
||||
```go
|
||||
// === 新增:促销价判定 ===
|
||||
promoResult, promoErr := commonLogic.EvaluatePromo(l.ctx, l.svcCtx, u.Id, targetSubscribeID)
|
||||
if promoErr != nil {
|
||||
return nil, promoErr
|
||||
}
|
||||
|
||||
var promoDiscount int64
|
||||
var promoRuleID int64
|
||||
|
||||
if promoResult.Eligible {
|
||||
// 促销命中 → 用促销价,跳过百分比折扣
|
||||
price = promoResult.PromoPrice * req.Quantity
|
||||
promoDiscount = (sub.UnitPrice * req.Quantity) - price
|
||||
promoRuleID = promoResult.RuleID
|
||||
discount = 1 // 不叠加批量折扣
|
||||
discountAmount = 0
|
||||
} else {
|
||||
// 未命中 → 走原有逻辑(不动)
|
||||
price = sub.UnitPrice * req.Quantity
|
||||
discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount)
|
||||
discountAmount = price - int64(math.Round(float64(price)*discount))
|
||||
}
|
||||
// === 新增结束 ===
|
||||
|
||||
// 后续 coupon / fee / gift 逻辑完全不动
|
||||
```
|
||||
|
||||
**订单创建时记录**:
|
||||
|
||||
```go
|
||||
orderInfo := &order.Order{
|
||||
// ... 原有字段不动 ...
|
||||
Price: sub.UnitPrice * req.Quantity, // 始终记录原价
|
||||
PromoRuleID: promoRuleID, // 新增
|
||||
PromoDiscount: promoDiscount, // 新增
|
||||
Discount: discountAmount, // 促销命中时为 0
|
||||
Amount: amount,
|
||||
}
|
||||
```
|
||||
|
||||
**激活时写 usage**(`activateOrderLogic.go` 追加):
|
||||
|
||||
```go
|
||||
if orderInfo.PromoRuleID > 0 {
|
||||
insertPromoUsage(ctx, orderInfo.UserId, orderInfo.PromoRuleID, orderInfo.SubscribeId, orderInfo.OrderNo, promoPrice)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 价格计算完整流程
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ 1. 判定促销 │
|
||||
│ EvaluatePromo(userId, subscribeId) │
|
||||
├──────────────┬────────────────────────────────────┤
|
||||
│ 促销命中 │ 促销未命中 │
|
||||
├──────────────┼────────────────────────────────────┤
|
||||
│ basePrice │ basePrice │
|
||||
│ = promoPrice│ = unitPrice │
|
||||
│ │ │
|
||||
│ discount = 0 │ discount = getDiscount(...) │
|
||||
│ (跳过折扣) │ (百分比折扣正常生效) │
|
||||
├──────────────┴────────────────────────────────────┤
|
||||
│ 2. price = basePrice × quantity │
|
||||
│ amount = price - discountAmount │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ 3. 优惠券(原有逻辑,不动) │
|
||||
│ amount -= couponDiscount │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ 4. 手续费(原有逻辑,不动) │
|
||||
│ amount += feeAmount │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ 5. 余额抵扣(原有逻辑,不动) │
|
||||
│ amount -= giftAmount │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 退款影响分析
|
||||
|
||||
### 5.1 结论:退款逻辑无需改动
|
||||
|
||||
当前退款流程(`refundOrderLogic.go`)基于**订单上已存储的字段**运作,不回查价格体系:
|
||||
|
||||
| 退款动作 | 数据来源 | 是否受促销影响 |
|
||||
|---------|---------|--------------|
|
||||
| 退款金额 | `order.Amount`(支付时已锁定) | 否 — Amount 已反映促销价 |
|
||||
| 佣金回退 | `system_log` 表中的 commission 记录 | 否 — 佣金是基于 Amount 计算的 |
|
||||
| 订阅终止 | `user_subscribe.status → 3` | 否 — 和价格无关 |
|
||||
| 审计日志 | `buildRefundAuditLog()` 读订单快照 | 否 — 记录的就是实际值 |
|
||||
|
||||
**原因**:订单创建时所有金额字段(Price、Amount、Discount、PromoDiscount、FeeAmount 等)都已写入 `order` 表。退款只读这些已存储的值,不会重新计算价格。
|
||||
|
||||
### 5.2 退款后的促销资格
|
||||
|
||||
退款后用户的订阅被终止(`expire_time = now - 1s`)。如果用户再次购买:
|
||||
|
||||
| 场景 | 促销资格 | 说明 |
|
||||
|------|---------|------|
|
||||
| 新客退款后重新购买 | 如仍在窗口期内 → 仍然可以享受促销价 | 正常行为,`promo_usage` 只是记录不做去重 |
|
||||
| 回归用户退款后重新购买 | 需重新判定 `inactive_months` | 退款后订阅 expire_time 被设为过去时间 |
|
||||
| 活动促销退款后重新购买 | 如活动仍在进行 → 可以继续购买 | 活动促销不限次数 |
|
||||
|
||||
这些都是合理的业务行为,不需要额外处理。
|
||||
|
||||
### 5.3 佣金影响
|
||||
|
||||
佣金计算公式(`activateOrderLogic.go:1104`):
|
||||
|
||||
```go
|
||||
amount := l.calculateCommission(orderInfo.Amount - orderInfo.FeeAmount, referralPercentage)
|
||||
```
|
||||
|
||||
- `Amount` 在促销命中时已反映促销价(更低的金额)
|
||||
- 所以佣金会相应减少 — **这是正确的行为**
|
||||
- 退款时佣金回退金额从 `system_log` 读取,回退的也是减少后的佣金
|
||||
|
||||
**无需任何改动**。
|
||||
|
||||
---
|
||||
|
||||
## 6. Apple IAP 影响分析
|
||||
|
||||
### 6.1 现状
|
||||
|
||||
- Apple IAP 价格在 App Store Connect 中配置,不支持后端动态定价
|
||||
- 当前通过 `discount[].MapApple` 字段映射 Apple Product ID
|
||||
- IAP 订单在 `appleIAPNotifyLogic.go` 中处理,走独立的价格逻辑
|
||||
|
||||
### 6.2 设计决策
|
||||
|
||||
**促销价不适用于 IAP 订单**。原因:
|
||||
- IAP 价格由 Apple 控制,后端无法干预
|
||||
- IAP 通知回调(`appleIAPNotifyLogic.go`)有独立的价格处理流程
|
||||
- IAP 审计订单设 `IsNew: false`,不走常规购买逻辑
|
||||
|
||||
**实现方式**:`EvaluatePromo()` 不需要特殊处理 — IAP 订单根本不经过 `purchaseLogic.go`,自然不会触发促销判定。
|
||||
|
||||
---
|
||||
|
||||
## 7. 各购买场景适配
|
||||
|
||||
### 7.1 需要集成促销的场景
|
||||
|
||||
| 文件 | 场景 | 集成方式 |
|
||||
|------|------|---------|
|
||||
| `purchaseLogic.go` | 新购 | 完整促销判定 + 不叠加逻辑 |
|
||||
| `preCreateOrderLogic.go` | 价格预览 | 同上(返回 promo_discount 字段) |
|
||||
|
||||
### 7.2 不需要改动的场景
|
||||
|
||||
| 文件 | 场景 | 原因 |
|
||||
|------|------|------|
|
||||
| `renewalLogic.go` | 续费 | 促销价仅限首购,续费走原价+折扣 |
|
||||
| `rechargeLogic.go` | 余额充值 | 充值不涉及套餐价格 |
|
||||
| `redeemCodeLogic.go` | 兑换码 | 兑换码有自己的固定逻辑 |
|
||||
| `recoverOrderLogic.go` | 历史导入 | 导入的是已完成订单 |
|
||||
| `appleIAPNotifyLogic.go` | IAP 续订 | Apple 控制价格 |
|
||||
| `portal/purchaseLogic.go` | 游客购买 | 游客无 user_id,无法判定促销资格 |
|
||||
| `refundOrderLogic.go` | 退款 | 读取订单已存储的金额,不重新计算 |
|
||||
| `activateOrderLogic.go` | 订单激活 | 只追加 promo_usage 写入,价格不重算 |
|
||||
|
||||
### 7.3 统计报表
|
||||
|
||||
现有统计 SQL(`order/model.go` 中 8 处)按 `is_new` 拆分收入,**不需要改动**。
|
||||
|
||||
未来如需促销维度报表,可通过 `order.promo_rule_id` 字段扩展:
|
||||
```sql
|
||||
SUM(CASE WHEN promo_rule_id > 0 THEN amount ELSE 0 END) AS promo_order_amount,
|
||||
SUM(CASE WHEN promo_rule_id = 0 THEN amount ELSE 0 END) AS normal_order_amount
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. API 设计
|
||||
|
||||
### 8.1 套餐列表 API(改造)
|
||||
|
||||
**接口**:`GET /v1/public/subscribe/list`
|
||||
|
||||
**响应变更**:在原有 `Subscribe` 结构体中追加 `promo` 字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "基础套餐",
|
||||
"unit_price": 288,
|
||||
"discount": [...],
|
||||
"promo": {
|
||||
"rule_name": "新客7天优惠",
|
||||
"rule_type": "new_user",
|
||||
"promo_price": 279,
|
||||
"expires_at": 1748870400
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "标准套餐",
|
||||
"unit_price": 688,
|
||||
"promo": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`promo` 字段说明**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `rule_name` | string | 规则名称,前端展示用 |
|
||||
| `rule_type` | string | 规则类型,前端可据此展示不同样式 |
|
||||
| `promo_price` | int64 | 优惠单价(分),注意是单价不是总价 |
|
||||
| `expires_at` | int64 | 优惠过期时间戳(秒),0 = 无过期 |
|
||||
|
||||
- 用户未登录时:仅展示 `campaign` 类型促销(不需要用户信息)
|
||||
- 用户已登录:展示所有命中的促销
|
||||
- 未命中任何规则时,`promo` 为 `null`
|
||||
|
||||
### 8.2 预算订单 API(改造)
|
||||
|
||||
**接口**:`POST /v1/public/order/pre`
|
||||
|
||||
**响应追加字段**:
|
||||
|
||||
```json
|
||||
{
|
||||
"price": 688,
|
||||
"amount": 499,
|
||||
"discount": 0,
|
||||
"promo_discount": 189,
|
||||
"coupon_discount": 0,
|
||||
"fee_amount": 0,
|
||||
"gift_amount": 0
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `price` | 原始总价 = `UnitPrice × Quantity` |
|
||||
| `promo_discount` | 促销优惠 = `(UnitPrice - PromoPrice) × Quantity` |
|
||||
| `discount` | 百分比折扣优惠(促销命中时为 0) |
|
||||
| `amount` | 最终支付金额 |
|
||||
|
||||
前端可展示:~~原价 ¥6.88~~ → 促销价 ¥4.99
|
||||
|
||||
### 8.3 管理后台 API(新增)
|
||||
|
||||
#### 8.3.1 促销规则 CRUD
|
||||
|
||||
```
|
||||
POST /v1/admin/promo/rule 创建规则
|
||||
GET /v1/admin/promo/rule/list 规则列表
|
||||
GET /v1/admin/promo/rule/:id 规则详情
|
||||
PUT /v1/admin/promo/rule/:id 更新规则
|
||||
DELETE /v1/admin/promo/rule/:id 删除规则(软删除)
|
||||
```
|
||||
|
||||
**创建/更新请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "新客7天优惠",
|
||||
"type": "new_user",
|
||||
"params": {
|
||||
"window_hours": 168
|
||||
},
|
||||
"priority": 10,
|
||||
"enabled": true,
|
||||
"start_time": null,
|
||||
"end_time": null
|
||||
}
|
||||
```
|
||||
|
||||
**校验规则**:
|
||||
- `type` 必须是已支持的类型
|
||||
- `params` 按 `type` 做结构校验(如 `new_user` 必须有 `window_hours > 0`)
|
||||
- `priority` >= 0
|
||||
- `start_time` < `end_time`(如果两者都提供)
|
||||
|
||||
#### 8.3.2 规格优惠价配置
|
||||
|
||||
```
|
||||
POST /v1/admin/promo/price 批量设置优惠价
|
||||
GET /v1/admin/promo/price/list 查询某规则下的所有优惠价
|
||||
DELETE /v1/admin/promo/price/:id 删除某条优惠价
|
||||
```
|
||||
|
||||
**批量设置请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"promo_rule_id": 1,
|
||||
"items": [
|
||||
{"subscribe_id": 1, "promo_price": 279},
|
||||
{"subscribe_id": 2, "promo_price": 599}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**校验**:`promo_price` 必须 < 对应规格的 `unit_price`(防止配置错误)。
|
||||
|
||||
#### 8.3.3 使用记录查询
|
||||
|
||||
```
|
||||
GET /v1/admin/promo/usage/list?rule_id=1&page=1&size=20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 缓存策略
|
||||
|
||||
### 9.1 规则缓存
|
||||
|
||||
```
|
||||
Key: promo:rules:enabled
|
||||
Value: JSON 数组(所有启用的规则,按 priority DESC)
|
||||
TTL: 300 秒(5 分钟)
|
||||
清除: 管理后台修改规则时主动删除
|
||||
```
|
||||
|
||||
### 9.2 规格优惠价缓存
|
||||
|
||||
```
|
||||
Key: promo:subscribe:{subscribe_id}
|
||||
Value: JSON 数组(该规格关联的所有 rule_id → promo_price)
|
||||
TTL: 300 秒
|
||||
清除: 管理后台修改优惠价时主动删除
|
||||
```
|
||||
|
||||
### 9.3 注意事项
|
||||
|
||||
- 缓存 TTL 300 秒意味着活动 `end_time` 到期后最多 5 分钟延迟,可接受
|
||||
- 管理后台操作后主动 DEL 缓存 key,确保配置变更及时生效
|
||||
- `EvaluatePromo()` 缓存未命中时回查 DB
|
||||
|
||||
---
|
||||
|
||||
## 10. 确定的决策项
|
||||
|
||||
| 编号 | 问题 | 结论 | 原因 |
|
||||
|------|------|------|------|
|
||||
| D-01 | 促销价与批量折扣叠加 | **不叠加** | 促销价即最终单价,跳过 `getDiscount()` |
|
||||
| D-02 | 未登录用户展示促销价 | 仅展示 `campaign` 类型 | `new_user`/`inactive_user` 需要用户信息 |
|
||||
| D-03 | 续费订单适用促销价 | **仅首购** | 促销价用于拉新/回归,续费走原价 |
|
||||
| D-04 | 回归用户判定方式 | 订阅过期时间 | `user_subscribe.expire_time`,数据最可靠 |
|
||||
| D-05 | 多规则命中 | 按 `priority` DESC 取第一条 | 运营可控 |
|
||||
| D-07 | Portal(游客)购买走促销 | **不走** | 游客无 user_id,无法判定资格 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 新增文件清单
|
||||
|
||||
| 层级 | 新增文件 | 说明 |
|
||||
|------|----------|------|
|
||||
| **Model** | `internal/model/promo_rule/promo_rule.go` | 促销规则模型 |
|
||||
| **Model** | `internal/model/subscribe_promo/subscribe_promo.go` | 规格优惠价模型 |
|
||||
| **Model** | `internal/model/promo_usage/promo_usage.go` | 使用记录模型 |
|
||||
| **Logic** | `internal/logic/common/promoEligibility.go` | 促销资格判定核心逻辑 |
|
||||
| **Logic** | `internal/logic/admin/promo/` 目录(CRUD) | 管理后台逻辑 |
|
||||
| **Handler** | `internal/handler/admin/promo/` 目录 | 管理后台 Handler |
|
||||
| **Types** | `internal/types/types.go` 追加 | 新增结构体 |
|
||||
| **Migration** | `initialize/migrate/database/02153_promo_rule.up.sql` | 建表 + order 加字段 |
|
||||
| **Migration** | `initialize/migrate/database/02153_promo_rule.down.sql` | 回滚 |
|
||||
|
||||
### 需改动的已有文件(仅追加)
|
||||
|
||||
| 文件 | 改动方式 |
|
||||
|------|----------|
|
||||
| `internal/logic/public/subscribe/querySubscribeListLogic.go` | 追加:查促销信息,填充 `promo` |
|
||||
| `internal/logic/public/order/purchaseLogic.go` | 追加:促销判定 + 不叠加分支 |
|
||||
| `internal/logic/public/order/preCreateOrderLogic.go` | 追加:预算时考虑促销价 |
|
||||
| `queue/logic/order/activateOrderLogic.go` | 追加:激活后写 `promo_usage` |
|
||||
| `internal/model/order/order.go` | 追加:`PromoRuleID`、`PromoDiscount` 字段 |
|
||||
| `internal/model/order/model.go` | 追加:`Details` 同步字段 |
|
||||
| `internal/types/types.go` | 追加:新增结构体、响应字段 |
|
||||
| `internal/svc/serviceContext.go` | 追加:注入新 Model |
|
||||
| 路由配置 | 追加:管理后台路由 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 运营配置示例
|
||||
|
||||
### 场景 1:新客 7 天优惠
|
||||
|
||||
```
|
||||
promo_rule:
|
||||
name = "新客7天优惠"
|
||||
type = "new_user"
|
||||
params = {"window_hours": 168}
|
||||
priority = 10
|
||||
enabled = true
|
||||
start_time = NULL(永久生效)
|
||||
end_time = NULL
|
||||
|
||||
subscribe_promo:
|
||||
规格"7天" → promo_price = 279
|
||||
规格"30天" → promo_price = 599
|
||||
规格"90天" → promo_price = 1299
|
||||
规格"365天" → promo_price = 4499
|
||||
```
|
||||
|
||||
### 场景 2:回归用户优惠
|
||||
|
||||
```
|
||||
promo_rule:
|
||||
name = "回归用户专属价"
|
||||
type = "inactive_user"
|
||||
params = {"inactive_months": 3}
|
||||
priority = 5
|
||||
enabled = true
|
||||
|
||||
subscribe_promo:
|
||||
规格"30天" → promo_price = 499
|
||||
规格"90天" → promo_price = 999
|
||||
```
|
||||
|
||||
### 场景 3:双十一全站活动
|
||||
|
||||
```
|
||||
promo_rule:
|
||||
name = "双十一特惠"
|
||||
type = "campaign"
|
||||
params = {}
|
||||
priority = 20(优先级高于新客和回归)
|
||||
enabled = true
|
||||
start_time = "2026-11-01 00:00:00"
|
||||
end_time = "2026-11-12 00:00:00"
|
||||
|
||||
subscribe_promo:
|
||||
规格"90天" → promo_price = 999
|
||||
规格"365天" → promo_price = 3999
|
||||
```
|
||||
|
||||
**优先级效果**:双十一期间(priority=20),即使用户是新客(priority=10),也走双十一价格。双十一结束后,新客仍可享受新客优惠。
|
||||
|
||||
---
|
||||
|
||||
## 13. 迁移脚本
|
||||
|
||||
### 02153_promo_system.up.sql
|
||||
|
||||
```sql
|
||||
-- 促销规则表
|
||||
CREATE TABLE IF NOT EXISTS `promo_rule` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`type` VARCHAR(32) NOT NULL DEFAULT '',
|
||||
`params` JSON NOT NULL,
|
||||
`priority` INT NOT NULL DEFAULT 0,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`start_time` DATETIME DEFAULT NULL,
|
||||
`end_time` DATETIME DEFAULT NULL,
|
||||
`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,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_enabled_priority` (`enabled`, `priority` DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表';
|
||||
|
||||
-- 规格促销价表
|
||||
CREATE TABLE IF NOT EXISTS `subscribe_promo` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`subscribe_id` BIGINT UNSIGNED NOT NULL,
|
||||
`promo_rule_id` BIGINT UNSIGNED NOT NULL,
|
||||
`promo_price` BIGINT NOT NULL DEFAULT 0,
|
||||
`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_subscribe_rule` (`subscribe_id`, `promo_rule_id`),
|
||||
KEY `idx_promo_rule_id` (`promo_rule_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表';
|
||||
|
||||
-- 促销使用记录表
|
||||
CREATE TABLE IF NOT EXISTS `promo_usage` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`promo_rule_id` BIGINT UNSIGNED NOT NULL,
|
||||
`subscribe_id` BIGINT UNSIGNED NOT NULL,
|
||||
`order_no` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`promo_price` BIGINT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_rule` (`user_id`, `promo_rule_id`),
|
||||
KEY `idx_order_no` (`order_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表';
|
||||
|
||||
-- order 表新增促销字段
|
||||
ALTER TABLE `order`
|
||||
ADD COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '促销规则ID, 0=未使用促销',
|
||||
ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT '促销优惠金额(分)';
|
||||
```
|
||||
|
||||
### 02153_promo_system.down.sql
|
||||
|
||||
```sql
|
||||
ALTER TABLE `order`
|
||||
DROP COLUMN IF EXISTS `promo_discount`,
|
||||
DROP COLUMN IF EXISTS `promo_rule_id`;
|
||||
|
||||
DROP TABLE IF EXISTS `promo_usage`;
|
||||
DROP TABLE IF EXISTS `subscribe_promo`;
|
||||
DROP TABLE IF EXISTS `promo_rule`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 风险与注意事项
|
||||
|
||||
| 风险 | 应对 |
|
||||
|------|------|
|
||||
| 促销价 > 原价(配置错误) | 管理后台校验:`promo_price` 必须 < `unit_price` |
|
||||
| 规则删除后已有订单受影响 | 软删除(`deleted_at`),订单上已存储 `promo_rule_id` 和 `promo_discount`,不依赖规则表 |
|
||||
| 缓存与数据库不一致 | 管理后台修改时主动清缓存,判定逻辑以 DB 为准 |
|
||||
| 新促销和老 NewUserOnly 折扣共存 | **互斥**:促销命中时跳过 `getDiscount()` 的百分比折扣 |
|
||||
| 活动到期后 5 分钟内仍可下单 | 缓存 TTL=300s 的延迟,可接受;下单时可选择实时查 DB 校验 |
|
||||
| 退款后重新购买仍享促销 | 正常行为 — `promo_usage` 只做记录不做去重 |
|
||||
@@ -128,6 +128,10 @@ curl -X PUT 'https://bucket.s3.ap-east-1.amazonaws.com/...' \
|
||||
说明:
|
||||
|
||||
- `Content-Type` 需和 `init` 返回的 `headers.Content-Type` 一致
|
||||
- 允许的 `Content-Type` 由服务端 `S3.AllowedContentTypes` 配置控制,默认包含:
|
||||
- 压缩包:`application/zip`、`application/x-zip-compressed`、`application/gzip`、`application/x-gzip`
|
||||
- 通用文件:`application/octet-stream`、`text/plain`、`application/json`
|
||||
- 图片:`image/jpeg`、`image/jpg`、`image/png`、`image/webp`、`image/gif`、`image/heic`、`image/heif`、`image/bmp`
|
||||
- `upload_url` 有过期时间,通常 300 秒
|
||||
- 成功时 S3 常见返回 `200` 或 `204`
|
||||
|
||||
@@ -164,11 +168,13 @@ curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload/complete' \
|
||||
- 如果请求带了 `X-App-Id`,就按现有逻辑验签
|
||||
- 如果没有 `X-App-Id`,仍按旧逻辑放行
|
||||
- 如果要给该接口加签,签名时必须对原始 multipart body 计算 `BODY_SHA256`
|
||||
- 允许的 `Content-Type` 与预签名三段式一致;multipart 文件字段未显式携带 `Content-Type` 时,服务端会基于文件内容嗅探常见类型。
|
||||
|
||||
## 常见错误码
|
||||
|
||||
- `200`: 成功
|
||||
- `400`: 参数错误
|
||||
- `400 content_type is not allowed`: 文件 `Content-Type` 不在 `S3.AllowedContentTypes` 白名单内
|
||||
- `40008`: 缺少签名头
|
||||
- `40009`: 签名已过期
|
||||
- `40010`: 签名无效
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# 提现接口文档
|
||||
|
||||
## 基础信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| Base URL | `/v1/public/user` |
|
||||
| 认证方式 | JWT Token(`AuthMiddleware` + `DeviceMiddleware`) |
|
||||
| 数据表 | `user_withdrawal` |
|
||||
|
||||
## 数据模型
|
||||
|
||||
### user_withdrawal 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | int64 | 主键 |
|
||||
| `user_id` | int64 | 用户 ID |
|
||||
| `amount` | int64 | 提现金额(单位:分) |
|
||||
| `content` | text | 收款信息(账号、姓名等) |
|
||||
| `status` | tinyint | 0=待审核, 1=已通过, 2=已拒绝 |
|
||||
| `reason` | varchar(500) | 拒绝原因(通过时为空) |
|
||||
| `created_at` | datetime | 创建时间 |
|
||||
| `updated_at` | datetime | 更新时间 |
|
||||
|
||||
### status 枚举
|
||||
|
||||
| 值 | 含义 | 说明 |
|
||||
|----|------|------|
|
||||
| 0 | Pending(待审核) | 用户提交申请后的初始状态 |
|
||||
| 1 | Approved(已通过) | 管理员审核通过,佣金已扣减 |
|
||||
| 2 | Rejected(已拒绝) | 管理员拒绝,无需退款(申请时未扣款) |
|
||||
|
||||
---
|
||||
|
||||
## 用户端接口
|
||||
|
||||
### 1. 申请提现
|
||||
|
||||
申请佣金提现,创建一条待审核记录。申请时**不扣余额**,管理员审核通过后才扣。
|
||||
|
||||
```
|
||||
POST /v1/public/user/commission_withdraw
|
||||
```
|
||||
|
||||
#### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"amount": 1000,
|
||||
"content": "支付宝:138xxxx1234 / 张三"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `amount` | int64 | 是 | 提现金额(单位:分) |
|
||||
| `content` | string | 是 | 收款信息(支付宝/银行卡等) |
|
||||
|
||||
#### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"user_id": 10001,
|
||||
"amount": 1000,
|
||||
"content": "支付宝:138xxxx1234 / 张三",
|
||||
"status": 0,
|
||||
"reason": "",
|
||||
"created_at": 1716624000000,
|
||||
"updated_at": 1716624000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:此接口的 `created_at` / `updated_at` 返回**毫秒级**时间戳(`.UnixMilli()`),与项目其他接口的秒级时间戳不一致。
|
||||
|
||||
#### 业务逻辑
|
||||
|
||||
1. 查询该用户所有 status=0(待审核)的提现记录,求和得 `pendingTotal`
|
||||
2. 校验可用余额:`commission >= amount + pendingTotal`
|
||||
3. 创建 `user_withdrawal` 记录,status=0
|
||||
4. **不扣减** `user.commission`,等审核通过才扣
|
||||
|
||||
#### 错误码
|
||||
|
||||
| 错误码 | 常量 | 说明 |
|
||||
|--------|------|------|
|
||||
| 20010 | `UserCommissionNotEnough` | 可用余额不足(余额 = commission - 所有 pending 提现总额) |
|
||||
| 40005 | `InvalidAccess` | 未登录 / Token 无效 |
|
||||
|
||||
#### 源码位置
|
||||
|
||||
- Handler: `internal/handler/public/user/commissionWithdrawHandler.go`
|
||||
- Logic: `internal/logic/public/user/commissionWithdrawLogic.go`
|
||||
|
||||
---
|
||||
|
||||
### 2. 查询提现记录
|
||||
|
||||
分页查询当前用户的提现记录。
|
||||
|
||||
```
|
||||
GET /v1/public/user/withdrawal_log
|
||||
```
|
||||
|
||||
#### 请求参数(Query)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `page` | int | 否 | 页码 |
|
||||
| `size` | int | 否 | 每页数量 |
|
||||
|
||||
#### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 10001,
|
||||
"amount": 1000,
|
||||
"content": "支付宝:138xxxx1234",
|
||||
"status": 0,
|
||||
"reason": "",
|
||||
"created_at": 1716624000,
|
||||
"updated_at": 1716624000
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 源码位置
|
||||
|
||||
- Handler: `internal/handler/public/user/queryWithdrawalLogHandler.go`
|
||||
- Logic: `internal/logic/public/user/queryWithdrawalLogLogic.go`
|
||||
|
||||
> **Warning**: Logic 层尚未实现(仍为 TODO),调用会返回空响应。
|
||||
|
||||
---
|
||||
@@ -0,0 +1,153 @@
|
||||
# 用户端提现列表 API — 订阅字段现状调研
|
||||
|
||||
> 调研日期:2026-05-27
|
||||
> 调研范围:用户端「提现记录列表」接口当前返回字段,重点关注是否包含订阅相关信息
|
||||
|
||||
## 一、接口信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 方法 | `GET` |
|
||||
| 路径 | `/v1/public/user/withdrawal_log` |
|
||||
| 认证 | JWT Token(`AuthMiddleware` + `DeviceMiddleware`) |
|
||||
| 分组 | `apis/public/user.api` |
|
||||
|
||||
## 二、文件定位
|
||||
|
||||
| 层 | 路径 |
|
||||
|----|------|
|
||||
| API DSL | `apis/public/user.api:118` (`WithdrawalLog`) / `apis/public/user.api:368` (路由) |
|
||||
| Handler | `internal/handler/public/user/queryWithdrawalLogHandler.go` |
|
||||
| Logic | `internal/logic/public/user/queryWithdrawalLogLogic.go:30` |
|
||||
| 类型生成 | `internal/types/types.go`(`WithdrawalLog`、`QueryWithdrawalLogListRequest`、`QueryWithdrawalLogListResponse`) |
|
||||
| 数据模型 | `internal/model/user/user.go:167` (`Withdrawal`,表名 `withdrawals`) |
|
||||
|
||||
## 三、请求参数
|
||||
|
||||
```go
|
||||
QueryWithdrawalLogListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
```
|
||||
|
||||
- 默认值:`page=1`、`size=10`(在 logic 内兜底)
|
||||
|
||||
## 四、响应结构
|
||||
|
||||
### 4.1 顶层响应
|
||||
|
||||
```go
|
||||
QueryWithdrawalLogListResponse {
|
||||
List []WithdrawalLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 列表项 `WithdrawalLog`
|
||||
|
||||
```go
|
||||
WithdrawalLog {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"` // 单位:分
|
||||
Content string `json:"content"` // 收款附加信息
|
||||
Status uint8 `json:"status"` // 0:Pending 1:Approved 2:Rejected 3:Cancelled
|
||||
Reason string `json:"reason,omitempty"` // 拒绝原因
|
||||
Method uint8 `json:"method"` // 0:其他 1:支付宝 2:微信 3:USDT
|
||||
Account string `json:"account"` // 收款账号
|
||||
QrCodeUrl string `json:"qr_code_url"` // 收款码图片 URL
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
## 五、底层数据模型 `Withdrawal`
|
||||
|
||||
```go
|
||||
type Withdrawal struct {
|
||||
Id int64
|
||||
UserId int64 // index:idx_user_id
|
||||
Amount int64
|
||||
Content string // type:text
|
||||
Status uint8 // 0:Pending 1:Approved 2:Rejected 3:Cancelled
|
||||
Reason string // varchar(500)
|
||||
Method uint8 // 0:其他 1:支付宝 2:微信 3:USDT
|
||||
Account string // varchar(255)
|
||||
QrCodeUrl string // varchar(500)
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
> 表名:`withdrawals`,与用户关联仅靠 `user_id` 外键,**无任何订阅 ID / 订阅快照字段**。
|
||||
|
||||
## 六、订阅字段现状(核心结论)
|
||||
|
||||
### 6.1 当前结论
|
||||
|
||||
| 维度 | 是否包含订阅信息 |
|
||||
|------|------------------|
|
||||
| API 响应(`WithdrawalLog`) | ❌ 无 |
|
||||
| 数据库表(`withdrawals`) | ❌ 无 |
|
||||
| Logic 查询逻辑 | ❌ 无 JOIN、无附加查询 `user_subscribe` |
|
||||
|
||||
提现记录与订阅之间**完全没有关联**。原因:佣金来源于多次订单累计,提现是从「佣金余额(`user.commission`)」整体扣减,不绑定到任何具体订阅。
|
||||
|
||||
### 6.2 Logic 当前实现要点
|
||||
|
||||
```go
|
||||
// internal/logic/public/user/queryWithdrawalLogLogic.go:46-72
|
||||
query := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&user.Withdrawal{}).
|
||||
Where("user_id = ?", u.Id)
|
||||
|
||||
// 仅按 user_id 过滤 + 分页 + 倒序,无任何 Preload / Join
|
||||
```
|
||||
|
||||
## 七、已发现的隐患(与本次需求关联)
|
||||
|
||||
### 7.1 时间戳违反项目约定 ⚠️
|
||||
|
||||
`queryWithdrawalLogLogic.go:70-71`:
|
||||
|
||||
```go
|
||||
CreatedAt: row.CreatedAt.UnixMilli(),
|
||||
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
||||
```
|
||||
|
||||
- 项目约定:**后端统一返回秒级 Unix 时间戳**(前端 `formatDate` 已按 `数字 × 1000` 处理)
|
||||
- 当前实现返回毫秒级,前端会解析为约公元 +55000 年的日期,**展示必然异常**
|
||||
- 修复方式:改为 `.Unix()`
|
||||
|
||||
> 该问题独立于「订阅字段」需求,但属于同一接口,建议同批修复。
|
||||
|
||||
## 八、可选扩展方向(待业务确认)
|
||||
|
||||
若产品希望在提现列表中展示订阅相关信息,可选方案如下:
|
||||
|
||||
| 方案 | 字段示意 | 实现成本 | 适用场景 |
|
||||
|------|----------|----------|----------|
|
||||
| A. 当前生效订阅摘要 | `current_subscribe: { id, name, expire_at }` | 中(每行额外查 `user_subscribe`) | 想让用户看到「我提的是哪个订阅产生的佣金对应的余额」 |
|
||||
| B. 用户全部订阅列表 | `subscribes: [{ id, name, expire_at }]` | 高(N+1 风险) | 极少场景,需评估必要性 |
|
||||
| C. 仅订阅 ID 数组 | `subscribe_ids: [int64]` | 低 | 仅前端跳详情用 |
|
||||
| D. 不加,保持现状 | — | 0 | 若业务上提现与订阅本就无关 |
|
||||
|
||||
> **推荐先与产品确认动机**:提现是佣金余额提现,与订阅本身没有直接业务关系,加字段前需明确「让用户看到订阅信息要解决什么问题」。
|
||||
|
||||
## 九、相关接口(一并列出,便于对照)
|
||||
|
||||
| 接口 | 方法 | 路径 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 提交提现 | POST | `/v1/public/user/commission_withdraw` | 入参 `CommissionWithdrawRequest`,返回 `WithdrawalLog` |
|
||||
| 取消提现 | POST | `/v1/public/user/withdrawal_cancel` | 入参 `CancelWithdrawalRequest`,返回 `WithdrawalLog` |
|
||||
| 提现记录列表 | GET | `/v1/public/user/withdrawal_log` | 本文主角 |
|
||||
|
||||
> 三个接口共用 `WithdrawalLog` 类型,**任何字段变更需统一同步**,否则前端类型会错位。
|
||||
|
||||
## 十、后续动作建议
|
||||
|
||||
1. **产品确认**:是否真的需要在提现列表里返回订阅字段?目的是什么?
|
||||
2. **若需新增**:在 `apis/public/user.api` 修改 `WithdrawalLog`,运行 goctl 重新生成,再补 Logic 查询。
|
||||
3. **顺手修复**:将 `UnixMilli()` 改为 `Unix()`(独立小 PR 即可)。
|
||||
4. **如新增订阅字段**:注意三个接口(list / cancel / withdraw)的返回结构同步,避免前端类型联动断裂。
|
||||
+2
-230
@@ -1,33 +1,12 @@
|
||||
# PPanel 服务部署 (云端/无源码版)
|
||||
# 使用方法:
|
||||
# 1. 确保已将 docker-compose.cloud.yml, configs/, loki/, grafana/, prometheus/, tempo/ 目录上传到服务器同一目录
|
||||
# 2. 确保 configs/ 目录下有 ppanel.yaml 配置文件(参考 etc/ppanel.yaml)
|
||||
# 3. 确保 logs/ cache/ tempo_data/ 目录存在 (mkdir -p logs cache tempo_data)
|
||||
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
|
||||
#
|
||||
# 网络说明:
|
||||
# ppanel-server 使用 host 网络(可出外网,直接访问 AWS RDS / 本机 Redis)
|
||||
# 监控服务(Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中
|
||||
# Tempo(4317) 将端口映射到 127.0.0.1,ppanel-server 通过 host 网络访问
|
||||
# 监控端口绑定 127.0.0.1,需通过 SSH 隧道或 Nginx 反代访问
|
||||
#
|
||||
# 未来多开 ppanel-server 时:
|
||||
# 修复宿主机 iptables bridge 出网规则后,可将 ppanel-server 切回 bridge 网络
|
||||
# 多实例用不同端口: ports: ["8081:8080"] + container_name: ppanel-server-2
|
||||
|
||||
services:
|
||||
# ----------------------------------------------------
|
||||
# 1. 业务后端 (PPanel Server)
|
||||
# host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo
|
||||
# ----------------------------------------------------
|
||||
ppanel-server:
|
||||
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:-latest}
|
||||
image: ${PPANEL_SERVER_IMAGE:-registry.kxsw.us/vpn-server}:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag}
|
||||
container_name: ppanel-server
|
||||
restart: always
|
||||
volumes:
|
||||
- ./configs:/app/etc
|
||||
- ./logs:/app/logs
|
||||
- ./cache:/app/cache # GeoLite2-City.mmdb IP 地理位置数据库
|
||||
- ./cache:/app/cache
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
network_mode: host
|
||||
@@ -36,215 +15,8 @@ services:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
depends_on:
|
||||
tempo:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 2. Tempo (链路追踪存储)
|
||||
# ----------------------------------------------------
|
||||
tempo:
|
||||
image: grafana/tempo:2.4.1
|
||||
container_name: ppanel-tempo
|
||||
user: root
|
||||
restart: always
|
||||
command:
|
||||
- "-config.file=/etc/tempo.yaml"
|
||||
- "-target=all"
|
||||
volumes:
|
||||
- ./tempo/tempo-config.yaml:/etc/tempo.yaml
|
||||
- ./tempo_data:/var/tempo
|
||||
ports:
|
||||
- "127.0.0.1:4317:4317" # OTLP gRPC,ppanel-server(host网络)通过127.0.0.1:4317发送trace
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 3. Loki (日志存储)
|
||||
# ----------------------------------------------------
|
||||
loki:
|
||||
image: grafana/loki:3.0.0
|
||||
container_name: ppanel-loki
|
||||
restart: always
|
||||
volumes:
|
||||
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
|
||||
- loki_data:/loki
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
# 不对外暴露端口,仅内网访问
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 4. Promtail (日志采集)
|
||||
# ----------------------------------------------------
|
||||
promtail:
|
||||
image: grafana/promtail:3.0.0
|
||||
container_name: ppanel-promtail
|
||||
restart: always
|
||||
volumes:
|
||||
- ./loki/promtail-config.yaml:/etc/promtail/config.yaml
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./logs:/var/log/ppanel-server:ro
|
||||
- /var/log/nginx:/var/log/nginx:ro
|
||||
command: -config.file=/etc/promtail/config.yaml
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- loki
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 5. Grafana (可观测面板)
|
||||
# 访问: ssh -L 3333:localhost:3333 your-server 后浏览器打开 http://localhost:3333
|
||||
# 或配置 Nginx 反代(建议加认证)
|
||||
# ----------------------------------------------------
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
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:latest
|
||||
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:latest
|
||||
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:latest
|
||||
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:latest
|
||||
container_name: ppanel-cadvisor
|
||||
restart: always
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk/:/dev/disk:ro
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
prometheus_data:
|
||||
tempo_data:
|
||||
|
||||
networks:
|
||||
ppanel_net:
|
||||
name: ppanel_net
|
||||
driver: bridge
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
ppanel:
|
||||
container_name: ppanel-server
|
||||
|
||||
+9
-9
@@ -15,10 +15,10 @@ Logger: # 日志配置
|
||||
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
||||
|
||||
MySQL:
|
||||
Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
||||
Addr: 127.0.0.1:3306 # 本地开发默认;Docker bridge 模式可改为 mysql:3306
|
||||
Username: root # MySQL用户名
|
||||
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
||||
Dbname: hifast # MySQL数据库名
|
||||
Password: CHANGE_ME_TO_DB_PASSWORD # MySQL密码
|
||||
Dbname: ppanel # MySQL数据库名
|
||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||
MaxIdleConns: 10
|
||||
MaxOpenConns: 100
|
||||
@@ -42,9 +42,9 @@ Redis:
|
||||
|
||||
AppSignature:
|
||||
AppSecrets:
|
||||
android-client: uB4G,XxL2{7b # Android 客户端签名密钥
|
||||
ios-client: uB4G,XxL2{7b # iOS 客户端签名密钥
|
||||
web-client: uB4G,XxL2{7b # Web 客户端签名密钥
|
||||
android-client: CHANGE_ME_ANDROID_APP_SECRET # Android 客户端签名密钥
|
||||
ios-client: CHANGE_ME_IOS_APP_SECRET # iOS 客户端签名密钥
|
||||
web-client: CHANGE_ME_WEB_APP_SECRET # Web 客户端签名密钥
|
||||
ValidWindowSeconds: 300 # 签名时间窗口(秒)
|
||||
SkipPrefixes:
|
||||
- /v1/notify/ # 支付回调不验签
|
||||
@@ -58,8 +58,8 @@ Signature:
|
||||
Trace: # 链路追踪配置 (OpenTelemetry)
|
||||
Name: ppanel # 服务名
|
||||
Sampler: 1.0 # 采样率 0.0-1.0,生产建议 0.1
|
||||
Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc
|
||||
Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317
|
||||
Batcher: "" # 本地开发留空;生产如需链路追踪再配置 exporter
|
||||
Endpoint: ""
|
||||
|
||||
S3:
|
||||
Enable: false
|
||||
@@ -74,7 +74,7 @@ S3:
|
||||
UsePathStyle: false
|
||||
PresignExpireSeconds: 300
|
||||
MaxUploadSize: 104857600
|
||||
AllowedContentTypes: "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json"
|
||||
AllowedContentTypes: "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp"
|
||||
|
||||
device:
|
||||
enable: true # 开启设备加密通信
|
||||
|
||||
@@ -53,6 +53,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/Masterminds/sprig/v3 v3.3.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||
|
||||
@@ -8,6 +8,8 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f h1:RDkg3pyE1qGbBpRWmvSN9RNZC5nUrOaEPiEpEb8y2f0=
|
||||
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f/go.mod h1:zA7AF9RTfpluCfz0omI4t5KCMaWHUMicsZoMccnaT44=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
@@ -264,6 +266,7 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
|
||||
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
apiVersion: 1
|
||||
|
||||
groups:
|
||||
- orgId: 1
|
||||
name: ppanel-core
|
||||
folder: PPanel
|
||||
interval: 1m
|
||||
rules:
|
||||
- uid: ppanel-target-down
|
||||
title: PPanel monitoring target down
|
||||
condition: C
|
||||
for: 2m
|
||||
noDataState: Alerting
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Monitoring target is down"
|
||||
description: "{{ $labels.job }} on {{ $labels.instance }} has been down for more than 2 minutes."
|
||||
labels:
|
||||
severity: critical
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 300
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: 'up{job=~"grafana|prometheus|node-exporter|cadvisor|nginx-exporter|loki|tempo"}'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 1
|
||||
type: lt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-host-disk-high
|
||||
title: PPanel host disk usage high
|
||||
condition: C
|
||||
for: 10m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Host disk usage is high"
|
||||
description: "{{ $labels.instance }} {{ $labels.mountpoint }} disk usage is above 85% for 10 minutes."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 900
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: '100 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} * 100)'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 85
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-host-memory-high
|
||||
title: PPanel host memory usage high
|
||||
condition: C
|
||||
for: 10m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Host memory usage is high"
|
||||
description: "{{ $labels.instance }} memory usage is above 90% for 10 minutes."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 900
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 90
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-host-cpu-high
|
||||
title: PPanel host CPU usage high
|
||||
condition: C
|
||||
for: 10m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Host CPU usage is high"
|
||||
description: "{{ $labels.instance }} CPU usage is above 90% for 10 minutes."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 900
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 90
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
|
||||
- uid: ppanel-container-restarts
|
||||
title: PPanel container restarted
|
||||
condition: C
|
||||
for: 1m
|
||||
noDataState: NoData
|
||||
execErrState: Error
|
||||
annotations:
|
||||
summary: "Container restarted"
|
||||
description: "{{ $labels.name }} restarted or changed start time in the last hour."
|
||||
labels:
|
||||
severity: warning
|
||||
service: ppanel
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange:
|
||||
from: 3600
|
||||
to: 0
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: prometheus
|
||||
editorMode: code
|
||||
expr: 'sum by (name) (changes(container_start_time_seconds{name!=""}[1h]))'
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: A
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- A
|
||||
reducer:
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
@@ -1,14 +0,0 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: PPanel
|
||||
orgId: 1
|
||||
folder: PPanel
|
||||
folderUid: ppanel
|
||||
type: file
|
||||
disableDeletion: false
|
||||
allowUiUpdates: true
|
||||
updateIntervalSeconds: 30
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards/json
|
||||
foldersFromFilesStructure: false
|
||||
@@ -1,520 +0,0 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 3,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"content": "<b>AWS CloudWatch overview</b><br/>Region: ap-east-1 (Hong Kong)<br/>RDS DBInstanceIdentifier: hifast-mysql-prod-v2<br/>Redis: current production uses a local Docker Redis container (<code>hifast-redis</code>) on EC2 rather than AWS ElastiCache.<br/><br/>This dashboard keeps the RDS CloudWatch panels. Redis should be observed from the local ops dashboard via Prometheus/cAdvisor instead of ElastiCache metrics.",
|
||||
"mode": "html"
|
||||
},
|
||||
"pluginVersion": "11.0.0",
|
||||
"title": "Read Me",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 3
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "CPUUtilization",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS CPU",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 3
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "DatabaseConnections",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS Connections",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "bytes"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 3
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "FreeStorageSpace",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Minimum"
|
||||
}
|
||||
],
|
||||
"title": "RDS Free Storage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 11
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "ReadLatency",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "WriteLatency",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "B",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS Read / Write Latency",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "iops"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 11
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "ReadIOPS",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||
},
|
||||
"metricName": "WriteIOPS",
|
||||
"namespace": "AWS/RDS",
|
||||
"period": "",
|
||||
"refId": "B",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "RDS Read / Write IOPS",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 19
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "CPUUtilization",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Host CPU (Legacy ElastiCache)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 19
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "EngineCPUUtilization",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Engine CPU (Legacy ElastiCache)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 12,
|
||||
"y": 19
|
||||
},
|
||||
"id": 9,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "CurrConnections",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Connections (Legacy ElastiCache)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 19
|
||||
},
|
||||
"id": 10,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "cloudwatch",
|
||||
"uid": "cloudwatch"
|
||||
},
|
||||
"dimensions": {
|
||||
"ReplicationGroupId": "hifastapp-redis"
|
||||
},
|
||||
"metricName": "DatabaseMemoryUsagePercentage",
|
||||
"namespace": "AWS/ElastiCache",
|
||||
"period": "",
|
||||
"refId": "A",
|
||||
"region": "ap-east-1",
|
||||
"statistic": "Average"
|
||||
}
|
||||
],
|
||||
"title": "Redis Memory Usage % (Legacy ElastiCache)",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"style": "dark",
|
||||
"tags": [
|
||||
"aws",
|
||||
"cloudwatch",
|
||||
"rds",
|
||||
"redis"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "AWS RDS & Redis Overview",
|
||||
"uid": "aws-rds-redis-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"0": {
|
||||
"text": "DOWN"
|
||||
},
|
||||
"1": {
|
||||
"text": "UP"
|
||||
}
|
||||
},
|
||||
"type": "value"
|
||||
}
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "red",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "13.0.1",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "up{job=~\"prometheus|grafana|node-exporter|cadvisor|nginx-exporter|loki|tempo\"}",
|
||||
"instant": true,
|
||||
"legendFormat": "{{job}}",
|
||||
"range": false,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Service Availability",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 75
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 4
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"legendFormat": "{{instance}} CPU",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Host CPU Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 80
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 4
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
|
||||
"legendFormat": "{{instance}} memory",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Host Memory Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "orange",
|
||||
"value": 80
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 90
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 4
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "100 - (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} * 100)",
|
||||
"legendFormat": "{{mountpoint}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Host Disk Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 12
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name!=\"\"}[5m]))",
|
||||
"legendFormat": "{{name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Container CPU",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "bytes"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 12
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (name) (container_memory_working_set_bytes{name!=\"\"})",
|
||||
"legendFormat": "{{name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Container Memory",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 12
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (name) (changes(container_start_time_seconds{name!=\"\"}[1h]))",
|
||||
"legendFormat": "{{name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Container Restarts / Changes",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 20
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "rate(nginx_http_requests_total[5m])",
|
||||
"legendFormat": "requests",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Nginx Requests",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "loki"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 20
|
||||
},
|
||||
"id": 9,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "loki"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "{job=~\"ppanel-server|nginx|docker\"} |~ \"(?i)(error|panic|fatal|timeout|exception|failed)\"",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Recent Errors",
|
||||
"type": "logs"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 20
|
||||
},
|
||||
"id": 10,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (service_name) (rate(traces_spanmetrics_calls_total[5m]))",
|
||||
"legendFormat": "{{service_name}}",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Trace Span Calls",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 42,
|
||||
"tags": [
|
||||
"ppanel",
|
||||
"ops",
|
||||
"prometheus",
|
||||
"loki",
|
||||
"tempo"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "PPanel Ops Overview",
|
||||
"uid": "ppanel-ops-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(count_over_time({compose_service=\"ppanel-server\"}[5m]))",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Matched Log Volume",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "bottom"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(count_over_time({compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\" [5m]))",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Matched Error Volume",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 12,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 7
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "{compose_service=\"ppanel-server\"}",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Filtered Server Logs",
|
||||
"type": "logs"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 19
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "loki",
|
||||
"uid": "P8E80F9AEF21F6940"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "{compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\"",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Filtered Server Errors",
|
||||
"type": "logs"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 42,
|
||||
"tags": [
|
||||
"ppanel",
|
||||
"logs",
|
||||
"server",
|
||||
"loki"
|
||||
],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "输入用户 ID;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "用户ID",
|
||||
"name": "user_id",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "输入邮箱或邮箱片段;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "邮箱",
|
||||
"name": "email",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "输入订单号/支付单号/交易号片段;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "订单",
|
||||
"name": "order",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": "All",
|
||||
"value": "."
|
||||
},
|
||||
"description": "日志等级",
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "等级",
|
||||
"multi": false,
|
||||
"name": "level",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "All",
|
||||
"value": "."
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "debug",
|
||||
"value": "debug"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "info",
|
||||
"value": "info"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "warn",
|
||||
"value": "warn"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "error",
|
||||
"value": "error"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "slow",
|
||||
"value": "slow"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "panic",
|
||||
"value": "panic"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "fatal",
|
||||
"value": "fatal"
|
||||
}
|
||||
],
|
||||
"query": "All : .,debug,info,warn,error,slow,panic,fatal",
|
||||
"queryValue": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": ".",
|
||||
"value": "."
|
||||
},
|
||||
"description": "任意关键字;默认 . 表示不过滤",
|
||||
"hide": 0,
|
||||
"label": "关键字",
|
||||
"name": "keyword",
|
||||
"options": [],
|
||||
"query": ".",
|
||||
"skipUrlSync": false,
|
||||
"type": "textbox"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "PPanel Server Logs",
|
||||
"uid": "ppanel-server-logs",
|
||||
"version": 5,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
uid: prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: true
|
||||
jsonData:
|
||||
httpMethod: POST
|
||||
manageAlerts: true
|
||||
prometheusType: Prometheus
|
||||
prometheusVersion: 2.50.0
|
||||
timeInterval: 15s
|
||||
|
||||
- name: Loki
|
||||
uid: loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
editable: true
|
||||
jsonData:
|
||||
derivedFields:
|
||||
- datasourceUid: tempo
|
||||
matcherRegex: '"(?:trace|traceID|trace_id)"\s*:\s*"([a-f0-9]{32})"'
|
||||
name: TraceID
|
||||
url: '$${__value.raw}'
|
||||
|
||||
- name: Tempo
|
||||
uid: tempo
|
||||
type: tempo
|
||||
access: proxy
|
||||
url: http://tempo:3200
|
||||
editable: true
|
||||
jsonData:
|
||||
tracesToLogsV2:
|
||||
datasourceUid: loki
|
||||
filterByTraceID: true
|
||||
filterBySpanID: false
|
||||
tags:
|
||||
- key: service.name
|
||||
value: service_name
|
||||
tracesToMetrics:
|
||||
datasourceUid: prometheus
|
||||
serviceMap:
|
||||
datasourceUid: prometheus
|
||||
|
||||
- name: CloudWatch
|
||||
uid: cloudwatch
|
||||
type: cloudwatch
|
||||
access: proxy
|
||||
editable: true
|
||||
jsonData:
|
||||
authType: default
|
||||
defaultRegion: ap-east-1
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Remove app_account_token column from order table if it exists
|
||||
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'order' AND COLUMN_NAME = 'app_account_token');
|
||||
SET @sql = IF(@col_exists = 1, 'ALTER TABLE `order` DROP COLUMN `app_account_token`', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Remove subscription_user_id column from order table if it exists
|
||||
SET @col_exists2 = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'order' AND COLUMN_NAME = 'subscription_user_id');
|
||||
SET @sql2 = IF(@col_exists2 = 1, 'ALTER TABLE `order` DROP COLUMN `subscription_user_id`', 'SELECT 1');
|
||||
PREPARE stmt2 FROM @sql2;
|
||||
EXECUTE stmt2;
|
||||
DEALLOCATE PREPARE stmt2;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Rollback: re-deduct commission for users with pending (status=0) withdrawals.
|
||||
-- This re-applies the OLD behaviour where commission is deducted on application.
|
||||
-- Only run this if you are rolling back to the old code; do NOT run against
|
||||
-- the new code or commission will be double-deducted on approval.
|
||||
|
||||
UPDATE `user` u
|
||||
JOIN (
|
||||
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
|
||||
FROM withdrawals
|
||||
WHERE status = 0
|
||||
GROUP BY user_id
|
||||
) p ON u.id = p.user_id
|
||||
SET u.commission = u.commission - p.pending_total
|
||||
WHERE p.pending_total > 0;
|
||||
|
||||
-- Remove the migration log entries written by the up migration.
|
||||
DELETE FROM system_logs
|
||||
WHERE type = 33
|
||||
AND content LIKE '%migration: refund pending withdrawal commission (HIF-22)%';
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Migration: refund commission for existing pending (status=0) withdrawals
|
||||
--
|
||||
-- Under the old logic, commission was deducted when a withdrawal was submitted.
|
||||
-- Under the new logic, commission is only deducted on approval.
|
||||
-- This migration refunds the deducted amounts back to each user so that
|
||||
-- the system is in a consistent state before the new code is deployed.
|
||||
--
|
||||
-- Idempotency: the UPDATE only touches rows whose commission would need
|
||||
-- to increase, and each execution produces the same result because
|
||||
-- COALESCE(SUM(amount),0) is deterministic given the same pending set.
|
||||
-- Running this script multiple times is safe only if no new pending
|
||||
-- withdrawals are created between runs; deploy new code immediately after.
|
||||
|
||||
-- Compatibility: some historical databases missed migration 02122, so the
|
||||
-- withdrawals table may not exist yet. Create it idempotently before the
|
||||
-- refund logic so this migration can self-heal older installations.
|
||||
CREATE TABLE IF NOT EXISTS `withdrawals` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Primary Key',
|
||||
`user_id` BIGINT NOT NULL COMMENT 'User ID',
|
||||
`amount` BIGINT NOT NULL COMMENT 'Withdrawal Amount',
|
||||
`content` TEXT COMMENT 'Withdrawal Content',
|
||||
`status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Withdrawal Status',
|
||||
`reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Rejection Reason',
|
||||
`created_at` DATETIME NOT NULL COMMENT 'Creation Time',
|
||||
`updated_at` DATETIME NOT NULL COMMENT 'Update Time',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
INSERT IGNORE INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
('invite', 'WithdrawalMethod', '', 'string', 'withdrawal method', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637');
|
||||
|
||||
-- Step 1: refund commission for all users with pending withdrawals.
|
||||
UPDATE `user` u
|
||||
JOIN (
|
||||
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
|
||||
FROM withdrawals
|
||||
WHERE status = 0
|
||||
GROUP BY user_id
|
||||
) p ON u.id = p.user_id
|
||||
SET u.commission = u.commission + p.pending_total
|
||||
WHERE p.pending_total > 0;
|
||||
|
||||
-- Step 2: write a migration log entry for each refunded user.
|
||||
INSERT INTO system_logs (type, date, object_id, content, created_at)
|
||||
SELECT
|
||||
33 AS type,
|
||||
DATE(NOW()) AS date,
|
||||
p.user_id AS object_id,
|
||||
JSON_OBJECT(
|
||||
'type', 99,
|
||||
'amount', p.pending_total,
|
||||
'order_no', '',
|
||||
'timestamp', UNIX_TIMESTAMP(NOW()) * 1000,
|
||||
'note', 'migration: refund pending withdrawal commission (HIF-22)'
|
||||
) AS content,
|
||||
NOW() AS created_at
|
||||
FROM (
|
||||
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
|
||||
FROM withdrawals
|
||||
WHERE status = 0
|
||||
GROUP BY user_id
|
||||
HAVING pending_total > 0
|
||||
) p;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Remove activation_context column from order table
|
||||
ALTER TABLE `order` DROP COLUMN IF EXISTS `activation_context`;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add activation_context column to order table for Redis fallback persistence (idempotent)
|
||||
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'order' AND COLUMN_NAME = 'activation_context');
|
||||
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `order` ADD COLUMN `activation_context` TEXT DEFAULT NULL COMMENT ''Activation context JSON (guest/redemption info for DB fallback)'' AFTER `app_account_token`', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `withdrawals`
|
||||
DROP COLUMN `qr_code_url`,
|
||||
DROP COLUMN `account`,
|
||||
DROP COLUMN `method`;
|
||||
@@ -0,0 +1,10 @@
|
||||
SELECT COUNT(*) INTO @col_exists FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'withdrawals' AND COLUMN_NAME = 'method';
|
||||
|
||||
SET @ddl = IF(@col_exists = 0,
|
||||
'ALTER TABLE `withdrawals` ADD COLUMN `method` TINYINT(1) NOT NULL DEFAULT 0 AFTER `content`, ADD COLUMN `account` VARCHAR(255) NOT NULL DEFAULT '''' AFTER `method`, ADD COLUMN `qr_code_url` VARCHAR(500) NOT NULL DEFAULT '''' AFTER `account`',
|
||||
'SELECT 1');
|
||||
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,35 @@
|
||||
SET @traffic_limit_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_subscribe'
|
||||
AND COLUMN_NAME = 'traffic_limit'
|
||||
);
|
||||
|
||||
SET @traffic_limit_sql = IF(
|
||||
@traffic_limit_exists = 1,
|
||||
'ALTER TABLE `user_subscribe` DROP COLUMN `traffic_limit`',
|
||||
'SELECT 1'
|
||||
);
|
||||
|
||||
PREPARE traffic_limit_stmt FROM @traffic_limit_sql;
|
||||
EXECUTE traffic_limit_stmt;
|
||||
DEALLOCATE PREPARE traffic_limit_stmt;
|
||||
|
||||
SET @speed_limit_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_subscribe'
|
||||
AND COLUMN_NAME = 'speed_limit'
|
||||
);
|
||||
|
||||
SET @speed_limit_sql = IF(
|
||||
@speed_limit_exists = 1,
|
||||
'ALTER TABLE `user_subscribe` DROP COLUMN `speed_limit`',
|
||||
'SELECT 1'
|
||||
);
|
||||
|
||||
PREPARE speed_limit_stmt FROM @speed_limit_sql;
|
||||
EXECUTE speed_limit_stmt;
|
||||
DEALLOCATE PREPARE speed_limit_stmt;
|
||||
@@ -0,0 +1,35 @@
|
||||
SET @speed_limit_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_subscribe'
|
||||
AND COLUMN_NAME = 'speed_limit'
|
||||
);
|
||||
|
||||
SET @speed_limit_sql = IF(
|
||||
@speed_limit_exists = 0,
|
||||
'ALTER TABLE `user_subscribe` ADD COLUMN `speed_limit` BIGINT NOT NULL DEFAULT 0 COMMENT ''User-level speed limit override (Mbps), 0 uses plan-level'' AFTER `upload`',
|
||||
'SELECT 1'
|
||||
);
|
||||
|
||||
PREPARE speed_limit_stmt FROM @speed_limit_sql;
|
||||
EXECUTE speed_limit_stmt;
|
||||
DEALLOCATE PREPARE speed_limit_stmt;
|
||||
|
||||
SET @traffic_limit_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_subscribe'
|
||||
AND COLUMN_NAME = 'traffic_limit'
|
||||
);
|
||||
|
||||
SET @traffic_limit_sql = IF(
|
||||
@traffic_limit_exists = 0,
|
||||
'ALTER TABLE `user_subscribe` ADD COLUMN `traffic_limit` TEXT DEFAULT NULL COMMENT ''User-level traffic limit override (JSON), NULL uses plan-level'' AFTER `speed_limit`',
|
||||
'SELECT 1'
|
||||
);
|
||||
|
||||
PREPARE traffic_limit_stmt FROM @traffic_limit_sql;
|
||||
EXECUTE traffic_limit_stmt;
|
||||
DEALLOCATE PREPARE traffic_limit_stmt;
|
||||
@@ -0,0 +1,39 @@
|
||||
SET @column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'order'
|
||||
AND COLUMN_NAME = 'promo_discount'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@column_exists = 1,
|
||||
'ALTER TABLE `order` DROP COLUMN `promo_discount`',
|
||||
'SELECT ''Column promo_discount does not exist in order table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'order'
|
||||
AND COLUMN_NAME = 'promo_rule_id'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@column_exists = 1,
|
||||
'ALTER TABLE `order` DROP COLUMN `promo_rule_id`',
|
||||
'SELECT ''Column promo_rule_id does not exist in order table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
DROP TABLE IF EXISTS `promo_usage`;
|
||||
DROP TABLE IF EXISTS `subscribe_promo`;
|
||||
DROP TABLE IF EXISTS `promo_rule`;
|
||||
@@ -0,0 +1,213 @@
|
||||
CREATE TABLE IF NOT EXISTS `promo_rule` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '规则名称',
|
||||
`type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '规则类型:new_user / inactive_user / campaign',
|
||||
`params` JSON NOT NULL COMMENT '类型专属参数',
|
||||
`priority` INT NOT NULL DEFAULT 0 COMMENT '优先级,数值越大越优先匹配',
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`start_time` DATETIME DEFAULT NULL COMMENT '生效开始时间',
|
||||
`end_time` DATETIME DEFAULT NULL COMMENT '生效结束时间',
|
||||
`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_enabled_priority_deleted` (`enabled`, `deleted_at`, `priority` DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表';
|
||||
|
||||
SET @index_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'promo_rule'
|
||||
AND INDEX_NAME = 'idx_enabled_priority'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@index_exists = 1,
|
||||
'ALTER TABLE `promo_rule` DROP INDEX `idx_enabled_priority`',
|
||||
'SELECT ''Index idx_enabled_priority does not exist on promo_rule table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @index_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'promo_rule'
|
||||
AND INDEX_NAME = 'idx_deleted_at'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@index_exists = 1,
|
||||
'ALTER TABLE `promo_rule` DROP INDEX `idx_deleted_at`',
|
||||
'SELECT ''Index idx_deleted_at does not exist on promo_rule table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @index_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'promo_rule'
|
||||
AND INDEX_NAME = 'idx_enabled_priority_deleted'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@index_exists = 0,
|
||||
'ALTER TABLE `promo_rule` ADD KEY `idx_enabled_priority_deleted` (`enabled`, `deleted_at`, `priority` DESC)',
|
||||
'SELECT ''Index idx_enabled_priority_deleted already exists on promo_rule table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `subscribe_promo` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID',
|
||||
`quantity` BIGINT NOT NULL DEFAULT 1 COMMENT '购买数量',
|
||||
`promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID',
|
||||
`promo_price` 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_subscribe_quantity_rule` (`subscribe_id`, `quantity`, `promo_rule_id`),
|
||||
KEY `idx_promo_rule_id` (`promo_rule_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表';
|
||||
|
||||
SET @column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subscribe_promo'
|
||||
AND COLUMN_NAME = 'quantity'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@column_exists = 0,
|
||||
'ALTER TABLE `subscribe_promo` ADD COLUMN `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT ''购买数量'' AFTER `subscribe_id`',
|
||||
'SELECT ''Column quantity already exists in subscribe_promo table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = IF(
|
||||
@column_exists = 1,
|
||||
'ALTER TABLE `subscribe_promo` MODIFY COLUMN `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT ''购买数量''',
|
||||
'SELECT ''Column quantity does not exist in subscribe_promo table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @index_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subscribe_promo'
|
||||
AND INDEX_NAME = 'uk_subscribe_rule'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@index_exists = 1,
|
||||
'ALTER TABLE `subscribe_promo` DROP INDEX `uk_subscribe_rule`',
|
||||
'SELECT ''Index uk_subscribe_rule does not exist on subscribe_promo table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @index_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subscribe_promo'
|
||||
AND INDEX_NAME = 'uk_subscribe_qty_rule'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@index_exists = 1,
|
||||
'ALTER TABLE `subscribe_promo` DROP INDEX `uk_subscribe_qty_rule`',
|
||||
'SELECT ''Index uk_subscribe_qty_rule does not exist on subscribe_promo table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @index_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subscribe_promo'
|
||||
AND INDEX_NAME = 'uk_subscribe_quantity_rule'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@index_exists = 0,
|
||||
'ALTER TABLE `subscribe_promo` ADD UNIQUE KEY `uk_subscribe_quantity_rule` (`subscribe_id`, `quantity`, `promo_rule_id`)',
|
||||
'SELECT ''Index uk_subscribe_quantity_rule already exists on subscribe_promo table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `promo_usage` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '使用的规则 ID',
|
||||
`subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '购买的规格 ID',
|
||||
`order_no` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联订单号',
|
||||
`promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '使用时的促销单价(分)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_rule` (`user_id`, `promo_rule_id`),
|
||||
KEY `idx_order_no` (`order_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表';
|
||||
|
||||
SET @column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'order'
|
||||
AND COLUMN_NAME = 'promo_rule_id'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@column_exists = 0,
|
||||
'ALTER TABLE `order` ADD COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''促销规则ID, 0=未使用促销'' AFTER `discount`',
|
||||
'SELECT ''Column promo_rule_id already exists in order table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'order'
|
||||
AND COLUMN_NAME = 'promo_discount'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@column_exists = 0,
|
||||
'ALTER TABLE `order` ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT ''促销优惠金额(分)'' AFTER `promo_rule_id`',
|
||||
'SELECT ''Column promo_discount already exists in order table'''
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 02155 down
|
||||
--
|
||||
-- 本迁移只是把 02154 的偏差修正回它应有的目标定义,没有引入新的列/表。
|
||||
-- 回滚 02155 并不应该把列重新改坏成 varchar/INT NULL 的旧偏差,因此 down
|
||||
-- 为空操作。若需彻底删除 promo 系统,请回滚到 02154 的 down。
|
||||
SELECT '02155 has no destructive forward step; down is a no-op.';
|
||||
@@ -0,0 +1,241 @@
|
||||
-- 02155 Promo Schema Fix
|
||||
--
|
||||
-- 修复历史环境中 02154 未正确执行(或部分 GORM AutoMigrate 推断)导致的
|
||||
-- promo 系统列类型 / 索引偏差。完全幂等:可重复执行。
|
||||
--
|
||||
-- 覆盖偏差:
|
||||
-- 1) subscribe_promo.quantity 实际 int/NULL -> BIGINT NOT NULL DEFAULT 1
|
||||
-- 2) subscribe_promo 唯一索引 实际 (subscribe_id, promo_rule_id) -> (subscribe_id, quantity, promo_rule_id)
|
||||
-- 3) order.promo_rule_id 实际 int/NULL -> BIGINT UNSIGNED NOT NULL DEFAULT 0
|
||||
-- 4) order.promo_discount 实际 varchar(255)/NULL -> BIGINT NOT NULL DEFAULT 0
|
||||
--
|
||||
-- 设计原则:
|
||||
-- - 所有 ALTER 前先做 NULL/空串兜底,避免 NOT NULL 转换失败。
|
||||
-- - 类型已经正确的环境(02154 正常跑过)不会被改动,所有 IF 判断都基于
|
||||
-- INFORMATION_SCHEMA 当前真实状态。
|
||||
-- - 索引差异处理 4 个分支:仅当索引确实是错的旧形态时才替换,已经是新形态则不动。
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 1) subscribe_promo.quantity
|
||||
-- ============================================================================
|
||||
|
||||
-- 1.1 列不存在则补建(极端历史环境兜底)
|
||||
SET @col_exists = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subscribe_promo'
|
||||
AND COLUMN_NAME = 'quantity'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `subscribe_promo` ADD COLUMN `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT ''购买数量'' AFTER `subscribe_id`',
|
||||
'SELECT ''subscribe_promo.quantity exists, skip ADD'''
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 1.2 NULL 兜底为 1(旧 AutoMigrate 推断列允许 NULL,必须先回填再 NOT NULL)
|
||||
UPDATE `subscribe_promo` SET `quantity` = 1 WHERE `quantity` IS NULL;
|
||||
|
||||
-- 1.3 类型 / 可空 / 默认值修正:只在与目标定义不一致时改
|
||||
SET @col_def_wrong = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subscribe_promo'
|
||||
AND COLUMN_NAME = 'quantity'
|
||||
AND (
|
||||
LOWER(DATA_TYPE) <> 'bigint'
|
||||
OR IS_NULLABLE = 'YES'
|
||||
OR COLUMN_DEFAULT IS NULL
|
||||
OR COLUMN_DEFAULT <> '1'
|
||||
)
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@col_def_wrong = 1,
|
||||
'ALTER TABLE `subscribe_promo` MODIFY COLUMN `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT ''购买数量''',
|
||||
'SELECT ''subscribe_promo.quantity already matches target definition'''
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 2) subscribe_promo 唯一索引:旧形态 -> (subscribe_id, quantity, promo_rule_id)
|
||||
-- ============================================================================
|
||||
|
||||
-- 2.1 删除已知的所有旧形态唯一索引(如果存在)
|
||||
SET @idx_exists = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subscribe_promo'
|
||||
AND INDEX_NAME = 'idx_subscribe_rule'
|
||||
);
|
||||
SET @sql = IF(
|
||||
@idx_exists = 1,
|
||||
'ALTER TABLE `subscribe_promo` DROP INDEX `idx_subscribe_rule`',
|
||||
'SELECT ''subscribe_promo.idx_subscribe_rule absent'''
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subscribe_promo'
|
||||
AND INDEX_NAME = 'uk_subscribe_rule'
|
||||
);
|
||||
SET @sql = IF(
|
||||
@idx_exists = 1,
|
||||
'ALTER TABLE `subscribe_promo` DROP INDEX `uk_subscribe_rule`',
|
||||
'SELECT ''subscribe_promo.uk_subscribe_rule absent'''
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_exists = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subscribe_promo'
|
||||
AND INDEX_NAME = 'uk_subscribe_qty_rule'
|
||||
);
|
||||
SET @sql = IF(
|
||||
@idx_exists = 1,
|
||||
'ALTER TABLE `subscribe_promo` DROP INDEX `uk_subscribe_qty_rule`',
|
||||
'SELECT ''subscribe_promo.uk_subscribe_qty_rule absent'''
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 2.2 新建目标唯一索引(缺失时才建)
|
||||
SET @idx_exists = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subscribe_promo'
|
||||
AND INDEX_NAME = 'uk_subscribe_quantity_rule'
|
||||
);
|
||||
SET @sql = IF(
|
||||
@idx_exists = 0,
|
||||
'ALTER TABLE `subscribe_promo` ADD UNIQUE KEY `uk_subscribe_quantity_rule` (`subscribe_id`, `quantity`, `promo_rule_id`)',
|
||||
'SELECT ''subscribe_promo.uk_subscribe_quantity_rule exists'''
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 3) order.promo_rule_id -> BIGINT UNSIGNED NOT NULL DEFAULT 0
|
||||
-- ============================================================================
|
||||
|
||||
SET @col_exists = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'order'
|
||||
AND COLUMN_NAME = 'promo_rule_id'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `order` ADD COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''促销规则ID, 0=未使用促销'' AFTER `discount`',
|
||||
'SELECT ''order.promo_rule_id exists, skip ADD'''
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE `order` SET `promo_rule_id` = 0 WHERE `promo_rule_id` IS NULL;
|
||||
|
||||
SET @col_def_wrong = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'order'
|
||||
AND COLUMN_NAME = 'promo_rule_id'
|
||||
AND (
|
||||
LOWER(DATA_TYPE) <> 'bigint'
|
||||
OR INSTR(LOWER(COLUMN_TYPE), 'unsigned') = 0
|
||||
OR IS_NULLABLE = 'YES'
|
||||
OR COLUMN_DEFAULT IS NULL
|
||||
OR COLUMN_DEFAULT <> '0'
|
||||
)
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@col_def_wrong = 1,
|
||||
'ALTER TABLE `order` MODIFY COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''促销规则ID, 0=未使用促销''',
|
||||
'SELECT ''order.promo_rule_id already matches target definition'''
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 4) order.promo_discount -> BIGINT NOT NULL DEFAULT 0
|
||||
-- 历史 AutoMigrate 推断为 varchar(255)/NULL,金额字段错存为字符串。
|
||||
-- 必须先把空串/NULL 兜底为 '0',再 MODIFY,否则 MySQL 转 BIGINT 会写 0
|
||||
-- (这里我们仍兜底显式化,避免触发 strict mode 报错)。
|
||||
-- ============================================================================
|
||||
|
||||
SET @col_exists = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'order'
|
||||
AND COLUMN_NAME = 'promo_discount'
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `order` ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT ''促销优惠金额(分)'' AFTER `promo_rule_id`',
|
||||
'SELECT ''order.promo_discount exists, skip ADD'''
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 当且仅当当前是字符串型时做兜底(避免对已经是 BIGINT 的环境跑无谓 UPDATE)
|
||||
SET @col_is_string = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'order'
|
||||
AND COLUMN_NAME = 'promo_discount'
|
||||
AND LOWER(DATA_TYPE) IN ('varchar', 'char', 'text')
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@col_is_string = 1,
|
||||
'UPDATE `order` SET `promo_discount` = ''0'' WHERE `promo_discount` IS NULL OR `promo_discount` = ''''',
|
||||
'SELECT ''order.promo_discount not string type, skip backfill'''
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @col_def_wrong = (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'order'
|
||||
AND COLUMN_NAME = 'promo_discount'
|
||||
AND (
|
||||
LOWER(DATA_TYPE) <> 'bigint'
|
||||
OR IS_NULLABLE = 'YES'
|
||||
OR COLUMN_DEFAULT IS NULL
|
||||
OR COLUMN_DEFAULT <> '0'
|
||||
)
|
||||
);
|
||||
|
||||
SET @sql = IF(
|
||||
@col_def_wrong = 1,
|
||||
'ALTER TABLE `order` MODIFY COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT ''促销优惠金额(分)''',
|
||||
'SELECT ''order.promo_discount already matches target definition'''
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -58,7 +58,7 @@ type S3Config struct {
|
||||
UsePathStyle bool `yaml:"UsePathStyle" default:"false"`
|
||||
PresignExpireSeconds int64 `yaml:"PresignExpireSeconds" default:"300"`
|
||||
MaxUploadSize int64 `yaml:"MaxUploadSize" default:"104857600"`
|
||||
AllowedContentTypes string `yaml:"AllowedContentTypes" default:"application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json"`
|
||||
AllowedContentTypes string `yaml:"AllowedContentTypes" default:"application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp"`
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package invite
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/invite"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Get invite manage list
|
||||
func GetInviteManageListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetInviteManageListRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := invite.NewGetInviteManageListLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetInviteManageList(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/log"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func FilterOrderRefundLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.FilterOrderRefundLogRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := log.NewFilterOrderRefundLogLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.FilterOrderRefundLog(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/log"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func GetLogMessageRawHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetLogMessageRawRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := log.NewGetLogMessageRawLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetLogMessageRaw(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/order"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func RefundOrderHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.RefundOrderRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := order.NewRefundOrderLogic(c.Request.Context(), svcCtx)
|
||||
err := l.RefundOrder(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package promo
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/promo"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func CreateRuleHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.CreatePromoRuleRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := promo.NewCreateRuleLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.CreateRule(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package promo
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/promo"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func DeletePriceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.DeletePromoPriceRequest
|
||||
if err := c.ShouldBindUri(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := promo.NewDeletePriceLogic(c.Request.Context(), svcCtx)
|
||||
err := l.DeletePrice(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package promo
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/promo"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func DeleteRuleHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.DeletePromoRuleRequest
|
||||
if err := c.ShouldBindUri(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := promo.NewDeleteRuleLogic(c.Request.Context(), svcCtx)
|
||||
err := l.DeleteRule(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package promo
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/promo"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func GetPriceListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetPromoPriceListRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := promo.NewGetPriceListLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetPriceList(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package promo
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/promo"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func GetRuleDetailHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetPromoRuleDetailRequest
|
||||
if err := c.ShouldBindUri(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := promo.NewGetRuleDetailLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetRuleDetail(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package promo
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/promo"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func GetRuleListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetPromoRuleListRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := promo.NewGetRuleListLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetRuleList(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package promo
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/promo"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func GetUsageListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetPromoUsageListRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := promo.NewGetUsageListLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetUsageList(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package promo
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/promo"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func SetPriceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.SetPromoPriceRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := promo.NewSetPriceLogic(c.Request.Context(), svcCtx)
|
||||
err := l.SetPrice(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package promo
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/promo"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func UpdateRuleHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdatePromoRuleRequest
|
||||
if err := c.ShouldBindUri(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := promo.NewUpdateRuleLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.UpdateRule(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,10 @@ import (
|
||||
func UpdateUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateUserSubscribeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
)
|
||||
|
||||
func TestUpdateUserSubscribeHandlerRejectsInvalidLimits(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "negative speed limit",
|
||||
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",
|
||||
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"traffic_limit":"not-json"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.PUT("/v1/admin/user/subscribe", UpdateUserSubscribeHandler(&svc.ServiceContext{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v1/admin/user/subscribe", bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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.InvalidParams {
|
||||
t.Fatalf("expected code %d, got %d (%s)", xerr.InvalidParams, resp.Code, resp.Msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func ReportLogMessageHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ReportLogMessageRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
l := common.NewReportLogMessageLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ReportLogMessage(&req, c)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var req types.ReportLogMessageRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
l := common.NewReportLogMessageLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ReportLogMessage(&req, c)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Cancel Withdrawal
|
||||
func CancelWithdrawalHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.CancelWithdrawalRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewCancelWithdrawalLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.CancelWithdrawal(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// Get invite gift records
|
||||
func GetInviteRecordsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetInviteRecordsRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewGetInviteRecordsLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetInviteRecords(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,12 @@ import (
|
||||
adminCoupon "github.com/perfect-panel/server/internal/handler/admin/coupon"
|
||||
adminDocument "github.com/perfect-panel/server/internal/handler/admin/document"
|
||||
adminGroup "github.com/perfect-panel/server/internal/handler/admin/group"
|
||||
adminInvite "github.com/perfect-panel/server/internal/handler/admin/invite"
|
||||
adminLog "github.com/perfect-panel/server/internal/handler/admin/log"
|
||||
adminMarketing "github.com/perfect-panel/server/internal/handler/admin/marketing"
|
||||
adminOrder "github.com/perfect-panel/server/internal/handler/admin/order"
|
||||
adminPayment "github.com/perfect-panel/server/internal/handler/admin/payment"
|
||||
adminPromo "github.com/perfect-panel/server/internal/handler/admin/promo"
|
||||
adminRedemption "github.com/perfect-panel/server/internal/handler/admin/redemption"
|
||||
adminServer "github.com/perfect-panel/server/internal/handler/admin/server"
|
||||
adminSubscribe "github.com/perfect-panel/server/internal/handler/admin/subscribe"
|
||||
@@ -193,6 +195,14 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
adminDocumentGroupRouter.GET("/list", adminDocument.GetDocumentListHandler(serverCtx))
|
||||
}
|
||||
|
||||
adminInviteGroupRouter := router.Group("/v1/admin/invite")
|
||||
adminInviteGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
|
||||
|
||||
{
|
||||
// Get invite manage list
|
||||
adminInviteGroupRouter.GET("/list", adminInvite.GetInviteManageListHandler(serverCtx))
|
||||
}
|
||||
|
||||
adminGroupGroupRouter := router.Group("/v1/admin/group")
|
||||
adminGroupGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
|
||||
|
||||
@@ -250,12 +260,18 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// Filter commission log
|
||||
adminLogGroupRouter.GET("/commission/list", adminLog.FilterCommissionLogHandler(serverCtx))
|
||||
|
||||
// Filter order refund log
|
||||
adminLogGroupRouter.GET("/order/refund/list", adminLog.FilterOrderRefundLogHandler(serverCtx))
|
||||
|
||||
// Filter email log
|
||||
adminLogGroupRouter.GET("/email/list", adminLog.FilterEmailLogHandler(serverCtx))
|
||||
|
||||
// Get error log message detail
|
||||
adminLogGroupRouter.GET("/error_message/detail", adminLog.GetErrorLogMessageDetailHandler(serverCtx))
|
||||
|
||||
// Get log message raw detail (temporary)
|
||||
adminLogGroupRouter.GET("/message/detail", adminLog.GetLogMessageRawHandler(serverCtx))
|
||||
|
||||
// Get error log message list
|
||||
adminLogGroupRouter.GET("/error_message/list", adminLog.GetErrorLogMessageListHandler(serverCtx))
|
||||
|
||||
@@ -341,6 +357,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// Update order status
|
||||
adminOrderGroupRouter.PUT("/status", adminOrder.UpdateOrderStatusHandler(serverCtx))
|
||||
|
||||
// Refund order
|
||||
adminOrderGroupRouter.POST("/refund", adminOrder.RefundOrderHandler(serverCtx))
|
||||
|
||||
// Manually activate order
|
||||
adminOrderGroupRouter.POST("/activate", adminOrder.ActivateOrderHandler(serverCtx))
|
||||
}
|
||||
@@ -365,6 +384,38 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
adminPaymentGroupRouter.GET("/platform", adminPayment.GetPaymentPlatformHandler(serverCtx))
|
||||
}
|
||||
|
||||
adminPromoGroupRouter := router.Group("/v1/admin/promo")
|
||||
adminPromoGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
|
||||
|
||||
{
|
||||
// Create promo rule
|
||||
adminPromoGroupRouter.POST("/rule", adminPromo.CreateRuleHandler(serverCtx))
|
||||
|
||||
// Get promo rule list
|
||||
adminPromoGroupRouter.GET("/rule/list", adminPromo.GetRuleListHandler(serverCtx))
|
||||
|
||||
// Get promo rule detail
|
||||
adminPromoGroupRouter.GET("/rule/:id", adminPromo.GetRuleDetailHandler(serverCtx))
|
||||
|
||||
// Update promo rule
|
||||
adminPromoGroupRouter.PUT("/rule/:id", adminPromo.UpdateRuleHandler(serverCtx))
|
||||
|
||||
// Delete promo rule
|
||||
adminPromoGroupRouter.DELETE("/rule/:id", adminPromo.DeleteRuleHandler(serverCtx))
|
||||
|
||||
// Set promo prices
|
||||
adminPromoGroupRouter.POST("/price", adminPromo.SetPriceHandler(serverCtx))
|
||||
|
||||
// Get promo price list
|
||||
adminPromoGroupRouter.GET("/price/list", adminPromo.GetPriceListHandler(serverCtx))
|
||||
|
||||
// Delete promo price
|
||||
adminPromoGroupRouter.DELETE("/price/:id", adminPromo.DeletePriceHandler(serverCtx))
|
||||
|
||||
// Get promo usage list
|
||||
adminPromoGroupRouter.GET("/usage/list", adminPromo.GetUsageListHandler(serverCtx))
|
||||
}
|
||||
|
||||
adminRedemptionGroupRouter := router.Group("/v1/admin/redemption")
|
||||
adminRedemptionGroupRouter.Use(middleware.AuthMiddleware(serverCtx))
|
||||
|
||||
@@ -969,17 +1020,16 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
}
|
||||
|
||||
publicSubscribeGroupRouter := router.Group("/v1/public/subscribe")
|
||||
publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||
|
||||
{
|
||||
// Get subscribe list
|
||||
publicSubscribeGroupRouter.GET("/list", publicSubscribe.QuerySubscribeListHandler(serverCtx))
|
||||
publicSubscribeGroupRouter.GET("/list", middleware.OptionalAuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QuerySubscribeListHandler(serverCtx))
|
||||
|
||||
// Get user subscribe node info
|
||||
publicSubscribeGroupRouter.GET("/node/list", publicSubscribe.QueryUserSubscribeNodeListHandler(serverCtx))
|
||||
publicSubscribeGroupRouter.GET("/node/list", middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QueryUserSubscribeNodeListHandler(serverCtx))
|
||||
|
||||
// Get subscribe group list
|
||||
publicSubscribeGroupRouter.GET("/group/list", publicSubscribe.QuerySubscribeGroupListHandler(serverCtx))
|
||||
publicSubscribeGroupRouter.GET("/group/list", middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QuerySubscribeGroupListHandler(serverCtx))
|
||||
}
|
||||
|
||||
publicTicketGroupRouter := router.Group("/v1/public/ticket")
|
||||
@@ -1050,6 +1100,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// Commission Withdraw
|
||||
publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx))
|
||||
|
||||
// Cancel Withdrawal
|
||||
publicUserGroupRouter.POST("/withdrawal_cancel", publicUser.CancelWithdrawalHandler(serverCtx))
|
||||
|
||||
// Delete Current User Account
|
||||
publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx))
|
||||
|
||||
@@ -1065,6 +1118,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// Query User Info
|
||||
publicUserGroupRouter.GET("/info", publicUser.QueryUserInfoHandler(serverCtx))
|
||||
|
||||
// Get Invite Records
|
||||
publicUserGroupRouter.GET("/invite_records", publicUser.GetInviteRecordsHandler(serverCtx))
|
||||
|
||||
// Get Invite Sales
|
||||
publicUserGroupRouter.GET("/invite_sales", publicUser.GetInviteSalesHandler(serverCtx))
|
||||
publicUserGroupRouter.GET("/invite/sales", publicUser.GetInviteSalesHandler(serverCtx)) // alias: backward-compat
|
||||
|
||||
@@ -29,7 +29,7 @@ func (l *DeleteSubscribeApplicationLogic) DeleteSubscribeApplication(req *types.
|
||||
err := l.svcCtx.ClientModel.Delete(l.ctx, req.Id)
|
||||
if err != nil {
|
||||
l.Errorf("Failed to delete subscribe application with ID %d: %v", req.Id, err)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/group"
|
||||
"github.com/perfect-panel/server/internal/model/node"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
@@ -48,6 +49,33 @@ func (l *DeleteNodeGroupLogic) DeleteNodeGroup(req *types.DeleteNodeGroupRequest
|
||||
return fmt.Errorf("cannot delete group with %d associated nodes, please migrate nodes first", nodeCount)
|
||||
}
|
||||
|
||||
var defaultSubscribeCount int64
|
||||
if err := l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("node_group_id = ?", nodeGroup.Id).Count(&defaultSubscribeCount).Error; err != nil {
|
||||
logger.Errorf("failed to count subscribes with default group: %v", err)
|
||||
return err
|
||||
}
|
||||
if defaultSubscribeCount > 0 {
|
||||
return fmt.Errorf("cannot delete group referenced by %d subscribes' default node group", defaultSubscribeCount)
|
||||
}
|
||||
|
||||
var subscribeGroupCount int64
|
||||
if err := l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroup.Id)).Count(&subscribeGroupCount).Error; err != nil {
|
||||
logger.Errorf("failed to count subscribes in group: %v", err)
|
||||
return err
|
||||
}
|
||||
if subscribeGroupCount > 0 {
|
||||
return fmt.Errorf("cannot delete group referenced by %d subscribes' node group list", subscribeGroupCount)
|
||||
}
|
||||
|
||||
var userSubscribeCount int64
|
||||
if err := l.svcCtx.DB.Table("user_subscribe").Where("node_group_id = ?", nodeGroup.Id).Count(&userSubscribeCount).Error; err != nil {
|
||||
logger.Errorf("failed to count user subscribes in group: %v", err)
|
||||
return err
|
||||
}
|
||||
if userSubscribeCount > 0 {
|
||||
return fmt.Errorf("cannot delete group referenced by %d user subscribes", userSubscribeCount)
|
||||
}
|
||||
|
||||
// 使用 GORM Transaction 删除节点组
|
||||
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 删除节点组
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/group"
|
||||
@@ -52,10 +53,9 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
|
||||
Email string `json:"email"`
|
||||
}
|
||||
var users []UserInfo
|
||||
if err := l.svcCtx.DB.Raw("SELECT * FROM JSON_ARRAY(?)", detail.UserData).Scan(&users).Error; err != nil {
|
||||
// 如果解析失败,尝试用标准 JSON 解析
|
||||
if err := json.Unmarshal([]byte(detail.UserData), &users); err != nil {
|
||||
logger.Errorf("failed to parse user data: %v", err)
|
||||
continue
|
||||
return nil, "", fmt.Errorf("parse group history user_data failed: %w", err)
|
||||
}
|
||||
|
||||
// 查询节点组名称
|
||||
@@ -123,7 +123,10 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR
|
||||
result = append(result, csvData...)
|
||||
|
||||
// 生成文件名
|
||||
filename := fmt.Sprintf("group_result_%d.csv", req.HistoryId)
|
||||
filename := "group_result_current.csv"
|
||||
if req.HistoryId != nil {
|
||||
filename = fmt.Sprintf("group_result_%d.csv", *req.HistoryId)
|
||||
}
|
||||
|
||||
return result, filename, nil
|
||||
}
|
||||
|
||||
@@ -76,16 +76,16 @@ func (l *GetGroupHistoryDetailLogic) GetGroupHistoryDetail(req *types.GetGroupHi
|
||||
configSnapshot := make(map[string]interface{})
|
||||
configSnapshot["group_details"] = details
|
||||
|
||||
// 获取配置快照(从 system_config 读取)
|
||||
// 获取配置快照(从 system 读取)
|
||||
var configValue string
|
||||
if history.GroupMode == "average" {
|
||||
l.svcCtx.DB.Table("system_config").
|
||||
Where("`key` = ?", "group.average_config").
|
||||
l.svcCtx.DB.Table("system").
|
||||
Where("`category` = ? AND `key` = ?", "group", "average_config").
|
||||
Select("value").
|
||||
Scan(&configValue)
|
||||
} else if history.GroupMode == "traffic" {
|
||||
l.svcCtx.DB.Table("system_config").
|
||||
Where("`key` = ?", "group.traffic_config").
|
||||
l.svcCtx.DB.Table("system").
|
||||
Where("`category` = ? AND `key` = ?", "group", "traffic_config").
|
||||
Select("value").
|
||||
Scan(&configValue)
|
||||
}
|
||||
|
||||
@@ -44,9 +44,9 @@ func (l *GetGroupHistoryLogic) GetGroupHistory(req *types.GetGroupHistoryRequest
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (req.Page - 1) * req.Size
|
||||
if err := query.Order("id DESC").Offset(offset).Limit(req.Size).Find(&histories).Error; err != nil {
|
||||
page, size := normalizePagination(req.Page, req.Size)
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&histories).Error; err != nil {
|
||||
logger.Errorf("failed to find group histories: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -38,9 +38,9 @@ func (l *GetNodeGroupListLogic) GetNodeGroupList(req *types.GetNodeGroupListRequ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (req.Page - 1) * req.Size
|
||||
if err := query.Order("sort ASC").Offset(offset).Limit(req.Size).Find(&nodeGroups).Error; err != nil {
|
||||
page, size := normalizePagination(req.Page, req.Size)
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("sort ASC").Offset(offset).Limit(size).Find(&nodeGroups).Error; err != nil {
|
||||
logger.Errorf("failed to find node groups: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
modelgroup "github.com/perfect-panel/server/internal/model/group"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestGetGroupHistoryDetailReadsConfigFromSystem(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
now := time.Unix(1710000000, 0)
|
||||
mock.ExpectQuery("FROM `group_history`").
|
||||
WithArgs(int64(9), 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "group_mode", "trigger_type", "state", "total_users", "success_count", "failed_count", "start_time", "end_time", "error_message", "created_at",
|
||||
}).AddRow(int64(9), "traffic", "manual", "completed", 1, 1, 0, now, now, "", now))
|
||||
mock.ExpectQuery("FROM `group_history_detail`").
|
||||
WithArgs(int64(9)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
|
||||
}).AddRow(int64(1), int64(9), int64(3), 1, 2, `[{"id":7,"email":"u@example.com"}]`, now))
|
||||
mock.ExpectQuery("FROM `system`").
|
||||
WithArgs("group", "traffic_config").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(`{"strategy":"closed"}`))
|
||||
|
||||
logic := newTestGroupHistoryDetailLogic(db)
|
||||
resp, err := logic.GetGroupHistoryDetail(&types.GetGroupHistoryDetailRequest{Id: 9})
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroupHistoryDetail error: %v", err)
|
||||
}
|
||||
if got := resp.ConfigSnapshot["config"].(map[string]interface{})["strategy"]; got != "closed" {
|
||||
t.Fatalf("config snapshot strategy = %v, want closed", got)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestExportGroupResultParsesHistoryUserDataJSON(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
historyID := int64(11)
|
||||
now := time.Unix(1710000000, 0)
|
||||
mock.ExpectQuery("FROM `group_history_detail`").
|
||||
WithArgs(historyID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
|
||||
}).AddRow(int64(1), historyID, int64(8), 1, 2, `[{"id":42,"email":"u@example.com"}]`, now))
|
||||
mock.ExpectQuery("FROM `node_group`").
|
||||
WithArgs(int64(8), 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(8), "VIP"))
|
||||
|
||||
logic := newTestExportGroupResultLogic(db)
|
||||
data, filename, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID})
|
||||
if err != nil {
|
||||
t.Fatalf("ExportGroupResult error: %v", err)
|
||||
}
|
||||
if filename != "group_result_11.csv" {
|
||||
t.Fatalf("filename = %q, want group_result_11.csv", filename)
|
||||
}
|
||||
|
||||
records, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(string(data), "\ufeff"))).ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("read csv: %v", err)
|
||||
}
|
||||
if len(records) != 2 || strings.Join(records[1], ",") != "42,8,VIP" {
|
||||
t.Fatalf("csv records = %#v, want user/group row", records)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestExportGroupResultRejectsInvalidHistoryUserDataJSON(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
historyID := int64(12)
|
||||
now := time.Unix(1710000000, 0)
|
||||
mock.ExpectQuery("FROM `group_history_detail`").
|
||||
WithArgs(historyID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at",
|
||||
}).AddRow(int64(1), historyID, int64(8), 1, 2, `{bad-json`, now))
|
||||
|
||||
logic := newTestExportGroupResultLogic(db)
|
||||
_, _, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID})
|
||||
if err == nil || !strings.Contains(err.Error(), "parse group history user_data failed") {
|
||||
t.Fatalf("ExportGroupResult error = %v, want parse error", err)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestDeleteNodeGroupRejectsSubscribeReferences(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultSubscribeCount int64
|
||||
subscribeGroupCount int64
|
||||
userSubscribeCount int64
|
||||
wantErr string
|
||||
}{
|
||||
{name: "subscribe default group", defaultSubscribeCount: 1, wantErr: "default node group"},
|
||||
{name: "subscribe group list", subscribeGroupCount: 1, wantErr: "node group list"},
|
||||
{name: "user subscribe group", userSubscribeCount: 1, wantErr: "user subscribes"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectDeleteNodeGroupBaseChecks(mock, 5)
|
||||
mock.ExpectQuery("FROM `subscribe`").
|
||||
WithArgs(int64(5)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.defaultSubscribeCount))
|
||||
if tt.defaultSubscribeCount == 0 {
|
||||
mock.ExpectQuery("FROM `subscribe`").
|
||||
WithArgs("[5]").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.subscribeGroupCount))
|
||||
}
|
||||
if tt.defaultSubscribeCount == 0 && tt.subscribeGroupCount == 0 {
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(int64(5)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.userSubscribeCount))
|
||||
}
|
||||
|
||||
logic := newTestDeleteNodeGroupLogic(db)
|
||||
err := logic.DeleteNodeGroup(&types.DeleteNodeGroupRequest{Id: 5})
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("DeleteNodeGroup error = %v, want contains %q", err, tt.wantErr)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetGroupsRollsBackOnFailure(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("DELETE FROM `node_group`").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE `subscribe`").
|
||||
WithArgs(int64(0), "[]").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE `nodes`").
|
||||
WithArgs("[]").
|
||||
WillReturnError(fmt.Errorf("node update failed"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
logic := newTestResetGroupsLogic(db)
|
||||
err := logic.ResetGroups()
|
||||
if err == nil || !strings.Contains(err.Error(), "node update failed") {
|
||||
t.Fatalf("ResetGroups error = %v, want node update failure", err)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestPreviewUserNodesIncludesPublicNodesInGroupMode(t *testing.T) {
|
||||
db, mock, cleanup := newGroupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
now := time.Unix(1710000000, 0)
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WithArgs(int64(100), int8(0), int8(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subscribe_id", "node_group_id"}).
|
||||
AddRow(int64(1), int64(100), int64(10), int64(5)))
|
||||
mock.ExpectQuery("FROM `subscribe`").
|
||||
WithArgs(int64(10)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "node_group_id", "node_group_ids", "nodes", "node_tags"}).
|
||||
AddRow(int64(10), int64(0), "[]", "", ""))
|
||||
mock.ExpectQuery("FROM `system`").
|
||||
WithArgs("group", "enabled").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("true"))
|
||||
mock.ExpectQuery("FROM `nodes`").
|
||||
WithArgs(true).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "name", "tags", "port", "address", "server_id", "protocol", "enabled", "sort", "node_group_ids", "created_at", "updated_at",
|
||||
}).
|
||||
AddRow(int64(1), "group-node", "", uint16(443), "g.example.com", int64(1), "vless", true, 1, "[5]", now, now).
|
||||
AddRow(int64(2), "public-node", "", uint16(443), "p.example.com", int64(1), "vless", true, 2, "[]", now, now))
|
||||
mock.ExpectQuery("FROM `node_group`").
|
||||
WithArgs(int64(5)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(5), "Group A"))
|
||||
|
||||
logic := newTestPreviewUserNodesLogic(db)
|
||||
resp, err := logic.PreviewUserNodes(&types.PreviewUserNodesRequest{UserId: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("PreviewUserNodes error: %v", err)
|
||||
}
|
||||
if !hasNodeGroup(resp.NodeGroups, 0, "public-node") {
|
||||
t.Fatalf("PreviewUserNodes node groups = %#v, want public node group", resp.NodeGroups)
|
||||
}
|
||||
assertGroupExpectations(t, mock)
|
||||
}
|
||||
|
||||
func TestTrafficRangeMatchingUsesClosedBounds(t *testing.T) {
|
||||
min0, max10 := int64(0), int64(10)
|
||||
min10, max20 := int64(10), int64(20)
|
||||
nodeGroups := []modelgroup.NodeGroup{
|
||||
{Id: 1, MinTrafficGB: &min0, MaxTrafficGB: &max10},
|
||||
{Id: 2, MinTrafficGB: &min10, MaxTrafficGB: &max20},
|
||||
}
|
||||
|
||||
tests := map[float64]int64{
|
||||
0: 1,
|
||||
10: 1,
|
||||
15: 2,
|
||||
21: 0,
|
||||
}
|
||||
for used, want := range tests {
|
||||
if got := matchTrafficNodeGroup(used, nodeGroups); got != want {
|
||||
t.Fatalf("matchTrafficNodeGroup(%v) = %d, want %d", used, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePaginationDefaultsAndMax(t *testing.T) {
|
||||
tests := []struct {
|
||||
page, size int
|
||||
wantPage int
|
||||
wantSize int
|
||||
}{
|
||||
{page: 0, size: 0, wantPage: 1, wantSize: defaultPageSize},
|
||||
{page: -1, size: -5, wantPage: 1, wantSize: defaultPageSize},
|
||||
{page: 2, size: maxPageSize + 1, wantPage: 2, wantSize: maxPageSize},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
gotPage, gotSize := normalizePagination(tt.page, tt.size)
|
||||
if gotPage != tt.wantPage || gotSize != tt.wantSize {
|
||||
t.Fatalf("normalizePagination(%d, %d) = (%d, %d), want (%d, %d)", tt.page, tt.size, gotPage, gotSize, tt.wantPage, tt.wantSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newGroupTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||
if strings.Contains(actualSQL, expectedSQL) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
|
||||
return db, mock, func() {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func newTestGroupHistoryDetailLogic(db *gorm.DB) *GetGroupHistoryDetailLogic {
|
||||
ctx := context.Background()
|
||||
return &GetGroupHistoryDetailLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestExportGroupResultLogic(db *gorm.DB) *ExportGroupResultLogic {
|
||||
ctx := context.Background()
|
||||
return &ExportGroupResultLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestDeleteNodeGroupLogic(db *gorm.DB) *DeleteNodeGroupLogic {
|
||||
ctx := context.Background()
|
||||
return &DeleteNodeGroupLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestResetGroupsLogic(db *gorm.DB) *ResetGroupsLogic {
|
||||
ctx := context.Background()
|
||||
return &ResetGroupsLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPreviewUserNodesLogic(db *gorm.DB) *PreviewUserNodesLogic {
|
||||
ctx := context.Background()
|
||||
return &PreviewUserNodesLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: &svc.ServiceContext{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
func expectDeleteNodeGroupBaseChecks(mock sqlmock.Sqlmock, nodeGroupId int64) {
|
||||
now := time.Unix(1710000000, 0)
|
||||
mock.ExpectQuery("FROM `node_group`").
|
||||
WithArgs(nodeGroupId, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at"}).
|
||||
AddRow(nodeGroupId, "Group", now, now))
|
||||
mock.ExpectQuery("FROM `nodes`").
|
||||
WithArgs(fmt.Sprintf("[%d]", nodeGroupId)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
}
|
||||
|
||||
func hasNodeGroup(items []types.NodeGroupItem, id int64, nodeName string) bool {
|
||||
for _, item := range items {
|
||||
if item.Id != id {
|
||||
continue
|
||||
}
|
||||
for _, n := range item.Nodes {
|
||||
if n.Name == nodeName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertGroupExpectations(t *testing.T, mock sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/node"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
)
|
||||
|
||||
func normalizePagination(page, size int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = defaultPageSize
|
||||
}
|
||||
if size > maxPageSize {
|
||||
size = maxPageSize
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
|
||||
func countNodesInGroup(db *gorm.DB, nodeGroupId int64) (int, error) {
|
||||
var count int64
|
||||
err := db.Model(&node.Node{}).
|
||||
Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroupId)).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(count), nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user