From b0eb6595ace337396ed50f1418e0b7189e78ce08 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Tue, 3 Mar 2026 17:29:45 -0800 Subject: [PATCH] =?UTF-8?q?add:=20=E6=96=B0=E5=A2=9Eci=20diamant=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows /docker.yml | 236 ++++++++++++ .github/environments/production.yml | 27 ++ .github/environments/staging.yml | 23 ++ .github/workflows/deploy-linux.yml | 79 ++++ ...ld github.com_perfect-panel_server.run.xml | 12 + README.md | 11 +- docker-compose.cloud.yml | 354 ++++++++++++++++++ .../logic/public/order/preCreateOrderLogic.go | 36 +- internal/logic/public/order/purchaseLogic.go | 89 +++-- internal/model/user/model.go | 1 + internal/model/user/subscribe.go | 14 + pkg/xerr/errCode.go | 1 + pkg/xerr/errMsg.go | 1 + queue/logic/order/activateOrderLogic.go | 74 +++- readme_zh.md | 11 +- script/db_query.go | 78 ++++ 16 files changed, 996 insertions(+), 51 deletions(-) create mode 100644 .gitea/workflows /docker.yml create mode 100644 .github/environments/production.yml create mode 100644 .github/environments/staging.yml create mode 100644 .github/workflows/deploy-linux.yml create mode 100644 .run/go build github.com_perfect-panel_server.run.xml create mode 100644 docker-compose.cloud.yml create mode 100644 script/db_query.go diff --git a/.gitea/workflows /docker.yml b/.gitea/workflows /docker.yml new file mode 100644 index 0000000..4cfa1c1 --- /dev/null +++ b/.gitea/workflows /docker.yml @@ -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 + \ No newline at end of file diff --git a/.github/environments/production.yml b/.github/environments/production.yml new file mode 100644 index 0000000..8e06538 --- /dev/null +++ b/.github/environments/production.yml @@ -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 \ No newline at end of file diff --git a/.github/environments/staging.yml b/.github/environments/staging.yml new file mode 100644 index 0000000..a62b4c4 --- /dev/null +++ b/.github/environments/staging.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/deploy-linux.yml b/.github/workflows/deploy-linux.yml new file mode 100644 index 0000000..1996507 --- /dev/null +++ b/.github/workflows/deploy-linux.yml @@ -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 diff --git a/.run/go build github.com_perfect-panel_server.run.xml b/.run/go build github.com_perfect-panel_server.run.xml new file mode 100644 index 0000000..608afe7 --- /dev/null +++ b/.run/go build github.com_perfect-panel_server.run.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 975f5a7..a487fa7 100644 --- a/README.md +++ b/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). \ No newline at end of file +This project is licensed under the [GPL-3.0 License](LICENSE). diff --git a/docker-compose.cloud.yml b/docker-compose.cloud.yml new file mode 100644 index 0000000..6a69b84 --- /dev/null +++ b/docker-compose.cloud.yml @@ -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 diff --git a/internal/logic/public/order/preCreateOrderLogic.go b/internal/logic/public/order/preCreateOrderLogic.go index 5b3ce05..9abc37d 100644 --- a/internal/logic/public/order/preCreateOrderLogic.go +++ b/internal/logic/public/order/preCreateOrderLogic.go @@ -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) diff --git a/internal/logic/public/order/purchaseLogic.go b/internal/logic/public/order/purchaseLogic.go index 95f32ed..4c1c3c3 100644 --- a/internal/logic/public/order/purchaseLogic.go +++ b/internal/logic/public/order/purchaseLogic.go @@ -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 diff --git a/internal/model/user/model.go b/internal/model/user/model.go index 47707e5..ec8f1b4 100644 --- a/internal/model/user/model.go +++ b/internal/model/user/model.go @@ -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 diff --git a/internal/model/user/subscribe.go b/internal/model/user/subscribe.go index d9c14e5..3786fe3 100644 --- a/internal/model/user/subscribe.go +++ b/internal/model/user/subscribe.go @@ -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) diff --git a/pkg/xerr/errCode.go b/pkg/xerr/errCode.go index 369ab51..59d24cd 100644 --- a/pkg/xerr/errCode.go +++ b/pkg/xerr/errCode.go @@ -77,6 +77,7 @@ const ( SingleSubscribeModeExceedsLimit uint32 = 60005 SubscribeQuotaLimit uint32 = 60006 SubscribeOutOfStock uint32 = 60007 + SingleSubscribePlanMismatch uint32 = 60008 ) // Auth error diff --git a/pkg/xerr/errMsg.go b/pkg/xerr/errMsg.go index 9a1e68a..46d0c64 100644 --- a/pkg/xerr/errMsg.go +++ b/pkg/xerr/errMsg.go @@ -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", diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 39ccdf5..871191f 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -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) { diff --git a/readme_zh.md b/readme_zh.md index ee4a1c4..618d1d8 100644 --- a/readme_zh.md +++ b/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) 授权。 \ No newline at end of file +本项目采用 [GPL-3.0 许可证](LICENSE) 授权。 diff --git a/script/db_query.go b/script/db_query.go new file mode 100644 index 0000000..60c880c --- /dev/null +++ b/script/db_query.go @@ -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) + } +} +