add: 新增ci diamanté
This commit is contained in:
parent
d220516183
commit
b0eb6595ac
236
.gitea/workflows /docker.yml
Normal file
236
.gitea/workflows /docker.yml
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
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: ${{ vars.SSH_USER }}
|
||||||
|
SSH_PASSWORD: ${{ github.ref_name == 'main' && vars.SSH_PASSWORD || vars.DEV_SSH_PASSWORD }}
|
||||||
|
# TG通知
|
||||||
|
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
||||||
|
TG_CHAT_ID: "-49402438031"
|
||||||
|
# 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=/root/bindbox" >> $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 "镜像推送完成"
|
||||||
|
|
||||||
|
# 调试: 打印 SSH 连接信息
|
||||||
|
- name: 🔍 调试 - 打印 SSH 连接信息
|
||||||
|
run: |
|
||||||
|
echo "========== SSH 连接信息调试 =========="
|
||||||
|
echo "当前分支: ${{ github.ref_name }}"
|
||||||
|
echo "SSH_HOST: ${{ env.SSH_HOST }}"
|
||||||
|
echo "SSH_PORT: ${{ env.SSH_PORT }}"
|
||||||
|
echo "SSH_USER: ${{ env.SSH_USER }}"
|
||||||
|
echo "SSH_PASSWORD 长度: ${#SSH_PASSWORD}"
|
||||||
|
echo "SSH_PASSWORD 前3位: $(echo "$SSH_PASSWORD" | cut -c1-3)***"
|
||||||
|
echo "SSH_PASSWORD 完整值: ${{ env.SSH_PASSWORD }}"
|
||||||
|
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 }}
|
||||||
|
password: ${{ env.SSH_PASSWORD }}
|
||||||
|
port: ${{ env.SSH_PORT }}
|
||||||
|
source: "docker-compose.cloud.yml"
|
||||||
|
target: "${{ env.DEPLOY_PATH }}/"
|
||||||
|
|
||||||
|
# 步骤6: 连接服务器更新并启动
|
||||||
|
- name: 🚀 连接服务器更新并启动
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ env.SSH_HOST }}
|
||||||
|
username: ${{ env.SSH_USER }}
|
||||||
|
password: ${{ env.SSH_PASSWORD }}
|
||||||
|
port: ${{ env.SSH_PORT }}
|
||||||
|
timeout: 300s
|
||||||
|
command_timeout: 600s
|
||||||
|
script: |
|
||||||
|
echo "连接服务器成功,开始部署..."
|
||||||
|
echo "部署目录: ${{ env.DEPLOY_PATH }}"
|
||||||
|
echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
||||||
|
|
||||||
|
# 进入部署目录
|
||||||
|
cd ${{ env.DEPLOY_PATH }}
|
||||||
|
|
||||||
|
# 创建/更新环境变量文件
|
||||||
|
echo "PPANEL_SERVER_TAG=${{ env.DOCKER_TAG_SUFFIX }}" > .env
|
||||||
|
|
||||||
|
# 拉取最新镜像
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
27
.github/environments/production.yml
vendored
Normal file
27
.github/environments/production.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Production Environment Configuration for GitHub Actions
|
||||||
|
# This file defines production-specific deployment settings
|
||||||
|
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
url: https://api.ppanel.example.com
|
||||||
|
protection_rules:
|
||||||
|
- type: wait_timer
|
||||||
|
minutes: 5
|
||||||
|
- type: reviewers
|
||||||
|
reviewers:
|
||||||
|
- "@admin-team"
|
||||||
|
- "@devops-team"
|
||||||
|
variables:
|
||||||
|
ENVIRONMENT: production
|
||||||
|
LOG_LEVEL: info
|
||||||
|
DEPLOY_TIMEOUT: 300
|
||||||
|
|
||||||
|
# Environment-specific secrets required:
|
||||||
|
# PRODUCTION_HOST - Production server hostname/IP
|
||||||
|
# PRODUCTION_USER - SSH username for production server
|
||||||
|
# PRODUCTION_SSH_KEY - SSH private key for production server
|
||||||
|
# PRODUCTION_PORT - SSH port (default: 22)
|
||||||
|
# PRODUCTION_URL - Application URL for health checks
|
||||||
|
# DATABASE_PASSWORD - Production database password
|
||||||
|
# REDIS_PASSWORD - Production Redis password
|
||||||
|
# JWT_SECRET - JWT secret key for production
|
||||||
23
.github/environments/staging.yml
vendored
Normal file
23
.github/environments/staging.yml
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Staging Environment Configuration for GitHub Actions
|
||||||
|
# This file defines staging-specific deployment settings
|
||||||
|
|
||||||
|
environment:
|
||||||
|
name: staging
|
||||||
|
url: https://staging-api.ppanel.example.com
|
||||||
|
protection_rules:
|
||||||
|
- type: wait_timer
|
||||||
|
minutes: 2
|
||||||
|
variables:
|
||||||
|
ENVIRONMENT: staging
|
||||||
|
LOG_LEVEL: debug
|
||||||
|
DEPLOY_TIMEOUT: 180
|
||||||
|
|
||||||
|
# Environment-specific secrets required:
|
||||||
|
# STAGING_HOST - Staging server hostname/IP
|
||||||
|
# STAGING_USER - SSH username for staging server
|
||||||
|
# STAGING_SSH_KEY - SSH private key for staging server
|
||||||
|
# STAGING_PORT - SSH port (default: 22)
|
||||||
|
# STAGING_URL - Application URL for health checks
|
||||||
|
# DATABASE_PASSWORD - Staging database password
|
||||||
|
# REDIS_PASSWORD - Staging Redis password
|
||||||
|
# JWT_SECRET - JWT secret key for staging
|
||||||
79
.github/workflows/deploy-linux.yml
vendored
Normal file
79
.github/workflows/deploy-linux.yml
vendored
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
name: Build Linux Binary
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to build (leave empty for auto)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Linux Binary
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
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
|
||||||
12
.run/go build github.com_perfect-panel_server.run.xml
Normal file
12
.run/go build github.com_perfect-panel_server.run.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="go build github.com/perfect-panel/server" type="GoApplicationRunConfiguration" factoryName="Go Application" nameIsGenerated="true">
|
||||||
|
<module name="server" />
|
||||||
|
<working_directory value="$PROJECT_DIR$" />
|
||||||
|
<parameters value="run --config etc/ppanel-dev.yaml" />
|
||||||
|
<kind value="PACKAGE" />
|
||||||
|
<package value="github.com/perfect-panel/server" />
|
||||||
|
<directory value="$PROJECT_DIR$" />
|
||||||
|
<filePath value="$PROJECT_DIR$/ppanel.go" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@ -45,6 +45,15 @@ proxy services. Built with Go, it emphasizes performance, security, and scalabil
|
|||||||
- **Node Management**: Monitor and control server nodes.
|
- **Node Management**: Monitor and control server nodes.
|
||||||
- **API Framework**: Comprehensive RESTful APIs for frontend integration.
|
- **API Framework**: Comprehensive RESTful APIs for frontend integration.
|
||||||
|
|
||||||
|
### Subscription Mode Behavior
|
||||||
|
|
||||||
|
The subscription behavior can be switched by the backend config `Subscribe.SingleModel`:
|
||||||
|
|
||||||
|
- `false` (**multi-subscription mode**): each successful `purchase` creates a new `user_subscribe` record.
|
||||||
|
- `true` (**single-subscription mode**): `purchase` is auto-routed to renewal semantics when the user already has a paid subscription:
|
||||||
|
- a new order is still created,
|
||||||
|
- but the existing subscription is extended (instead of creating another `user_subscribe`).
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|||||||
354
docker-compose.cloud.yml
Normal file
354
docker-compose.cloud.yml
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
# PPanel 服务部署 (云端/无源码版)
|
||||||
|
# 使用方法:
|
||||||
|
# 1. 确保已将 docker-compose.cloud.yml, configs/, loki/, grafana/, prometheus/ 目录上传到服务器同一目录
|
||||||
|
# 2. 确保 configs/ 目录下有 ppanel.yaml 配置文件
|
||||||
|
# 3. 确保 logs/ 目录存在 (mkdir logs)
|
||||||
|
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 1. 业务后端 (PPanel Server)
|
||||||
|
# ----------------------------------------------------
|
||||||
|
ppanel-server:
|
||||||
|
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:-latest}
|
||||||
|
container_name: ppanel-server
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./configs:/app/etc
|
||||||
|
- ./logs:/app/logs
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
# 链路追踪配置 (OTLP)
|
||||||
|
- OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
|
||||||
|
- OTEL_SERVICE_NAME=ppanel-server
|
||||||
|
- OTEL_TRACES_EXPORTER=otlp
|
||||||
|
- OTEL_METRICS_EXPORTER=prometheus # 指标由 tempo 抓取,不使用 OTLP
|
||||||
|
network_mode: host
|
||||||
|
ulimits:
|
||||||
|
nproc: 65535
|
||||||
|
nofile:
|
||||||
|
soft: 65535
|
||||||
|
hard: 65535
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
- redis
|
||||||
|
- tempo
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 14. Tempo (链路追踪存储 - 替代/增强 Jaeger)
|
||||||
|
# ----------------------------------------------------
|
||||||
|
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
|
||||||
|
- ./tempo_data:/var/tempo # 改为映射到当前目录,确保数据彻底干净
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "3200:3200"
|
||||||
|
- "4317:4317"
|
||||||
|
- "4318:4318"
|
||||||
|
- "9095:9095"
|
||||||
|
networks:
|
||||||
|
- ppanel_net
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 2. MySQL Database
|
||||||
|
# ----------------------------------------------------
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: ppanel-mysql
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3306:3306" # 临时开放外部访问,用完记得关闭!
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: "jpcV41ppanel" # 请修改为强密码
|
||||||
|
MYSQL_DATABASE: "ppanel"
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
command:
|
||||||
|
- --default-authentication-plugin=mysql_native_password
|
||||||
|
- --innodb_buffer_pool_size=16G
|
||||||
|
- --innodb_buffer_pool_instances=16
|
||||||
|
- --innodb_log_file_size=2G
|
||||||
|
- --innodb_flush_log_at_trx_commit=2
|
||||||
|
- --innodb_io_capacity=5000
|
||||||
|
- --max_connections=5000
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
ulimits:
|
||||||
|
nproc: 65535
|
||||||
|
nofile:
|
||||||
|
soft: 65535
|
||||||
|
hard: 65535
|
||||||
|
networks:
|
||||||
|
- ppanel_net
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 3. Redis
|
||||||
|
# ----------------------------------------------------
|
||||||
|
redis:
|
||||||
|
image: redis:8.2.1
|
||||||
|
container_name: ppanel-redis
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- --tcp-backlog 65535
|
||||||
|
- --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
ulimits:
|
||||||
|
nproc: 65535
|
||||||
|
nofile:
|
||||||
|
soft: 65535
|
||||||
|
hard: 65535
|
||||||
|
networks:
|
||||||
|
- ppanel_net
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 4. Loki (日志存储)
|
||||||
|
# ----------------------------------------------------
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:3.0.0
|
||||||
|
container_name: ppanel-loki
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
# 必须上传 loki 目录到服务器
|
||||||
|
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
|
||||||
|
- loki_data:/loki
|
||||||
|
command: -config.file=/etc/loki/local-config.yaml
|
||||||
|
ports:
|
||||||
|
- "3100:3100"
|
||||||
|
networks:
|
||||||
|
- ppanel_net
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 5. 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 文件夹
|
||||||
|
- ./logs:/var/log/ppanel-server:ro
|
||||||
|
# 采集 Nginx 访问日志(用于追踪邀请码来源)
|
||||||
|
- /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"
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 6. Grafana (日志界面)
|
||||||
|
# ----------------------------------------------------
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
container_name: ppanel-grafana
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3333:3000"
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
- GF_FEATURE_TOGGLES_ENABLE=appObservability #- GF_INSTALL_PLUGINS=redis-datasource
|
||||||
|
volumes:
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
networks:
|
||||||
|
- ppanel_net
|
||||||
|
depends_on:
|
||||||
|
- loki
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 7. Prometheus (指标采集)
|
||||||
|
# ----------------------------------------------------
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
container_name: ppanel-prometheus
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "9090:9090" # 暴露端口便于调试
|
||||||
|
volumes:
|
||||||
|
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
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"
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 8. Redis Exporter (Redis指标导出)
|
||||||
|
# ----------------------------------------------------
|
||||||
|
redis-exporter:
|
||||||
|
image: oliver006/redis_exporter:latest
|
||||||
|
container_name: ppanel-redis-exporter
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- REDIS_ADDR=redis://redis:6379
|
||||||
|
networks:
|
||||||
|
- ppanel_net
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 9. Nginx Exporter (监控宿主机 Nginx)
|
||||||
|
# ----------------------------------------------------
|
||||||
|
nginx-exporter:
|
||||||
|
image: nginx/nginx-prometheus-exporter:latest
|
||||||
|
container_name: ppanel-nginx-exporter
|
||||||
|
restart: always
|
||||||
|
# 使用 host.docker.internal 访问宿主机
|
||||||
|
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"
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 10. MySQL Exporter (MySQL指标导出)
|
||||||
|
# ----------------------------------------------------
|
||||||
|
mysql-exporter:
|
||||||
|
image: prom/mysqld-exporter:latest
|
||||||
|
container_name: ppanel-mysql-exporter
|
||||||
|
restart: always
|
||||||
|
command:
|
||||||
|
- --config.my-cnf=/etc/.my.cnf
|
||||||
|
volumes:
|
||||||
|
- ./mysql/.my.cnf:/etc/.my.cnf:ro
|
||||||
|
networks:
|
||||||
|
- ppanel_net
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 12. 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"
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 13. 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:
|
||||||
|
mysql_data:
|
||||||
|
redis_data:
|
||||||
|
loki_data:
|
||||||
|
grafana_data:
|
||||||
|
prometheus_data:
|
||||||
|
tempo_data:
|
||||||
|
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ppanel_net:
|
||||||
|
name: ppanel_net
|
||||||
|
driver: bridge
|
||||||
@ -50,15 +50,39 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
|||||||
req.Quantity = 1
|
req.Quantity = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targetSubscribeID := req.SubscribeId
|
||||||
|
isSingleModeRenewal := false
|
||||||
|
if l.svcCtx.Config.Subscribe.SingleModel {
|
||||||
|
anchorSub, anchorErr := l.svcCtx.UserModel.FindSingleModeAnchorSubscribe(l.ctx, u.Id)
|
||||||
|
switch {
|
||||||
|
case anchorErr == nil && anchorSub != nil:
|
||||||
|
targetSubscribeID = anchorSub.SubscribeId
|
||||||
|
isSingleModeRenewal = true
|
||||||
|
if req.SubscribeId != targetSubscribeID {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch")
|
||||||
|
}
|
||||||
|
l.Infow("[PreCreateOrder] single mode purchase routed to renewal preview",
|
||||||
|
logger.Field("mode", "single"),
|
||||||
|
logger.Field("route", "purchase_to_renewal"),
|
||||||
|
logger.Field("anchor_user_subscribe_id", anchorSub.Id),
|
||||||
|
logger.Field("user_id", u.Id),
|
||||||
|
)
|
||||||
|
case errors.Is(anchorErr, gorm.ErrRecordNotFound):
|
||||||
|
case anchorErr != nil:
|
||||||
|
l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", anchorErr.Error()), logger.Field("user_id", u.Id))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find single mode anchor subscribe error: %v", anchorErr.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// find subscribe plan
|
// find subscribe plan
|
||||||
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, req.SubscribeId)
|
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, targetSubscribeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", err.Error()), logger.Field("subscribe_id", req.SubscribeId))
|
l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", err.Error()), logger.Field("subscribe_id", targetSubscribeID))
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// check subscribe plan quota limit
|
// check subscribe plan quota limit for new purchase flow only
|
||||||
if sub.Quota > 0 {
|
if !isSingleModeRenewal && sub.Quota > 0 {
|
||||||
userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id)
|
userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
|
l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
|
||||||
@ -66,7 +90,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
|||||||
}
|
}
|
||||||
var count int64
|
var count int64
|
||||||
for _, v := range userSub {
|
for _, v := range userSub {
|
||||||
if v.SubscribeId == req.SubscribeId {
|
if v.SubscribeId == targetSubscribeID {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -112,7 +136,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
|||||||
}
|
}
|
||||||
|
|
||||||
couponSub := tool.StringToInt64Slice(couponInfo.Subscribe)
|
couponSub := tool.StringToInt64Slice(couponInfo.Subscribe)
|
||||||
if len(couponSub) > 0 && !tool.Contains(couponSub, req.SubscribeId) {
|
if len(couponSub) > 0 && !tool.Contains(couponSub, targetSubscribeID) {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponNotApplicable), "coupon not match")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponNotApplicable), "coupon not match")
|
||||||
}
|
}
|
||||||
couponAmount = calculateCoupon(amount, couponInfo)
|
couponAmount = calculateCoupon(amount, couponInfo)
|
||||||
|
|||||||
@ -65,34 +65,44 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "quantity exceeds maximum limit of %d", MaxQuantity)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "quantity exceeds maximum limit of %d", MaxQuantity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// find user subscription
|
targetSubscribeID := req.SubscribeId
|
||||||
userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id)
|
orderType := uint8(1)
|
||||||
if err != nil {
|
parentOrderID := int64(0)
|
||||||
l.Errorw("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
|
subscribeToken := ""
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user subscription error: %v", err.Error())
|
anchorUserSubscribeID := int64(0)
|
||||||
}
|
isSingleModeRenewal := false
|
||||||
if l.svcCtx.Config.Subscribe.SingleModel {
|
if l.svcCtx.Config.Subscribe.SingleModel {
|
||||||
if len(userSub) > 0 {
|
anchorSub, anchorErr := l.svcCtx.UserModel.FindSingleModeAnchorSubscribe(l.ctx, u.Id)
|
||||||
// Only block if user has a paid subscription (OrderId > 0)
|
switch {
|
||||||
// Allow purchase if user only has gift subscriptions
|
case anchorErr == nil && anchorSub != nil:
|
||||||
hasPaidSubscription := false
|
if req.SubscribeId != anchorSub.SubscribeId {
|
||||||
for _, s := range userSub {
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch")
|
||||||
if s.OrderId > 0 {
|
|
||||||
hasPaidSubscription = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasPaidSubscription {
|
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserSubscribeExist), "user has subscription")
|
|
||||||
}
|
}
|
||||||
|
targetSubscribeID = anchorSub.SubscribeId
|
||||||
|
orderType = 2
|
||||||
|
parentOrderID = anchorSub.OrderId
|
||||||
|
subscribeToken = anchorSub.Token
|
||||||
|
anchorUserSubscribeID = anchorSub.Id
|
||||||
|
isSingleModeRenewal = true
|
||||||
|
l.Infow("[Purchase] single mode purchase routed to renewal",
|
||||||
|
logger.Field("mode", "single"),
|
||||||
|
logger.Field("route", "purchase_to_renewal"),
|
||||||
|
logger.Field("anchor_user_subscribe_id", anchorSub.Id),
|
||||||
|
logger.Field("order_no", "pending"),
|
||||||
|
logger.Field("user_id", u.Id),
|
||||||
|
)
|
||||||
|
case errors.Is(anchorErr, gorm.ErrRecordNotFound):
|
||||||
|
case anchorErr != nil:
|
||||||
|
l.Errorw("[Purchase] Database query error", logger.Field("error", anchorErr.Error()), logger.Field("user_id", u.Id))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find single mode anchor subscribe error: %v", anchorErr.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// find subscribe plan
|
// find subscribe plan
|
||||||
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, req.SubscribeId)
|
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, targetSubscribeID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("subscribe_id", req.SubscribeId))
|
l.Errorw("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("subscribe_id", targetSubscribeID))
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe error: %v", err.Error())
|
||||||
}
|
}
|
||||||
// check subscribe plan status
|
// check subscribe plan status
|
||||||
@ -100,8 +110,8 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "subscribe not sell")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "subscribe not sell")
|
||||||
}
|
}
|
||||||
|
|
||||||
// check subscribe plan inventory
|
// check subscribe plan inventory for new purchase flow only
|
||||||
if sub.Inventory == 0 {
|
if orderType == 1 && sub.Inventory == 0 {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeOutOfStock), "subscribe out of stock")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeOutOfStock), "subscribe out of stock")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,7 +150,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponInsufficientUsage), "coupon used")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponInsufficientUsage), "coupon used")
|
||||||
}
|
}
|
||||||
couponSub := tool.StringToInt64Slice(couponInfo.Subscribe)
|
couponSub := tool.StringToInt64Slice(couponInfo.Subscribe)
|
||||||
if len(couponSub) > 0 && !tool.Contains(couponSub, req.SubscribeId) {
|
if len(couponSub) > 0 && !tool.Contains(couponSub, targetSubscribeID) {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponNotApplicable), "coupon not match")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponNotApplicable), "coupon not match")
|
||||||
}
|
}
|
||||||
var count int64
|
var count int64
|
||||||
@ -192,16 +202,20 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// query user is new purchase or renewal
|
// query user is new purchase or renewal
|
||||||
isNew, err := l.svcCtx.OrderModel.IsUserEligibleForNewOrder(l.ctx, u.Id)
|
isNew := false
|
||||||
|
if orderType == 1 {
|
||||||
|
isNew, err = l.svcCtx.OrderModel.IsUserEligibleForNewOrder(l.ctx, u.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
|
l.Errorw("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user order error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user order error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// create order
|
// create order
|
||||||
orderInfo := &order.Order{
|
orderInfo := &order.Order{
|
||||||
UserId: u.Id,
|
UserId: u.Id,
|
||||||
|
ParentId: parentOrderID,
|
||||||
OrderNo: tool.GenerateTradeNo(),
|
OrderNo: tool.GenerateTradeNo(),
|
||||||
Type: 1,
|
Type: orderType,
|
||||||
Quantity: req.Quantity,
|
Quantity: req.Quantity,
|
||||||
Price: price,
|
Price: price,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
@ -214,12 +228,23 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
FeeAmount: feeAmount,
|
FeeAmount: feeAmount,
|
||||||
Status: 1,
|
Status: 1,
|
||||||
IsNew: isNew,
|
IsNew: isNew,
|
||||||
SubscribeId: req.SubscribeId,
|
SubscribeId: targetSubscribeID,
|
||||||
|
SubscribeToken: subscribeToken,
|
||||||
|
}
|
||||||
|
if isSingleModeRenewal {
|
||||||
|
l.Infow("[Purchase] single mode purchase order created as renewal",
|
||||||
|
logger.Field("mode", "single"),
|
||||||
|
logger.Field("route", "purchase_to_renewal"),
|
||||||
|
logger.Field("anchor_user_subscribe_id", anchorUserSubscribeID),
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
logger.Field("parent_id", orderInfo.ParentId),
|
||||||
|
logger.Field("user_id", u.Id),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// Database transaction
|
// Database transaction
|
||||||
err = l.svcCtx.DB.Transaction(func(db *gorm.DB) error {
|
err = l.svcCtx.DB.Transaction(func(db *gorm.DB) error {
|
||||||
// check subscribe plan quota limit inside transaction to prevent race condition
|
// check subscribe plan quota limit inside transaction to prevent race condition
|
||||||
if sub.Quota > 0 {
|
if orderInfo.Type == 1 && sub.Quota > 0 {
|
||||||
var currentUserSub []user.Subscribe
|
var currentUserSub []user.Subscribe
|
||||||
if e := db.Model(&user.Subscribe{}).Where("user_id = ?", u.Id).Find(¤tUserSub).Error; e != nil {
|
if e := db.Model(&user.Subscribe{}).Where("user_id = ?", u.Id).Find(¤tUserSub).Error; e != nil {
|
||||||
l.Errorw("[Purchase] Database query error", logger.Field("error", e.Error()), logger.Field("user_id", u.Id))
|
l.Errorw("[Purchase] Database query error", logger.Field("error", e.Error()), logger.Field("user_id", u.Id))
|
||||||
@ -227,7 +252,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
}
|
}
|
||||||
var count int64
|
var count int64
|
||||||
for _, v := range currentUserSub {
|
for _, v := range currentUserSub {
|
||||||
if v.SubscribeId == req.SubscribeId {
|
if v.SubscribeId == targetSubscribeID {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -270,7 +295,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if sub.Inventory != -1 {
|
if orderInfo.Type == 1 && sub.Inventory != -1 {
|
||||||
// decrease subscribe plan stock
|
// decrease subscribe plan stock
|
||||||
sub.Inventory -= 1
|
sub.Inventory -= 1
|
||||||
// update subscribe plan stock
|
// update subscribe plan stock
|
||||||
|
|||||||
@ -72,6 +72,7 @@ type customUserLogicModel interface {
|
|||||||
BatchDeleteUser(ctx context.Context, ids []int64, tx ...*gorm.DB) error
|
BatchDeleteUser(ctx context.Context, ids []int64, tx ...*gorm.DB) error
|
||||||
InsertSubscribe(ctx context.Context, data *Subscribe, tx ...*gorm.DB) error
|
InsertSubscribe(ctx context.Context, data *Subscribe, tx ...*gorm.DB) error
|
||||||
FindOneSubscribeByToken(ctx context.Context, token string) (*Subscribe, error)
|
FindOneSubscribeByToken(ctx context.Context, token string) (*Subscribe, error)
|
||||||
|
FindSingleModeAnchorSubscribe(ctx context.Context, userId int64) (*Subscribe, error)
|
||||||
FindOneSubscribeByOrderId(ctx context.Context, orderId int64) (*Subscribe, error)
|
FindOneSubscribeByOrderId(ctx context.Context, orderId int64) (*Subscribe, error)
|
||||||
FindOneSubscribe(ctx context.Context, id int64) (*Subscribe, error)
|
FindOneSubscribe(ctx context.Context, id int64) (*Subscribe, error)
|
||||||
UpdateSubscribe(ctx context.Context, data *Subscribe, tx ...*gorm.DB) error
|
UpdateSubscribe(ctx context.Context, data *Subscribe, tx ...*gorm.DB) error
|
||||||
|
|||||||
@ -49,6 +49,20 @@ func (m *defaultUserModel) FindOneSubscribeByOrderId(ctx context.Context, orderI
|
|||||||
return &data, err
|
return &data, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindSingleModeAnchorSubscribe finds the latest paid subscription for single subscribe mode routing.
|
||||||
|
func (m *defaultUserModel) FindSingleModeAnchorSubscribe(ctx context.Context, userId int64) (*Subscribe, error) {
|
||||||
|
var data Subscribe
|
||||||
|
err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, _ interface{}) error {
|
||||||
|
return conn.Model(&Subscribe{}).
|
||||||
|
Where("user_id = ? AND order_id > 0 AND token != '' AND `status` IN ?", userId, []int64{0, 1, 2, 3, 5}).
|
||||||
|
Order("expire_time DESC").
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&data).Error
|
||||||
|
})
|
||||||
|
return &data, err
|
||||||
|
}
|
||||||
|
|
||||||
func (m *defaultUserModel) FindOneSubscribe(ctx context.Context, id int64) (*Subscribe, error) {
|
func (m *defaultUserModel) FindOneSubscribe(ctx context.Context, id int64) (*Subscribe, error) {
|
||||||
var data Subscribe
|
var data Subscribe
|
||||||
key := fmt.Sprintf("%s%d", cacheUserSubscribeIdPrefix, id)
|
key := fmt.Sprintf("%s%d", cacheUserSubscribeIdPrefix, id)
|
||||||
|
|||||||
@ -77,6 +77,7 @@ const (
|
|||||||
SingleSubscribeModeExceedsLimit uint32 = 60005
|
SingleSubscribeModeExceedsLimit uint32 = 60005
|
||||||
SubscribeQuotaLimit uint32 = 60006
|
SubscribeQuotaLimit uint32 = 60006
|
||||||
SubscribeOutOfStock uint32 = 60007
|
SubscribeOutOfStock uint32 = 60007
|
||||||
|
SingleSubscribePlanMismatch uint32 = 60008
|
||||||
)
|
)
|
||||||
|
|
||||||
// Auth error
|
// Auth error
|
||||||
|
|||||||
@ -62,6 +62,7 @@ func init() {
|
|||||||
SingleSubscribeModeExceedsLimit: "Single subscribe mode exceeds limit",
|
SingleSubscribeModeExceedsLimit: "Single subscribe mode exceeds limit",
|
||||||
SubscribeQuotaLimit: "Subscribe quota limit",
|
SubscribeQuotaLimit: "Subscribe quota limit",
|
||||||
SubscribeOutOfStock: "Subscribe out of stock",
|
SubscribeOutOfStock: "Subscribe out of stock",
|
||||||
|
SingleSubscribePlanMismatch: "Single subscribe mode does not support switching subscription by purchase",
|
||||||
|
|
||||||
// auth error
|
// auth error
|
||||||
VerifyCodeError: "Verify code error",
|
VerifyCodeError: "Verify code error",
|
||||||
|
|||||||
@ -225,10 +225,57 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
|
|||||||
|
|
||||||
var userSub *user.Subscribe
|
var userSub *user.Subscribe
|
||||||
|
|
||||||
// 单订阅模式下,检查用户是否已有赠送订阅(order_id=0)
|
// 单订阅模式下,优先兜底为“续费语义”:延长已购订阅,避免并发下重复创建 user_subscribe
|
||||||
if l.svc.Config.Subscribe.SingleModel {
|
if l.svc.Config.Subscribe.SingleModel {
|
||||||
giftSub, err := l.findGiftSubscription(ctx, orderInfo.UserId, orderInfo.SubscribeId)
|
anchorSub, anchorErr := l.svc.UserModel.FindSingleModeAnchorSubscribe(ctx, orderInfo.UserId)
|
||||||
if err == nil && giftSub != nil {
|
switch {
|
||||||
|
case anchorErr == nil && anchorSub != nil:
|
||||||
|
if anchorSub.SubscribeId == orderInfo.SubscribeId {
|
||||||
|
if orderInfo.ParentId == 0 && anchorSub.OrderId > 0 && anchorSub.OrderId != orderInfo.Id {
|
||||||
|
if patchErr := l.patchOrderParentID(ctx, orderInfo.Id, anchorSub.OrderId); patchErr != nil {
|
||||||
|
logger.WithContext(ctx).Error("Patch order parent_id failed",
|
||||||
|
logger.Field("error", patchErr.Error()),
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
orderInfo.ParentId = anchorSub.OrderId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if renewErr := l.updateSubscriptionForRenewal(ctx, anchorSub, sub, orderInfo); renewErr != nil {
|
||||||
|
logger.WithContext(ctx).Error("Single mode renewal fallback failed",
|
||||||
|
logger.Field("error", renewErr.Error()),
|
||||||
|
logger.Field("anchor_user_subscribe_id", anchorSub.Id),
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
userSub = anchorSub
|
||||||
|
logger.WithContext(ctx).Infow("Single mode purchase routed to renewal in activation",
|
||||||
|
logger.Field("mode", "single"),
|
||||||
|
logger.Field("route", "purchase_to_renewal"),
|
||||||
|
logger.Field("anchor_user_subscribe_id", anchorSub.Id),
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.WithContext(ctx).Errorw("Single mode anchor subscribe mismatch in activation",
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
logger.Field("order_subscribe_id", orderInfo.SubscribeId),
|
||||||
|
logger.Field("anchor_subscribe_id", anchorSub.SubscribeId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case errors.Is(anchorErr, gorm.ErrRecordNotFound):
|
||||||
|
case anchorErr != nil:
|
||||||
|
logger.WithContext(ctx).Error("Find single mode anchor subscribe failed",
|
||||||
|
logger.Field("error", anchorErr.Error()),
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有合并已购订阅,再尝试合并赠送订阅(order_id=0)
|
||||||
|
if userSub == nil {
|
||||||
|
giftSub, giftErr := l.findGiftSubscription(ctx, orderInfo.UserId, orderInfo.SubscribeId)
|
||||||
|
if giftErr == nil && giftSub != nil {
|
||||||
// 在赠送订阅上延长时间,保持 token 不变
|
// 在赠送订阅上延长时间,保持 token 不变
|
||||||
userSub, err = l.extendGiftSubscription(ctx, giftSub, orderInfo, sub)
|
userSub, err = l.extendGiftSubscription(ctx, giftSub, orderInfo, sub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -241,6 +288,7 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 如果没有合并赠送订阅,则正常创建新订阅
|
// 如果没有合并赠送订阅,则正常创建新订阅
|
||||||
if userSub == nil {
|
if userSub == nil {
|
||||||
@ -439,6 +487,10 @@ func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderIn
|
|||||||
return userSub, nil
|
return userSub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *ActivateOrderLogic) patchOrderParentID(ctx context.Context, orderID int64, parentID int64) error {
|
||||||
|
return l.svc.DB.WithContext(ctx).Model(&order.Order{}).Where("id = ? AND (parent_id = 0 OR parent_id IS NULL)", orderID).Update("parent_id", parentID).Error
|
||||||
|
}
|
||||||
|
|
||||||
// findGiftSubscription 查找用户指定套餐的赠送订阅(order_id=0),包括已过期的
|
// findGiftSubscription 查找用户指定套餐的赠送订阅(order_id=0),包括已过期的
|
||||||
// 返回找到的赠送订阅记录,如果没有则返回 nil
|
// 返回找到的赠送订阅记录,如果没有则返回 nil
|
||||||
func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId int64, subscribeId int64) (*user.Subscribe, error) {
|
func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId int64, subscribeId int64) (*user.Subscribe, error) {
|
||||||
|
|||||||
@ -44,6 +44,15 @@ PPanel 服务端是 PPanel 项目的后端组件,为代理服务提供强大
|
|||||||
- **节点管理**:监控和控制服务器节点。
|
- **节点管理**:监控和控制服务器节点。
|
||||||
- **API 框架**:提供全面的 RESTful API,供前端集成。
|
- **API 框架**:提供全面的 RESTful API,供前端集成。
|
||||||
|
|
||||||
|
### 订阅模式行为
|
||||||
|
|
||||||
|
可通过后端配置 `Subscribe.SingleModel` 切换订阅模式:
|
||||||
|
|
||||||
|
- `false`(**多订阅模式**):每次成功 `purchase` 都会创建一条新的 `user_subscribe` 记录。
|
||||||
|
- `true`(**单订阅模式**):当用户已存在已购订阅时,`purchase` 会自动按续费语义处理:
|
||||||
|
- 仍然会创建新订单,
|
||||||
|
- 但会延长已有订阅,而不是再新增一条 `user_subscribe`。
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### 前提条件
|
### 前提条件
|
||||||
|
|||||||
78
script/db_query.go
Normal file
78
script/db_query.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/perfect-panel/server/pkg/conf"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "etc/ppanel.yaml", "config file path")
|
||||||
|
query := flag.String("query", "", "sql query")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *query == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "query is required")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg config.Config
|
||||||
|
conf.MustLoad(*configPath, &cfg)
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", cfg.MySQL.Username, cfg.MySQL.Password, cfg.MySQL.Addr, cfg.MySQL.Dbname, cfg.MySQL.Config)
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "connect db failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Raw(*query).Rows()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "query failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "read columns failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]map[string]interface{}, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
values := make([]interface{}, len(columns))
|
||||||
|
pointers := make([]interface{}, len(columns))
|
||||||
|
for i := range values {
|
||||||
|
pointers[i] = &values[i]
|
||||||
|
}
|
||||||
|
if err = rows.Scan(pointers...); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "scan row failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowMap := make(map[string]interface{}, len(columns))
|
||||||
|
for i, col := range columns {
|
||||||
|
v := values[i]
|
||||||
|
if b, ok := v.([]byte); ok {
|
||||||
|
rowMap[col] = string(b)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rowMap[col] = v
|
||||||
|
}
|
||||||
|
result = append(result, rowMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
if err = encoder.Encode(result); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "encode result failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user