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>
|
||||
11
README.md
11
README.md
@ -45,6 +45,15 @@ proxy services. Built with Go, it emphasizes performance, security, and scalabil
|
||||
- **Node Management**: Monitor and control server nodes.
|
||||
- **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
|
||||
|
||||
### Prerequisites
|
||||
@ -288,4 +297,4 @@ project's development! 🚀
|
||||
Please give these projects a ⭐ to support the open-source movement!
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the [GPL-3.0 License](LICENSE).
|
||||
This project is licensed under the [GPL-3.0 License](LICENSE).
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, req.SubscribeId)
|
||||
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, targetSubscribeID)
|
||||
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())
|
||||
}
|
||||
|
||||
// check subscribe plan quota limit
|
||||
if sub.Quota > 0 {
|
||||
// check subscribe plan quota limit for new purchase flow only
|
||||
if !isSingleModeRenewal && sub.Quota > 0 {
|
||||
userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id)
|
||||
if err != nil {
|
||||
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
|
||||
for _, v := range userSub {
|
||||
if v.SubscribeId == req.SubscribeId {
|
||||
if v.SubscribeId == targetSubscribeID {
|
||||
count++
|
||||
}
|
||||
}
|
||||
@ -112,7 +136,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// find user subscription
|
||||
userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id)
|
||||
if err != nil {
|
||||
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 subscription error: %v", err.Error())
|
||||
}
|
||||
targetSubscribeID := req.SubscribeId
|
||||
orderType := uint8(1)
|
||||
parentOrderID := int64(0)
|
||||
subscribeToken := ""
|
||||
anchorUserSubscribeID := int64(0)
|
||||
isSingleModeRenewal := false
|
||||
if l.svcCtx.Config.Subscribe.SingleModel {
|
||||
if len(userSub) > 0 {
|
||||
// Only block if user has a paid subscription (OrderId > 0)
|
||||
// Allow purchase if user only has gift subscriptions
|
||||
hasPaidSubscription := false
|
||||
for _, s := range userSub {
|
||||
if s.OrderId > 0 {
|
||||
hasPaidSubscription = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasPaidSubscription {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserSubscribeExist), "user has subscription")
|
||||
anchorSub, anchorErr := l.svcCtx.UserModel.FindSingleModeAnchorSubscribe(l.ctx, u.Id)
|
||||
switch {
|
||||
case anchorErr == nil && anchorSub != nil:
|
||||
if req.SubscribeId != anchorSub.SubscribeId {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch")
|
||||
}
|
||||
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
|
||||
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, req.SubscribeId)
|
||||
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, targetSubscribeID)
|
||||
|
||||
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())
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
|
||||
// check subscribe plan inventory
|
||||
if sub.Inventory == 0 {
|
||||
// check subscribe plan inventory for new purchase flow only
|
||||
if orderType == 1 && sub.Inventory == 0 {
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
var count int64
|
||||
@ -192,16 +202,20 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
}
|
||||
}
|
||||
// query user is new purchase or renewal
|
||||
isNew, err := l.svcCtx.OrderModel.IsUserEligibleForNewOrder(l.ctx, u.Id)
|
||||
if err != nil {
|
||||
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())
|
||||
isNew := false
|
||||
if orderType == 1 {
|
||||
isNew, err = l.svcCtx.OrderModel.IsUserEligibleForNewOrder(l.ctx, u.Id)
|
||||
if err != nil {
|
||||
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())
|
||||
}
|
||||
}
|
||||
// create order
|
||||
orderInfo := &order.Order{
|
||||
UserId: u.Id,
|
||||
ParentId: parentOrderID,
|
||||
OrderNo: tool.GenerateTradeNo(),
|
||||
Type: 1,
|
||||
Type: orderType,
|
||||
Quantity: req.Quantity,
|
||||
Price: price,
|
||||
Amount: amount,
|
||||
@ -214,12 +228,23 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
FeeAmount: feeAmount,
|
||||
Status: 1,
|
||||
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
|
||||
err = l.svcCtx.DB.Transaction(func(db *gorm.DB) error {
|
||||
// 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
|
||||
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))
|
||||
@ -227,7 +252,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
}
|
||||
var count int64
|
||||
for _, v := range currentUserSub {
|
||||
if v.SubscribeId == req.SubscribeId {
|
||||
if v.SubscribeId == targetSubscribeID {
|
||||
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
|
||||
sub.Inventory -= 1
|
||||
// update subscribe plan stock
|
||||
|
||||
@ -72,6 +72,7 @@ type customUserLogicModel interface {
|
||||
BatchDeleteUser(ctx context.Context, ids []int64, tx ...*gorm.DB) error
|
||||
InsertSubscribe(ctx context.Context, data *Subscribe, tx ...*gorm.DB) 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)
|
||||
FindOneSubscribe(ctx context.Context, id int64) (*Subscribe, 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var data Subscribe
|
||||
key := fmt.Sprintf("%s%d", cacheUserSubscribeIdPrefix, id)
|
||||
|
||||
@ -77,6 +77,7 @@ const (
|
||||
SingleSubscribeModeExceedsLimit uint32 = 60005
|
||||
SubscribeQuotaLimit uint32 = 60006
|
||||
SubscribeOutOfStock uint32 = 60007
|
||||
SingleSubscribePlanMismatch uint32 = 60008
|
||||
)
|
||||
|
||||
// Auth error
|
||||
|
||||
@ -62,6 +62,7 @@ func init() {
|
||||
SingleSubscribeModeExceedsLimit: "Single subscribe mode exceeds limit",
|
||||
SubscribeQuotaLimit: "Subscribe quota limit",
|
||||
SubscribeOutOfStock: "Subscribe out of stock",
|
||||
SingleSubscribePlanMismatch: "Single subscribe mode does not support switching subscription by purchase",
|
||||
|
||||
// auth error
|
||||
VerifyCodeError: "Verify code error",
|
||||
|
||||
@ -225,19 +225,67 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
|
||||
|
||||
var userSub *user.Subscribe
|
||||
|
||||
// 单订阅模式下,检查用户是否已有赠送订阅(order_id=0)
|
||||
// 单订阅模式下,优先兜底为“续费语义”:延长已购订阅,避免并发下重复创建 user_subscribe
|
||||
if l.svc.Config.Subscribe.SingleModel {
|
||||
giftSub, err := l.findGiftSubscription(ctx, orderInfo.UserId, orderInfo.SubscribeId)
|
||||
if err == nil && giftSub != nil {
|
||||
// 在赠送订阅上延长时间,保持 token 不变
|
||||
userSub, err = l.extendGiftSubscription(ctx, giftSub, orderInfo, sub)
|
||||
if err != nil {
|
||||
logger.WithContext(ctx).Error("Extend gift subscription failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("gift_subscribe_id", giftSub.Id),
|
||||
anchorSub, anchorErr := l.svc.UserModel.FindSingleModeAnchorSubscribe(ctx, orderInfo.UserId)
|
||||
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),
|
||||
)
|
||||
// 合并失败时回退到创建新订阅
|
||||
userSub = nil
|
||||
}
|
||||
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 不变
|
||||
userSub, err = l.extendGiftSubscription(ctx, giftSub, orderInfo, sub)
|
||||
if err != nil {
|
||||
logger.WithContext(ctx).Error("Extend gift subscription failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("gift_subscribe_id", giftSub.Id),
|
||||
)
|
||||
// 合并失败时回退到创建新订阅
|
||||
userSub = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -439,6 +487,10 @@ func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderIn
|
||||
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),包括已过期的
|
||||
// 返回找到的赠送订阅记录,如果没有则返回 nil
|
||||
func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId int64, subscribeId int64) (*user.Subscribe, error) {
|
||||
|
||||
11
readme_zh.md
11
readme_zh.md
@ -44,6 +44,15 @@ PPanel 服务端是 PPanel 项目的后端组件,为代理服务提供强大
|
||||
- **节点管理**:监控和控制服务器节点。
|
||||
- **API 框架**:提供全面的 RESTful API,供前端集成。
|
||||
|
||||
### 订阅模式行为
|
||||
|
||||
可通过后端配置 `Subscribe.SingleModel` 切换订阅模式:
|
||||
|
||||
- `false`(**多订阅模式**):每次成功 `purchase` 都会创建一条新的 `user_subscribe` 记录。
|
||||
- `true`(**单订阅模式**):当用户已存在已购订阅时,`purchase` 会自动按续费语义处理:
|
||||
- 仍然会创建新订单,
|
||||
- 但会延长已有订阅,而不是再新增一条 `user_subscribe`。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前提条件
|
||||
@ -285,4 +294,4 @@ make linux-arm64 # 构建特定平台
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [GPL-3.0 许可证](LICENSE) 授权。
|
||||
本项目采用 [GPL-3.0 许可证](LICENSE) 授权。
|
||||
|
||||
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