add: 新增ci diamanté

This commit is contained in:
shanshanzhong 2026-03-03 17:29:45 -08:00
parent d220516183
commit b0eb6595ac
16 changed files with 996 additions and 51 deletions

View 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
View File

@ -0,0 +1,27 @@
# Production Environment Configuration for GitHub Actions
# This file defines production-specific deployment settings
environment:
name: production
url: https://api.ppanel.example.com
protection_rules:
- type: wait_timer
minutes: 5
- type: reviewers
reviewers:
- "@admin-team"
- "@devops-team"
variables:
ENVIRONMENT: production
LOG_LEVEL: info
DEPLOY_TIMEOUT: 300
# Environment-specific secrets required:
# PRODUCTION_HOST - Production server hostname/IP
# PRODUCTION_USER - SSH username for production server
# PRODUCTION_SSH_KEY - SSH private key for production server
# PRODUCTION_PORT - SSH port (default: 22)
# PRODUCTION_URL - Application URL for health checks
# DATABASE_PASSWORD - Production database password
# REDIS_PASSWORD - Production Redis password
# JWT_SECRET - JWT secret key for production

23
.github/environments/staging.yml vendored Normal file
View File

@ -0,0 +1,23 @@
# Staging Environment Configuration for GitHub Actions
# This file defines staging-specific deployment settings
environment:
name: staging
url: https://staging-api.ppanel.example.com
protection_rules:
- type: wait_timer
minutes: 2
variables:
ENVIRONMENT: staging
LOG_LEVEL: debug
DEPLOY_TIMEOUT: 180
# Environment-specific secrets required:
# STAGING_HOST - Staging server hostname/IP
# STAGING_USER - SSH username for staging server
# STAGING_SSH_KEY - SSH private key for staging server
# STAGING_PORT - SSH port (default: 22)
# STAGING_URL - Application URL for health checks
# DATABASE_PASSWORD - Staging database password
# REDIS_PASSWORD - Staging Redis password
# JWT_SECRET - JWT secret key for staging

79
.github/workflows/deploy-linux.yml vendored Normal file
View File

@ -0,0 +1,79 @@
name: Build Linux Binary
on:
push:
branches: [ main, master ]
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to build (leave empty for auto)'
required: false
type: string
permissions:
contents: write
jobs:
build:
name: Build Linux Binary
runs-on: 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

View 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>

View File

@ -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
@ -288,4 +297,4 @@ project's development! 🚀
Please give these projects a ⭐ to support the open-source movement! Please give these projects a ⭐ to support the open-source movement!
## 📄 License ## 📄 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
View 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

View File

@ -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)

View File

@ -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 err != nil { if orderType == 1 {
l.Errorw("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id)) isNew, err = l.svcCtx.OrderModel.IsUserEligibleForNewOrder(l.ctx, u.Id)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user order error: %v", err.Error()) 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 // 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(&currentUserSub).Error; e != nil { if e := db.Model(&user.Subscribe{}).Where("user_id = ?", u.Id).Find(&currentUserSub).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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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",

View File

@ -225,19 +225,67 @@ 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 {
// 在赠送订阅上延长时间,保持 token 不变 case anchorErr == nil && anchorSub != nil:
userSub, err = l.extendGiftSubscription(ctx, giftSub, orderInfo, sub) if anchorSub.SubscribeId == orderInfo.SubscribeId {
if err != nil { if orderInfo.ParentId == 0 && anchorSub.OrderId > 0 && anchorSub.OrderId != orderInfo.Id {
logger.WithContext(ctx).Error("Extend gift subscription failed", if patchErr := l.patchOrderParentID(ctx, orderInfo.Id, anchorSub.OrderId); patchErr != nil {
logger.Field("error", err.Error()), logger.WithContext(ctx).Error("Patch order parent_id failed",
logger.Field("gift_subscribe_id", giftSub.Id), 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 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) {

View File

@ -44,6 +44,15 @@ PPanel 服务端是 PPanel 项目的后端组件,为代理服务提供强大
- **节点管理**:监控和控制服务器节点。 - **节点管理**:监控和控制服务器节点。
- **API 框架**:提供全面的 RESTful API供前端集成。 - **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
View 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)
}
}