diff --git a/.codex-tmp/inspect_device_cache_roundtrip.go b/.codex-tmp/inspect_device_cache_roundtrip.go deleted file mode 100644 index 4a6f5c6..0000000 --- a/.codex-tmp/inspect_device_cache_roundtrip.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/perfect-panel/server/internal/config" - "github.com/perfect-panel/server/internal/svc" - "github.com/perfect-panel/server/pkg/conf" -) - -func main() { - var c config.Config - conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c) - ctx := svc.NewServiceContext(c) - const key = "cache:auth:method:device" - before, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result() - fmt.Printf("cache before=%q\n", before) - m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device") - fmt.Printf("model err=%v enabled_nil=%v", err, m == nil || m.Enabled == nil) - if m != nil && m.Enabled != nil { fmt.Printf(" enabled=%v", *m.Enabled) } - if m != nil { fmt.Printf(" config=%s", m.Config) } - fmt.Println() - after, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result() - fmt.Printf("cache after=%q\n", after) -} diff --git a/.codex-tmp/inspect_device_config.go b/.codex-tmp/inspect_device_config.go deleted file mode 100644 index 9299623..0000000 --- a/.codex-tmp/inspect_device_config.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -import ( - "fmt" - - initpkg "github.com/perfect-panel/server/initialize" - "github.com/perfect-panel/server/internal/config" - "github.com/perfect-panel/server/internal/svc" - "github.com/perfect-panel/server/pkg/conf" -) - -func main() { - var c config.Config - conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c) - ctx := svc.NewServiceContext(c) - method, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device") - if err != nil { - panic(err) - } - fmt.Printf("db auth_method.enabled=%v config=%s\n", *method.Enabled, method.Config) - initpkg.Device(ctx) - fmt.Printf("ctx.Config.Device.Enable=%v SecuritySecret=%q EnableSecurity=%v\n", ctx.Config.Device.Enable, ctx.Config.Device.SecuritySecret, ctx.Config.Device.EnableSecurity) -} diff --git a/.codex-tmp/inspect_device_db_vs_model.go b/.codex-tmp/inspect_device_db_vs_model.go deleted file mode 100644 index ee58a04..0000000 --- a/.codex-tmp/inspect_device_db_vs_model.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/perfect-panel/server/internal/config" - "github.com/perfect-panel/server/internal/svc" - "github.com/perfect-panel/server/pkg/conf" -) - -type row struct { - ID int64 - Method string - Enabled int - Config string -} - -func main() { - var c config.Config - conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c) - ctx := svc.NewServiceContext(c) - - var rows []row - if err := ctx.DB.Raw("SELECT id, method, enabled, config FROM auth_method WHERE method = ?", "device").Scan(&rows).Error; err != nil { - panic(err) - } - fmt.Printf("raw rows: %+v\n", rows) - - m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device") - fmt.Printf("model err=%v\n", err) - if err == nil && m != nil && m.Enabled != nil { - fmt.Printf("model row: id=%d method=%s enabled=%v config=%s\n", m.Id, m.Method, *m.Enabled, m.Config) - } else { - fmt.Printf("model row nil or no enabled ptr: %#v\n", m) - } -} diff --git a/.codex-tmp/inspect_device_gorm.go b/.codex-tmp/inspect_device_gorm.go deleted file mode 100644 index 7da8d6f..0000000 --- a/.codex-tmp/inspect_device_gorm.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/perfect-panel/server/internal/config" - authmodel "github.com/perfect-panel/server/internal/model/auth" - "github.com/perfect-panel/server/internal/svc" - "github.com/perfect-panel/server/pkg/conf" -) - -func main() { - var c config.Config - conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c) - ctx := svc.NewServiceContext(c) - - var a1 authmodel.Auth - err1 := ctx.DB.Model(&authmodel.Auth{}).Where("method = ?", "device").First(&a1).Error - fmt.Printf("gorm direct err=%v enabled_nil=%v", err1, a1.Enabled == nil) - if a1.Enabled != nil { fmt.Printf(" enabled=%v", *a1.Enabled) } - fmt.Printf(" config=%s\n", a1.Config) - - var a2 authmodel.Auth - err2 := ctx.DB.Table("auth_method").Where("method = ?", "device").First(&a2).Error - fmt.Printf("gorm table err=%v enabled_nil=%v", err2, a2.Enabled == nil) - if a2.Enabled != nil { fmt.Printf(" enabled=%v", *a2.Enabled) } - fmt.Printf(" config=%s\n", a2.Config) -} diff --git a/.env.example b/.env.example index 6951e3f..0d1de2b 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,5 @@ # 复制此文件为 .env 并填写真实值 # cp .env.example .env -# MySQL root 密码(同时需要在 configs/ppanel.yaml 的 MySQL.Password 中填写相同的值) -MYSQL_ROOT_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD - -# Grafana 管理员密码 -GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD - # PPanel Server 镜像标签(由 CI/CD 传入不可变 tag,如 git SHA) PPANEL_SERVER_TAG=CHANGE_ME_TO_GIT_SHA - -# AWS 区域(香港) -AWS_REGION=ap-east-1 - -# Grafana 公开域名(如需反代) -GRAFANA_DOMAIN=logs-new.hifast.biz -GRAFANA_ROOT_URL=https://logs-new.hifast.biz diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml deleted file mode 100644 index 6057447..0000000 --- a/.gitea/workflows/docker.yml +++ /dev/null @@ -1,337 +0,0 @@ -name: Build docker and publish -run-name: 简化的Docker构建和部署流程 - -on: - push: - branches: - - main - - internal - pull_request: - branches: - - main - - internal - -env: - # Docker镜像仓库 - REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }} - # SSH连接信息 (根据分支自动选择服务器和用户) - SSH_HOST: ${{ github.ref_name == 'main' && vars.SSH_HOST || vars.DEV_SSH_HOST }} - SSH_PORT: ${{ vars.SSH_PORT }} - SSH_USER: ${{ github.ref_name == 'main' && 'ubuntu' || 'root' }} - # SSH私钥(Gitea Secret 名称:AWS) - SSH_KEY: ${{ secrets.AWS }} - # TG通知 - TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }} - TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }} - # Go构建变量 - SERVICE: vpn - SERVICE_STYLE: vpn - VERSION: ${{ github.sha }} - BUILDTIME: ${{ github.event.head_commit.timestamp }} - GOARCH: amd64 - -jobs: - build: - runs-on: ario-server - container: - image: node:20 - strategy: - matrix: - # 只有node支持版本号别名 - node: ['20.15.1'] - steps: - # 步骤1: 下载代码 - - name: 📥 下载代码 - uses: actions/checkout@v4 - - # 步骤2: 设置动态环境变量 - - name: ⚙️ 设置动态环境变量 - run: | - if [ "${{ github.ref_name }}" = "main" ]; then - echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV - echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV - echo "DEPLOY_PATH=/opt/ppanel" >> $GITHUB_ENV - echo "DEPLOY_ENV_LABEL=🚀 服务已成功部署到生产环境" >> $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 "DEPLOY_ENV_LABEL=🧪 服务已成功部署到测试环境" >> $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 "DEPLOY_ENV_LABEL=🔧 服务已成功部署到其他环境" >> $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 }}" - - BUILD_TAG_ARGS="-t ${{ env.REPO }}:${{ env.VERSION }}" - if [ "${{ github.event_name }}" = "push" ]; then - BUILD_TAG_ARGS="$BUILD_TAG_ARGS -t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}" - else - echo "PR事件仅构建版本标签,不推送镜像、不部署" - fi - - docker build -f Dockerfile \ - --platform linux/amd64 \ - --build-arg TARGETARCH=amd64 \ - --build-arg VERSION=${{ env.VERSION }} \ - --build-arg BUILDTIME=${{ env.BUILDTIME }} \ - $BUILD_TAG_ARGS \ - . - - echo "镜像构建完成" - - # 步骤5: 发布到镜像仓库 - - name: 📤 发布到镜像仓库 - if: github.event_name == 'push' - run: | - echo "开始推送镜像..." - - 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 "镜像推送完成" - - # 步骤6: 调试 - 打印部署目标(不输出敏感信息) - - name: 🔍 调试 - 打印部署目标 - if: github.event_name == 'push' - run: | - echo "========== 部署目标调试 ==========" - echo "当前分支: ${{ github.ref_name }}" - echo "SSH_HOST: ${{ env.SSH_HOST }}" - echo "SSH_PORT: ${{ env.SSH_PORT }}" - echo "SSH_USER: ${{ env.SSH_USER }}" - echo "SSH认证方式: 私钥 (AWS)" - echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}" - echo "=====================================" - - # 步骤7: 传输配置文件 - - name: 📂 传输配置文件 - if: github.event_name == 'push' - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ env.SSH_HOST }} - username: ${{ env.SSH_USER }} - key: ${{ env.SSH_KEY }} - port: ${{ env.SSH_PORT }} - source: "docker-compose.cloud.yml" - target: "/tmp/ppanel-deploy/" - - # 步骤8: 连接服务器更新、健康检查并按需回滚 - - name: 🚀 连接服务器更新并启动 - if: github.event_name == 'push' - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ env.SSH_HOST }} - username: ${{ env.SSH_USER }} - key: ${{ env.SSH_KEY }} - port: ${{ env.SSH_PORT }} - timeout: 300s - command_timeout: 600s - script: | - set -e - - echo "连接服务器成功,开始部署..." - echo "部署目录: ${{ env.DEPLOY_PATH }}" - echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}" - echo "登录用户: ${{ env.SSH_USER }}" - - HEALTHCHECK_URL="http://127.0.0.1:8080/v1/common/heartbeat" - NEW_TAG="${{ env.DOCKER_TAG_SUFFIX }}" - ROLLBACK_TAG="rollback-${{ env.VERSION }}" - SUDO="" - if [ "${{ github.ref_name }}" = "main" ]; then - SUDO="sudo" - fi - - docker_cmd() { - if [ -n "$SUDO" ]; then - sudo docker "$@" - else - docker "$@" - fi - } - - compose_with_tag() { - tag="$1" - shift - if [ -n "$SUDO" ]; then - sudo env PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@" - else - PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@" - fi - } - - write_previous_tag() { - if [ -n "$SUDO" ]; then - printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" | sudo tee .previous-ppanel-image-tag >/dev/null - else - printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" > .previous-ppanel-image-tag - fi - } - - health_check() { - attempt=1 - while [ "$attempt" -le 3 ]; do - if curl -sf "$HEALTHCHECK_URL"; then - echo - return 0 - fi - - echo "健康检查第 ${attempt}/3 次失败,10s 后重试..." - attempt=$((attempt + 1)) - sleep 10 - done - - return 1 - } - - if [ "${{ github.ref_name }}" = "main" ]; then - sudo mkdir -p ${{ env.DEPLOY_PATH }} - sudo cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml - else - mkdir -p ${{ env.DEPLOY_PATH }} - cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml - fi - - cd ${{ env.DEPLOY_PATH }} - - PREVIOUS_IMAGE_TAG="$(docker_cmd inspect --format '{{.Config.Image}}' ppanel-server 2>/dev/null || true)" - PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)" - echo "上一版本镜像tag: ${PREVIOUS_IMAGE_TAG:-未发现}" - echo "上一版本镜像ID: ${PREVIOUS_IMAGE_ID:-未发现}" - write_previous_tag - - echo "📥 拉取镜像: ${{ env.REPO }}:${NEW_TAG}" - compose_with_tag "$NEW_TAG" pull ppanel-server - echo "🚀 启动服务..." - compose_with_tag "$NEW_TAG" up -d ppanel-server - - echo "🩺 部署后健康检查: ${HEALTHCHECK_URL}" - if health_check; then - docker_cmd image prune -f || true - echo "✅ 部署后健康检查通过" - echo "✅ 部署命令执行完成" - exit 0 - fi - - echo "❌ 部署后健康检查连续 3 次失败,开始回滚..." - if [ -n "$PREVIOUS_IMAGE_ID" ]; then - docker_cmd tag "$PREVIOUS_IMAGE_ID" "${{ env.REPO }}:${ROLLBACK_TAG}" - echo "回滚镜像tag: ${{ env.REPO }}:${ROLLBACK_TAG}" - compose_with_tag "$ROLLBACK_TAG" up -d ppanel-server - - echo "🩺 回滚后健康检查: ${HEALTHCHECK_URL}" - if health_check; then - echo "✅ 回滚后健康检查通过" - else - echo "❌ 回滚后健康检查仍失败" - fi - else - echo "未找到上一版本镜像ID,无法自动回滚" - fi - - docker_cmd image prune -f || true - exit 1 - - # 步骤9: TG通知 (成功) - - name: 📱 发送成功通知到Telegram - if: success() && github.event_name == 'push' - 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 }} - - ${{ env.DEPLOY_ENV_LABEL }} - 🩺 健康检查: 通过 (http://127.0.0.1:8080/v1/common/heartbeat) - parse_mode: Markdown - - # 步骤10: TG通知 (失败) - - name: 📱 发送失败通知到Telegram - if: failure() && github.event_name == 'push' - 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 }} - - 🩺 健康检查: 失败或未完成;若新版本健康检查连续 3 次失败,已自动尝试回滚并重新检查 - ⚠️ 请检查构建日志获取详细信息 - parse_mode: Markdown diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..e9d259f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,24 @@ +# Code owners — 自动 request review +# +# 仓库私有 + 当前 plan 不支持 branch protection(详见 doc/development-workflow-zh.md +# 「平台层约束的现状」一节),CODEOWNERS 在此用作"自动 request review + 显性责任划分", +# 而非强制门禁。 +# +# 任何 PR 默认 request 给 @shanshanzhong147 (owner) review。若后续引入团队 +# handle(例如 @TawCorp/backend),把对应 path 改成 team handle 即可。 + +# 全部路径 — owner 默认 reviewer +* @shanshanzhong147 + +# 部署/CI/Docker — 改这些要再确认一次(涉及生产部署链路) +/.github/ @shanshanzhong147 +/Dockerfile @shanshanzhong147 +/docker-compose.*.yml @shanshanzhong147 +/scripts/ @shanshanzhong147 +/Makefile @shanshanzhong147 + +# 流程文档自身 — 改这里就是改流程 +/CONTRIBUTING.md @shanshanzhong147 +/CONTRIBUTING_ZH.md @shanshanzhong147 +/doc/development-workflow-zh.md @shanshanzhong147 +/.github/CODEOWNERS @shanshanzhong147 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..99ad6a3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,51 @@ + + +## 关联 Issue + +Closes HIF-XXX + + +## 改动摘要 + + + +## 改动细节 + + + +- +- + +## 测试计划 + +- [ ] `go build ./...` 通过 +- [ ] `go vet ./...` 通过 +- [ ] `go test -race ./... -count=1` 通过 +- [ ] golangci-lint 通过 +- [ ] 新增/修改的逻辑有对应单测覆盖 +- [ ] (如涉及 DB 变更)migration up/down 双向验证 +- [ ] (如涉及 API)curl / Postman 验证命令贴在下面 + + + +``` +``` + +## 风险 / 回滚 + + + +- + +## Reviewer 自检清单 + +- [ ] PR 标题符合 commitlint 规范(`修复/新功能/重构/文档/配置(#): ...`) +- [ ] 分支命名 `fix/-…` / `feat/-…` / `chore/…` +- [ ] 目标分支 = `internal` +- [ ] 改动 scope 与 Issue 描述一致,无 scope creep +- [ ] **无无关代码改动**(架构师红线) +- [ ] 无密钥/凭证泄露 +- [ ] CI 全绿 +- [ ] 测试工程师已验收(如涉及业务逻辑) diff --git a/.github/environments/production.yml b/.github/environments/production.yml deleted file mode 100644 index 8e06538..0000000 --- a/.github/environments/production.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Production Environment Configuration for GitHub Actions -# This file defines production-specific deployment settings - -environment: - name: production - url: https://api.ppanel.example.com - protection_rules: - - type: wait_timer - minutes: 5 - - type: reviewers - reviewers: - - "@admin-team" - - "@devops-team" - variables: - ENVIRONMENT: production - LOG_LEVEL: info - DEPLOY_TIMEOUT: 300 - -# Environment-specific secrets required: -# PRODUCTION_HOST - Production server hostname/IP -# PRODUCTION_USER - SSH username for production server -# PRODUCTION_SSH_KEY - SSH private key for production server -# PRODUCTION_PORT - SSH port (default: 22) -# PRODUCTION_URL - Application URL for health checks -# DATABASE_PASSWORD - Production database password -# REDIS_PASSWORD - Production Redis password -# JWT_SECRET - JWT secret key for production \ No newline at end of file diff --git a/.github/environments/staging.yml b/.github/environments/staging.yml deleted file mode 100644 index a62b4c4..0000000 --- a/.github/environments/staging.yml +++ /dev/null @@ -1,23 +0,0 @@ -# Staging Environment Configuration for GitHub Actions -# This file defines staging-specific deployment settings - -environment: - name: staging - url: https://staging-api.ppanel.example.com - protection_rules: - - type: wait_timer - minutes: 2 - variables: - ENVIRONMENT: staging - LOG_LEVEL: debug - DEPLOY_TIMEOUT: 180 - -# Environment-specific secrets required: -# STAGING_HOST - Staging server hostname/IP -# STAGING_USER - SSH username for staging server -# STAGING_SSH_KEY - SSH private key for staging server -# STAGING_PORT - SSH port (default: 22) -# STAGING_URL - Application URL for health checks -# DATABASE_PASSWORD - Staging database password -# REDIS_PASSWORD - Staging Redis password -# JWT_SECRET - JWT secret key for staging \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2661f29 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: 持续集成 + +on: + pull_request: + branches: + - internal + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: 构建/Vet/测试 + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: 检出代码 + uses: actions/checkout@v6 + + - name: 配置 Go 环境 + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: 下载依赖模块 + run: go mod download + + - name: 构建 + run: go build ./... + + - name: 运行 go vet + run: go vet ./... + + - name: 运行测试 + run: go test -race -count=1 ./... + + lint: + name: golangci-lint + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: 检出代码 + uses: actions/checkout@v6 + with: + # Fetch base ref so golangci-lint can diff against it for only-new-issues. + fetch-depth: 0 + + - name: 配置 Go 环境 + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: latest + args: --timeout=5m + # Legacy codebase has ~47 pre-existing lint issues (errcheck / unused + # carried over from upstream perfect-panel/server). Only flag NEW + # issues introduced by this PR so CI stays useful without forcing a + # mass cleanup. Backlog cleanup tracked separately. + only-new-issues: true diff --git a/.github/workflows/deploy-linux.yml b/.github/workflows/deploy-linux.yml deleted file mode 100644 index 83447ec..0000000 --- a/.github/workflows/deploy-linux.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Build Linux Binary - -on: - push: - branches: [ main, master ] - tags: - - 'v*' - workflow_dispatch: - inputs: - version: - description: 'Version to build (leave empty for auto)' - required: false - type: string - -permissions: - contents: write - -jobs: - build: - name: Build Linux Binary - runs-on: ario-server - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: '1.23.3' - cache: true - - - name: Build - env: - CGO_ENABLED: 0 - GOOS: linux - GOARCH: amd64 - run: | - VERSION=${{ github.event.inputs.version }} - if [ -z "$VERSION" ]; then - VERSION=$(git describe --tags --always --dirty) - fi - - echo "Building ppanel-server $VERSION" - BUILD_TIME=$(date +"%Y-%m-%d_%H:%M:%S") - go build -ldflags="-w -s -X github.com/perfect-panel/server/pkg/constant.Version=$VERSION -X github.com/perfect-panel/server/pkg/constant.BuildTime=$BUILD_TIME" -o ppanel-server ./ppanel.go - tar -czf ppanel-server-${VERSION}-linux-amd64.tar.gz ppanel-server - sha256sum ppanel-server ppanel-server-${VERSION}-linux-amd64.tar.gz > checksum.txt - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: ppanel-server-linux-amd64 - path: | - ppanel-server - ppanel-server-*-linux-amd64.tar.gz - checksum.txt - - - name: Create Release - if: startsWith(github.ref, 'refs/tags/') - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION=${GITHUB_REF#refs/tags/} - - # Check if release exists - if gh release view $VERSION >/dev/null 2>&1; then - echo "Release $VERSION already exists, deleting old assets..." - # Delete existing assets if they exist - gh release delete-asset $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz --yes 2>/dev/null || true - gh release delete-asset $VERSION checksum.txt --yes 2>/dev/null || true - else - echo "Creating new release $VERSION..." - gh release create $VERSION --title "PPanel Server $VERSION" --notes "Release $VERSION" - fi - - # Upload assets (will overwrite if --clobber is supported, otherwise will fail gracefully) - echo "Uploading assets..." - gh release upload $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz checksum.txt --clobber diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..29918b8 --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,253 @@ +name: 测试环境部署 + +on: + push: + branches: + - main + - internal + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-staging-${{ github.ref }} + cancel-in-progress: false + +env: + REGISTRY_HOST: ${{ vars.REGISTRY_HOST || 'registry.kxsw.us' }} + IMAGE_NAME: ${{ vars.REGISTRY_IMAGE || 'registry.kxsw.us/vpn-server' }} + STAGING_HOST: ${{ vars.STAGING_HOST || '154.12.35.103' }} + STAGING_DEPLOY_PATH: ${{ vars.STAGING_DEPLOY_PATH || '/opt/hifast-server' }} + STAGING_HEALTHCHECK_URL: ${{ vars.STAGING_HEALTHCHECK_URL || 'http://127.0.0.1:8080/v1/common/heartbeat' }} + # 关闭 docker/build-push-action 自动生成的英文 job summary + # 我们用 Telegram 通知传递部署结果,不需要 GitHub Actions 页面上那段英文 + DOCKER_BUILD_SUMMARY: false + +jobs: + build-and-deploy: + name: 构建镜像并部署到测试环境 + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: 检出代码 + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: 收集发布说明 + id: release-notes + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then + RANGE="${{ github.event.before }}..${{ github.sha }}" + else + RANGE="-5" + fi + + NOTES="$(git log "$RANGE" --pretty=format:'- %h %s' --no-merges | head -20)" + if [ -z "$NOTES" ]; then + NOTES="$(git log -1 --pretty=format:'- %h %s')" + fi + + { + echo "notes<> "$GITHUB_OUTPUT" + + - name: 配置 Go 环境 + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: 运行测试 + run: go test ./... + + - name: 配置 Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: 构建并推送镜像 + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: true + build-args: | + TARGETARCH=amd64 + VERSION=${{ github.sha }} + tags: | + ${{ env.IMAGE_NAME }}:${{ github.sha }} + ${{ env.IMAGE_NAME }}:staging + + - name: 上传 compose 配置 + uses: appleboy/scp-action@v1.0.0 + with: + host: ${{ env.STAGING_HOST }} + username: ${{ secrets.STAGING_SSH_USER }} + password: ${{ secrets.STAGING_SSH_PASSWORD }} + port: ${{ secrets.STAGING_SSH_PORT || 22 }} + source: docker-compose.cloud.yml + target: /tmp/hifast-server-deploy/ + + - name: 在测试服务器上部署 + uses: appleboy/ssh-action@v1.2.5 + with: + host: ${{ env.STAGING_HOST }} + username: ${{ secrets.STAGING_SSH_USER }} + password: ${{ secrets.STAGING_SSH_PASSWORD }} + port: ${{ secrets.STAGING_SSH_PORT || 22 }} + timeout: 300s + command_timeout: 600s + script: | + set -euo pipefail + + IMAGE_NAME="${{ env.IMAGE_NAME }}" + NEW_TAG="${{ github.sha }}" + DEPLOY_PATH="${{ env.STAGING_DEPLOY_PATH }}" + HEALTHCHECK_URL="${{ env.STAGING_HEALTHCHECK_URL }}" + ROLLBACK_TAG="rollback-${NEW_TAG}" + + if command -v sudo >/dev/null 2>&1 && ! docker ps >/dev/null 2>&1; then + SUDO="sudo" + else + SUDO="" + fi + + docker_cmd() { + if [ -n "$SUDO" ]; then + sudo docker "$@" + else + docker "$@" + fi + } + + compose_cmd() { + tag="$1" + shift + if docker compose version >/dev/null 2>&1; then + if [ -n "$SUDO" ]; then + sudo env PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker compose -f docker-compose.cloud.yml "$@" + else + PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker compose -f docker-compose.cloud.yml "$@" + fi + else + if [ -n "$SUDO" ]; then + sudo env PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@" + else + PPANEL_SERVER_IMAGE="$IMAGE_NAME" PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@" + fi + fi + } + + health_check() { + attempt=1 + while [ "$attempt" -le 6 ]; do + if curl -fsS "$HEALTHCHECK_URL"; then + echo + return 0 + fi + echo "Health check ${attempt}/6 failed; retrying in 10s..." + attempt=$((attempt + 1)) + sleep 10 + done + return 1 + } + + if [ -n "$SUDO" ]; then + sudo mkdir -p "$DEPLOY_PATH" + sudo cp /tmp/hifast-server-deploy/docker-compose.cloud.yml "$DEPLOY_PATH/docker-compose.cloud.yml" + else + mkdir -p "$DEPLOY_PATH" + cp /tmp/hifast-server-deploy/docker-compose.cloud.yml "$DEPLOY_PATH/docker-compose.cloud.yml" + fi + + cd "$DEPLOY_PATH" + + PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)" + echo "Previous image ID: ${PREVIOUS_IMAGE_ID:-none}" + + echo "Pulling ${IMAGE_NAME}:${NEW_TAG}" + compose_cmd "$NEW_TAG" pull ppanel-server + + echo "Starting ppanel-server" + compose_cmd "$NEW_TAG" up -d ppanel-server + + echo "Checking ${HEALTHCHECK_URL}" + if health_check; then + docker_cmd image prune -f || true + echo "Staging deployment succeeded" + exit 0 + fi + + echo "Health check failed; attempting rollback" + if [ -n "$PREVIOUS_IMAGE_ID" ]; then + docker_cmd tag "$PREVIOUS_IMAGE_ID" "${IMAGE_NAME}:${ROLLBACK_TAG}" + compose_cmd "$ROLLBACK_TAG" up -d ppanel-server + health_check || true + else + echo "No previous image found; rollback skipped" + fi + + docker_cmd image prune -f || true + exit 1 + + - name: Telegram 成功通知 + if: success() + env: + TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }} + TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }} + run: | + if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then + echo "Telegram 密钥未配置,跳过通知。" + exit 0 + fi + + MESSAGE="$(cat <<'EOF' + ✅ 测试环境发布成功 + 仓库:${{ github.repository }} + 分支:${{ github.ref_name }} + 提交:${{ github.sha }} + 操作人:${{ github.actor }} + 镜像:${{ env.IMAGE_NAME }}:${{ github.sha }} + 本次更新: + ${{ steps.release-notes.outputs.notes }} + 运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + EOF + )" + + curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TG_CHAT_ID}" \ + --data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,但发布结果不受影响。" + + - name: Telegram 失败通知 + if: failure() + env: + TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }} + TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }} + run: | + if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then + echo "Telegram 密钥未配置,跳过通知。" + exit 0 + fi + + MESSAGE="$(cat <<'EOF' + ❌ 测试环境发布失败 + 仓库:${{ github.repository }} + 分支:${{ github.ref_name }} + 提交:${{ github.sha }} + 操作人:${{ github.actor }} + 镜像:${{ env.IMAGE_NAME }}:${{ github.sha }} + 本次更新: + ${{ steps.release-notes.outputs.notes }} + 运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + EOF + )" + + curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TG_CHAT_ID}" \ + --data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,工作流失败状态已记录。" diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml new file mode 100644 index 0000000..ec4a3ce --- /dev/null +++ b/.github/workflows/release-acceptance.yml @@ -0,0 +1,266 @@ +name: 发布验收测试 + +on: + workflow_run: + workflows: + - 测试环境部署 + types: + - completed + branches: + - internal + - main + workflow_dispatch: + +permissions: + contents: read + actions: read + +concurrency: + group: release-acceptance + cancel-in-progress: false + +env: + ACCEPTANCE_ARTIFACT_NAME: release-acceptance-${{ github.run_id }}-${{ github.run_attempt }} + ACCEPTANCE_REPORT_DIR: ${{ github.workspace }}/acceptance-artifacts + ACCEPTANCE_REPORT_PATH: ${{ github.workspace }}/acceptance-artifacts/acceptance-report.json + ACCEPTANCE_TEST_JSON: ${{ github.workspace }}/acceptance-artifacts/go-test.json + ACCEPTANCE_FAILURE_LOG: ${{ github.workspace }}/acceptance-artifacts/failure.log + ACCEPTANCE_NODE_SERVER_ID: ${{ vars.ACCEPTANCE_NODE_SERVER_ID || '31' }} + ACCEPTANCE_NODE_PROTOCOL: ${{ vars.ACCEPTANCE_NODE_PROTOCOL || 'trojan' }} + +jobs: + acceptance: + name: Staging acceptance + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }} + if: >- + ${{ + github.event_name == 'workflow_dispatch' || + ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' + ) + }} + + steps: + - name: 检出代码 + uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: 配置 Go 环境 + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: 准备报告目录 + run: mkdir -p "$ACCEPTANCE_REPORT_DIR" + + - name: 校验必需配置 + env: + ACCEPTANCE_ADMIN_EMAIL: ${{ secrets.ACCEPTANCE_ADMIN_EMAIL }} + ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }} + ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }} + ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }} + STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }} + STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }} + STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }} + STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }} + STAGING_DB_NAME: ${{ secrets.STAGING_DB_NAME }} + STAGING_REDIS_ADDR: ${{ secrets.STAGING_REDIS_ADDR }} + STAGING_REDIS_PASSWORD: ${{ secrets.STAGING_REDIS_PASSWORD }} + run: | + set -euo pipefail + + if [ -z "${STAGING_BASE_URL:-}" ]; then + echo "STAGING_BASE_URL 未配置且默认值不可用" | tee "$ACCEPTANCE_FAILURE_LOG" + { + echo "## 发布验收测试" + echo + echo "状态:配置错误" + echo + echo "- `STAGING_BASE_URL` 不能为空。" + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + missing_optional=() + optional=( + ACCEPTANCE_ADMIN_EMAIL + ACCEPTANCE_ADMIN_PASSWORD + ACCEPTANCE_USER_EMAIL + ACCEPTANCE_USER_PASSWORD + STAGING_DB_HOST + STAGING_DB_USER + STAGING_DB_PASSWORD + STAGING_DB_NAME + STAGING_REDIS_ADDR + STAGING_REDIS_PASSWORD + ) + + for key in "${optional[@]}"; do + if [ -z "${!key:-}" ]; then + missing_optional+=("$key") + fi + done + + : > "$ACCEPTANCE_FAILURE_LOG" + + if [ "${#missing_optional[@]}" -gt 0 ]; then + printf '缺少可选 GitHub Actions secrets/vars,部分验收用例将被跳过:\n' | tee -a "$ACCEPTANCE_FAILURE_LOG" + printf -- '- %s\n' "${missing_optional[@]}" | tee -a "$ACCEPTANCE_FAILURE_LOG" + { + echo "## 发布验收测试" + echo + echo "状态:部分配置缺失" + echo + echo "缺少以下可选 secrets/vars,对应验收用例会在测试阶段自动跳过:" + printf -- '- `%s`\n' "${missing_optional[@]}" + echo + echo "- `STAGING_BASE_URL`:${STAGING_BASE_URL}" + } >> "$GITHUB_STEP_SUMMARY" + else + { + echo "## 发布验收测试" + echo + echo "状态:配置检查通过" + echo + echo "- `STAGING_BASE_URL`:${STAGING_BASE_URL}" + } >> "$GITHUB_STEP_SUMMARY" + fi + + - name: 下载依赖模块 + run: go mod download + + - name: 运行 acceptance 测试 + env: + ACCEPTANCE_ADMIN_EMAIL: ${{ secrets.ACCEPTANCE_ADMIN_EMAIL }} + ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }} + ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }} + ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }} + ACCEPTANCE_NODE_SECRET: ${{ secrets.ACCEPTANCE_NODE_SECRET }} + ACCEPTANCE_RUN_ID: qa_${{ github.run_id }}_${{ github.run_attempt }} + STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }} + STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }} + STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }} + STAGING_DB_NAME: ${{ secrets.STAGING_DB_NAME }} + STAGING_REDIS_ADDR: ${{ secrets.STAGING_REDIS_ADDR }} + STAGING_REDIS_PASSWORD: ${{ secrets.STAGING_REDIS_PASSWORD }} + STAGING_REDIS_DB: ${{ vars.STAGING_REDIS_DB || '0' }} + run: | + set -o pipefail + + go test -json ./tests/acceptance/... \ + -staging-url="$STAGING_BASE_URL" \ + -report-path="$ACCEPTANCE_REPORT_PATH" \ + > >(tee "$ACCEPTANCE_TEST_JSON") \ + 2> >(tee "$ACCEPTANCE_FAILURE_LOG" >&2) + + - name: 生成测试摘要 + if: always() + run: | + set -euo pipefail + + { + echo "## 发布验收测试" + echo + echo "- 状态:${{ job.status }}" + echo "- Staging URL:${STAGING_BASE_URL}" + echo "- Artifact:${ACCEPTANCE_ARTIFACT_NAME}" + echo "- Run URL:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + if [ "${{ github.event_name }}" = "workflow_run" ]; then + echo "- Deploy run:${{ github.event.workflow_run.html_url }}" + echo "- Deploy SHA:${{ github.event.workflow_run.head_sha }}" + else + echo "- 手动触发 SHA:${{ github.sha }}" + fi + echo + } | tee "$ACCEPTANCE_REPORT_DIR/summary.md" >> "$GITHUB_STEP_SUMMARY" + + if [ "${{ job.status }}" = "failure" ]; then + if [ -s "$ACCEPTANCE_FAILURE_LOG" ]; then + { + echo + echo "Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "Artifact: ${ACCEPTANCE_ARTIFACT_NAME}" + } >> "$ACCEPTANCE_FAILURE_LOG" + else + { + echo "Acceptance 测试失败。" + echo "Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "Artifact: ${ACCEPTANCE_ARTIFACT_NAME}" + echo + tail -100 "$ACCEPTANCE_TEST_JSON" 2>/dev/null || true + } > "$ACCEPTANCE_FAILURE_LOG" + fi + elif [ ! -f "$ACCEPTANCE_FAILURE_LOG" ]; then + : > "$ACCEPTANCE_FAILURE_LOG" + fi + + - name: 上传 acceptance artifact + if: always() + uses: actions/upload-artifact@v6 + with: + name: ${{ env.ACCEPTANCE_ARTIFACT_NAME }} + path: | + ${{ env.ACCEPTANCE_REPORT_PATH }} + ${{ env.ACCEPTANCE_TEST_JSON }} + ${{ env.ACCEPTANCE_FAILURE_LOG }} + ${{ env.ACCEPTANCE_REPORT_DIR }}/summary.md + if-no-files-found: warn + retention-days: 14 + + - name: Telegram 成功通知 + if: success() + env: + TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }} + TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }} + run: | + if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then + echo "Telegram 密钥未配置,跳过通知。" + exit 0 + fi + + MESSAGE="$(cat <<'EOF' + ✅ 发布验收测试通过 + 仓库:${{ github.repository }} + 分支:${{ github.event.workflow_run.head_branch || github.ref_name }} + 提交:${{ github.event.workflow_run.head_sha || github.sha }} + Staging:${{ env.STAGING_BASE_URL }} + Artifact:${{ env.ACCEPTANCE_ARTIFACT_NAME }} + 运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + EOF + )" + + curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TG_CHAT_ID}" \ + --data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,但验收结果不受影响。" + + - name: Telegram 失败通知 + if: failure() + env: + TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }} + TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }} + run: | + if [ -z "${TG_BOT_TOKEN:-}" ] || [ -z "${TG_CHAT_ID:-}" ]; then + echo "Telegram 密钥未配置,跳过通知。" + exit 0 + fi + + MESSAGE="$(cat <<'EOF' + ❌ 发布验收测试失败 + 仓库:${{ github.repository }} + 分支:${{ github.event.workflow_run.head_branch || github.ref_name }} + 提交:${{ github.event.workflow_run.head_sha || github.sha }} + Staging:${{ env.STAGING_BASE_URL }} + Artifact:${{ env.ACCEPTANCE_ARTIFACT_NAME }} + 运行记录:${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + EOF + )" + + curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TG_CHAT_ID}" \ + --data-urlencode "text=${MESSAGE}" || echo "Telegram 通知发送失败,工作流失败状态已记录。" diff --git a/.gitignore b/.gitignore index ce9f7ff..ed218a5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,13 @@ Thumbs.db *.crt *.key *.pem +*.pub +*id_rsa* +*id_ed25519* +*_bak +*.go_bak +deploy/**/keys/ +deliverables/ # ==================== 日志 ==================== *.log @@ -35,6 +42,7 @@ logs/ # ==================== 测试 ==================== /test/ *_test.go +!tests/acceptance/*_test.go *_test_config.go **/logtest/ *_test.yaml @@ -70,6 +78,7 @@ script/*.sh # Codex local configuration .codex/ +.codex-tmp/ # Claude Flow runtime data .claude-flow/data/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index fa6d223..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,66 +0,0 @@ -project_name: ppanel -version: 1 -release: - prerelease: auto -builds: - - # If true, skip the build. - # Useful for library projects. - # Default is false - skip: true - -changelog: - # Set it to true if you wish to skip the changelog generation. - # This may result in an empty release notes on GitHub/GitLab/Gitea. - disable: false - - # Changelog generation implementation to use. - # - # Valid options are: - # - `git`: uses `git log`; - # - `github`: uses the compare GitHub API, appending the author login to the changelog. - # - `gitlab`: uses the compare GitLab API, appending the author name and email to the changelog. - # - `github-native`: uses the GitHub release notes generation API, disables the groups feature. - # - # Defaults to `git`. - use: github - - # Sorts the changelog by the commit's messages. - # Could either be asc, desc or empty - # Default is empty - sort: asc - - # Format to use for commit formatting. - # Only available when use is one of `github`, `gitea`, or `gitlab`. - # - # Default: '{{ .SHA }}: {{ .Message }} ({{ with .AuthorUsername }}@{{ . }}{{ else }}{{ .AuthorName }} <{{ .AuthorEmail }}>{{ end }})'. - # Extra template fields: `SHA`, `Message`, `AuthorName`, `AuthorEmail`, and - # `AuthorUsername`. - format: "{{ .Message }}" - - # Group commits messages by given regex and title. - # Order value defines the order of the groups. - # Proving no regex means all commits will be grouped under the default group. - # Groups are disabled when using github-native, as it already groups things by itself. - # - # Default is no groups. - groups: - - title: "✨ Features" - regexp: "^.*feat[(\\w)]*:+.*$" - order: 0 - - title: "🐛 Bug Fixes" - regexp: "^.*fix[(\\w)]*:+.*$" - order: 1 - - title: "🎫 Chores" - regexp: "^.*chore[(\\w)]*:+.*$" - order: 2 - - title: "🔨 Refactor" - regexp: "^.*refactor[(\\w)]*:+.*$" - order: 3 - - title: "🔧 Build" - regexp: "^.*?(ci)(\\(.+\\))??!?:.+$" - order: 4 - - title: "📝 Documentation" - regexp: "^.*?docs?(\\(.+\\))??!?:.+$" - order: 5 - - title: "✨ Others" - order: 999 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 063be39..7906132 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,7 @@ # Pull Request Submission Guidelines +> **TawCorp internal contributors**: read [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md) first. It documents the canonical end-to-end flow (Multica issue → branch → PR → CI → review → squash merge via GitHub UI → Deploy Staging → QA), agent role boundaries, branch / commit conventions, and the soft-constraint model used in place of branch protection. The guidelines below apply to all contributors (internal and external) as the baseline. + To ensure the quality of the codebase and maintainability of the project, please follow these guidelines before submitting a Pull Request (PR): ## 1. PR Title and Description diff --git a/CONTRIBUTING_ZH.md b/CONTRIBUTING_ZH.md index c27158e..38aeb0f 100644 --- a/CONTRIBUTING_ZH.md +++ b/CONTRIBUTING_ZH.md @@ -1,5 +1,7 @@ # Pull Request 提交须知 +> **TawCorp 内部协作者**:请先阅读 [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md)。该文档定义了从 Multica issue 到 PR 合并到 Deploy Staging 到 QA 验收的端到端流程,以及 agent 角色边界、分支/commit 约定、和不依赖 GitHub branch protection 的软约束模型。下面的通用指南仍然适用,但内部协作以 `doc/development-workflow-zh.md` 为准。 + 为了确保代码库的质量和项目的可维护性,在提交 Pull Request(PR)之前,请务必遵循以下准则: ## 1. PR 标题和描述 diff --git a/README.md b/README.md index a487fa7..c83e98b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ + + +> ### TawCorp hifast-server (internal fork) +> +> This repository is the **canonical** TawCorp fork of [perfect-panel/server](https://github.com/perfect-panel/server). Migrated from `git.kxsw.us/HI-VPN/hi-server` on **2026-06-03**; the old Gitea remote is deprecated — do not push or pull from it. +> +> - **Development workflow (required reading for internal contributors)**: [`doc/development-workflow-zh.md`](doc/development-workflow-zh.md) +> - **Branch model**: `internal` (dev mainline, auto-deploys to staging) → `main` (release) +> - **Merge policy**: PR + architect review + GitHub UI "Squash and merge"; direct push to `internal`/`main` is blocked by `lefthook` pre-push and forbidden by policy +> - **Issue tracker**: Multica workspace `Hifast` (prefix `HIF-`) +> +> External contributors: read [`CONTRIBUTING.md`](CONTRIBUTING.md) for the upstream-compatible baseline. + +--- + # PPanel Server
diff --git a/apis/admin/invite.api b/apis/admin/invite.api new file mode 100644 index 0000000..988b9f9 --- /dev/null +++ b/apis/admin/invite.api @@ -0,0 +1,52 @@ +syntax = "v1" + +info ( + title: "Invite API" + desc: "API for ppanel" + author: "Tension" + email: "tension@ppanel.com" + version: "0.0.1" +) + +import "../types.api" + +type ( + GetInviteManageListRequest { + Page int `form:"page"` + Size int `form:"size"` + Search string `form:"search"` + InviterId int64 `form:"inviter_id"` + InviteeId int64 `form:"invitee_id"` + } + InviteManageRecord { + InviterId int64 `json:"inviter_id"` + InviterIdentifier string `json:"inviter_identifier"` + InviterDeviceNo string `json:"inviter_device_no"` + InviteeId int64 `json:"invitee_id"` + InviteeIdentifier string `json:"invitee_identifier"` + InviteeDeviceNo string `json:"invitee_device_no"` + InviteeAvatar string `json:"invitee_avatar"` + InviteeEnable bool `json:"invitee_enable"` + InvitedAt int64 `json:"invited_at"` + OrderCount int64 `json:"order_count"` + HasPurchased bool `json:"has_purchased"` + InviterCommission int64 `json:"inviter_commission"` + InviterGiftDays int64 `json:"inviter_gift_days"` + InviteeGiftDays int64 `json:"invitee_gift_days"` + } + GetInviteManageListResponse { + Total int64 `json:"total"` + List []InviteManageRecord `json:"list"` + } +) + +@server ( + prefix: v1/admin/invite + group: admin/invite + middleware: AuthMiddleware +) +service ppanel { + @doc "Get invite manage list" + @handler GetInviteManageList + get /list (GetInviteManageListRequest) returns (GetInviteManageListResponse) +} diff --git a/apis/admin/promo.api b/apis/admin/promo.api new file mode 100644 index 0000000..ecf9575 --- /dev/null +++ b/apis/admin/promo.api @@ -0,0 +1,122 @@ +syntax = "v1" + +info ( + title: "promo admin API" + desc: "API for ppanel" + author: "Tension" + email: "tension@ppanel.com" + version: "0.0.1" +) + +import "../types.api" + +type ( + CreatePromoRuleRequest { + Name string `json:"name" validate:"required,max=100"` + Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority" validate:"gte=0"` + Enabled *bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` + } + UpdatePromoRuleRequest { + Id int64 `uri:"id" validate:"required,gt=0"` + Name string `json:"name" validate:"required,max=100"` + Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority" validate:"gte=0"` + Enabled *bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` + } + GetPromoRuleDetailRequest { + Id int64 `uri:"id" validate:"required,gt=0"` + } + DeletePromoRuleRequest { + Id int64 `uri:"id" validate:"required,gt=0"` + } + GetPromoRuleListRequest { + Page int64 `form:"page" validate:"required,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` + Type string `form:"type" validate:"omitempty,oneof=new_user inactive_user campaign"` + Enabled *bool `form:"enabled"` + Search string `form:"search,omitempty"` + } + GetPromoRuleListResponse { + Total int64 `json:"total"` + List []PromoRule `json:"list"` + } + SetPromoPriceRequest { + PromoRuleId int64 `json:"promo_rule_id" validate:"required,gt=0"` + Items []PromoPriceItem `json:"items" validate:"required,min=1,dive"` + } + GetPromoPriceListRequest { + Page int64 `form:"page" validate:"required,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` + RuleId int64 `form:"rule_id,omitempty"` + SubscribeId int64 `form:"subscribe_id,omitempty"` + } + GetPromoPriceListResponse { + Total int64 `json:"total"` + List []PromoPrice `json:"list"` + } + DeletePromoPriceRequest { + Id int64 `uri:"id" validate:"required,gt=0"` + } + GetPromoUsageListRequest { + Page int64 `form:"page" validate:"required,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` + RuleId int64 `form:"rule_id,omitempty"` + UserId int64 `form:"user_id,omitempty"` + SubscribeId int64 `form:"subscribe_id,omitempty"` + OrderNo string `form:"order_no,omitempty"` + } + GetPromoUsageListResponse { + Total int64 `json:"total"` + List []PromoUsage `json:"list"` + } +) + +@server ( + prefix: v1/admin/promo + group: admin/promo + middleware: AuthMiddleware +) +service ppanel { + @doc "Create promo rule" + @handler CreateRule + post /rule (CreatePromoRuleRequest) returns (PromoRule) + + @doc "Get promo rule list" + @handler GetRuleList + get /rule/list (GetPromoRuleListRequest) returns (GetPromoRuleListResponse) + + @doc "Get promo rule detail" + @handler GetRuleDetail + get /rule/:id (GetPromoRuleDetailRequest) returns (PromoRule) + + @doc "Update promo rule" + @handler UpdateRule + put /rule/:id (UpdatePromoRuleRequest) returns (PromoRule) + + @doc "Delete promo rule" + @handler DeleteRule + delete /rule/:id (DeletePromoRuleRequest) + + @doc "Set promo prices" + @handler SetPrice + post /price (SetPromoPriceRequest) + + @doc "Get promo price list" + @handler GetPriceList + get /price/list (GetPromoPriceListRequest) returns (GetPromoPriceListResponse) + + @doc "Delete promo price" + @handler DeletePrice + delete /price/:id (DeletePromoPriceRequest) + + @doc "Get promo usage list" + @handler GetUsageList + get /usage/list (GetPromoUsageListRequest) returns (GetPromoUsageListResponse) +} diff --git a/apis/admin/subscribe.api b/apis/admin/subscribe.api index 8a662b8..19c9dc3 100644 --- a/apis/admin/subscribe.api +++ b/apis/admin/subscribe.api @@ -46,6 +46,7 @@ type ( SpeedLimit int64 `json:"speed_limit"` DeviceLimit int64 `json:"device_limit"` Quota int64 `json:"quota"` + NewUserOnly *bool `json:"new_user_only"` Nodes []int64 `json:"nodes"` NodeTags []string `json:"node_tags"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"` @@ -74,6 +75,7 @@ type ( SpeedLimit int64 `json:"speed_limit"` DeviceLimit int64 `json:"device_limit"` Quota int64 `json:"quota"` + NewUserOnly *bool `json:"new_user_only"` Nodes []int64 `json:"nodes"` NodeTags []string `json:"node_tags"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"` @@ -175,4 +177,3 @@ service ppanel { @handler ResetAllSubscribeToken post /reset_all_token returns (ResetAllSubscribeTokenResponse) } - diff --git a/apis/admin/user.api b/apis/admin/user.api index bb585ff..ce66cf2 100644 --- a/apis/admin/user.api +++ b/apis/admin/user.api @@ -23,10 +23,12 @@ type ( SubscribeId *int64 `form:"subscribe_id,omitempty"` UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"` ShortCode string `form:"short_code,omitempty"` + DeviceId *int64 `form:"device_id,omitempty"` FamilyJoined *bool `form:"family_joined,omitempty"` FamilyStatus string `form:"family_status,omitempty"` FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"` FamilyId *int64 `form:"family_id,omitempty"` + SortOrder string `form:"sort_order,omitempty"` } // GetUserListResponse GetUserListResponse { @@ -76,29 +78,6 @@ type ( GiftAmount int64 `json:"gift_amount"` IsAdmin bool `json:"is_admin"` } - UserSubscribeDetail { - Id int64 `json:"id"` - UserId int64 `json:"user_id"` - User User `json:"user"` - OrderId int64 `json:"order_id"` - SubscribeId int64 `json:"subscribe_id"` - Subscribe Subscribe `json:"subscribe"` - NodeGroupId int64 `json:"node_group_id"` - GroupLocked bool `json:"group_locked"` - StartTime int64 `json:"start_time"` - ExpireTime int64 `json:"expire_time"` - ResetTime int64 `json:"reset_time"` - Traffic int64 `json:"traffic"` - Download int64 `json:"download"` - Upload int64 `json:"upload"` - Token string `json:"token"` - Status uint8 `json:"status"` - EffectiveSpeed int64 `json:"effective_speed"` - IsThrottled bool `json:"is_throttled"` - ThrottleRule string `json:"throttle_rule,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - } BatchDeleteUserRequest { Ids []int64 `json:"ids" validate:"required"` } @@ -158,18 +137,22 @@ type ( Total int64 `json:"total"` } CreateUserSubscribeRequest { - UserId int64 `json:"user_id"` - ExpiredAt int64 `json:"expired_at"` - Traffic int64 `json:"traffic"` - SubscribeId int64 `json:"subscribe_id"` + UserId int64 `json:"user_id"` + ExpiredAt int64 `json:"expired_at"` + Traffic int64 `json:"traffic"` + SubscribeId int64 `json:"subscribe_id"` + SpeedLimit int64 `json:"speed_limit,optional"` + TrafficLimit string `json:"traffic_limit,optional"` } UpdateUserSubscribeRequest { - UserSubscribeId int64 `json:"user_subscribe_id"` - SubscribeId int64 `json:"subscribe_id"` - Traffic int64 `json:"traffic"` - ExpiredAt int64 `json:"expired_at"` - Upload int64 `json:"upload"` - Download int64 `json:"download"` + UserSubscribeId int64 `json:"user_subscribe_id"` + SubscribeId int64 `json:"subscribe_id"` + Traffic int64 `json:"traffic"` + ExpiredAt int64 `json:"expired_at"` + Upload int64 `json:"upload"` + Download int64 `json:"download"` + SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"` + TrafficLimit []TrafficLimit `json:"traffic_limit,omitempty"` } GetUserLoginLogsRequest { Page int `form:"page"` @@ -248,6 +231,40 @@ type ( WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` Reason string `json:"reason" validate:"required,max=500"` } + GetAdminUserInviteStatsRequest { + UserId int64 `form:"user_id" validate:"required"` + } + GetAdminUserInviteStatsResponse { + InviteCount int64 `json:"invite_count"` + TotalCommission int64 `json:"total_commission"` + CurrentCommission int64 `json:"current_commission"` + ReferralPercentage uint8 `json:"referral_percentage"` + OnlyFirstPurchase bool `json:"only_first_purchase"` + } + GetAdminUserInviteListRequest { + UserId int64 `form:"user_id" validate:"required"` + Page int `form:"page"` + Size int `form:"size"` + Search string `form:"search"` + Enable *int `form:"enable"` + UserIdSearch int64 `form:"user_id_search"` + } + AdminInvitedUser { + Id int64 `json:"id"` + Avatar string `json:"avatar"` + Identifier string `json:"identifier"` + Enable bool `json:"enable"` + CreatedAt int64 `json:"created_at"` + OrderCount int64 `json:"order_count"` + HasPurchased bool `json:"has_purchased"` + InviterCommission int64 `json:"inviter_commission"` + InviterGiftDays int64 `json:"inviter_gift_days"` + InviteeGiftDays int64 `json:"invitee_gift_days"` + } + GetAdminUserInviteListResponse { + Total int64 `json:"total"` + List []AdminInvitedUser `json:"list"` + } ) @server ( @@ -400,4 +417,12 @@ service ppanel { @doc "Reject withdrawal" @handler RejectWithdrawal post /withdrawal/reject (RejectWithdrawalRequest) + + @doc "Get admin user invite stats" + @handler GetAdminUserInviteStats + get /invite/stats (GetAdminUserInviteStatsRequest) returns (GetAdminUserInviteStatsResponse) + + @doc "Get admin user invite list" + @handler GetAdminUserInviteList + get /invite/list (GetAdminUserInviteListRequest) returns (GetAdminUserInviteListResponse) } diff --git a/apis/node/node.api b/apis/node/node.api index 8dee713..e028a37 100644 --- a/apis/node/node.api +++ b/apis/node/node.api @@ -64,6 +64,8 @@ type ( ServerUser { Id int64 `json:"id"` UUID string `json:"uuid"` + // SpeedLimit 单位为 Mbps,0 表示不限速。 + // 节点端 (V2bX/XrayR 等) 按 Mbps 解释该值,服务端透传不做单位换算。 SpeedLimit int64 `json:"speed_limit"` DeviceLimit int64 `json:"device_limit"` } @@ -111,7 +113,7 @@ type ( @server ( prefix: v1/server - group: server + group: node/server middleware: ServerMiddleware ) service ppanel { @@ -138,11 +140,10 @@ service ppanel { @server ( prefix: v2/server - group: server + group: node/server ) service ppanel { @doc "Get Server Protocol Config" @handler QueryServerProtocolConfig get /:server_id (QueryServerConfigRequest) returns (QueryServerConfigResponse) } - diff --git a/apis/public/file.api b/apis/public/file.api index a9b79ba..b2cd9ca 100644 --- a/apis/public/file.api +++ b/apis/public/file.api @@ -16,13 +16,7 @@ type ( } FileUploadResponse { - FileId string `json:"file_id"` - FileName string `json:"file_name"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` + Url string `json:"url"` } FileUploadInitRequest { @@ -47,12 +41,7 @@ type ( } FileUploadCompleteResponse { - FileId string `json:"file_id"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` + Url string `json:"url"` } ) diff --git a/apis/public/user.api b/apis/public/user.api index f88fc26..e7968c4 100644 --- a/apis/public/user.api +++ b/apis/public/user.api @@ -117,6 +117,7 @@ type ( } WithdrawalLog { Id int64 `json:"id"` + BizType string `json:"biz_type"` UserId int64 `json:"user_id"` Amount int64 `json:"amount"` Content string `json:"content"` @@ -132,12 +133,39 @@ type ( WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` } QueryWithdrawalLogListRequest { + Page int `form:"page"` + Size int `form:"size"` + BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"` + } + WithdrawalLogSummary { + CommissionBalance int64 `json:"commission_balance"` + LockedByPending int64 `json:"locked_by_pending"` + AvailableToWithdraw int64 `json:"available_to_withdraw"` + TotalHistoricalAmount int64 `json:"total_historical_amount"` + TotalRefundedAmount int64 `json:"total_refunded_amount"` + TotalIncomeAmount int64 `json:"total_income_amount"` + } + QueryWithdrawalLogListResponse { + List []WithdrawalLog `json:"list"` + Total int64 `json:"total"` + Summary *WithdrawalLogSummary `json:"summary,omitempty"` + } + QueryCommissionReturnLogRequest { Page int `form:"page"` Size int `form:"size"` } - QueryWithdrawalLogListResponse { - List []WithdrawalLog `json:"list"` - Total int64 `json:"total"` + CommissionReturnLog { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + Amount int64 `json:"amount"` + EventType uint16 `json:"event_type"` + Content string `json:"content"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + QueryCommissionReturnLogResponse { + List []CommissionReturnLog `json:"list"` + Total int64 `json:"total"` } GetDeviceOnlineStatsResponse { WeeklyStats []WeeklyStat `json:"weekly_stats"` @@ -201,6 +229,23 @@ type ( GrowthRate string `json:"growth_rate"` PaidGrowthRate string `json:"paid_growth_rate"` } + GetInviteRecordsRequest { + Page int `form:"page"` + Size int `form:"size"` + StartTime int64 `form:"start_time"` + EndTime int64 `form:"end_time"` + } + InviteRecord { + Role string `json:"role"` + PeerHash string `json:"peer_hash"` + GiftDays int64 `json:"gift_days"` + OrderNo string `json:"order_no"` + CreatedAt int64 `json:"created_at"` + } + GetInviteRecordsResponse { + Total int64 `json:"total"` + List []InviteRecord `json:"list"` + } GetInviteSalesRequest { Page int `form:"page"` Size int `form:"size"` @@ -365,10 +410,14 @@ service ppanel { @handler CancelWithdrawal post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog) - @doc "Query Withdrawal Log" + @doc "Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log)" @handler QueryWithdrawalLog get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse) + @doc "Query Commission Return Log" + @handler QueryCommissionReturnLog + get /commission_return_log (QueryCommissionReturnLogRequest) returns (QueryCommissionReturnLogResponse) + @doc "Device Online Statistics" @handler DeviceOnlineStatistics get /device_online_statistics returns (GetDeviceOnlineStatsResponse) @@ -397,6 +446,10 @@ service ppanel { @handler GetAgentRealtime get /agent_realtime (GetAgentRealtimeRequest) returns (GetAgentRealtimeResponse) + @doc "Get Invite Records" + @handler GetInviteRecords + get /invite_records (GetInviteRecordsRequest) returns (GetInviteRecordsResponse) + @doc "Get Invite Sales" @handler GetInviteSales get /invite_sales (GetInviteSalesRequest) returns (GetInviteSalesResponse) diff --git a/apis/types.api b/apis/types.api index 88a347a..f3bb0b5 100644 --- a/apis/types.api +++ b/apis/types.api @@ -160,24 +160,26 @@ type ( OnlyRealDevice bool `json:"only_real_device"` } RegisterConfig { - StopRegister bool `json:"stop_register"` - EnableTrial bool `json:"enable_trial"` - TrialSubscribe int64 `json:"trial_subscribe"` - TrialTime int64 `json:"trial_time"` - TrialTimeUnit string `json:"trial_time_unit"` - EnableIpRegisterLimit bool `json:"enable_ip_register_limit"` - IpRegisterLimit int64 `json:"ip_register_limit"` - IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"` - DeviceLimit int64 `json:"device_limit"` + StopRegister bool `json:"stop_register"` + EnableTrial bool `json:"enable_trial"` + EnableTrialEmailWhitelist bool `json:"enable_trial_email_whitelist"` + TrialSubscribe int64 `json:"trial_subscribe"` + TrialTime int64 `json:"trial_time"` + TrialTimeUnit string `json:"trial_time_unit"` + TrialEmailDomainWhitelist string `json:"trial_email_domain_whitelist"` + EnableIpRegisterLimit bool `json:"enable_ip_register_limit"` + IpRegisterLimit int64 `json:"ip_register_limit"` + IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"` + DeviceLimit int64 `json:"device_limit"` } VerifyConfig { - CaptchaType string `json:"captcha_type"` // local or turnstile - TurnstileSiteKey string `json:"turnstile_site_key"` - TurnstileSecret string `json:"turnstile_secret"` - EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` // User login captcha - EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` // User register captcha - EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` // Admin login captcha - EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` // User reset password captcha + CaptchaType string `json:"captcha_type"` // local or turnstile + TurnstileSiteKey string `json:"turnstile_site_key"` + TurnstileSecret string `json:"turnstile_secret"` + EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` // User login captcha + EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` // User register captcha + EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` // Admin login captcha + EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` // User reset password captcha } NodeConfig { NodeSecret string `json:"node_secret"` @@ -226,9 +228,52 @@ type ( CurrencySymbol string `json:"currency_symbol"` } SubscribeDiscount { - Quantity int64 `json:"quantity"` - Discount float64 `json:"discount"` - MapApple string `json:"map_apple"` + Quantity int64 `json:"quantity"` + Discount float64 `json:"discount"` + NewUserOnly bool `json:"new_user_only"` + MapApple string `json:"map_apple"` + Promo *SubscribePromo `json:"promo"` + } + PromoPrice { + Id int64 `json:"id"` + SubscribeId int64 `json:"subscribe_id"` + PromoRuleId int64 `json:"promo_rule_id"` + Quantity int64 `json:"quantity"` + PromoPrice int64 `json:"promo_price"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + PromoPriceItem { + SubscribeId int64 `json:"subscribe_id" validate:"required,gt=0"` + Quantity int64 `json:"quantity" validate:"required,gt=0,lte=1000"` + PromoPrice int64 `json:"promo_price" validate:"required,gt=0"` + } + PromoRule { + Id int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority"` + Enabled bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + PromoUsage { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + PromoRuleId int64 `json:"promo_rule_id"` + SubscribeId int64 `json:"subscribe_id"` + OrderNo string `json:"order_no"` + PromoPrice int64 `json:"promo_price"` + CreatedAt int64 `json:"created_at"` + } + SubscribePromo { + RuleName string `json:"rule_name"` + RuleType string `json:"rule_type"` + PromoPrice int64 `json:"promo_price"` + ExpiresAt int64 `json:"expires_at"` } TrafficLimit { StatType string `json:"stat_type"` @@ -251,6 +296,7 @@ type ( SpeedLimit int64 `json:"speed_limit"` DeviceLimit int64 `json:"device_limit"` Quota int64 `json:"quota"` + NewUserOnly bool `json:"new_user_only"` Nodes []int64 `json:"nodes"` NodeTags []string `json:"node_tags"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"` @@ -515,10 +561,13 @@ type ( } UserSubscribe { Id int64 `json:"id"` + IdStr string `json:"id_str"` UserId int64 `json:"user_id"` OrderId int64 `json:"order_id"` SubscribeId int64 `json:"subscribe_id"` Subscribe Subscribe `json:"subscribe"` + NodeGroupId int64 `json:"node_group_id"` + NodeGroupName string `json:"node_group_name"` StartTime int64 `json:"start_time"` ExpireTime int64 `json:"expire_time"` FinishedAt int64 `json:"finished_at"` @@ -526,6 +575,7 @@ type ( Traffic int64 `json:"traffic"` Download int64 `json:"download"` Upload int64 `json:"upload"` + TrafficLimit []TrafficLimit `json:"user_traffic_limit"` Token string `json:"token"` Status uint8 `json:"status"` EntitlementSource string `json:"entitlement_source"` @@ -536,6 +586,35 @@ type ( CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } + UserSubscribeDetail { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + User User `json:"user"` + OrderId int64 `json:"order_id"` + SubscribeId int64 `json:"subscribe_id"` + Subscribe Subscribe `json:"subscribe"` + NodeGroupId int64 `json:"node_group_id"` + NodeGroupName string `json:"node_group_name"` + GroupLocked bool `json:"group_locked"` + StartTime int64 `json:"start_time"` + ExpireTime int64 `json:"expire_time"` + ResetTime int64 `json:"reset_time"` + Traffic int64 `json:"traffic"` + Download int64 `json:"download"` + Upload int64 `json:"upload"` + SpeedLimit int64 `json:"speed_limit"` + TrafficLimit []TrafficLimit `json:"user_traffic_limit"` + PlanSpeedLimit int64 `json:"plan_speed_limit"` + Token string `json:"token"` + Status uint8 `json:"status"` + EffectiveSpeed int64 `json:"effective_speed"` + IsThrottled bool `json:"is_throttled"` + ThrottleRule string `json:"throttle_rule,omitempty"` + ThrottleStart int64 `json:"throttle_start,omitempty"` + ThrottleEnd int64 `json:"throttle_end,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } UserAffiliate { Avatar string `json:"avatar"` Identifier string `json:"identifier"` @@ -679,6 +758,7 @@ type ( Price int64 `json:"price"` Amount int64 `json:"amount"` Discount int64 `json:"discount"` + PromoDiscount int64 `json:"promo_discount"` GiftAmount int64 `json:"gift_amount"` Coupon string `json:"coupon"` CouponDiscount int64 `json:"coupon_discount"` @@ -834,8 +914,9 @@ type ( Sandbox *bool `json:"sandbox,omitempty"` } AttachAppleTransactionResponse { - ExpiresAt int64 `json:"expires_at"` - Tier string `json:"tier"` + ExpiresAt int64 `json:"expires_at"` + Tier string `json:"tier"` + ExistingOrderNo string `json:"existing_order_no,omitempty"` } RestoreAppleTransactionsRequest { Transactions []string `json:"transactions" validate:"required"` diff --git a/common.json b/common.json index edb009d..3acfcaa 100644 --- a/common.json +++ b/common.json @@ -4316,6 +4316,9 @@ "discount": { "type": "number", "format": "double" + }, + "promo": { + "$ref": "#/definitions/SubscribePromo" } }, "title": "SubscribeDiscount", diff --git a/deliverables/ec2-ssh/README.txt b/deliverables/ec2-ssh/README.txt deleted file mode 100644 index 114b4ae..0000000 --- a/deliverables/ec2-ssh/README.txt +++ /dev/null @@ -1,20 +0,0 @@ -EC2 SSH 连接资料 - -服务器名称: hifast-hk-app-01 -公网 IP: 43.198.248.161 -登录用户: ubuntu - -私钥文件: -- hifast-hk-app-01-reset - -公钥文件: -- hifast-hk-app-01-reset.pub - -连接命令: -ssh -i hifast-hk-app-01-reset ubuntu@43.198.248.161 - -如果在 Mac / Linux 上使用,先执行: -chmod 600 hifast-hk-app-01-reset - -如果要给别人使用,只需要把私钥文件 hifast-hk-app-01-reset 发给对方即可。 -出于安全考虑,建议通过安全渠道传输,并在后续需要时重新轮换密钥。 diff --git a/deliverables/ec2-ssh/hifast-hk-app-01-reset b/deliverables/ec2-ssh/hifast-hk-app-01-reset deleted file mode 100644 index 28b76e8..0000000 --- a/deliverables/ec2-ssh/hifast-hk-app-01-reset +++ /dev/null @@ -1,8 +0,0 @@ ------BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCgAAAKjy5KDa8uSg -2gAAAAtzc2gtZWQyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCg -AAAEDwSb0b/0S6Tw8Od5hAtIKqt1JvomqQQS44Ty3xL+FjPtvqawOqZIcu/SZaAI/i9+ND -cDnnaq/3m5B2wf3hGegKAAAAIWhpZmFzdC1oay1hcHAtMDEtcmVzZXQtMjAyNi0wNS0xMA -ECAwQ= ------END OPENSSH PRIVATE KEY----- diff --git a/deliverables/ec2-ssh/hifast-hk-app-01-reset.pub b/deliverables/ec2-ssh/hifast-hk-app-01-reset.pub deleted file mode 100644 index 7a96127..0000000 --- a/deliverables/ec2-ssh/hifast-hk-app-01-reset.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvqawOqZIcu/SZaAI/i9+NDcDnnaq/3m5B2wf3hGegK hifast-hk-app-01-reset-2026-05-10 diff --git a/deploy/aws/ap-east-1/README.md b/deploy/aws/ap-east-1/README.md deleted file mode 100644 index cc77a0b..0000000 --- a/deploy/aws/ap-east-1/README.md +++ /dev/null @@ -1,363 +0,0 @@ -# PPanel 香港区新 AWS 账号部署说明 - -本目录用于在 **新 AWS 账号** 中按 **香港区 `ap-east-1`** 重建一套全新空环境。 - -目标架构: - -`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL` - -## 1. 资源清单 - -按下面顺序创建资源: - -1. VPC -2. 2 个公有子网 + 2 个私有子网 -3. Internet Gateway -4. 公有 / 私有路由表 -5. 安全组 -6. RDS MySQL -7. EC2 本机 Redis Docker -8. EC2 -9. ACM 证书 -10. ALB + Target Group -11. WAF Web ACL -12. 平行环境域名 - -建议命名: - -- VPC: `ppanel-hk-prod` -- EC2: `ppanel-app-hk-01` -- RDS: `ppanel-mysql-hk` -- Redis container: `hifast-redis` -- ALB: `ppanel-alb-hk` -- WAF: `ppanel-waf-hk` - -## 2. 默认规格 - -### EC2 - -- Region: `ap-east-1` -- OS: Ubuntu 24.04 LTS -- Instance type: `t4g.large` 起步 -- Disk: `gp3 80GB` -- Public subnet: 是 -- IAM Role: 允许读取 CloudWatch / SSM(如使用) -- 如果要在 AWS EC2 本机执行 S3 备份:额外允许写入专用备份桶 - -### RDS MySQL - -- Engine: MySQL 8.0 -- Class: `db.r7g.xlarge` -- Storage: `gp3 100GB` -- DB name: `hifast` -- Username: `admin` -- Public access: `No` -- Charset: `utf8mb4` -- Backup: `7-14 days` - -### Redis - -- 部署位置:业务 EC2 本机 -- 部署方式:Docker -- 版本:`redis:8.2.1` -- 监听:`0.0.0.0:6379` -- 应用连接:`127.0.0.1:6379` -- 安全组:仅对白名单备用节点或同机应用开放 - -## 3. 网络与安全组 - -### 子网布局 - -- `public-a`, `public-b`: ALB / EC2 -- `private-a`, `private-b`: RDS - -### 安全组建议 - -#### `sg-alb` - -- Inbound - - `80/tcp` from `0.0.0.0/0` - - `443/tcp` from `0.0.0.0/0` -- Outbound - - `80/tcp` to `sg-ec2` - -#### `sg-ec2` - -- Inbound - - `80/tcp` from `sg-alb` - - `22/tcp` from `你的固定运维 IP` -- Outbound - - all - -说明: - -- 应用容器监听 `127.0.0.1:8080` -- EC2 对外只让 Nginx 监听 `80` -- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1` - -#### `sg-rds` - -- Inbound - - `3306/tcp` from `sg-ec2` - -#### `sg-ec2` 额外说明 - -- 如果需要外部备用节点复制 Redis,再额外放行: - - `6379/tcp` from `104.238.220.230/32` - -## 4. ALB / Target Group / 健康检查 - -### Target Group - -- Type: `Instance` -- Protocol: `HTTP` -- Port: `80` -- Health check path: `/v1/common/heartbeat` -- Success code: `200` - -这个路径已由项目现有接口提供,无需额外改代码。 - -### ALB 监听器 - -- `80` -> redirect to `443` -- `443` -> forward 到 target group - -### ACM - -- 在 `ap-east-1` 申请证书 -- 先给平行环境域名,例如: - - `api-new.hifast.biz` - - `logs-new.hifast.biz` - -## 5. WAF 规则 - -首版至少启用: - -1. `AWSManagedRulesCommonRuleSet` -2. `AWSManagedRulesKnownBadInputsRuleSet` -3. `AWSManagedRulesAmazonIpReputationList` -4. 全站 rate-based rule -5. 针对高风险路径的 rate-based rule - -建议的第一版限流: - -- 全站:每 IP `2000 / 5 分钟` -- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟` -- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟` - -节点上报接口建议后续补: - -- `/v1/server/status` -- `/v1/server/online` -- `/v1/server/traffic` - -优先用节点出口 IP 白名单;没有固定出口 IP 的节点暂时保留 `secret_key`,但不要把它当成唯一防线。 - -## 6. EC2 文件落地 - -在 EC2 上建议使用: - -- 应用目录:`/opt/ppanel` -- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf` - -需要上传这些文件 / 目录: - -- `docker-compose.cloud.yml` -- `deploy/aws/ap-east-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml` -- `deploy/aws/ap-east-1/nginx/ppanel-api.conf` -- `grafana/` -- `loki/` -- `prometheus/` -- `tempo/` -- `.env.example` -> 重命名为 `.env` - -目标目录示例: - -```text -/opt/ppanel/ - docker-compose.cloud.yml - .env - configs/ppanel.yaml - grafana/ - loki/ - prometheus/ - tempo/ - logs/ - cache/ - tempo_data/ -``` - -## 7. 应用配置 - -基线模板见: - -- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example) -- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf) - -关键值必须替换: - -- `MySQL.Addr` -- `MySQL.Password` -- `Redis.Host` -- `Redis.Pass` -- `JwtAuth.AccessSecret` -- `Administrator.Email` -- `Administrator.Password` -- `AppSignature.AppSecrets.*` -- `device.security_secret` -- `Site.Host` -- `Site.SiteName` - -Redis 约定保持不变: - -- 业务缓存:DB `0` -- Asynq:DB `5`(代码内部已固定使用) - -## 8. 部署步骤 - -### 8.1 初始化 EC2 - -把脚本上传到 EC2 后执行: - -## 9. 104 灾备节点常用运维脚本 - -如果你要在 `104.238.220.230` 上执行数据迁移、主从重拉、主库提升,可以直接复用仓库里的这几份脚本: - -- 数据导出 / 导入交互工具: - - [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh) -- MySQL 主从运维工具: - - [`deploy/scripts/mysql_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/mysql_replica_ops.sh) -- Redis 主从运维工具: - - [`deploy/scripts/redis_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/redis_replica_ops.sh) -- 统一总入口: - - [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh) -- 主从运维环境模板: - - [`deploy/aws/ap-east-1/configs/replica-ops.env.example`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/aws/ap-east-1/configs/replica-ops.env.example) - -### 9.1 数据迁移工具 - -支持: - -- 备份 MySQL 到 S3 -- 备份 Redis 到 S3 -- 从正式库导出 MySQL `sql.gz` -- 把 `sql.gz` 导入 AWS RDS -- 从正式 Redis 导出 `RDB` -- 将 `RDB` 导入 Docker Redis 或宿主机 Redis -- 查看 MySQL / Redis 当前主从状态 -- 强制重拉 MySQL / Redis 主从 -- 把 MySQL / Redis 从库提升为可写主库 - -示例: - -```bash -bash deploy/scripts/hifast_data_sync_tool.sh /root/replica-ops.env -``` - -```bash -chmod +x deploy/scripts/bootstrap_aws_ec2.sh -sudo APP_DIR=/opt/ppanel deploy/scripts/bootstrap_aws_ec2.sh -``` - -### 8.2 安装 Nginx 配置 - -```bash -sudo cp deploy/aws/ap-east-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf -sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf -sudo nginx -t -sudo systemctl reload nginx -``` - -### 8.3 启动容器 - -```bash -cd /opt/ppanel -docker compose -f docker-compose.cloud.yml up -d -``` - -### 8.4 预检 - -```bash -chmod +x deploy/scripts/preflight_aws_hk.sh -APP_DIR=/opt/ppanel \ -RDS_HOST= \ -REDIS_HOST=127.0.0.1 \ -deploy/scripts/preflight_aws_hk.sh -``` - -## 9. 平行环境验证 - -先验证 `api-new.hifast.biz`,不要直接切正式域名。 - -必测项: - -1. `ALB target` 为 healthy -2. `GET /v1/common/heartbeat` 返回 200 -3. 管理员登录 -4. 用户注册 / 登录 -5. 订阅查询 -6. 节点上报 `/v1/server/status` -7. 本机 Redis 可写缓存 -8. Asynq 可入队并消费 - -## 10. 正式切换 - -切换前检查: - -1. ALB 5xx 为 0 -2. EC2 CPU / Memory 正常 -3. RDS CPU / Connections 正常 -4. 本机 Redis CPU / Connections / Memory 正常 -5. WAF 已挂到 ALB -6. EC2 安全组没有对公网放 `8080/3333/9090/4317` - -切换方式: - -1. 保持新环境先跑平行域名 -2. 正式域名切到新 ALB -3. 观察至少 1 小时 -4. 确认无误后再处理旧环境 - -## 11. 监控建议 - -至少建这些 CloudWatch / Grafana 观测项: - -- ALB `RequestCount`, `HTTPCode_ELB_5XX_Count`, `TargetResponseTime` -- EC2 `CPUUtilization`, `NetworkIn`, `NetworkOut`, `StatusCheckFailed` -- RDS `CPUUtilization`, `DatabaseConnections`, `ReadLatency`, `WriteLatency` -- Redis 容器 CPU / Memory / restart count - -## 12. 这次方案的边界 - -本目录交付的是: - -- 香港区新账号的部署模板 -- 新空环境启动与验证流程 -- ALB / WAF / EC2 / RDS / 本机 Redis 的落地约定 - -不包含: - -- 旧数据迁移 -- Terraform / CloudFormation 自动建资源 -- Redis 托管版改造 -- 多活 / 自动扩缩容 - -## 13. S3 备份补强 - -当前已落地的 S3 备份桶: - -- `hifast-prod-backups-200810848252-ap-east-1` - -建议与现网结合方式: - -1. `RDS automated backup` 继续保留,作为第一层恢复能力 -2. `104` 外部 MySQL 从库执行逻辑备份并上传到 S3,作为第二层可下载备份 -3. `104` 外部 Redis 从库按需导出 `RDB` 到 S3,补齐缓存类灾备材料 - -仓库中已补充: - -- 环境变量模板:[`configs/backup-to-s3.env.example`](./configs/backup-to-s3.env.example) -- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh) -- Redis 备份脚本:[`../../scripts/redis_rdb_backup_to_s3.sh`](../../scripts/redis_rdb_backup_to_s3.sh) - -建议把 MySQL 备份脚本优先部署到 `104`,因为它直接连接本地只读从库,对 AWS 主库扰动最小。 diff --git a/deploy/aws/ap-east-1/configs/backup-to-s3.env.example b/deploy/aws/ap-east-1/configs/backup-to-s3.env.example deleted file mode 100644 index a45fc7e..0000000 --- a/deploy/aws/ap-east-1/configs/backup-to-s3.env.example +++ /dev/null @@ -1,18 +0,0 @@ -AWS_REGION=ap-east-1 -S3_BUCKET=hifast-prod-backups-200810848252-ap-east-1 -S3_PREFIX=mysql -BACKUP_DIR=/var/backups/hifast -HOST_TAG=104-standby -KEEP_LOCAL_DAYS=3 -CHECK_REPLICA=1 - -MYSQL_HOST=127.0.0.1 -MYSQL_PORT=3306 -MYSQL_USER=backup_reader -MYSQL_PASSWORD=CHANGE_ME -MYSQL_SOCKET= -MYSQL_DATABASE=hifast - -REDIS_HOST=127.0.0.1 -REDIS_PORT=6379 -REDIS_PASSWORD=CHANGE_ME diff --git a/deploy/aws/ap-east-1/configs/mysql-seed-primary-and-replica.env.example b/deploy/aws/ap-east-1/configs/mysql-seed-primary-and-replica.env.example deleted file mode 100644 index 38382f4..0000000 --- a/deploy/aws/ap-east-1/configs/mysql-seed-primary-and-replica.env.example +++ /dev/null @@ -1,20 +0,0 @@ -PRIMARY_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -PRIMARY_PORT=3306 -PRIMARY_USER=admin -PRIMARY_PASSWORD=CHANGE_ME -PRIMARY_DB=hifast - -PRIMARY_REPL_USER=repl -PRIMARY_REPL_PASSWORD=CHANGE_ME -PRIMARY_REPL_HOST=104.238.220.230 -PRIMARY_BINLOG_RETENTION_HOURS=24 - -REPLICA_HOST=127.0.0.1 -REPLICA_PORT=3306 -REPLICA_USER=root -REPLICA_PASSWORD= -REPLICA_SOCKET=/var/run/mysqld/mysqld.sock -REPLICA_DB=hifast -REPLICA_SOURCE_SSL=1 - -DUMP_FILE= diff --git a/deploy/aws/ap-east-1/configs/ppanel.yaml.example b/deploy/aws/ap-east-1/configs/ppanel.yaml.example deleted file mode 100644 index 8d16e5d..0000000 --- a/deploy/aws/ap-east-1/configs/ppanel.yaml.example +++ /dev/null @@ -1,111 +0,0 @@ -Host: 0.0.0.0 -Port: 8080 -Debug: false - -JwtAuth: - AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET - AccessExpire: 604800 - -Logger: - ServiceName: PPanel - Mode: console - Encoding: plain - TimeFormat: "2006-01-02 15:04:05.000" - Path: logs - Level: info - MaxContentLength: 0 - Compress: false - Stat: true - KeepDays: 7 - StackCooldownMillis: 100 - MaxBackups: 7 - MaxSize: 100 - Rotation: daily - FileTimeFormat: "2006-01-02T15:04:05.000Z07:00" - -MySQL: - Addr: YOUR_RDS_ENDPOINT:3306 - Dbname: hifast - Username: admin - Password: CHANGE_ME_TO_RDS_PASSWORD - Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai - MaxIdleConns: 10 - MaxOpenConns: 100 - SlowThreshold: 1000 - -Redis: - Host: 127.0.0.1:6379 - Pass: CHANGE_ME_TO_REDIS_PASSWORD - DB: 0 - PoolSize: 100 - MinIdleConns: 10 - MaxRetries: 3 - PoolTimeout: 4 - IdleTimeout: 300 - MaxConnAge: 0 - DialTimeout: 5 - ReadTimeout: 3 - WriteTimeout: 3 - -Trace: - Name: ppanel-server - Endpoint: 127.0.0.1:4317 - Sampler: 0.1 - Batcher: otlpgrpc - -Site: - Host: api-new.hifast.biz - SiteName: HiFastVPN - -Administrator: - Email: admin@example.com - Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD - -Telegram: - Enable: false - BotID: 0 - BotName: "" - BotToken: "" - GroupChatID: "" - EnableNotify: false - WebHookDomain: "" - -Kutt: - Enable: false - ApiURL: "" - ApiKey: "" - TargetURL: "" - Domain: "" - -OpenInstall: - Enable: false - AppKey: "" - ApiKey: "" - -Loki: - Enable: true - URL: "http://localhost:3100" - -AppSignature: - AppSecrets: - android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET - ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET - web-client: CHANGE_ME_WEB_SIGNATURE_SECRET - ValidWindowSeconds: 300 - SkipPrefixes: - - /v1/notify/ - - /v1/iap/notifications - - /v1/telegram/webhook - - /v1/subscribe/config - -Signature: - EnableSignature: false - -device: - enable: true - security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET - -Register: - EnableTrial: true - EnableTrialEmailWhitelist: true - TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com" diff --git a/deploy/aws/ap-east-1/configs/replica-ops.env.example b/deploy/aws/ap-east-1/configs/replica-ops.env.example deleted file mode 100644 index f6d7e59..0000000 --- a/deploy/aws/ap-east-1/configs/replica-ops.env.example +++ /dev/null @@ -1,23 +0,0 @@ -MYSQL_HOST=127.0.0.1 -MYSQL_PORT=3306 -MYSQL_USER=root -MYSQL_PASSWORD=CHANGE_ME -MYSQL_SOCKET= - -REPL_SOURCE_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -REPL_SOURCE_PORT=3306 -REPL_SOURCE_USER=repl -REPL_SOURCE_PASSWORD=CHANGE_ME -REPL_SOURCE_SSL=1 -REPL_SOURCE_LOG_FILE= -REPL_SOURCE_LOG_POS= -REPL_SOURCE_AUTO_POSITION=1 - -REDIS_HOST=127.0.0.1 -REDIS_PORT=6379 -REDIS_PASSWORD=CHANGE_ME - -REDIS_SOURCE_HOST=18.163.33.75 -REDIS_SOURCE_PORT=6379 -REDIS_SOURCE_USER= -REDIS_SOURCE_PASSWORD=CHANGE_ME diff --git a/deploy/aws/ap-east-1/nginx/ppanel-api.conf b/deploy/aws/ap-east-1/nginx/ppanel-api.conf deleted file mode 100644 index 868f4ee..0000000 --- a/deploy/aws/ap-east-1/nginx/ppanel-api.conf +++ /dev/null @@ -1,33 +0,0 @@ -server { - listen 80 default_server; - listen [::]:80 default_server; - server_name _; - - client_max_body_size 20m; - - access_log /var/log/nginx/ppanel-access.log; - error_log /var/log/nginx/ppanel-error.log warn; - - location / { - proxy_http_version 1.1; - proxy_pass http://127.0.0.1:8080; - - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - - proxy_connect_timeout 10s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - } - - location = /nginx_status { - stub_status; - access_log off; - allow 127.0.0.1; - deny all; - } -} diff --git a/deploy/aws/ap-northeast-1/README.md b/deploy/aws/ap-northeast-1/README.md deleted file mode 100644 index c4272ed..0000000 --- a/deploy/aws/ap-northeast-1/README.md +++ /dev/null @@ -1,358 +0,0 @@ -# PPanel 日本东京区 AWS 部署说明 - -本目录用于在 **AWS 日本东京区 `ap-northeast-1`** 重建一套全新生产环境,并承接当前香港区 `ap-east-1` 的正式迁移。 - -如果你要看“当前已经真实跑起来的东京架构”,优先看: - -- [`ops/hifast-current-architecture-zh.md`](/Users/Apple/code_vpn/vpn/ppanel-server/ops/hifast-current-architecture-zh.md) -- [`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md) - -这份 README 更偏向: - -- 目标架构 -- 资源规划 -- 部署方法 -- 后续待完成项 - -目标架构: - -`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL` - -灾备链路: - -`RDS MySQL / EC2 Redis -> 104.238.220.230 外部灾备` - -当前仓库内已补充: - -- 东京基础设施参数模板:[`configs/aws-jp-infra.env.example`](./configs/aws-jp-infra.env.example) -- 东京真实实施状态登记:[`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md) -- 东京底座资源创建脚本:[`../../scripts/aws_jp_create_base_infra.sh`](../../scripts/aws_jp_create_base_infra.sh) -- 东京资源状态检查脚本:[`../../scripts/aws_jp_describe_state.sh`](../../scripts/aws_jp_describe_state.sh) -- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh) -- 10 分钟 MySQL 备份定时器安装脚本:[`../../scripts/install_mysql_backup_timer.sh`](../../scripts/install_mysql_backup_timer.sh) - -## 1. 资源清单 - -按下面顺序创建资源: - -1. VPC -2. 2 个公有子网 + 2 个私有子网 -3. Internet Gateway -4. 公有 / 私有路由表 -5. 安全组 -6. RDS MySQL -7. EC2 本机 Redis Docker -8. EC2 -9. ACM 证书 -10. ALB + Target Group -11. WAF Web ACL -12. 东京平行环境域名 -13. 东京 S3 备份桶 - -建议命名: - -- VPC: `ppanel-jp-prod` -- EC2: `ppanel-app-jp-01` -- RDS: `ppanel-mysql-jp` -- Redis container: `hifast-redis` -- ALB: `ppanel-alb-jp` -- WAF: `ppanel-waf-jp` -- S3: `hifast-prod-backups-200810848252-ap-northeast-1` - -## 2. 默认规格 - -### EC2 - -- Region: `ap-northeast-1` -- OS: Ubuntu 24.04 LTS -- Instance type: `t4g.large` -- Disk: `gp3 80GB` -- Public subnet: 是 -- IAM Role: - - 允许读取 CloudWatch / SSM(如使用) - - 如果要在东京 EC2 上执行 S3 备份:额外允许写入东京备份桶 - -### RDS MySQL - -- Engine: `MySQL 8.4` -- Class: `db.r7g.xlarge` -- Storage: `gp3 100GB` -- DB name: `hifast` -- Username: `admin` -- Public access: `Yes` -- Charset: `utf8mb4` -- Backup retention: `7-14 days` -- Deletion protection: `On` -- Multi-AZ: `Yes`(当前按 2 实例 Multi-AZ 创建) - -说明: - -- 当前东京 RDS 需要允许 `104.238.220.230` 从公网直连 `3306`,用于外部 MySQL 从库复制 -- 因此本阶段 RDS 使用 `public subnet group + Publicly accessible = Yes` -- 访问面只通过 `sg-rds` 严格限制到业务 EC2 安全组和 `104.238.220.230/32` - -### Redis - -- 部署位置:业务 EC2 本机 -- 部署方式:Docker -- 版本:`redis:8.2.1` -- 监听:`0.0.0.0:6379` -- 应用连接:`127.0.0.1:6379` -- 安全组:仅对白名单备用节点 `104.238.220.230/32` 或同机应用开放 - -## 3. 网络与安全组 - -### 子网布局 - -- `public-a`, `public-c`: ALB / EC2 -- `private-a`, `private-c`: RDS - -说明: - -- 东京优先使用 `ap-northeast-1a` 和 `ap-northeast-1c` -- 如果账户映射不同,也可以用任意 2 个可用区,但公私网必须各 2 个子网 - -### 安全组建议 - -#### `sg-alb` - -- Inbound - - `80/tcp` from `0.0.0.0/0` - - `443/tcp` from `0.0.0.0/0` -- Outbound - - `80/tcp` to `sg-ec2` - -#### `sg-ec2` - -- Inbound - - `80/tcp` from `sg-alb` - - `22/tcp` from `你的固定运维 IP` - - `6379/tcp` from `104.238.220.230/32` -- Outbound - - all - -说明: - -- 应用容器监听 `127.0.0.1:8080` -- EC2 对外只让 Nginx 监听 `80` -- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1` - -#### `sg-rds` - -- Inbound - - `3306/tcp` from `sg-ec2` - - `3306/tcp` from `104.238.220.230/32` - -## 4. ALB / Target Group / 健康检查 - -### Target Group - -- Type: `Instance` -- Protocol: `HTTP` -- Port: `80` -- Health check path: `/v1/common/heartbeat` -- Success code: `200` - -### ALB 监听器 - -- `80` -> redirect to `443` -- `443` -> forward 到 target group - -### ACM - -- 在 `ap-northeast-1` 重新申请证书 -- 先给平行环境域名,例如: - - `api-jp.hifast.biz` - - `logs-jp.hifast.biz` - -## 5. WAF 规则 - -首版至少启用: - -1. `AWSManagedRulesCommonRuleSet` -2. `AWSManagedRulesKnownBadInputsRuleSet` -3. `AWSManagedRulesAmazonIpReputationList` -4. 全站 rate-based rule -5. 针对高风险路径的 rate-based rule - -建议的第一版限流: - -- 全站:每 IP `2000 / 5 分钟` -- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟` -- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟` - -节点上报接口建议后续补: - -- `/v1/server/status` -- `/v1/server/online` -- `/v1/server/traffic` - -## 6. EC2 文件落地 - -在 EC2 上建议使用: - -- 应用目录:`/opt/ppanel` -- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf` - -需要上传这些文件 / 目录: - -- `docker-compose.cloud.yml` -- `deploy/aws/ap-northeast-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml` -- `deploy/aws/ap-northeast-1/nginx/ppanel-api.conf` -- `grafana/` -- `loki/` -- `prometheus/` -- `tempo/` -- `.env.example` -> 重命名为 `.env` - -目标目录示例: - -```text -/opt/ppanel/ - docker-compose.cloud.yml - .env - configs/ppanel.yaml - grafana/ - loki/ - prometheus/ - tempo/ - logs/ - cache/ - tempo_data/ -``` - -## 7. 应用配置 - -基线模板见: - -- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example) -- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf) - -关键值必须替换: - -- `MySQL.Addr` -- `MySQL.Password` -- `Redis.Host` -- `Redis.Pass` -- `JwtAuth.AccessSecret` -- `Administrator.Email` -- `Administrator.Password` -- `AppSignature.AppSecrets.*` -- `device.security_secret` -- `Site.Host` -- `Site.SiteName` - -Redis 约定保持不变: - -- 业务缓存:DB `0` -- Asynq:DB `5` - -## 8. 部署步骤 - -### 8.0 创建东京基础设施 - -如果本机或跳板机已经配置好 AWS CLI 凭据,可以先直接执行: - -```bash -cp deploy/aws/ap-northeast-1/configs/aws-jp-infra.env.example /root/aws-jp-infra.env -chmod 600 /root/aws-jp-infra.env -vim /root/aws-jp-infra.env - -chmod +x deploy/scripts/aws_jp_create_base_infra.sh -bash deploy/scripts/aws_jp_create_base_infra.sh /root/aws-jp-infra.env -``` - -执行后可用下面命令随时核对东京底座状态: - -```bash -chmod +x deploy/scripts/aws_jp_describe_state.sh -bash deploy/scripts/aws_jp_describe_state.sh /root/aws-jp-infra.env -``` - -### 8.1 初始化 EC2 - -把脚本上传到东京 EC2 后执行: - -```bash -chmod +x deploy/scripts/bootstrap_aws_ec2.sh -sudo APP_DIR=/opt/ppanel APP_USER=ubuntu deploy/scripts/bootstrap_aws_ec2.sh -``` - -### 8.2 安装 Nginx 配置 - -```bash -sudo cp deploy/aws/ap-northeast-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf -sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf -sudo nginx -t -sudo systemctl reload nginx -``` - -### 8.3 启动容器 - -```bash -cd /opt/ppanel -docker compose -f docker-compose.cloud.yml up -d -``` - -### 8.4 预检 - -```bash -chmod +x deploy/scripts/preflight_aws_jp.sh -APP_DIR=/opt/ppanel \ -RDS_HOST= \ -REDIS_HOST=127.0.0.1 \ -deploy/scripts/preflight_aws_jp.sh -``` - -## 9. 数据迁移与切换 - -正式迁移请按: - -- [`ops/hifast-aws-jp-migration-runbook-zh.md`](/Users/Apple/code_vpn/vpn/ppanel-server/ops/hifast-aws-jp-migration-runbook-zh.md) - -执行。 - -核心原则: - -- 先搭平行环境 -- 停机后再导出香港主数据 -- 东京验收通过后再切正式域名 -- 切换后再重挂 `104` 灾备 - -## 10. 104 灾备节点常用模板 - -如果迁移完成后要把 `104.238.220.230` 重挂为东京主站从库,可复用: - -- [`deploy/scripts/hifast_mysql_seed_primary_and_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_mysql_seed_primary_and_replica.sh) -- [`deploy/scripts/hifast_mysql_attach_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_mysql_attach_replica.sh) -- [`deploy/scripts/hifast_redis_attach_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_redis_attach_replica.sh) -- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh) -- [`configs/replica-ops.env.example`](./configs/replica-ops.env.example) - -## 11. 东京资源创建前置检查 - -在 AWS 控制台里至少先确认: - -- 东京区已启用 -- `ap-northeast-1` 可创建 `t4g.large` -- `ap-northeast-1` RDS 可创建 `db.r7g.xlarge` -- ACM / ALB / WAF / S3 服务在东京区可正常使用 -- Tokyo 对应配额满足: - - On-Demand Standard vCPU - - ALB 数量 - - Elastic IP(如需) - - RDS 实例数 - -## 12. 当前已知真实进度 - -截至 `2026-05-20`,已知状态如下: - -- 东京 VPC `ppanel-jp-prod` 已创建 -- VPC ID: `vpc-0846b23b4a7d64eac` -- VPC CIDR: `10.20.0.0/16` -- 4 个子网在 AWS 控制台里曾填写完成,但提交时控制台 session 失效 -- 因此: - - 子网是否真正创建成功,需要重新核实 - - IGW / 路由表 / 安全组 / RDS / EC2 / ALB / WAF 都应按“未完成”处理,重新复核 - -实时状态请以后续更新的 [`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md) 为准。 diff --git a/deploy/aws/ap-northeast-1/configs/aws-jp-infra.env.example b/deploy/aws/ap-northeast-1/configs/aws-jp-infra.env.example deleted file mode 100644 index 583b37d..0000000 --- a/deploy/aws/ap-northeast-1/configs/aws-jp-infra.env.example +++ /dev/null @@ -1,55 +0,0 @@ -AWS_REGION=ap-northeast-1 -AWS_ACCOUNT_ID=200810848252 - -VPC_NAME=ppanel-jp-prod -VPC_ID= -VPC_CIDR=10.20.0.0/16 - -PUBLIC_SUBNET_A_NAME=ppanel-jp-public-a -PUBLIC_SUBNET_A_AZ=ap-northeast-1a -PUBLIC_SUBNET_A_CIDR=10.20.0.0/24 - -PUBLIC_SUBNET_C_NAME=ppanel-jp-public-c -PUBLIC_SUBNET_C_AZ=ap-northeast-1c -PUBLIC_SUBNET_C_CIDR=10.20.1.0/24 - -PRIVATE_SUBNET_A_NAME=ppanel-jp-private-a -PRIVATE_SUBNET_A_AZ=ap-northeast-1a -PRIVATE_SUBNET_A_CIDR=10.20.10.0/24 - -PRIVATE_SUBNET_C_NAME=ppanel-jp-private-c -PRIVATE_SUBNET_C_AZ=ap-northeast-1c -PRIVATE_SUBNET_C_CIDR=10.20.11.0/24 - -IGW_NAME=ppanel-jp-igw -PUBLIC_ROUTE_TABLE_NAME=ppanel-jp-public-rt -PRIVATE_ROUTE_TABLE_NAME=ppanel-jp-private-rt - -SG_ALB_NAME=ppanel-jp-sg-alb -SG_EC2_NAME=ppanel-jp-sg-ec2 -SG_RDS_NAME=ppanel-jp-sg-rds - -OPS_SSH_CIDR=CHANGE_ME_TO_YOUR_FIXED_PUBLIC_IP_OR_CIDR -DR_REPLICA_IP=104.238.220.230/32 - -EC2_NAME=ppanel-app-jp-01 -EC2_AMI_FAMILY=ubuntu-24.04 -EC2_INSTANCE_TYPE=t4g.large -EC2_DISK_GB=80 -EC2_KEY_PAIR=CHANGE_ME - -RDS_IDENTIFIER=ppanel-mysql-jp -RDS_DB_NAME=hifast -RDS_ADMIN_USER=admin -RDS_INSTANCE_CLASS=db.r7g.xlarge -RDS_STORAGE_GB=100 - -ALB_NAME=ppanel-alb-jp -TARGET_GROUP_NAME=ppanel-tg-jp -WAF_NAME=ppanel-waf-jp - -PARALLEL_API_DOMAIN=api-jp.hifast.biz -PARALLEL_LOGS_DOMAIN=logs-jp.hifast.biz -PRODUCTION_API_DOMAIN=CHANGE_ME - -S3_BACKUP_BUCKET=hifast-prod-backups-200810848252-ap-northeast-1 diff --git a/deploy/aws/ap-northeast-1/configs/backup-to-s3.env.example b/deploy/aws/ap-northeast-1/configs/backup-to-s3.env.example deleted file mode 100644 index 1a72193..0000000 --- a/deploy/aws/ap-northeast-1/configs/backup-to-s3.env.example +++ /dev/null @@ -1,19 +0,0 @@ -AWS_REGION=ap-northeast-1 -S3_BUCKET=hifast-prod-backups-200810848252-ap-northeast-1 -S3_PREFIX=mysql -BACKUP_DIR=/var/backups/hifast -HOST_TAG=104-standby-for-jp -KEEP_LOCAL_DAYS=3 -CHECK_REPLICA=1 - -MYSQL_HOST=127.0.0.1 -MYSQL_PORT=3306 -MYSQL_USER=backup_reader -MYSQL_PASSWORD=CHANGE_ME -MYSQL_SOCKET= -MYSQL_DATABASE=hifast - -REDIS_HOST=127.0.0.1 -REDIS_PORT=6379 -REDIS_PASSWORD=CHANGE_ME - diff --git a/deploy/aws/ap-northeast-1/configs/mysql-seed-primary-and-replica.env.example b/deploy/aws/ap-northeast-1/configs/mysql-seed-primary-and-replica.env.example deleted file mode 100644 index 93535fc..0000000 --- a/deploy/aws/ap-northeast-1/configs/mysql-seed-primary-and-replica.env.example +++ /dev/null @@ -1,21 +0,0 @@ -PRIMARY_HOST=ppanel-mysql-jp..ap-northeast-1.rds.amazonaws.com -PRIMARY_PORT=3306 -PRIMARY_USER=admin -PRIMARY_PASSWORD=CHANGE_ME -PRIMARY_DB=hifast - -PRIMARY_REPL_USER=repl -PRIMARY_REPL_PASSWORD=CHANGE_ME -PRIMARY_REPL_HOST=104.238.220.230 -PRIMARY_BINLOG_RETENTION_HOURS=24 - -REPLICA_HOST=127.0.0.1 -REPLICA_PORT=3306 -REPLICA_USER=root -REPLICA_PASSWORD= -REPLICA_SOCKET=/var/run/mysqld/mysqld.sock -REPLICA_DB=hifast -REPLICA_SOURCE_SSL=1 - -DUMP_FILE= - diff --git a/deploy/aws/ap-northeast-1/configs/ppanel.yaml.example b/deploy/aws/ap-northeast-1/configs/ppanel.yaml.example deleted file mode 100644 index 2173937..0000000 --- a/deploy/aws/ap-northeast-1/configs/ppanel.yaml.example +++ /dev/null @@ -1,112 +0,0 @@ -Host: 0.0.0.0 -Port: 8080 -Debug: false - -JwtAuth: - AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET - AccessExpire: 604800 - -Logger: - ServiceName: PPanel - Mode: console - Encoding: plain - TimeFormat: "2006-01-02 15:04:05.000" - Path: logs - Level: info - MaxContentLength: 0 - Compress: false - Stat: true - KeepDays: 7 - StackCooldownMillis: 100 - MaxBackups: 7 - MaxSize: 100 - Rotation: daily - FileTimeFormat: "2006-01-02T15:04:05.000Z07:00" - -MySQL: - Addr: YOUR_TOKYO_RDS_ENDPOINT:3306 - Dbname: hifast - Username: admin - Password: CHANGE_ME_TO_TOKYO_RDS_PASSWORD - Config: charset=utf8mb4&parseTime=true&loc=Asia%2FTokyo - MaxIdleConns: 10 - MaxOpenConns: 100 - SlowThreshold: 1000 - -Redis: - Host: 127.0.0.1:6379 - Pass: CHANGE_ME_TO_TOKYO_REDIS_PASSWORD - DB: 0 - PoolSize: 100 - MinIdleConns: 10 - MaxRetries: 3 - PoolTimeout: 4 - IdleTimeout: 300 - MaxConnAge: 0 - DialTimeout: 5 - ReadTimeout: 3 - WriteTimeout: 3 - -Trace: - Name: ppanel-server - Endpoint: 127.0.0.1:4317 - Sampler: 0.1 - Batcher: otlpgrpc - -Site: - Host: api-jp.hifast.biz - SiteName: HiFastVPN - -Administrator: - Email: admin@example.com - Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD - -Telegram: - Enable: false - BotID: 0 - BotName: "" - BotToken: "" - GroupChatID: "" - EnableNotify: false - WebHookDomain: "" - -Kutt: - Enable: false - ApiURL: "" - ApiKey: "" - TargetURL: "" - Domain: "" - -OpenInstall: - Enable: false - AppKey: "" - ApiKey: "" - -Loki: - Enable: true - URL: "http://localhost:3100" - -AppSignature: - AppSecrets: - android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET - ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET - web-client: CHANGE_ME_WEB_SIGNATURE_SECRET - ValidWindowSeconds: 300 - SkipPrefixes: - - /v1/notify/ - - /v1/iap/notifications - - /v1/telegram/webhook - - /v1/subscribe/config - -Signature: - EnableSignature: false - -device: - enable: true - security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET - -Register: - EnableTrial: true - EnableTrialEmailWhitelist: true - TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com" - diff --git a/deploy/aws/ap-northeast-1/configs/replica-ops.env.example b/deploy/aws/ap-northeast-1/configs/replica-ops.env.example deleted file mode 100644 index 8f4b19b..0000000 --- a/deploy/aws/ap-northeast-1/configs/replica-ops.env.example +++ /dev/null @@ -1,23 +0,0 @@ -MYSQL_HOST=127.0.0.1 -MYSQL_PORT=3306 -MYSQL_USER=root -MYSQL_PASSWORD=CHANGE_ME -MYSQL_SOCKET= - -REPL_SOURCE_HOST=ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com -REPL_SOURCE_PORT=3306 -REPL_SOURCE_USER=repl -REPL_SOURCE_PASSWORD=XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer -REPL_SOURCE_SSL=1 -REPL_SOURCE_LOG_FILE=mysql-bin-changelog.000189 -REPL_SOURCE_LOG_POS=185053 -REPL_SOURCE_AUTO_POSITION=1 - -REDIS_HOST=127.0.0.1 -REDIS_PORT=6379 -REDIS_PASSWORD=CHANGE_ME - -REDIS_SOURCE_HOST=3.114.29.208 -REDIS_SOURCE_PORT=6379 -REDIS_SOURCE_USER= -REDIS_SOURCE_PASSWORD=hifast67yj diff --git a/deploy/aws/ap-northeast-1/configs/resource-inventory.current.md b/deploy/aws/ap-northeast-1/configs/resource-inventory.current.md deleted file mode 100644 index 628847e..0000000 --- a/deploy/aws/ap-northeast-1/configs/resource-inventory.current.md +++ /dev/null @@ -1,187 +0,0 @@ -# Tokyo Resource Inventory - -最后更新:`2026-05-21` - -这个文件记录当前东京迁移的真实实施状态,不是示例。 - -## Region - -- AWS account: `hifastvpn (200810848252)` -- Region: `ap-northeast-1` - -## Current Status - -- 东京迁移方案已在仓库内落地为执行资产 -- 东京 VPC 已创建 -- 东京子网、IGW、路由表已在 AWS 控制台创建并复核 -- 东京三层安全组已在 AWS 控制台创建并复核 -- 东京 VPC DNS 开关已开启,可支持公网可访问 RDS -- 东京 S3 备份桶已在 AWS 控制台创建并复核 -- 东京 ACM 证书请求已创建,等待 DNS 验证 -- 东京 RDS MySQL 已创建完成并可用 -- 东京业务 EC2 已创建完成并绑定固定 EIP -- 因当前本机没有可用 AWS CLI 凭据,云上资源状态仍需在 AWS 控制台或已登录环境中复查 - -## Networking - -- VPC - - Name: `ppanel-jp-prod` - - VPC ID: `vpc-0846b23b4a7d64eac` - - CIDR: `10.20.0.0/16` - - Status: `created` -- Public subnet A - - Name: `ppanel-jp-public-a` - - AZ: `ap-northeast-1a` - - CIDR: `10.20.0.0/24` - - Subnet ID: `subnet-091232bdb53e71490` - - Status: `created` -- Public subnet C - - Name: `ppanel-jp-public-c` - - AZ: `ap-northeast-1c` - - CIDR: `10.20.1.0/24` - - Subnet ID: `subnet-01ba0975c525ce8cf` - - Status: `created` -- Private subnet A - - Name: `ppanel-jp-private-a` - - AZ: `ap-northeast-1a` - - CIDR: `10.20.10.0/24` - - Subnet ID: `subnet-0bd13111c02f0edbe` - - Status: `created` -- Private subnet C - - Name: `ppanel-jp-private-c` - - AZ: `ap-northeast-1c` - - CIDR: `10.20.11.0/24` - - Subnet ID: `subnet-0d86c5c756dbc84b2` - - Status: `created` -- Internet Gateway - - Name: `ppanel-jp-igw` - - IGW ID: `igw-028041bcbf63b672c` - - Status: `created` -- Public route table - - Name: `ppanel-jp-public-rt` - - Route Table ID: `rtb-061b101080e4800e5` - - Default route: `0.0.0.0/0 -> igw-028041bcbf63b672c` - - Status: `created` -- Private route table - - Name: `ppanel-jp-private-rt` - - Route Table ID: `rtb-0d7a191a515031c45` - - Status: `created` - -## Security - -- `sg-alb` - - Name: `ppanel-jp-sg-alb` - - Security Group ID: `sg-0b3a23c31041a5a5a` - - Inbound: - - `80/tcp <- 0.0.0.0/0` - - `443/tcp <- 0.0.0.0/0` - - Status: `created` -- `sg-ec2` - - Name: `ppanel-jp-sg-ec2` - - Security Group ID: `sg-01f2a5a81e7505c91` - - Inbound: - - `80/tcp <- sg-0b3a23c31041a5a5a` - - `22/tcp <- 64.118.144.142/32` - - `6379/tcp <- 104.238.220.230/32` - - Status: `created` -- `sg-rds` - - Name: `ppanel-jp-sg-rds` - - Security Group ID: `sg-0b71db1e2c18b57c0` - - Inbound: - - `3306/tcp <- sg-01f2a5a81e7505c91` - - `3306/tcp <- 104.238.220.230/32` - - Status: `created` - -## Compute / Database / Edge - -- EC2 `ppanel-app-jp-01`: - - Instance ID: `i-07839130074cd7ed9` - - Type: `c7i.xlarge` - - Platform: `Ubuntu 26.04 / Linux` - - AZ: `ap-northeast-1c` - - VPC: `ppanel-jp-prod (vpc-0846b23b4a7d64eac)` - - Subnet: `ppanel-jp-public-c (subnet-01ba0975c525ce8cf)` - - Private IP: `10.20.1.168` - - Public IP / Elastic IP: `3.114.29.208` - - Public DNS: `ec2-3-114-29-208.ap-northeast-1.compute.amazonaws.com` - - Security group: `ppanel-jp-sg-ec2 (sg-01f2a5a81e7505c91)` - - Key pair: `ppanel-jp-key-20260521` - - Root volume: `gp3 100GiB` - - ENI: `eni-08accb427a470c9f7` - - EIP allocation ID: `eipalloc-038b32d5c0119accf` - - EIP association ID: `eipassoc-09184aa9161b6a4d9` - - Status: `running` - - SSH recovery private key: `deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery` - - SSH recovery public key: `deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery.pub` -- RDS subnet group: - - Name: `ppanel-jp-rds-subnet-group` - - VPC: `vpc-0846b23b4a7d64eac` - - Subnets: - - `subnet-0bd13111c02f0edbe` / `ppanel-jp-private-a` - - `subnet-0d86c5c756dbc84b2` / `ppanel-jp-private-c` - - Status: `created` -- RDS public subnet group: - - Name: `ppanel-jp-rds-public-subnet-group` - - VPC: `vpc-0846b23b4a7d64eac` - - Subnets: - - `subnet-091232bdb53e71490` / `ppanel-jp-public-a` - - `subnet-01ba0975c525ce8cf` / `ppanel-jp-public-c` - - Status: `created` -- RDS `ppanel-mysql-jp`: - - Engine: `MySQL Community 8.4.8` - - Class: `db.r7g.xlarge` - - Storage: `gp3 100GiB` - - Deployment: `Single instance (current actual state)` - - VPC: `ppanel-jp-prod (vpc-0846b23b4a7d64eac)` - - Subnet group: `ppanel-jp-rds-public-subnet-group` - - Security group: `ppanel-jp-sg-rds (sg-0b71db1e2c18b57c0)` - - Master username: `admin` - - Credential management: `self-managed` - - Secrets Manager managed password: `disabled` - - Current master password visibility: `not retrievable from AWS console; reset only` - - Public access: `enabled (set at creation time for external replication)` - - Status: `available` - - Endpoint: `ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com` - - Public IP (resolved via public DNS): `52.196.204.186` - - Connection test from Tokyo EC2: - - `mysql -h ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com -u admin -e "select 1"` - - Result: `ERROR 1045 (28000): Access denied for user 'admin'@'ip-10-20-1-168.ap-northeast-1.compute.internal' (using password: NO)` - - Meaning: `network path and security group are working; only the password is missing` - - Current admin password: `TkyRds20260521!N9mQ8sKe2vLp7Xa` - - External replica prep for `104.238.220.230`: - - binlog retention hours: `24` - - replication user: `repl@104.238.220.230` - - replication password: `XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer` - - current binlog file: `mysql-bin-changelog.000189` - - current binlog position: `185053` -- ALB `ppanel-alb-jp`: `not created` -- WAF `ppanel-waf-jp`: `not created` -- ACM certificate in `ap-northeast-1`: - - Certificate ID: `29d0b9b6-ab37-44d9-ad9e-18fa7e9aae3a` - - Domains: - - `api-jp.hifast.biz` - - `logs-jp.hifast.biz` - - Status: `pending_validation` - - Route 53 hosted zone in current AWS account: `not found` -- S3 backup bucket `hifast-prod-backups-200810848252-ap-northeast-1`: `created` - -## Domains - -- Parallel API domain: `api-jp.hifast.biz` -- Parallel logs domain: `logs-jp.hifast.biz` -- Production API domain: `pending user final confirmation` -- ACM DNS validation records pending external DNS add: - - `api-jp.hifast.biz` - - Name: `_0de5970dfbadaf46759447b2ea627a10.api-jp.hifast.biz.` - - Type: `CNAME` - - Value: `_6a632a5b85c5b3f304cc492090b741b3.jkddzztszm.acm-validations.aws.` - - `logs-jp.hifast.biz` - - Name: `_349a2b2bc4678d76c3ab341ccf73db61.logs-jp.hifast.biz.` - - Type: `CNAME` - - Value: `_74ced20dc96aa39070188605cf0ced18.jkddzztszm.acm-validations.aws.` - -## DR - -- DR host: `104.238.220.230` -- Planned MySQL upstream after cutover: `Tokyo RDS` -- Planned Redis upstream after cutover: `Tokyo EC2 public IP` diff --git a/deploy/aws/ap-northeast-1/configs/resource-inventory.example.md b/deploy/aws/ap-northeast-1/configs/resource-inventory.example.md deleted file mode 100644 index c58e1ce..0000000 --- a/deploy/aws/ap-northeast-1/configs/resource-inventory.example.md +++ /dev/null @@ -1,69 +0,0 @@ -# Tokyo Resource Inventory Example - -Use this file as the single source of truth while building the Tokyo environment. - -## Region - -- AWS account: `hifastvpn (200810848252)` -- Region: `ap-northeast-1` - -## DNS - -- Production API domain: `CHANGE_ME` -- Parallel API domain: `api-jp.hifast.biz` -- Parallel logs domain: `logs-jp.hifast.biz` - -## Networking - -- VPC name: `ppanel-jp-prod` -- VPC CIDR: `10.20.0.0/16` -- Public subnet A: `10.20.0.0/24` -- Public subnet C: `10.20.1.0/24` -- Private subnet A: `10.20.10.0/24` -- Private subnet C: `10.20.11.0/24` -- Ops CIDR for SSH: `CHANGE_ME` - -## Compute - -- EC2 name: `ppanel-app-jp-01` -- EC2 type: `t4g.large` -- EC2 disk: `gp3 80GB` -- SSH key pair: `CHANGE_ME` - -## Database - -- RDS identifier: `ppanel-mysql-jp` -- RDS engine: `MySQL 8.4` -- RDS class: `db.r7g.xlarge` -- RDS storage: `gp3 100GB` -- DB name: `hifast` -- DB admin user: `admin` - -## Cache - -- Redis container: `hifast-redis` -- Redis port: `6379` -- Redis password: `CHANGE_ME` - -## Security / Secrets - -- JWT secret: `CHANGE_ME` -- Admin email: `CHANGE_ME` -- Admin password: `CHANGE_ME` -- Android app signature secret: `CHANGE_ME` -- iOS app signature secret: `CHANGE_ME` -- Web app signature secret: `CHANGE_ME` -- Device security secret: `CHANGE_ME` - -## Backup - -- S3 backup bucket: `hifast-prod-backups-200810848252-ap-northeast-1` -- Versioning: `Enabled` - -## DR - -- DR host: `104.238.220.230` -- MySQL repl user: `repl` -- MySQL repl password: `CHANGE_ME` -- Redis source password: `CHANGE_ME` - diff --git a/deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery b/deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery deleted file mode 100644 index 5c82884..0000000 --- a/deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery +++ /dev/null @@ -1,7 +0,0 @@ ------BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1YwAAAKBaQXT3WkF0 -9wAAAAtzc2gtZWQyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1Yw -AAAEArWQWWwPoJp+za5JhSnrE0Pw1/TtqWRrdY5e8hULqYDGwnFkOMq9oh1WN6vdAwdFjD -jfCRWfMe6sDZNCoeWvVjAAAAF0FwcGxlQE1hY0Jvb2stUHJvLmxvY2FsAQIDBAUG ------END OPENSSH PRIVATE KEY----- diff --git a/deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery.pub b/deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery.pub deleted file mode 100644 index 60be616..0000000 --- a/deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGwnFkOMq9oh1WN6vdAwdFjDjfCRWfMe6sDZNCoeWvVj Apple@MacBook-Pro.local diff --git a/deploy/aws/ap-northeast-1/nginx/ppanel-api.conf b/deploy/aws/ap-northeast-1/nginx/ppanel-api.conf deleted file mode 100644 index a103e03..0000000 --- a/deploy/aws/ap-northeast-1/nginx/ppanel-api.conf +++ /dev/null @@ -1,34 +0,0 @@ -server { - listen 80 default_server; - listen [::]:80 default_server; - server_name _; - - client_max_body_size 20m; - - access_log /var/log/nginx/ppanel-access.log; - error_log /var/log/nginx/ppanel-error.log warn; - - location / { - proxy_http_version 1.1; - proxy_pass http://127.0.0.1:8080; - - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - - proxy_connect_timeout 10s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - } - - location = /nginx_status { - stub_status; - access_log off; - allow 127.0.0.1; - deny all; - } -} - diff --git a/deploy/systemd/hifast-mysql-backup.service b/deploy/systemd/hifast-mysql-backup.service deleted file mode 100644 index ac2dbb9..0000000 --- a/deploy/systemd/hifast-mysql-backup.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=Hifast MySQL backup to S3 -Wants=network-online.target -After=network-online.target - -[Service] -Type=oneshot -User=root -Group=root -EnvironmentFile=/root/backup-to-s3.env -ExecStart=/usr/bin/env bash -lc 'exec /opt/ppanel/deploy/scripts/mysql_backup_to_s3.sh' -Nice=10 -IOSchedulingClass=best-effort -IOSchedulingPriority=7 - -[Install] -WantedBy=multi-user.target diff --git a/deploy/systemd/hifast-mysql-backup.timer b/deploy/systemd/hifast-mysql-backup.timer deleted file mode 100644 index aa623d9..0000000 --- a/deploy/systemd/hifast-mysql-backup.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Run Hifast MySQL backup to S3 every 10 minutes - -[Timer] -OnCalendar=*:0/10 -Persistent=true -RandomizedDelaySec=30 -Unit=hifast-mysql-backup.service - -[Install] -WantedBy=timers.target diff --git a/doc/api-withdrawal-and-log.md b/doc/api-withdrawal-and-log.md new file mode 100644 index 0000000..a597f12 --- /dev/null +++ b/doc/api-withdrawal-and-log.md @@ -0,0 +1,560 @@ +# 提现 & 文件上传 & 日志上报 — 用户端 API 接口文档 + +> 基于 ppanel-server 源码整理,所有时间戳均为**秒级 Unix**。 + +--- + +## 目录 + +- [一、提现接口](#一提现接口) + - [1.1 申请提现](#11-申请提现) + - [1.2 取消提现](#12-取消提现) + - [1.3 查询提现记录](#13-查询提现记录) +- [二、枚举值与状态流转](#二枚举值与状态流转) +- [三、文件上传接口](#三文件上传接口) + - [3.1 直传文件(小文件)](#31-直传文件小文件) + - [3.2 初始化上传(大文件 — 预签名)](#32-初始化上传大文件--预签名) + - [3.3 确认上传完成](#33-确认上传完成) +- [四、日志查询接口 (Admin)](#四日志查询接口-admin) + - [4.1 错误日志列表](#41-错误日志列表) + - [4.2 错误日志详情](#42-错误日志详情) + - [4.3 日志消息原始详情](#43-日志消息原始详情) + +--- + +## 一、提现接口 + +> 认证方式: JWT(用户登录态) +> +> 路由前缀: `/v1/public/user` + +### 1.1 申请提现 + +提交佣金提现申请,创建一条待审核的提现记录。 + +``` +POST /v1/public/user/commission_withdraw +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `amount` | int64 | 是 | — | 提现金额(分) | +| `method` | uint8 | 是 | `oneof=0 1 2 3` | 收款方式(见枚举表) | +| `content` | string | 否 | — | 提现备注 | +| `account` | string | 条件必填 | — | 收款账号 | +| `qr_code_url` | string | 条件必填 | — | 收款码图片 URL | + +**各收款方式的必填字段** + +| method | 收款方式 | 必填字段 | +|--------|---------|---------| +| `1` 支付宝 | `qr_code_url` | 收款码图片 | +| `2` 微信 | `qr_code_url` | 收款码图片 | +| `3` 银行卡 | `account` | 收款账号 | +| `0` 其他 | `account` 必填 | + +**Request 示例** + +```json +{ + "amount": 5000, + "content": "提现到支付宝", + "method": 1, + "account": "user@example.com", + "qr_code_url": "https://cdn.example.com/qrcode/alipay.png" +} +``` + +**Response**: [`WithdrawalLog`](#withdrawallog-对象) + +--- + +### 1.2 取消提现 + +用户取消自己的待审核提现申请,佣金退回账户。 + +``` +POST /v1/public/user/withdrawal_cancel +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `withdrawal_id` | int64 | 是 | `required,gt=0` | 提现记录 ID | + +**Request 示例** + +```json +{ + "withdrawal_id": 123 +} +``` + +**Response**: [`WithdrawalLog`](#withdrawallog-对象)(状态已变为 `3=已取消`) + +--- + +### 1.3 查询提现记录 + +分页查询当前用户的提现记录(自动按 JWT 中的 userId 过滤)。 + +``` +GET /v1/public/user/withdrawal_log +``` + +**Query 参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `page` | int | 否 | 页码,默认 1 | +| `size` | int | 否 | 每页条数,默认 10 | + +**Request 示例** + +``` +GET /v1/public/user/withdrawal_log?page=1&size=10 +``` + +**Response** + +```json +{ + "list": [WithdrawalLog, ...], + "total": 25 +} +``` + +--- + +## 二、枚举值与状态流转 + +### 提现状态 (`status`) + +| 值 | 说明 | +|----|------| +| 0 | 待审核 | +| 1 | 已通过 | +| 2 | 已拒绝 | +| 3 | 已取消 | + +### 收款方式 (`method`) + +| 值 | 说明 | +|----|------| +| 0 | 其他 | +| 1 | 支付宝 | +| 2 | 微信 | +| 3 | 银行卡 | + +### 状态流转 + +``` + ┌── 管理员通过 ──▶ 已通过 (1) + │ +待审核 (0) ──────┼── 管理员拒绝 ──▶ 已拒绝 (2) + │ + └── 用户取消 ───▶ 已取消 (3) +``` + +### WithdrawalLog 对象 + +所有提现接口共用的响应结构: + +```json +{ + "id": 1, + "user_id": 100, + "amount": 5000, + "content": "提现备注", + "status": 0, + "reason": "", + "method": 1, + "account": "user@example.com", + "qr_code_url": "https://cdn.example.com/qrcode/alipay.png", + "created_at": 1716700000, + "updated_at": 1716700000 +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | int64 | 提现记录 ID | +| `user_id` | int64 | 用户 ID | +| `amount` | int64 | 提现金额(分) | +| `content` | string | 提现备注 | +| `status` | uint8 | 状态(见枚举表) | +| `reason` | string | 拒绝原因(仅 status=2 时有值,其余 omitempty) | +| `method` | uint8 | 收款方式(见枚举表) | +| `account` | string | 收款账号 | +| `qr_code_url` | string | 收款码图片 URL | +| `created_at` | int64 | 创建时间(秒级 Unix) | +| `updated_at` | int64 | 更新时间(秒级 Unix) | + +--- + +## 三、文件上传接口 + +> 认证方式: JWT + DeviceMiddleware(用户登录态 + 设备认证) +> +> 路由前缀: `/v1/public/file` +> +> 存储后端: S3 兼容(RustFS) + +提供两种上传方式: + +| 方式 | 适用场景 | 流程 | +|------|---------|------| +| **直传** | 小文件(收款码等) | 1 次请求,`multipart/form-data` 直接上传 | +| **预签名** | 大文件 / 客户端直传 S3 | init → 客户端 PUT 到预签名 URL → complete 确认 | + +--- + +### 3.1 直传文件(小文件) + +通过 `multipart/form-data` 直接上传文件到服务端,服务端转存至 S3。 + +``` +POST /v1/public/file/upload +Content-Type: multipart/form-data +``` + +**Form 参数** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `biz_type` | string | 是 | 业务类型(如 `withdrawal_qrcode`、`avatar` 等) | +| `file` | file | 是 | 上传的文件(multipart) | + +**cURL 示例** + +```bash +curl -X POST /v1/public/file/upload \ + -H "Authorization: Bearer " \ + -F "biz_type=withdrawal_qrcode" \ + -F "file=@/path/to/alipay_qr.png" +``` + +**Response** + +```json +{ + "file_id": "a1b2c3d4e5f678901234", + "file_name": "alipay_qr.png", + "object_key": "app-upload/2026/05/27/100/alipay_qr.png__a1b2c3d4e5f678901234", + "size": 52480, + "content_type": "image/png", + "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "status": "completed" +} +``` + +**FileUploadResponse 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `file_id` | string | 文件唯一 ID(24 字符 hex) | +| `file_name` | string | 原始文件名 | +| `object_key` | string | S3 对象路径 | +| `size` | int64 | 文件大小(字节) | +| `content_type` | string | MIME 类型 | +| `etag` | string | S3 ETag | +| `status` | string | 状态,直传成功即 `completed` | + +--- + +### 3.2 初始化上传(大文件 — 预签名) + +获取 S3 预签名 URL,客户端直接 PUT 到 S3,避免文件经过服务端。 + +``` +POST /v1/public/file/upload/init +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `biz_type` | string | 是 | `required` | 业务类型 | +| `file_name` | string | 是 | `required` | 文件名 | +| `content_type` | string | 是 | `required` | MIME 类型(如 `image/png`) | +| `size` | int64 | 是 | `required` | 文件大小(字节) | +| `sha256` | string | 否 | — | 文件 SHA256(可选校验) | + +**Request 示例** + +```json +{ + "biz_type": "withdrawal_qrcode", + "file_name": "wechat_qr.png", + "content_type": "image/png", + "size": 102400, + "sha256": "e3b0c44298fc1c149afbf4c8996fb924..." +} +``` + +**Response** + +```json +{ + "file_id": "b2c3d4e5f6789012345a", + "object_key": "app-upload/2026/05/27/100/wechat_qr.png__b2c3d4e5f6789012345a", + "upload_url": "https://s3.example.com/bucket/app-upload/...?X-Amz-Signature=...", + "method": "PUT", + "headers": { + "Content-Type": "image/png" + }, + "expired_at": 1716700300 +} +``` + +**FileUploadInitResponse 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `file_id` | string | 文件唯一 ID | +| `object_key` | string | S3 对象路径 | +| `upload_url` | string | 预签名上传 URL | +| `method` | string | HTTP 方法(`PUT`) | +| `headers` | map | 上传时需携带的请求头 | +| `expired_at` | int64 | 预签名过期时间(秒级 Unix,默认 300 秒) | + +**客户端上传流程** + +``` +1. 调用 /upload/init 获取 upload_url +2. 用返回的 method + headers 直接上传文件到 upload_url +3. 上传成功后调用 /upload/complete 确认 +``` + +--- + +### 3.3 确认上传完成 + +客户端通过预签名 URL 上传完成后,调用此接口确认文件状态。 + +``` +POST /v1/public/file/upload/complete +``` + +**Request Body** + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|------|------|------|------|------| +| `file_id` | string | 是 | `required` | init 返回的 file_id | + +**Request 示例** + +```json +{ + "file_id": "b2c3d4e5f6789012345a" +} +``` + +**Response** + +```json +{ + "file_id": "b2c3d4e5f6789012345a", + "object_key": "app-upload/2026/05/27/100/wechat_qr.png__b2c3d4e5f6789012345a", + "size": 102400, + "content_type": "image/png", + "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "status": "completed" +} +``` + +**FileUploadCompleteResponse 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `file_id` | string | 文件唯一 ID | +| `object_key` | string | S3 对象路径 | +| `size` | int64 | 实际文件大小(S3 HeadObject 获取) | +| `content_type` | string | MIME 类型 | +| `etag` | string | S3 ETag | +| `status` | string | `completed` | + +**校验规则** + +- 文件大小不能超过配置的 `S3.MaxUploadSize` +- Content-Type 必须在配置的 `S3.AllowedContentTypes` 白名单内(若配置了) +- complete 时会校验 S3 上的实际文件大小是否与 init 声明的一致 +- 只能确认自己发起的上传(userId 校验) + +--- + +## 四、日志查询接口 (Admin) + +> 认证方式: AuthMiddleware(管理员权限) +> +> 路由前缀: `/v1/admin/log` +> +> 数据来源: `log_message` 表(客户端上报的错误/崩溃日志) + +--- + +### 4.1 错误日志列表 + +分页查询客户端上报的错误日志,支持多维度筛选。 + +``` +GET /v1/admin/log/error_message/list +``` + +**Query 参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `page` | int | 是 | 页码 | +| `size` | int | 是 | 每页条数 | +| `platform` | string | 否 | 平台筛选(ios / android / windows / mac / harmony) | +| `level` | uint8 | 否 | 日志级别 | +| `user_id` | int64 | 否 | 用户 ID | +| `device_id` | string | 否 | 设备 ID | +| `error_code` | string | 否 | 错误码 | +| `keyword` | string | 否 | 关键字搜索(匹配 message) | +| `start` | int64 | 否 | 开始时间(秒级 Unix) | +| `end` | int64 | 否 | 结束时间(秒级 Unix) | + +**Request 示例** + +``` +GET /v1/admin/log/error_message/list?page=1&size=20&platform=ios&start=1716600000&end=1716700000 +``` + +**Response** + +```json +{ + "total": 50, + "list": [ + { + "id": 1, + "platform": "ios", + "app_version": "2.1.0", + "os_name": "iOS", + "os_version": "17.5", + "device_id": "A1B2C3D4", + "user_id": 100, + "session_id": "sess_xxx", + "level": 3, + "error_code": "VPN_CONNECT_FAIL", + "message": "Failed to establish VPN tunnel", + "created_at": 1716700000 + } + ] +} +``` + +**ErrorLogMessage 字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | int64 | 日志 ID | +| `platform` | string | 平台 | +| `app_version` | string | 客户端版本 | +| `os_name` | string | 操作系统名称 | +| `os_version` | string | 操作系统版本 | +| `device_id` | string | 设备 ID | +| `user_id` | int64 | 用户 ID | +| `session_id` | string | 会话 ID | +| `level` | uint8 | 日志级别 | +| `error_code` | string | 错误码 | +| `message` | string | 错误消息 | +| `created_at` | int64 | 创建时间(秒级 Unix) | + +--- + +### 4.2 错误日志详情 + +获取单条错误日志的完整详情(列表字段 + 堆栈/IP/UA 等扩展信息)。 + +``` +GET /v1/admin/log/error_message/detail +``` + +**Response** + +```json +{ + "id": 1, + "platform": "ios", + "app_version": "2.1.0", + "os_name": "iOS", + "os_version": "17.5", + "device_id": "A1B2C3D4", + "user_id": 100, + "session_id": "sess_xxx", + "level": 3, + "error_code": "VPN_CONNECT_FAIL", + "message": "Failed to establish VPN tunnel", + "stack": "at VPNManager.connect() line 42\nat ...", + "client_ip": "1.2.3.4", + "user_agent": "PPanel/2.1.0 iOS/17.5", + "locale": "zh-CN", + "occurred_at": 1716700000, + "created_at": 1716700000 +} +``` + +**相比列表额外返回的字段** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `stack` | string | 堆栈信息 | +| `client_ip` | string | 客户端 IP | +| `user_agent` | string | User-Agent | +| `locale` | string | 客户端语言/地区 | +| `occurred_at` | int64 | 错误发生时间(秒级 Unix) | + +--- + +### 4.3 日志消息原始详情 + +获取单条 `log_message` 的完整原始数据(含 context、digest 等全量字段)。 + +``` +GET /v1/admin/log/message/detail +``` + +**Query 参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | int64 | 是 | 日志消息 ID | + +**Response** + +```json +{ + "id": 1, + "platform": "ios", + "app_version": "2.1.0", + "os_name": "iOS", + "os_version": "17.5", + "device_id": "A1B2C3D4", + "user_id": 100, + "session_id": "sess_xxx", + "level": 3, + "error_code": "VPN_CONNECT_FAIL", + "message": "Failed to establish VPN tunnel", + "stack": "at VPNManager.connect() line 42\nat ...", + "context": { "server_id": 5, "protocol": "vmess" }, + "client_ip": "1.2.3.4", + "user_agent": "PPanel/2.1.0 iOS/17.5", + "locale": "zh-CN", + "digest": "sha256_abc123...", + "occurred_at": 1716700000, + "created_at": 1716700000 +} +``` + +**相比详情额外返回的字段** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `context` | any | 附加上下文(原始 JSON) | +| `digest` | string | 内容摘要(用于去重) | diff --git a/doc/app-invite-and-subscribe-discount-requirements-zh.md b/doc/app-invite-and-subscribe-discount-requirements-zh.md new file mode 100644 index 0000000..f658f21 --- /dev/null +++ b/doc/app-invite-and-subscribe-discount-requirements-zh.md @@ -0,0 +1,559 @@ +# App 邀请列表与商品套餐折扣需求梳理 + +本文基于当前 `ppanel-server` 代码现状,对以下两个需求做整理: + +1. App 需要一个“邀请列表/邀请记录”接口。 +2. 商品套餐需要补充“折扣信息”,当存在折扣时,App 需要做样式展示,并支持用户继续下单购买。 + +目标是帮助产品、前端、后端快速统一口径,明确: + +- 现在已有哪些接口可以复用 +- 哪些地方确实需要新增 +- “接口增加三个字段”更适合加在哪一层 + +--- + +## 1. 需求结论 + +### 1.1 邀请列表 + +当前公开侧已经有一部分邀请能力,但**没有一个完全匹配“App 邀请记录列表”语义的公开接口**。 + +现状: + +- 已有 `GET /v1/public/user/affiliate/list` + - 能返回“我邀请了哪些用户” + - 但字段较少,只包含基础信息 +- 已有 `GET /v1/public/user/invite_sales` + - 返回的是“被邀请用户的成交订单记录” + - 不是“邀请用户记录列表” +- 已有 `GET /v1/public/user/invite_stats` + - 返回邀请统计 + - 不是列表 + +结论: + +- 如果 App 只是要展示“我邀请了哪些人”,`/affiliate/list` 可以复用。 +- 如果 App 需要展示“邀请时间、是否购买、购买次数、带来的佣金/赠送天数”等完整邀请记录,则**建议新增一个公开接口**。 + +### 1.2 商品套餐折扣 + +当前公开侧套餐列表接口 `GET /v1/public/subscribe/list` **已经返回 `discount` 字段**,下单预览接口 `POST /v1/public/order/pre` 也已经支持折扣计算。 + +现状: + +- 套餐列表已有折扣规则数组 `discount` +- 预下单接口已有: + - 原价 `price` + - 实付 `amount` + - 折扣金额 `discount` + - 活动优惠 `promo_discount` + - 优惠券减免 `coupon_discount` + - 手续费 `fee_amount` + +结论: + +- 后端**不是完全没有折扣能力**,而是“已经有计算能力,但 App 展示层使用起来不够直接”。 +- 如果需求明确要求“接口增加三个字段”,**更推荐补在套餐列表返回的 `discount[]` 子项里**,而不是直接加在下单接口里。 + +--- + +## 2. 当前代码现状 + +## 2.1 邀请相关 + +### 2.1.1 已有公开接口 + +#### A. 邀请基础列表 + +接口: + +- `GET /v1/public/user/affiliate/list` + +请求: + +- `page` +- `size` + +返回结构: + +- `total` +- `list[]` + - `identifier` + - `avatar` + - `registered_at` + - `enable` + +特点: + +- 能表达“我邀请了谁” +- 不能表达“是否购买 / 购买次数 / 给我带来多少收益” + +对应代码: + +- `apis/public/user.api` +- `internal/logic/public/user/queryUserAffiliateListLogic.go` + +#### B. 邀请成交记录 + +接口: + +- `GET /v1/public/user/invite_sales` + +返回结构: + +- `total` +- `list[]` + - `amount` + - `updated_at` + - `user_hash` + - `product_name` + +特点: + +- 更像“邀请带来的订单流水” +- 不是邀请用户列表 + +对应代码: + +- `internal/logic/public/user/getInviteSalesLogic.go` + +#### C. 邀请统计 + +接口: + +- `GET /v1/public/user/invite_stats` + +返回结构: + +- `friendly_count` +- `history_count` + +特点: + +- 只适合头部统计卡片 +- 不适合列表页 + +对应代码: + +- `internal/logic/public/user/getUserInviteStatsLogic.go` + +### 2.1.2 已有后台接口 + +后台已经有更完整的邀请记录能力,可以直接参考: + +- `GetAdminUserInviteList` +- `GetInviteManageList` + +这些接口已经能返回: + +- 邀请时间 +- 是否购买 +- 购买次数 +- 邀请人佣金 +- 邀请人赠送天数 +- 被邀请人赠送天数 + +对应代码: + +- `internal/logic/admin/user/getAdminUserInviteListLogic.go` +- `internal/logic/admin/invite/getInviteManageListLogic.go` + +结论: + +- 邀请记录的统计逻辑后端已经有现成实现思路。 +- 新增 App 公开接口时,建议复用这部分逻辑,不要从零再写一套。 + +--- + +## 2.2 套餐折扣相关 + +### 2.2.1 套餐列表接口已有折扣规则 + +接口: + +- `GET /v1/public/subscribe/list` + +当前返回的套餐结构 `Subscribe` 中已包含: + +- `unit_price` +- `discount []SubscribeDiscount` + +其中 `SubscribeDiscount` 当前字段为: + +- `quantity` +- `discount` +- `new_user_only` +- `map_apple` +- `promo` + +对应代码: + +- `apis/public/subscribe.api` +- `internal/logic/public/subscribe/querySubscribeListLogic.go` +- `internal/types/types.go` + +说明: + +- `discount` 是按购买数量 `quantity` 生效的阶梯折扣 +- 不是单个套餐固定只有一个折扣值 + +### 2.2.2 预下单接口已有价格计算结果 + +接口: + +- `POST /v1/public/order/pre` + +当前已返回: + +- `price`:原价 +- `amount`:最终应付 +- `discount`:折扣减免金额 +- `promo_discount`:活动优惠金额 +- `gift_amount`:礼品余额抵扣 +- `coupon_discount`:优惠券减免 +- `fee_amount`:手续费 + +对应代码: + +- `apis/public/order.api` +- `internal/logic/public/order/preCreateOrderLogic.go` + +说明: + +- 只要 App 知道 `subscribe_id + quantity [+ coupon] [+ payment]`,就已经能拿到准确的下单金额 +- 所以“下单购买”这件事本身,后端主链路已经具备 + +--- + +## 3. 差距分析 + +## 3.1 邀请列表的真实缺口 + +如果产品要的是“邀请记录页”,通常至少会关心以下内容: + +- 被邀请用户 +- 邀请时间 +- 是否已购买 +- 购买次数 +- 给邀请人带来的佣金 +- 双方赠送天数 + +而当前: + +- `/affiliate/list` 只有基础用户列表 +- `/invite_sales` 是订单成交记录 +- `/invite_stats` 是统计值 + +所以当前公开侧缺一个“**邀请关系维度的邀请记录列表**”。 + +## 3.2 套餐折扣的真实缺口 + +后端目前的主要问题不是“不会算折扣”,而是: + +- `discount[]` 更偏规则定义 +- App 如果只想直接展示“折后价 / 优惠金额 / 折扣标签”,还需要自己再算一层 +- 这会增加前端理解成本,也容易和后端口径不一致 + +所以当前更合理的改法是: + +- 保留现有折扣规则 +- 再额外补充几个**面向展示的字段** + +--- + +## 4. 推荐方案 + +## 4.1 邀请记录接口 + +### 方案建议 + +新增一个公开接口,例如: + +- `GET /v1/public/user/invite_records` + +说明: + +- 从登录态中取当前用户 ID +- 不从前端传 `user_id` +- 只查“当前用户邀请的记录” + +### 请求参数建议 + +```json +{ + "page": 1, + "size": 10 +} +``` + +### 返回字段建议 + +```json +{ + "total": 2, + "list": [ + { + "invitee_id": 1001, + "invitee_identifier": "138****8888", + "invitee_avatar": "https://...", + "invitee_enable": true, + "invited_at": 1716800000, + "order_count": 3, + "has_purchased": true, + "inviter_commission": 1200, + "inviter_gift_days": 30, + "invitee_gift_days": 7 + } + ] +} +``` + +### 字段说明 + +- `invitee_id`:被邀请用户 ID +- `invitee_identifier`:被邀请用户展示账号 +- `invitee_avatar`:头像 +- `invitee_enable`:是否启用 +- `invited_at`:邀请时间 +- `order_count`:该被邀请用户产生的有效订单数 +- `has_purchased`:是否已购买 +- `inviter_commission`:给邀请人带来的佣金,单位建议继续沿用分 +- `inviter_gift_days`:邀请人获赠天数 +- `invitee_gift_days`:被邀请人获赠天数 + +### 实现建议 + +优先复用现有后台逻辑思路: + +- 参考 `internal/logic/admin/user/getAdminUserInviteListLogic.go` +- 或参考 `internal/logic/admin/invite/getInviteManageListLogic.go` + +公开接口与后台接口的主要差异只有两点: + +- 公开接口不允许前端指定 `user_id` +- 公开接口按当前登录用户本人维度返回 + +### 是否可以不新增接口 + +可以,但前提是 App 接受以下拆分: + +- 列表页用 `/affiliate/list` +- 顶部统计用 `/invite_stats` +- 订单流水页用 `/invite_sales` + +如果产品要的是一个完整“邀请记录页”,不建议这样拆三次请求,前端维护成本偏高。 + +--- + +## 4.2 套餐折扣字段建议 + +### 核心建议 + +“接口增加三个字段”建议**加在 `SubscribeDiscount` 子项里**,不要直接加在 `Subscribe` 顶层。 + +原因: + +- 折扣是按 `quantity` 生效的 +- 一个套餐可能有多个折扣档位 +- 如果加在套餐顶层,很难表达“买 1 个月”和“买 12 个月”对应不同折扣 + +### 推荐新增字段 + +建议在 `SubscribeDiscount` 中增加以下三个展示字段: + +- `discount_price` +- `discount_amount` +- `discount_desc` + +推荐结构如下: + +```json +{ + "quantity": 12, + "discount": 80, + "new_user_only": false, + "map_apple": "", + "promo": null, + "discount_price": 9600, + "discount_amount": 2400, + "discount_desc": "年付8折" +} +``` + +### 三个字段的含义 + +#### 1. `discount_price` + +- 含义:该档位折后总价 +- 计算建议:`unit_price * quantity * discount / 100` +- 单位:分 + +作用: + +- App 可直接展示“折后价” +- 下单时直接把该项的 `quantity` 带入 `/order/pre` 或 `/order/purchase` + +#### 2. `discount_amount` + +- 含义:该档位比原价便宜多少钱 +- 计算建议:`unit_price * quantity - discount_price` +- 单位:分 + +作用: + +- App 可直接展示“立省 xx” + +#### 3. `discount_desc` + +- 含义:折扣展示文案 +- 示例: + - `年付8折` + - `季付9折` + - `新用户首单8折` + +作用: + +- App 可直接做角标、标签、促销文案展示 + +### 为什么不推荐这三个字段加在下单接口 + +因为下单接口本来就是“结果型接口”,它已经能返回: + +- 原价 +- 折扣金额 +- 实付金额 + +如果只是为了 App 卡片展示,再去每个套餐都调一次 `/order/pre`,成本会比较高: + +- 请求次数多 +- 页面首屏会更慢 +- 前端链路更复杂 + +更合适的做法是: + +- 套餐列表接口负责“展示友好” +- 预下单接口负责“结算准确” + +--- + +## 5. 推荐改动清单 + +## 5.1 邀请列表 + +建议新增: + +- 新接口:`GET /v1/public/user/invite_records` + +建议新增类型: + +- `GetUserInviteRecordsRequest` +- `UserInviteRecord` +- `GetUserInviteRecordsResponse` + +建议实现位置: + +- `apis/public/user.api` +- `internal/types/types.go` +- `internal/handler/public/user/` +- `internal/logic/public/user/` + +## 5.2 套餐折扣 + +建议调整: + +- `SubscribeDiscount` 增加 3 个字段: + - `discount_price` + - `discount_amount` + - `discount_desc` + +建议实现位置: + +- `apis/types.api` +- `internal/types/types.go` +- `internal/logic/public/subscribe/querySubscribeListLogic.go` + +--- + +## 6. 前后端协作建议 + +## 6.1 App 侧调用建议 + +邀请页建议: + +- 头部统计:`/v1/public/user/invite_stats` +- 邀请记录列表:`/v1/public/user/invite_records` +- 如果还要看成交流水:`/v1/public/user/invite_sales` + +套餐页建议: + +- 先调 `/v1/public/subscribe/list` 渲染套餐和折扣标签 +- 用户点某个折扣档位时,带 `subscribe_id + quantity` 调 `/v1/public/order/pre` +- 用户确认后再调 `/v1/public/order/purchase` + +## 6.2 单位口径建议 + +建议继续保持后端金额统一为“分”: + +- `unit_price` +- `discount_price` +- `discount_amount` +- `amount` +- `coupon_discount` + +这样可以避免前后端出现小数精度问题。 + +--- + +## 7. 最终建议 + +### 建议一 + +如果你们只是要“邀请用户名单”,可直接复用: + +- `GET /v1/public/user/affiliate/list` + +### 建议二 + +如果你们要的是完整“邀请记录”,建议新增: + +- `GET /v1/public/user/invite_records` + +这是本次需求里更合理的新增接口。 + +### 建议三 + +商品套餐“折扣信息”不建议重新设计一整套下单逻辑。 + +当前后端已经具备: + +- 套餐折扣规则 +- 预下单价格计算 +- 正式下单购买 + +更推荐做法是: + +- 在 `SubscribeDiscount` 里补 3 个展示字段: + - `discount_price` + - `discount_amount` + - `discount_desc` + +这样改动最小,也最贴近 App 展示场景。 + +--- + +## 8. 相关代码位置 + +- `internal/logic/public/user/queryUserAffiliateListLogic.go` +- `internal/logic/public/user/getInviteSalesLogic.go` +- `internal/logic/public/user/getUserInviteStatsLogic.go` +- `internal/logic/admin/user/getAdminUserInviteListLogic.go` +- `internal/logic/admin/invite/getInviteManageListLogic.go` +- `internal/logic/public/subscribe/querySubscribeListLogic.go` +- `internal/logic/public/order/preCreateOrderLogic.go` +- `internal/logic/public/order/purchaseLogic.go` +- `internal/types/types.go` +- `apis/public/user.api` +- `apis/public/subscribe.api` +- `apis/public/order.api` + diff --git a/doc/app-three-apis-zh.md b/doc/app-three-apis-zh.md new file mode 100644 index 0000000..4b68fee --- /dev/null +++ b/doc/app-three-apis-zh.md @@ -0,0 +1,435 @@ +# App 端三个接口对接文档 + +> 适用:移动端 / 桌面端 App +> 维护:基于当前 `internal/handler` + `internal/logic` 代码反向梳理 +> 时间:2026-05-27 + +涉及接口: + +1. [文件上传](#1-文件上传) — `POST /v1/public/file/upload` +2. [订阅列表(含促销 promo)](#2-订阅列表含促销-promo) — `GET /v1/public/subscribe/list` +3. [邀请赠送记录](#3-邀请赠送记录) — `GET /v1/public/user/invite_records` + +公共说明: + +- BaseURL 示例:`https://tapi.hifast.biz` +- 鉴权头:`Authorization: `(注意:**不要**写 `Bearer ` 前缀,本项目 `AuthMiddleware` 直接取 token 值) +- 业务码包在 `{ code, msg, data }` 信封中,`code = 200` 为成功 +- 默认 `Accept: application/json`,可选 `lang: zh_CN` +- 经过 `AuthMiddleware` + `DeviceMiddleware` 的接口都需要登录态 + 设备绑定校验 + +--- + +## 1. 文件上传 + +### 1.1 Endpoint + +``` +POST /v1/public/file/upload +Content-Type: multipart/form-data +``` + +- Handler: `internal/handler/public/file/fileUploadHandler.go` +- Logic: `internal/logic/public/file/fileuploadlogic.go:33` +- 路由: `internal/handler/routes.go:934` +- 中间件: `AuthMiddleware` + `DeviceMiddleware`(必须登录) + +### 1.2 请求 + +#### Form 参数 + +| 字段 | 位置 | 必填 | 说明 | +|---|---|---|---| +| `biz_type` | form | 是 | 业务分类标签,会作为对象 key 的一部分(如 `app-package`、`avatar`) | +| `file` | form file | 是 | 待上传文件二进制 | + +#### 文件约束(来自 `etc/ppanel.yaml` → `S3`,可调整) + +| 项 | 默认值 | +|---|---| +| 单文件最大 | **104857600 字节(100 MiB)** | +| 允许的 Content-Type | `application/zip, application/x-zip-compressed, application/gzip, application/x-gzip, application/octet-stream, text/plain, application/json, image/jpeg, image/jpg, image/png, image/webp, image/gif, image/heic, image/heif, image/bmp` | + +> Content-Type 判定优先级:multipart 文件头里的 `Content-Type` → 文件嗅探(前 512 字节)→ 兜底 `application/octet-stream`。 +> **前端 form 上传时尽量带上 `Content-Type`**,否则被嗅探成 `application/octet-stream` 可能不在白名单里。 + +### 1.3 请求示例 + +```bash +curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload' \ + -H 'Authorization: ' \ + -H 'Accept: application/json' \ + -F 'biz_type=app-package' \ + -F 'file=@"/Users/Apple/Documents/avatar.jpg";type=image/jpeg' +``` + +### 1.4 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.url` | string | 上传完成后的可访问 URL,规则:`{S3.PublicBaseURL or S3.Endpoint}/{bucket}/{prefix}/{YYYY}/{MM}/{DD}/{userId}/{safeFileName}__{fileId}` | + +成功示例: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "url": "http://107.173.50.22:5016/hifastvpn/app-upload/2026/05/28/510/2026-05-27_20.03.55.jpg__226ad097c2ee4e3546e729c5" + } +} +``` + +### 1.5 错误码 + +| 业务码 | 触发场景 | +|---|---| +| `InvalidAccess` | 未登录 / JWT 无效 | +| `ParamError` | 缺少 `biz_type` 或 `file` | +| `InvalidParams` | `biz_type` 为空、文件名为空、size <= 0、超过 `MaxUploadSize`、`Content-Type` 不在白名单 | +| `ERROR` | S3 未启用(`S3.Enable=false`) / S3 写入失败 | + +### 1.6 前端易踩坑 + +1. `file` 字段名必须是 `file`,写 `image` / `upload` 都不行。 +2. `biz_type` 走 form 字段(`form:"biz_type"`),不要塞 query 里。 +3. 上传成功只返回 `url`,不返回 `file_id` / 大小等元数据;如需附加元数据,请走分片协议 `POST /upload/init` + `POST /upload/complete`。 +4. 想上传 PDF / DOC 不会成功——白名单里没有,需要后端调 `S3.AllowedContentTypes`。 + +--- + +## 2. 订阅列表(含促销 promo) + +### 2.1 Endpoint + +``` +GET /v1/public/subscribe/list +``` + +- Handler: `internal/handler/public/subscribe/querySubscribeListHandler.go` +- Logic: `internal/logic/public/subscribe/querySubscribeListLogic.go:32` +- Promo 合并逻辑: `internal/logic/public/subscribe/promo.go` +- 路由: `internal/handler/routes.go:1026` +- 中间件: **`OptionalAuthMiddleware` + `DeviceMiddleware`**(**未登录也能请求**,但未登录时只能拿到 `rule_type = campaign` 的促销) + +### 2.2 请求 + +#### Query 参数 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `language` | string | 否 | 语言筛选,传值后返回该语言版本;不传则按系统默认语言返回 | + +#### 头部说明(影响返回内容) + +| Header | 影响 | +|---|---| +| `Authorization` | 传则识别为登录态,能拿到 `new_user` / `inactive_user` 类型的个性化促销;不传只返回 `campaign` 类型 | +| `X-App-Id` | **不传**会被识别为"老版本客户端",每个套餐的 `discount` 列表会被**截掉最后一个元素**。新版 App 必须带 `X-App-Id` | + +### 2.3 请求示例 + +```bash +curl -X GET 'https://tapi.hifast.biz/v1/public/subscribe/list?language=zh-CN' \ + -H 'Authorization: ' \ + -H 'X-App-Id: hifast-ios' \ + -H 'Accept: application/json' +``` + +### 2.4 响应 + +#### 顶层 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.total` | int64 | 返回的套餐数量(= `len(list)`,不是数据库总数) | +| `data.list` | Subscribe[] | 套餐列表 | + +#### `Subscribe` 关键字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int64 | 套餐 ID | +| `name` | string | 套餐名 | +| `language` | string | 当前返回的语言版本 | +| `description` | string | 套餐描述(可能是富文本/Markdown) | +| `unit_price` | int64 | 单时间单位**原价**,单位:**分** | +| `unit_time` | string | 时间单位,枚举:`Day` / `Month` / `Year`(注意首字母大写) | +| `discount` | SubscribeDiscount[] | 量级折扣 + 促销,按 `quantity` 升序 | +| `node_count` | int64 | 节点数 | +| `traffic` | int64 | 套餐总流量,单位:字节 | +| `speed_limit` | int64 | 限速,单位见后端约定 | +| `device_limit` | int64 | 同时在线设备数限制 | +| `quota` | int64 | 总配额 | +| `show` | bool | 是否在前端展示 | +| `sell` | bool | 是否可售卖(本接口只返回 `sell=true`) | +| `show_original_price` | bool | 是否展示划线原价 | +| `reset_cycle` | int64 | 流量重置周期 | +| `renewal_reset` | bool | 续费时是否重置流量 | +| `created_at` / `updated_at` | int64 | 秒级 Unix 时间戳 | + +#### `SubscribeDiscount` 字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `quantity` | int64 | 购买的时间单位数量(如 1 = 1 个月,3 = 3 个月) | +| `discount` | float64 | 量级折扣比例,0 表示无折扣,0.05 表示再优惠 5% | +| `map_apple` | string | 对应 Apple IAP 商品 ID | +| `promo` | SubscribePromo \| null | **#77 新增的促销对象**,命中促销规则时下发,否则为 `null` | + +#### `SubscribePromo` 字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `rule_name` | string | 促销规则名(运营在后台填写,可直接给用户展示,如"新人首单 8 折") | +| `rule_type` | string | 规则类型枚举(见下表) | +| `promo_price` | int64 | **促销价**,单位:**分**。优先级高于 `unit_price * discount`,前端命中促销时按此价显示 | +| `expires_at` | int64 | 该促销对当前用户的失效时间(**秒级 Unix**),`0` 表示无明确截止 | + +#### `rule_type` 枚举 + +| 值 | 含义 | 资格判定 | +|---|---|---| +| `campaign` | 全员/限时活动 | 仅看 `start_time` / `end_time` 是否在窗口内;**未登录也会下发** | +| `new_user` | 新用户首单 | 登录用户,且 `now < user.created_at + params.window_hours`;`expires_at = user.created_at + window_hours` | +| `inactive_user` | 老用户唤回 | 登录用户,且距离最近一个订阅过期已超过 `params.inactive_months` 个月;`expires_at = 规则 end_time` | + +> 多条促销规则命中同一 `(subscribe_id, quantity)` 时,按 `priority DESC, id ASC` 取**首条**,不是合并。 + +### 2.5 响应示例 + +```json +{ + "code": 200, + "msg": "success", + "data": { + "total": 1, + "list": [ + { + "id": 1, + "name": "月付套餐", + "language": "zh-CN", + "description": "...", + "unit_price": 1000, + "unit_time": "Month", + "show_original_price": true, + "node_count": 30, + "traffic": 107374182400, + "device_limit": 3, + "discount": [ + { + "quantity": 1, + "discount": 0, + "map_apple": "ios.month1", + "promo": { + "rule_name": "新人首单 8 折", + "rule_type": "new_user", + "promo_price": 800, + "expires_at": 1780500000 + } + }, + { + "quantity": 3, + "discount": 0.05, + "map_apple": "ios.month3", + "promo": null + } + ], + "show": true, + "sell": true, + "created_at": 1764547200, + "updated_at": 1779934580 + } + ] + } +} +``` + +### 2.6 价格计算建议(前端) + +对每个 `discount` 元素: + +``` +原价 = unit_price * quantity +量级折后价 = round(原价 * (1 - discount)) + +if promo != null: + 实付 = promo.promo_price * quantity // 注意:promo_price 是「单价」,乘以 quantity + 划线价 = 原价 // 用于展示「省 XX」 +else: + 实付 = 量级折后价 + 划线价 = 原价(show_original_price=true 时展示) +``` + +> 注意:`promo_price` 设计为**单价**(与 `unit_price` 同级),不是总价。 +> 命中促销时建议同时显示 `rule_name`("新人首单 8 折")和倒计时(基于 `expires_at`)。 + +### 2.7 前端易踩坑 + +1. **必带 `X-App-Id`**——否则 `discount` 数组最后一个会被砍掉。 +2. **促销分登录态**:未登录时只能拿到 `campaign`;未拿到 `new_user`/`inactive_user` 时先检查是否传了 `Authorization`。 +3. **`unit_time` 是 PascalCase**:`Day` / `Month` / `Year`,别小写匹配。 +4. **金额单位都是分**(`unit_price`、`promo_price`),展示时除以 100。 +5. **`expires_at = 0`** 表示无截止,不要展示成 1970 年。 +6. `total` 是当前返回的条数,不是数据库总数(接口在 logic 里强制 `Size: 9999`,相当于不分页)。 + +--- + +## 3. 邀请赠送记录 + +> 当前用户的"邀请赠送天数"流水。包含两类: +> - 当前用户作为**邀请人**,被邀请的朋友下单触发的赠送; +> - 当前用户作为**被邀请人**,自己下单触发的对应赠送(双向赠送)。 +> +> 数据源:`system_logs` 表,`type = 33 (TypeGift)` 且 `content.remark = "邀请赠送"`。 +> 这里**只是赠送天数**,不包含邀请佣金(请走 affiliate 系列接口)。 + +### 3.1 Endpoint + +``` +GET /v1/public/user/invite_records +``` + +- Handler: `internal/handler/public/user/getInviteRecordsHandler.go` +- Logic: `internal/logic/public/user/getInviteRecordsLogic.go:61` +- 路由: `internal/handler/routes.go:1122` +- 中间件: `AuthMiddleware` + `DeviceMiddleware`(必须登录) + +### 3.2 请求 + +#### Query 参数 + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `page` | int | 否 | `1` | 页码,<1 自动归一为 1 | +| `size` | int | 否 | `10` | 每页条数,<1 归一为 10,**>100 截断为 100** | +| `start_time` | int64 | 否 | `0` | 起始时间(**秒级 Unix**),`0` 表示不过滤下界 | +| `end_time` | int64 | 否 | `0` | 截止时间(**秒级 Unix**),`0` 表示不过滤上界 | + +> ⚠️ `start_time` / `end_time` 单位是**秒**(后端用 `FROM_UNIXTIME(?)`)。传毫秒会过滤掉所有记录。 + +#### 请求示例 + +```bash +# 不带时间过滤 +curl -X GET 'https://tapi.hifast.biz/v1/public/user/invite_records?page=1&size=20' \ + -H 'Authorization: ' \ + -H 'Accept: application/json' + +# 带时间过滤 +curl -X GET 'https://tapi.hifast.biz/v1/public/user/invite_records?page=1&size=20&start_time=1764547200&end_time=1780099200' \ + -H 'Authorization: ' +``` + +> 旧 curl 模板里的 `--data-urlencode 'page=1'` 等对 GET 是 form body,不会被读取,请用 query string。 + +### 3.3 响应 + +#### 顶层 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.total` | int64 | 当前过滤条件下的**记录总数**(用于分页) | +| `data.list` | InviteRecord[] | 当前页列表,可能为空数组 `[]` | + +#### `InviteRecord` 字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `role` | string | 当前用户在该条记录中的角色:`inviter` 或 `invitee`(详见下表) | +| `peer_hash` | string | 对端用户的脱敏哈希(10 位定长数字字符串),用于"匿名展示朋友"。订单已删 / 对端 id 缺失时为 `""` | +| `gift_days` | int64 | 本次赠送天数(来源 `system_logs.content.amount`) | +| `order_no` | string | 触发本次赠送的订单号 | +| `created_at` | int64 | 赠送时间,**毫秒级 Unix**(SQL 端 `UNIX_TIMESTAMP(created_at) * 1000`) | + +> ⚠️ **时间戳单位不一致**:请求里的 `start_time/end_time` 是**秒**,响应里的 `created_at` 是**毫秒**。前端请区分对待。 +> (与项目其它接口"统一秒级"约定不同,是该接口的当前实现。) + +#### `role` 取值 + +| 值 | 含义 | `peer_hash` 来源 | +|---|---|---| +| `inviter` | 当前用户是**邀请人**,朋友下单触发的赠送 | 被邀请人(即订单的 `user_id`)的脱敏 hash | +| `invitee` | 当前用户是**被邀请人**,自己下单触发的赠送 | 邀请人(`user.referer_id`)的脱敏 hash | + +判定规则:默认 `inviter`;若 `order.user_id == 当前用户 id`,切换为 `invitee` 并改用 `referer_id` 计算 hash。 + +#### 排序与分页 + +- 排序:`created_at DESC, id DESC`(最近一条在最前) +- 分页:`LIMIT size OFFSET (page-1)*size` +- `total` **不**受 `LIMIT/OFFSET` 影响 + +### 3.4 响应示例 + +非空: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "total": 2, + "list": [ + { + "role": "inviter", + "peer_hash": "0382716459", + "gift_days": 30, + "order_no": "20260527123456789", + "created_at": 1779934580000 + }, + { + "role": "invitee", + "peer_hash": "1745920031", + "gift_days": 30, + "order_no": "20260520112233445", + "created_at": 1779329780000 + } + ] + } +} +``` + +空: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "total": 0, + "list": [] + } +} +``` + +### 3.5 错误码 + +| 业务码 | 触发场景 | +|---|---| +| `InvalidAccess` | 未登录 / JWT 无效 | +| `ParamError` | 参数绑定失败 | +| `DatabaseQueryError` | DB 查询失败(count / 日志 / 订单任一) | + +### 3.6 前端易踩坑 + +1. 传**毫秒**给 `start_time/end_time` → 永远拿到空集。请传**秒**。 +2. 拿到的 `created_at` 是**毫秒**,**不要再 `*1000`**,直接 `new Date(created_at)` 即可。 +3. 空列表是 `[]` 不是 `null`,可直接 `.map`。 +4. `peer_hash` 可能为 `""`,UI 兜底展示"未知朋友"。 +5. `size` 上限 100,传 1000 会被截断。 +6. 本接口**只含赠送天数**,不含邀请佣金(佣金 → affiliate 接口)。 + +--- + +## 附录:业务码常量速查 + +| 名称 | HTTP 含义 | 出现场景 | +|---|---|---| +| `200` | 成功 | `{"code":200,"msg":"success",...}` | +| `InvalidAccess` | 未授权 | 未登录 / JWT 无效 / 设备未绑定 | +| `ParamError` | 参数错误 | 请求绑定失败、缺必填项 | +| `InvalidParams` | 参数校验不通过 | 业务规则校验失败(文件超限、Content-Type 不合法等) | +| `DatabaseQueryError` | DB 错 | SQL 查询失败 | +| `ERROR` | 通用错 | 第三方/中间件失败(S3 未启用、S3 写入失败等) | diff --git a/doc/development-workflow-zh.md b/doc/development-workflow-zh.md new file mode 100644 index 0000000..445f280 --- /dev/null +++ b/doc/development-workflow-zh.md @@ -0,0 +1,254 @@ +# 开发流程(迁移到 GitHub 后) + +> 适用:`github.com/TawCorp/hifast-server`(canonical 仓库)及配套的 Multica agent 工作区。 +> 历史背景:本仓库于 2026-06-03 从 `git.kxsw.us/HI-VPN/hi-server` 迁移到 GitHub。**`git.kxsw.us` 已废弃**,所有新开发只走本仓库。 + +--- + +## 0. TL;DR + +``` +Multica issue → 分支 fix/-中文简述 → 推 github → 开 PR → CI 绿 → 架构师 approve + → GitHub UI Squash and merge 进 internal → 测试环境部署 (deploy-staging.yml) 自动跑 + → QA 在 staging 验收 → Multica issue → done + +发版时:架构师把 internal squash 进 main → 对外正式版本 +``` + +不要在 GitHub UI 之外 squash 后直接 push `internal` / `main`。不要往 `git.kxsw.us` 推。 + +--- + +## 1. 分支模型 + +| 分支 | 角色 | 写入方式 | +|---|---|---| +| `main` | 对外正式版本(生产) | **只**由架构师 squash merge `internal` → `main` | +| `internal` | 日常开发主干,staging 触发分支 | **只**由架构师在 GitHub UI 上 Squash and merge PR | +| `fix/-…` | bug 修复分支 | 工程师自己开自己删(PR 合并后 GitHub 自动删) | +| `feat/-…` | 新功能分支 | 同上 | +| `chore/…` `refactor/…` `hotfix/…` | 杂项 / 重构 / 紧急修复 | 同上 | + +**分支命名约定**(严格遵守,便于 CI / CODEOWNERS / 历史追溯): + +| 前缀 | 用途 | 示例 | +|---|---|---| +| `fix/-` | 关联 Multica issue 编号的 bug 修复 | `fix/143-邀请权益单测flake` | +| `feat/-` | 关联 Multica issue 编号的新功能 | `feat/77-套餐列表促销信息` | +| `chore/` | 配置 / 文档 / 工具链(无关联 issue 可不带编号) | `chore/dev-workflow-docs` | +| `hotfix/-` | 生产紧急修复 | `hotfix/151-payment-callback-503` | +| `refactor/-` | 重构(行为不变) | `refactor/138-promo-eligibility` | + +**单分支存活上限:3 天**(架构师 CLAUDE.md 约定)。超时未合并的分支由架构师在 issue 上 ping 持有者收尾或重切。 + +--- + +## 2. 标准 PR 生命周期 + +### 2.1 立项 + +- Multica 上有对应 issue(HIF-XXX)。 +- issue 已 assigned,状态 `todo` 或 `in_progress`。 + +### 2.2 写代码 + +```bash +# 在 worktree 里 +git fetch origin +git checkout -B fix/-中文简述 origin/internal + +# 编码 + 写测试 +# lefthook pre-commit 会自动跑 fmt / imports / lint / vet / test +git commit -m "修复(#): 一句话说清楚做了什么" + +# 推到 github(不再推 git.kxsw.us) +git push origin fix/-中文简述 +``` + +**commit message**(commitlint 强制): + +``` +<类型>(#): <一句话描述,<= 72 字符> + +<可选正文,说明 why> +``` + +类型只有这五个:**`修复` / `新功能` / `重构` / `文档` / `配置`**。其它一律不允许。 + +### 2.3 开 PR + +```bash +gh pr create --base internal --title '修复(#): ...' --body-file +``` + +或 GitHub UI 开。PR 模板(`.github/PULL_REQUEST_TEMPLATE.md`)会自动填入,按指引补内容。 + +**PR 标题必须等于 squash 后的合入 commit 标题**(用 `<类型>(#): ...` 形式)。 + +### 2.4 CI 自动跑 + +`.github/workflows/ci.yml` 在 PR 上触发: + +| Job | 执行 | +|---|---| +| `build-and-test` | `go build ./...` + `go vet ./...` + `go test -race -count=1 ./...` | +| `lint` | `golangci-lint run` | + +红 → 工程师本地复现 + 修,反复直到全绿。 + +### 2.5 Code review + +- `CODEOWNERS` 自动 request review。 +- 架构师(或被指派的 reviewer)逐 hunk 看,特别关注: + - scope 与 issue 一致性(无 scope creep——架构师红线之一) + - 错误处理 / SQL 注入 / N+1 + - 测试覆盖关键边界 + - 是否引入了无关的代码改动(架构师红线:"发现成员修改了无关代码 → 立即打回重做") +- review 通过即在 PR 上点 Approve。 + +### 2.6 Merge to `internal` + +- **必须用 GitHub UI 的 "Squash and merge"**。 +- squash 后 commit message 由架构师编辑确认:保持 `<类型>(#): ...` 形式 + 必要正文。 +- 合并按钮按下后 PR 自动 close,分支由 GitHub 自动删("Automatically delete head branches" 应开启)。 +- 架构师在 Multica issue 上 `@运维工程师` 附 commit hash,通知可以部署(虽然测试环境部署 workflow 已自动跑,但运维需要确认部署状态)。 + +**禁止做的事**: +- ❌ 在本地 squash 再 `git push` 到 `internal` / `main` +- ❌ Rebase merge / Create a merge commit(保持 linear history) +- ❌ Force push 到 `internal` / `main` +- ❌ Bypass CI(CI 红时不要 merge) +- ❌ 自己 approve 自己的 PR +- ❌ 未经测试工程师验收的分支合并(架构师红线) + +### 2.7 测试环境部署自动触发 (`.github/workflows/deploy-staging.yml`) + +合并到 `internal` 后,`.github/workflows/deploy-staging.yml` 自动触发: + +1. `go test ./...`(已被 CI 保证过,理论上不会再红) +2. Build Docker image `registry.kxsw.us/vpn-server:` + `:staging` +3. scp `docker-compose.cloud.yml` 到 staging host +4. ssh 重启 `ppanel-server` 服务 +5. healthcheck + Telegram 通知 + +部署失败 → Telegram 告警 → 运维介入回滚。Deploy 不阻塞下一个 PR,但**修复 deploy 是当前 deploy 失败者的责任**。 + +### 2.8 QA 验收 + +- 测试工程师在 staging(`tapi.hifast.biz` / 配套前端)按 issue 描述的 acceptance criteria 验收。 +- 验收通过 → Multica issue 推 `done`。 +- 任何一项失败 → 单独开子 issue 指派对应工程师,**不要**回退 PR / revert(除非生产数据安全风险)。 + +### 2.9 发版到 `main`(对外正式版本) + +由架构师在合适的时机(feature 集齐、staging 跑稳一段时间后)执行: + +```bash +gh pr create --base main --head internal --title '发版: <版本号>' +``` + +走和普通 PR 一样的流程(CI 绿 + review + Squash and merge)。`main` 的 push 也可以触发后续生产部署 workflow(如未来增加 `deploy-production.yml`)。 + +--- + +## 3. Multica issue 状态映射 + +| Multica 状态 | 对应阶段 | +|---|---| +| `backlog` | 还没排期 | +| `todo` | 已排期,待 assigned agent 开始 | +| `in_progress` | 分支已开始写,未 push | +| `in_review` | PR 已开,等 review / CI / merge | +| `done` | PR merged + 测试环境部署通过 + QA 验收通过 | + +**三个条件没全满足就不要标 done**——否则验收链路看不到真问题。 + +Issue metadata 建议字段(与现有 agent CLAUDE.md 推荐一致): + +| 字段 | 内容 | +|---|---| +| `pr_url` | https://github.com/TawCorp/hifast-server/pull/XX | +| `merge_commit` | merge 后的 squash commit SHA | +| `deploy_url` | `tapi.hifast.biz` 或对应前端域名 | +| `pipeline_status` | `coding` / `pr_open` / `merged` / `deployed` / `qa_passed` | +| `waiting_on` | 当前阻塞点(如 `qa_e2e_access` / `architect_review`) | + +--- + +## 4. Agent 边界 + +| Agent | 可以做 | 不可以做 | +|---|---|---| +| 后端 / 前端 / 运维工程师 | push 自己的 fix/feat 分支;开 PR;回评 issue;本地 squash 自己分支 | 合 PR;push `internal` / `main`;改 CODEOWNERS;自己 approve 自己 PR | +| 架构师(Squad Leader) | review;approve;GitHub UI squash merge;在 issue 上拆解需求 + 分派子任务;维护 doc/ 和 CLAUDE.md | 在本地 merge 后 push `internal`(绕过 CI gate);直接编写业务逻辑或 UI 代码 | +| 测试工程师 | 在 staging 验收;回评 issue;开子 issue 报 bug | 改 prod 代码;改 CI workflow;自己合 PR | +| 仓库 owner(人类) | 任何越界操作 + 流程治理决策(如 branch protection 升级) | — | + +任何越界都需要在 issue 上声明 + 走另一个 PR。 + +--- + +## 5. 平台层约束的现状(重要) + +> 私有仓库 + 当前 GitHub plan 不支持 branch protection / rulesets API(HTTP 403)。 + +这意味着 "禁止直接 push internal" / "require CI pass" / "require approval" 这些**在 GitHub 平台层面没法硬强制**。当前依赖三层软约束: + +1. **客户端层**:`lefthook.yml` 的 `pre-push` 钩子会在尝试直接 push `internal` / `main` 时报错。绕过去要明确加 `--no-verify`,会留 git trailers。 +2. **流程层**:本文档 + `CODEOWNERS` 自动 request review + 架构师作为单一合并执行人。 +3. **审计层**:所有 merge 都对应 Multica issue,事后任何"非 PR 路径上去的 commit"都能在 git log + Multica 对照查出来。 + +如果未来组织规模扩大、人类协作者增多,建议升级 GitHub Team($4/user/月)拿到 branch protection 平台保障。**目前规模下软约束足够。** + +--- + +## 6. 常见场景 + +### Hotfix(生产紧急修复) + +走和 fix 同样的流程,只是分支前缀 `hotfix/`,PR 标题前加 `[HOTFIX]`。架构师可酌情把 review 等待时间压缩到 30 分钟以内。Hotfix 通常直接合 `internal` 后立即由架构师再开 `internal → main` 的 PR 一起发版。 + +### Revert + +- 在 GitHub UI 上找到要 revert 的 PR,点 "Revert"。 +- GitHub 会生成 revert PR,按正常流程过 review + merge。 +- Revert PR 标题用 `修复(#<原 issue 编号>): revert <原 PR 标题>`。 + +### 文档 / 配置改动 + +走 `chore/` 分支。CI 一样跑(保险),review 可走 fast-track。 + +### 跨多个 issue 的大改动 + +- 优先拆成多个独立 PR,每个 PR 对应一个 issue。 +- 如果实在拆不开,PR title 用 `<类型>(#XXX/#YYY/#ZZZ): ...`,PR body 里 `Closes` 三个。 + +--- + +## 7. FAQ + +**Q: 为什么不允许在本地 squash 再推 internal?** +A: 绕过 GitHub PR review 流程 + CI gate + 审计记录。即使你确定改动 100% 正确,也走 PR——这是文化约束,避免架构师红线被逐渐侵蚀。 + +**Q: CI 跑得慢,PR 一直 yellow,可以先 merge 吗?** +A: 不可以。CI 通常 5 分钟内出结果;如果超过 15 分钟仍 pending,检查是否有 stuck job,必要时 re-run。 + +**Q: 如果架构师不在,怎么办?** +A: 备份 reviewer = 仓库 owner(@shanshanzhong147)。CODEOWNERS 已经把 owner 列为兜底 reviewer。 + +**Q: lefthook pre-push 不让我 push internal,但我确实需要紧急修一行小字?** +A: 没有"紧急修一行小字"这种例外。开 hotfix 分支 + 开 PR + 走 fast-track review。 + +**Q: Multica issue 状态没流转到 done,是不是要手动推?** +A: 不要直接推。先确认 PR merged + deploy 绿 + QA 通过三个条件全满足。任一项没满足就维持 `in_review` 并加评论说卡在哪。 + +**Q: 我能不能用 Rebase / Merge commit 模式合 PR?** +A: 不行。必须用 Squash and merge。`internal` 必须保持 linear history(一个 PR = 一个 commit),方便回滚和审计。 + +--- + +## 8. 紧急联系 + +- Deploy 红 / staging 挂:运维工程师(Multica agent `071d94d9-38bb-43cc-8c58-29e3b52d7bd4`) +- 流程问题 / branch protection 决策:仓库 owner @shanshanzhong147 +- 架构 / API 设计:架构师(Multica agent `0de10589-f101-49ef-8dfa-0e9e992fe27c`) diff --git a/doc/promo-pricing-design-zh.md b/doc/promo-pricing-design-zh.md new file mode 100644 index 0000000..e189f98 --- /dev/null +++ b/doc/promo-pricing-design-zh.md @@ -0,0 +1,796 @@ +# 促销优惠价系统设计文档 + +## 1. 背景与目标 + +### 1.1 业务需求 + +为套餐规格提供可配置的优惠价格能力,支持多种促销场景: + +- **新客优惠**:注册 N 天内的用户享受优惠价 +- **回归用户**:N 个月未活跃的用户享受优惠价 +- **活动促销**:指定时间段内所有用户享受优惠价 +- **未来可扩展**:首充优惠、邀请用户专属价、指定地区优惠等 + +### 1.2 设计原则 + +1. **纯新增,不改老代码**:现有的 `new_user_only` + `discount.NewUserOnly` + 24h 窗口逻辑全部保留不动 +2. **固定价格,非百分比**:运营直接设定优惠价(如 $5.99),不再需要反算折扣百分比 +3. **后台可配置**:规则类型、参数、时间窗口、优先级均可在管理后台配置 +4. **促销价不叠加批量折扣**:促销价命中时即为最终基础单价,跳过 `getDiscount()` 的百分比折扣 + +### 1.3 与现有体系的关系 + +``` +现有体系(保留不动): + subscribe.NewUserOnly → 套餐级新客限制 + discount[].NewUserOnly → 折扣档位级新客限制 + newUserEligibility.go → 24h 窗口 + 家庭组判定 + newUserDiscountEligibility.go → 新客折扣资格组装 + getDiscount() → 百分比折扣选择 + order.IsNew → 订单首购标记(统计/佣金用) + +新增体系(本次设计): + promo_rule 表 → 可配置的促销规则 + subscribe_promo 表 → 规格×规则 的优惠价 + promo_usage 表 → 使用记录(运营分析用) + EvaluatePromo() → 促销资格判定 +``` + +**互斥规则**:促销价命中时,跳过老的百分比折扣逻辑(`getDiscount()`)。 +两套体系不叠加 — 用户要么走促销价,要么走原价+百分比折扣,不会同时生效。 + +--- + +## 2. 数据模型 + +### 2.1 新增表:`promo_rule`(促销规则) + +```sql +CREATE TABLE `promo_rule` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '规则名称,如"新客7天优惠"', + `type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '规则类型:new_user / inactive_user / campaign', + `params` JSON NOT NULL COMMENT '类型专属参数', + `priority` INT NOT NULL DEFAULT 0 COMMENT '优先级,数值越大越优先匹配', + `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + `start_time` DATETIME DEFAULT NULL COMMENT '生效开始时间,NULL=立即生效', + `end_time` DATETIME DEFAULT NULL COMMENT '生效结束时间,NULL=永不过期', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间', + PRIMARY KEY (`id`), + KEY `idx_enabled_priority` (`enabled`, `priority` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表'; +``` + +### 2.2 新增表:`subscribe_promo`(规格优惠价) + +```sql +CREATE TABLE `subscribe_promo` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID', + `promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID', + `promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '该规格在此规则下的优惠价(分)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_subscribe_rule` (`subscribe_id`, `promo_rule_id`), + KEY `idx_promo_rule_id` (`promo_rule_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表'; +``` + +### 2.3 新增表:`promo_usage`(促销使用记录) + +用于运营分析,不做强制去重约束。 + +```sql +CREATE TABLE `promo_usage` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID', + `promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '使用的规则 ID', + `subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '购买的规格 ID', + `order_no` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联订单号', + `promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '使用时的促销单价(分)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_rule` (`user_id`, `promo_rule_id`), + KEY `idx_order_no` (`order_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表'; +``` + +### 2.4 `order` 表新增字段 + +```sql +ALTER TABLE `order` + ADD COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '促销规则ID, 0=未使用促销', + ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT '促销优惠金额(分)'; +``` + +**字段说明**: + +| 订单字段 | 含义 | 促销命中时 | 未命中时 | +|---------|------|-----------|---------| +| `Price` | 原始总价 = `UnitPrice × Quantity` | 不变,始终记录原价 | 不变 | +| `promo_rule_id` | 使用的促销规则 | 规则 ID | 0 | +| `promo_discount` | 促销优惠金额 | `(UnitPrice - PromoPrice) × Quantity` | 0 | +| `Discount` | 百分比折扣金额 | **0**(不叠加) | 正常计算 | +| `Amount` | 最终支付金额 | 基于促销价计算 | 基于原价+折扣计算 | + +**订单自证**:任何一笔订单都能独立还原其价格构成,不需要回查促销规则表: +``` +Amount = Price - promo_discount - Discount - CouponDiscount + FeeAmount - GiftAmount +``` + +### 2.5 ER 关系 + +``` +subscribe (1) ──── (*) subscribe_promo (*) ──── (1) promo_rule + │ + │ +user (1) ──────── (*) promo_usage (*) ─────────── (1) promo_rule + │ + (*) order ← 新增 promo_rule_id, promo_discount +``` + +--- + +## 3. 规则类型定义 + +### 3.1 `new_user` — 新客优惠 + +**含义**:用户注册后 N 小时内可享受优惠价 + +**params 结构**: + +```json +{ + "window_hours": 168 +} +``` + +**判定逻辑**: + +``` +eligible = (当前时间 - 用户注册时间) < window_hours +expires_at = 用户注册时间 + window_hours +``` + +**与老逻辑的区别**: + +| | 老逻辑 | 新逻辑 | +|--|--------|--------| +| 窗口期 | 硬编码 24h | 配置化,后台可改 | +| 判定基准 | 首台设备注册时间 + 家庭组 | 用户注册时间(`user.created_at`) | +| 价格方式 | 百分比折扣 | 固定价格 | +| 与折扣叠加 | 是(百分比折扣本身) | 否(替代原价,跳过折扣) | + +### 3.2 `inactive_user` — 回归用户优惠 + +**含义**:最近 N 个月没有活跃订阅的用户可享受优惠价 + +**params 结构**: + +```json +{ + "inactive_months": 3 +} +``` + +**判定逻辑**: + +``` +last_active = 用户最后一个订阅的 expire_time +eligible = last_active 为空(从未购买过) + OR (当前时间 - last_active) >= inactive_months 个月 +expires_at = 规则的 end_time(如有),否则无过期 +``` + +**查询依据**:`user_subscribe` 表中该用户最近一条记录的 `expire_time` + +**注意**:「从未购买过」的用户同时满足 `new_user` 和 `inactive_user`,靠 `priority` 排序选择高优先级的那条。 + +### 3.3 `campaign` — 活动促销 + +**含义**:在指定时间段内,所有用户均可享受优惠价 + +**params 结构**: + +```json +{} +``` + +活动促销不需要额外参数,完全靠 `promo_rule.start_time` 和 `end_time` 控制。 + +**判定逻辑**: + +``` +eligible = start_time <= 当前时间 <= end_time +expires_at = end_time +``` + +### 3.4 扩展预留 + +未来新增规则类型只需: +1. 定义新的 `type` 字符串(如 `first_purchase`、`referral`、`region`) +2. 定义对应的 `params` 结构 +3. 在判定逻辑中增加一个 `case` 分支 + +不需要改表结构,不需要改 API 格式。 + +--- + +## 4. 核心逻辑 + +### 4.1 促销资格判定 + +新增文件:`internal/logic/common/promoEligibility.go` + +```go +type PromoResult struct { + Eligible bool + RuleID int64 + RuleName string + RuleType string + PromoPrice int64 // 促销单价(分) + ExpiresAt time.Time +} + +func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64) (*PromoResult, error) { + // 1. 查询该规格关联的所有已启用规则,按 priority DESC + // 2. 遍历规则,按类型判定 + // 3. 首条命中即返回 +} +``` + +### 4.2 各类型判定函数 + +```go +func evaluateNewUser(user *User, params RuleParams) (bool, time.Time) { + windowHours := params.WindowHours + if windowHours <= 0 { + return false, time.Time{} + } + expiresAt := user.CreatedAt.Add(time.Duration(windowHours) * time.Hour) + eligible := time.Now().Before(expiresAt) + return eligible, expiresAt +} + +func evaluateInactiveUser(ctx context.Context, userID int64, rule PromoRule) (bool, time.Time) { + inactiveMonths := rule.Params.InactiveMonths + if inactiveMonths <= 0 { + return false, time.Time{} + } + lastExpire := getLastSubscriptionExpireTime(ctx, userID) + if lastExpire.IsZero() { + return true, rule.GetExpiresAt() + } + threshold := time.Now().AddDate(0, -inactiveMonths, 0) + eligible := lastExpire.Before(threshold) + return eligible, rule.GetExpiresAt() +} +``` + +### 4.3 下单流程集成(不叠加方案) + +在 `purchaseLogic.go` 中 `sub.UnitPrice * req.Quantity` 之前,插入促销价判定: + +```go +// === 新增:促销价判定 === +promoResult, promoErr := commonLogic.EvaluatePromo(l.ctx, l.svcCtx, u.Id, targetSubscribeID) +if promoErr != nil { + return nil, promoErr +} + +var promoDiscount int64 +var promoRuleID int64 + +if promoResult.Eligible { + // 促销命中 → 用促销价,跳过百分比折扣 + price = promoResult.PromoPrice * req.Quantity + promoDiscount = (sub.UnitPrice * req.Quantity) - price + promoRuleID = promoResult.RuleID + discount = 1 // 不叠加批量折扣 + discountAmount = 0 +} else { + // 未命中 → 走原有逻辑(不动) + price = sub.UnitPrice * req.Quantity + discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount) + discountAmount = price - int64(math.Round(float64(price)*discount)) +} +// === 新增结束 === + +// 后续 coupon / fee / gift 逻辑完全不动 +``` + +**订单创建时记录**: + +```go +orderInfo := &order.Order{ + // ... 原有字段不动 ... + Price: sub.UnitPrice * req.Quantity, // 始终记录原价 + PromoRuleID: promoRuleID, // 新增 + PromoDiscount: promoDiscount, // 新增 + Discount: discountAmount, // 促销命中时为 0 + Amount: amount, +} +``` + +**激活时写 usage**(`activateOrderLogic.go` 追加): + +```go +if orderInfo.PromoRuleID > 0 { + insertPromoUsage(ctx, orderInfo.UserId, orderInfo.PromoRuleID, orderInfo.SubscribeId, orderInfo.OrderNo, promoPrice) +} +``` + +### 4.4 价格计算完整流程 + +``` +┌───────────────────────────────────────────────────┐ +│ 1. 判定促销 │ +│ EvaluatePromo(userId, subscribeId) │ +├──────────────┬────────────────────────────────────┤ +│ 促销命中 │ 促销未命中 │ +├──────────────┼────────────────────────────────────┤ +│ basePrice │ basePrice │ +│ = promoPrice│ = unitPrice │ +│ │ │ +│ discount = 0 │ discount = getDiscount(...) │ +│ (跳过折扣) │ (百分比折扣正常生效) │ +├──────────────┴────────────────────────────────────┤ +│ 2. price = basePrice × quantity │ +│ amount = price - discountAmount │ +├───────────────────────────────────────────────────┤ +│ 3. 优惠券(原有逻辑,不动) │ +│ amount -= couponDiscount │ +├───────────────────────────────────────────────────┤ +│ 4. 手续费(原有逻辑,不动) │ +│ amount += feeAmount │ +├───────────────────────────────────────────────────┤ +│ 5. 余额抵扣(原有逻辑,不动) │ +│ amount -= giftAmount │ +└───────────────────────────────────────────────────┘ +``` + +--- + +## 5. 退款影响分析 + +### 5.1 结论:退款逻辑无需改动 + +当前退款流程(`refundOrderLogic.go`)基于**订单上已存储的字段**运作,不回查价格体系: + +| 退款动作 | 数据来源 | 是否受促销影响 | +|---------|---------|--------------| +| 退款金额 | `order.Amount`(支付时已锁定) | 否 — Amount 已反映促销价 | +| 佣金回退 | `system_log` 表中的 commission 记录 | 否 — 佣金是基于 Amount 计算的 | +| 订阅终止 | `user_subscribe.status → 3` | 否 — 和价格无关 | +| 审计日志 | `buildRefundAuditLog()` 读订单快照 | 否 — 记录的就是实际值 | + +**原因**:订单创建时所有金额字段(Price、Amount、Discount、PromoDiscount、FeeAmount 等)都已写入 `order` 表。退款只读这些已存储的值,不会重新计算价格。 + +### 5.2 退款后的促销资格 + +退款后用户的订阅被终止(`expire_time = now - 1s`)。如果用户再次购买: + +| 场景 | 促销资格 | 说明 | +|------|---------|------| +| 新客退款后重新购买 | 如仍在窗口期内 → 仍然可以享受促销价 | 正常行为,`promo_usage` 只是记录不做去重 | +| 回归用户退款后重新购买 | 需重新判定 `inactive_months` | 退款后订阅 expire_time 被设为过去时间 | +| 活动促销退款后重新购买 | 如活动仍在进行 → 可以继续购买 | 活动促销不限次数 | + +这些都是合理的业务行为,不需要额外处理。 + +### 5.3 佣金影响 + +佣金计算公式(`activateOrderLogic.go:1104`): + +```go +amount := l.calculateCommission(orderInfo.Amount - orderInfo.FeeAmount, referralPercentage) +``` + +- `Amount` 在促销命中时已反映促销价(更低的金额) +- 所以佣金会相应减少 — **这是正确的行为** +- 退款时佣金回退金额从 `system_log` 读取,回退的也是减少后的佣金 + +**无需任何改动**。 + +--- + +## 6. Apple IAP 影响分析 + +### 6.1 现状 + +- Apple IAP 价格在 App Store Connect 中配置,不支持后端动态定价 +- 当前通过 `discount[].MapApple` 字段映射 Apple Product ID +- IAP 订单在 `appleIAPNotifyLogic.go` 中处理,走独立的价格逻辑 + +### 6.2 设计决策 + +**促销价不适用于 IAP 订单**。原因: +- IAP 价格由 Apple 控制,后端无法干预 +- IAP 通知回调(`appleIAPNotifyLogic.go`)有独立的价格处理流程 +- IAP 审计订单设 `IsNew: false`,不走常规购买逻辑 + +**实现方式**:`EvaluatePromo()` 不需要特殊处理 — IAP 订单根本不经过 `purchaseLogic.go`,自然不会触发促销判定。 + +--- + +## 7. 各购买场景适配 + +### 7.1 需要集成促销的场景 + +| 文件 | 场景 | 集成方式 | +|------|------|---------| +| `purchaseLogic.go` | 新购 | 完整促销判定 + 不叠加逻辑 | +| `preCreateOrderLogic.go` | 价格预览 | 同上(返回 promo_discount 字段) | + +### 7.2 不需要改动的场景 + +| 文件 | 场景 | 原因 | +|------|------|------| +| `renewalLogic.go` | 续费 | 促销价仅限首购,续费走原价+折扣 | +| `rechargeLogic.go` | 余额充值 | 充值不涉及套餐价格 | +| `redeemCodeLogic.go` | 兑换码 | 兑换码有自己的固定逻辑 | +| `recoverOrderLogic.go` | 历史导入 | 导入的是已完成订单 | +| `appleIAPNotifyLogic.go` | IAP 续订 | Apple 控制价格 | +| `portal/purchaseLogic.go` | 游客购买 | 游客无 user_id,无法判定促销资格 | +| `refundOrderLogic.go` | 退款 | 读取订单已存储的金额,不重新计算 | +| `activateOrderLogic.go` | 订单激活 | 只追加 promo_usage 写入,价格不重算 | + +### 7.3 统计报表 + +现有统计 SQL(`order/model.go` 中 8 处)按 `is_new` 拆分收入,**不需要改动**。 + +未来如需促销维度报表,可通过 `order.promo_rule_id` 字段扩展: +```sql +SUM(CASE WHEN promo_rule_id > 0 THEN amount ELSE 0 END) AS promo_order_amount, +SUM(CASE WHEN promo_rule_id = 0 THEN amount ELSE 0 END) AS normal_order_amount +``` + +--- + +## 8. API 设计 + +### 8.1 套餐列表 API(改造) + +**接口**:`GET /v1/public/subscribe/list` + +**响应变更**:在原有 `Subscribe` 结构体中追加 `promo` 字段。 + +```json +{ + "list": [ + { + "id": 1, + "name": "基础套餐", + "unit_price": 288, + "discount": [...], + "promo": { + "rule_name": "新客7天优惠", + "rule_type": "new_user", + "promo_price": 279, + "expires_at": 1748870400 + } + }, + { + "id": 2, + "name": "标准套餐", + "unit_price": 688, + "promo": null + } + ] +} +``` + +**`promo` 字段说明**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `rule_name` | string | 规则名称,前端展示用 | +| `rule_type` | string | 规则类型,前端可据此展示不同样式 | +| `promo_price` | int64 | 优惠单价(分),注意是单价不是总价 | +| `expires_at` | int64 | 优惠过期时间戳(秒),0 = 无过期 | + +- 用户未登录时:仅展示 `campaign` 类型促销(不需要用户信息) +- 用户已登录:展示所有命中的促销 +- 未命中任何规则时,`promo` 为 `null` + +### 8.2 预算订单 API(改造) + +**接口**:`POST /v1/public/order/pre` + +**响应追加字段**: + +```json +{ + "price": 688, + "amount": 499, + "discount": 0, + "promo_discount": 189, + "coupon_discount": 0, + "fee_amount": 0, + "gift_amount": 0 +} +``` + +| 字段 | 含义 | +|------|------| +| `price` | 原始总价 = `UnitPrice × Quantity` | +| `promo_discount` | 促销优惠 = `(UnitPrice - PromoPrice) × Quantity` | +| `discount` | 百分比折扣优惠(促销命中时为 0) | +| `amount` | 最终支付金额 | + +前端可展示:~~原价 ¥6.88~~ → 促销价 ¥4.99 + +### 8.3 管理后台 API(新增) + +#### 8.3.1 促销规则 CRUD + +``` +POST /v1/admin/promo/rule 创建规则 +GET /v1/admin/promo/rule/list 规则列表 +GET /v1/admin/promo/rule/:id 规则详情 +PUT /v1/admin/promo/rule/:id 更新规则 +DELETE /v1/admin/promo/rule/:id 删除规则(软删除) +``` + +**创建/更新请求体**: + +```json +{ + "name": "新客7天优惠", + "type": "new_user", + "params": { + "window_hours": 168 + }, + "priority": 10, + "enabled": true, + "start_time": null, + "end_time": null +} +``` + +**校验规则**: +- `type` 必须是已支持的类型 +- `params` 按 `type` 做结构校验(如 `new_user` 必须有 `window_hours > 0`) +- `priority` >= 0 +- `start_time` < `end_time`(如果两者都提供) + +#### 8.3.2 规格优惠价配置 + +``` +POST /v1/admin/promo/price 批量设置优惠价 +GET /v1/admin/promo/price/list 查询某规则下的所有优惠价 +DELETE /v1/admin/promo/price/:id 删除某条优惠价 +``` + +**批量设置请求体**: + +```json +{ + "promo_rule_id": 1, + "items": [ + {"subscribe_id": 1, "promo_price": 279}, + {"subscribe_id": 2, "promo_price": 599} + ] +} +``` + +**校验**:`promo_price` 必须 < 对应规格的 `unit_price`(防止配置错误)。 + +#### 8.3.3 使用记录查询 + +``` +GET /v1/admin/promo/usage/list?rule_id=1&page=1&size=20 +``` + +--- + +## 9. 缓存策略 + +### 9.1 规则缓存 + +``` +Key: promo:rules:enabled +Value: JSON 数组(所有启用的规则,按 priority DESC) +TTL: 300 秒(5 分钟) +清除: 管理后台修改规则时主动删除 +``` + +### 9.2 规格优惠价缓存 + +``` +Key: promo:subscribe:{subscribe_id} +Value: JSON 数组(该规格关联的所有 rule_id → promo_price) +TTL: 300 秒 +清除: 管理后台修改优惠价时主动删除 +``` + +### 9.3 注意事项 + +- 缓存 TTL 300 秒意味着活动 `end_time` 到期后最多 5 分钟延迟,可接受 +- 管理后台操作后主动 DEL 缓存 key,确保配置变更及时生效 +- `EvaluatePromo()` 缓存未命中时回查 DB + +--- + +## 10. 确定的决策项 + +| 编号 | 问题 | 结论 | 原因 | +|------|------|------|------| +| D-01 | 促销价与批量折扣叠加 | **不叠加** | 促销价即最终单价,跳过 `getDiscount()` | +| D-02 | 未登录用户展示促销价 | 仅展示 `campaign` 类型 | `new_user`/`inactive_user` 需要用户信息 | +| D-03 | 续费订单适用促销价 | **仅首购** | 促销价用于拉新/回归,续费走原价 | +| D-04 | 回归用户判定方式 | 订阅过期时间 | `user_subscribe.expire_time`,数据最可靠 | +| D-05 | 多规则命中 | 按 `priority` DESC 取第一条 | 运营可控 | +| D-07 | Portal(游客)购买走促销 | **不走** | 游客无 user_id,无法判定资格 | + +--- + +## 11. 新增文件清单 + +| 层级 | 新增文件 | 说明 | +|------|----------|------| +| **Model** | `internal/model/promo_rule/promo_rule.go` | 促销规则模型 | +| **Model** | `internal/model/subscribe_promo/subscribe_promo.go` | 规格优惠价模型 | +| **Model** | `internal/model/promo_usage/promo_usage.go` | 使用记录模型 | +| **Logic** | `internal/logic/common/promoEligibility.go` | 促销资格判定核心逻辑 | +| **Logic** | `internal/logic/admin/promo/` 目录(CRUD) | 管理后台逻辑 | +| **Handler** | `internal/handler/admin/promo/` 目录 | 管理后台 Handler | +| **Types** | `internal/types/types.go` 追加 | 新增结构体 | +| **Migration** | `initialize/migrate/database/02153_promo_rule.up.sql` | 建表 + order 加字段 | +| **Migration** | `initialize/migrate/database/02153_promo_rule.down.sql` | 回滚 | + +### 需改动的已有文件(仅追加) + +| 文件 | 改动方式 | +|------|----------| +| `internal/logic/public/subscribe/querySubscribeListLogic.go` | 追加:查促销信息,填充 `promo` | +| `internal/logic/public/order/purchaseLogic.go` | 追加:促销判定 + 不叠加分支 | +| `internal/logic/public/order/preCreateOrderLogic.go` | 追加:预算时考虑促销价 | +| `queue/logic/order/activateOrderLogic.go` | 追加:激活后写 `promo_usage` | +| `internal/model/order/order.go` | 追加:`PromoRuleID`、`PromoDiscount` 字段 | +| `internal/model/order/model.go` | 追加:`Details` 同步字段 | +| `internal/types/types.go` | 追加:新增结构体、响应字段 | +| `internal/svc/serviceContext.go` | 追加:注入新 Model | +| 路由配置 | 追加:管理后台路由 | + +--- + +## 12. 运营配置示例 + +### 场景 1:新客 7 天优惠 + +``` +promo_rule: + name = "新客7天优惠" + type = "new_user" + params = {"window_hours": 168} + priority = 10 + enabled = true + start_time = NULL(永久生效) + end_time = NULL + +subscribe_promo: + 规格"7天" → promo_price = 279 + 规格"30天" → promo_price = 599 + 规格"90天" → promo_price = 1299 + 规格"365天" → promo_price = 4499 +``` + +### 场景 2:回归用户优惠 + +``` +promo_rule: + name = "回归用户专属价" + type = "inactive_user" + params = {"inactive_months": 3} + priority = 5 + enabled = true + +subscribe_promo: + 规格"30天" → promo_price = 499 + 规格"90天" → promo_price = 999 +``` + +### 场景 3:双十一全站活动 + +``` +promo_rule: + name = "双十一特惠" + type = "campaign" + params = {} + priority = 20(优先级高于新客和回归) + enabled = true + start_time = "2026-11-01 00:00:00" + end_time = "2026-11-12 00:00:00" + +subscribe_promo: + 规格"90天" → promo_price = 999 + 规格"365天" → promo_price = 3999 +``` + +**优先级效果**:双十一期间(priority=20),即使用户是新客(priority=10),也走双十一价格。双十一结束后,新客仍可享受新客优惠。 + +--- + +## 13. 迁移脚本 + +### 02153_promo_system.up.sql + +```sql +-- 促销规则表 +CREATE TABLE IF NOT EXISTS `promo_rule` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL DEFAULT '', + `type` VARCHAR(32) NOT NULL DEFAULT '', + `params` JSON NOT NULL, + `priority` INT NOT NULL DEFAULT 0, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `start_time` DATETIME DEFAULT NULL, + `end_time` DATETIME DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_enabled_priority` (`enabled`, `priority` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表'; + +-- 规格促销价表 +CREATE TABLE IF NOT EXISTS `subscribe_promo` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `subscribe_id` BIGINT UNSIGNED NOT NULL, + `promo_rule_id` BIGINT UNSIGNED NOT NULL, + `promo_price` BIGINT NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_subscribe_rule` (`subscribe_id`, `promo_rule_id`), + KEY `idx_promo_rule_id` (`promo_rule_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表'; + +-- 促销使用记录表 +CREATE TABLE IF NOT EXISTS `promo_usage` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL, + `promo_rule_id` BIGINT UNSIGNED NOT NULL, + `subscribe_id` BIGINT UNSIGNED NOT NULL, + `order_no` VARCHAR(255) NOT NULL DEFAULT '', + `promo_price` BIGINT NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_rule` (`user_id`, `promo_rule_id`), + KEY `idx_order_no` (`order_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表'; + +-- order 表新增促销字段 +ALTER TABLE `order` + ADD COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '促销规则ID, 0=未使用促销', + ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT '促销优惠金额(分)'; +``` + +### 02153_promo_system.down.sql + +```sql +ALTER TABLE `order` + DROP COLUMN IF EXISTS `promo_discount`, + DROP COLUMN IF EXISTS `promo_rule_id`; + +DROP TABLE IF EXISTS `promo_usage`; +DROP TABLE IF EXISTS `subscribe_promo`; +DROP TABLE IF EXISTS `promo_rule`; +``` + +--- + +## 14. 风险与注意事项 + +| 风险 | 应对 | +|------|------| +| 促销价 > 原价(配置错误) | 管理后台校验:`promo_price` 必须 < `unit_price` | +| 规则删除后已有订单受影响 | 软删除(`deleted_at`),订单上已存储 `promo_rule_id` 和 `promo_discount`,不依赖规则表 | +| 缓存与数据库不一致 | 管理后台修改时主动清缓存,判定逻辑以 DB 为准 | +| 新促销和老 NewUserOnly 折扣共存 | **互斥**:促销命中时跳过 `getDiscount()` 的百分比折扣 | +| 活动到期后 5 分钟内仍可下单 | 缓存 TTL=300s 的延迟,可接受;下单时可选择实时查 DB 校验 | +| 退款后重新购买仍享促销 | 正常行为 — `promo_usage` 只做记录不做去重 | diff --git a/doc/tapi-file-upload-zh.md b/doc/tapi-file-upload-zh.md index c257041..8785fe1 100644 --- a/doc/tapi-file-upload-zh.md +++ b/doc/tapi-file-upload-zh.md @@ -128,6 +128,10 @@ curl -X PUT 'https://bucket.s3.ap-east-1.amazonaws.com/...' \ 说明: - `Content-Type` 需和 `init` 返回的 `headers.Content-Type` 一致 +- 允许的 `Content-Type` 由服务端 `S3.AllowedContentTypes` 配置控制,默认包含: + - 压缩包:`application/zip`、`application/x-zip-compressed`、`application/gzip`、`application/x-gzip` + - 通用文件:`application/octet-stream`、`text/plain`、`application/json` + - 图片:`image/jpeg`、`image/jpg`、`image/png`、`image/webp`、`image/gif`、`image/heic`、`image/heif`、`image/bmp` - `upload_url` 有过期时间,通常 300 秒 - 成功时 S3 常见返回 `200` 或 `204` @@ -164,11 +168,13 @@ curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload/complete' \ - 如果请求带了 `X-App-Id`,就按现有逻辑验签 - 如果没有 `X-App-Id`,仍按旧逻辑放行 - 如果要给该接口加签,签名时必须对原始 multipart body 计算 `BODY_SHA256` +- 允许的 `Content-Type` 与预签名三段式一致;multipart 文件字段未显式携带 `Content-Type` 时,服务端会基于文件内容嗅探常见类型。 ## 常见错误码 - `200`: 成功 - `400`: 参数错误 +- `400 content_type is not allowed`: 文件 `Content-Type` 不在 `S3.AllowedContentTypes` 白名单内 - `40008`: 缺少签名头 - `40009`: 签名已过期 - `40010`: 签名无效 diff --git a/doc/withdrawal-list-subscribe-fields-zh.md b/doc/withdrawal-list-subscribe-fields-zh.md new file mode 100644 index 0000000..dbd0470 --- /dev/null +++ b/doc/withdrawal-list-subscribe-fields-zh.md @@ -0,0 +1,153 @@ +# 用户端提现列表 API — 订阅字段现状调研 + +> 调研日期:2026-05-27 +> 调研范围:用户端「提现记录列表」接口当前返回字段,重点关注是否包含订阅相关信息 + +## 一、接口信息 + +| 项目 | 值 | +|------|-----| +| 方法 | `GET` | +| 路径 | `/v1/public/user/withdrawal_log` | +| 认证 | JWT Token(`AuthMiddleware` + `DeviceMiddleware`) | +| 分组 | `apis/public/user.api` | + +## 二、文件定位 + +| 层 | 路径 | +|----|------| +| API DSL | `apis/public/user.api:118` (`WithdrawalLog`) / `apis/public/user.api:368` (路由) | +| Handler | `internal/handler/public/user/queryWithdrawalLogHandler.go` | +| Logic | `internal/logic/public/user/queryWithdrawalLogLogic.go:30` | +| 类型生成 | `internal/types/types.go`(`WithdrawalLog`、`QueryWithdrawalLogListRequest`、`QueryWithdrawalLogListResponse`) | +| 数据模型 | `internal/model/user/user.go:167` (`Withdrawal`,表名 `withdrawals`) | + +## 三、请求参数 + +```go +QueryWithdrawalLogListRequest { + Page int `form:"page"` + Size int `form:"size"` +} +``` + +- 默认值:`page=1`、`size=10`(在 logic 内兜底) + +## 四、响应结构 + +### 4.1 顶层响应 + +```go +QueryWithdrawalLogListResponse { + List []WithdrawalLog `json:"list"` + Total int64 `json:"total"` +} +``` + +### 4.2 列表项 `WithdrawalLog` + +```go +WithdrawalLog { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + Amount int64 `json:"amount"` // 单位:分 + Content string `json:"content"` // 收款附加信息 + Status uint8 `json:"status"` // 0:Pending 1:Approved 2:Rejected 3:Cancelled + Reason string `json:"reason,omitempty"` // 拒绝原因 + Method uint8 `json:"method"` // 0:其他 1:支付宝 2:微信 3:USDT + Account string `json:"account"` // 收款账号 + QrCodeUrl string `json:"qr_code_url"` // 收款码图片 URL + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} +``` + +## 五、底层数据模型 `Withdrawal` + +```go +type Withdrawal struct { + Id int64 + UserId int64 // index:idx_user_id + Amount int64 + Content string // type:text + Status uint8 // 0:Pending 1:Approved 2:Rejected 3:Cancelled + Reason string // varchar(500) + Method uint8 // 0:其他 1:支付宝 2:微信 3:USDT + Account string // varchar(255) + QrCodeUrl string // varchar(500) + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +> 表名:`withdrawals`,与用户关联仅靠 `user_id` 外键,**无任何订阅 ID / 订阅快照字段**。 + +## 六、订阅字段现状(核心结论) + +### 6.1 当前结论 + +| 维度 | 是否包含订阅信息 | +|------|------------------| +| API 响应(`WithdrawalLog`) | ❌ 无 | +| 数据库表(`withdrawals`) | ❌ 无 | +| Logic 查询逻辑 | ❌ 无 JOIN、无附加查询 `user_subscribe` | + +提现记录与订阅之间**完全没有关联**。原因:佣金来源于多次订单累计,提现是从「佣金余额(`user.commission`)」整体扣减,不绑定到任何具体订阅。 + +### 6.2 Logic 当前实现要点 + +```go +// internal/logic/public/user/queryWithdrawalLogLogic.go:46-72 +query := l.svcCtx.DB.WithContext(l.ctx). + Model(&user.Withdrawal{}). + Where("user_id = ?", u.Id) + +// 仅按 user_id 过滤 + 分页 + 倒序,无任何 Preload / Join +``` + +## 七、已发现的隐患(与本次需求关联) + +### 7.1 时间戳违反项目约定 ⚠️ + +`queryWithdrawalLogLogic.go:70-71`: + +```go +CreatedAt: row.CreatedAt.UnixMilli(), +UpdatedAt: row.UpdatedAt.UnixMilli(), +``` + +- 项目约定:**后端统一返回秒级 Unix 时间戳**(前端 `formatDate` 已按 `数字 × 1000` 处理) +- 当前实现返回毫秒级,前端会解析为约公元 +55000 年的日期,**展示必然异常** +- 修复方式:改为 `.Unix()` + +> 该问题独立于「订阅字段」需求,但属于同一接口,建议同批修复。 + +## 八、可选扩展方向(待业务确认) + +若产品希望在提现列表中展示订阅相关信息,可选方案如下: + +| 方案 | 字段示意 | 实现成本 | 适用场景 | +|------|----------|----------|----------| +| A. 当前生效订阅摘要 | `current_subscribe: { id, name, expire_at }` | 中(每行额外查 `user_subscribe`) | 想让用户看到「我提的是哪个订阅产生的佣金对应的余额」 | +| B. 用户全部订阅列表 | `subscribes: [{ id, name, expire_at }]` | 高(N+1 风险) | 极少场景,需评估必要性 | +| C. 仅订阅 ID 数组 | `subscribe_ids: [int64]` | 低 | 仅前端跳详情用 | +| D. 不加,保持现状 | — | 0 | 若业务上提现与订阅本就无关 | + +> **推荐先与产品确认动机**:提现是佣金余额提现,与订阅本身没有直接业务关系,加字段前需明确「让用户看到订阅信息要解决什么问题」。 + +## 九、相关接口(一并列出,便于对照) + +| 接口 | 方法 | 路径 | 说明 | +|------|------|------|------| +| 提交提现 | POST | `/v1/public/user/commission_withdraw` | 入参 `CommissionWithdrawRequest`,返回 `WithdrawalLog` | +| 取消提现 | POST | `/v1/public/user/withdrawal_cancel` | 入参 `CancelWithdrawalRequest`,返回 `WithdrawalLog` | +| 提现记录列表 | GET | `/v1/public/user/withdrawal_log` | 本文主角 | + +> 三个接口共用 `WithdrawalLog` 类型,**任何字段变更需统一同步**,否则前端类型会错位。 + +## 十、后续动作建议 + +1. **产品确认**:是否真的需要在提现列表里返回订阅字段?目的是什么? +2. **若需新增**:在 `apis/public/user.api` 修改 `WithdrawalLog`,运行 goctl 重新生成,再补 Logic 查询。 +3. **顺手修复**:将 `UnixMilli()` 改为 `Unix()`(独立小 PR 即可)。 +4. **如新增订阅字段**:注意三个接口(list / cancel / withdraw)的返回结构同步,避免前端类型联动断裂。 diff --git a/docker-compose.cloud.yml b/docker-compose.cloud.yml index de93cc1..8cf2833 100644 --- a/docker-compose.cloud.yml +++ b/docker-compose.cloud.yml @@ -1,34 +1,12 @@ -# PPanel 服务部署 (云端/无源码版) -# 使用方法: -# 1. 确保已将 docker-compose.cloud.yml, configs/, loki/, grafana/, prometheus/, tempo/ 目录上传到服务器同一目录 -# 2. 确保 configs/ 目录下有 ppanel.yaml 配置文件(参考 etc/ppanel.yaml) -# 3. 确保 logs/ cache/ tempo_data/ 目录存在 (mkdir -p logs cache tempo_data) -# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d -# -# 网络说明: -# ppanel-server 使用 host 网络(可出外网,直接访问 AWS RDS / 本机 Redis) -# 监控服务(Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中 -# Tempo(4317) 将端口映射到 127.0.0.1,ppanel-server 通过 host 网络访问 -# 监控端口绑定 127.0.0.1,需通过 SSH 隧道或 Nginx 反代访问 -# -# 未来多开 ppanel-server 时: -# 修复宿主机 iptables bridge 出网规则后,可将 ppanel-server 切回 bridge 网络 -# 多实例用不同端口: ports: ["8081:8080"] + container_name: ppanel-server-2 - services: - # ---------------------------------------------------- - # 1. 业务后端 (PPanel Server) - # host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo - # PPANEL_SERVER_TAG 由 CI/CD 传入不可变镜像标签(如 git SHA) - # ---------------------------------------------------- ppanel-server: - image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag} + image: ${PPANEL_SERVER_IMAGE:-registry.kxsw.us/vpn-server}:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag} container_name: ppanel-server restart: always volumes: - ./configs:/app/etc - ./logs:/app/logs - - ./cache:/app/cache # GeoLite2-City.mmdb IP 地理位置数据库 + - ./cache:/app/cache environment: - TZ=Asia/Shanghai network_mode: host @@ -37,215 +15,8 @@ services: nofile: soft: 65535 hard: 65535 - depends_on: - tempo: - condition: service_started logging: driver: "json-file" options: max-size: "10m" max-file: "3" - - # ---------------------------------------------------- - # 2. Tempo (链路追踪存储) - # ---------------------------------------------------- - tempo: - image: grafana/tempo:2.4.1 - container_name: ppanel-tempo - user: root - restart: always - command: - - "-config.file=/etc/tempo.yaml" - - "-target=all" - volumes: - - ./tempo/tempo-config.yaml:/etc/tempo.yaml - - ./tempo_data:/var/tempo - ports: - - "127.0.0.1:4317:4317" # OTLP gRPC,ppanel-server(host网络)通过127.0.0.1:4317发送trace - networks: - - ppanel_net - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - # ---------------------------------------------------- - # 3. Loki (日志存储) - # ---------------------------------------------------- - loki: - image: grafana/loki:3.0.0 - container_name: ppanel-loki - restart: always - volumes: - - ./loki/loki-config.yaml:/etc/loki/local-config.yaml - - loki_data:/loki - command: -config.file=/etc/loki/local-config.yaml - # 不对外暴露端口,仅内网访问 - networks: - - ppanel_net - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - # ---------------------------------------------------- - # 4. Promtail (日志采集) - # ---------------------------------------------------- - promtail: - image: grafana/promtail:3.0.0 - container_name: ppanel-promtail - restart: always - volumes: - - ./loki/promtail-config.yaml:/etc/promtail/config.yaml - - /var/lib/docker/containers:/var/lib/docker/containers:ro - - /var/run/docker.sock:/var/run/docker.sock - - ./logs:/var/log/ppanel-server:ro - - /var/log/nginx:/var/log/nginx:ro - command: -config.file=/etc/promtail/config.yaml - networks: - - ppanel_net - depends_on: - - loki - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - # ---------------------------------------------------- - # 5. Grafana (可观测面板) - # 访问: ssh -L 3333:localhost:3333 your-server 后浏览器打开 http://localhost:3333 - # 或配置 Nginx 反代(建议加认证) - # ---------------------------------------------------- - grafana: - image: grafana/grafana:13.0.1 - container_name: ppanel-grafana - restart: always - ports: - - "3333:3000" # 仅本机可访问,需 SSH 隧道或 Nginx 反代 - environment: - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:?请在 .env 文件中设置 GRAFANA_PASSWORD} - - GF_USERS_ALLOW_SIGN_UP=false - - GF_SERVER_DOMAIN=${GRAFANA_DOMAIN:-logsx.hifast.biz} - - GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-https://logsx.hifast.biz} - - GF_FEATURE_TOGGLES_ENABLE=appObservability - - AWS_REGION=${AWS_REGION:-ap-east-1} - - AWS_DEFAULT_REGION=${AWS_REGION:-ap-east-1} - - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-} - - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-} - - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-} - volumes: - - grafana_data:/var/lib/grafana - - ./grafana/provisioning:/etc/grafana/provisioning - networks: - - ppanel_net - depends_on: - - loki - - tempo - - prometheus - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - # ---------------------------------------------------- - # 6. Prometheus (指标采集) - # ---------------------------------------------------- - prometheus: - image: prom/prometheus:v3.11.3 - container_name: ppanel-prometheus - restart: always - ports: - - "127.0.0.1:9090:9090" # 仅本机可访问 - volumes: - - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - - prometheus_data:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - - '--web.enable-lifecycle' - - '--web.enable-remote-write-receiver' - networks: - - ppanel_net - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - # ---------------------------------------------------- - # 7. Nginx Exporter (监控宿主机 Nginx) - # ---------------------------------------------------- - nginx-exporter: - image: nginx/nginx-prometheus-exporter:1.5.0 - container_name: ppanel-nginx-exporter - restart: always - command: - - -nginx.scrape-uri=http://host.docker.internal:8090/nginx_status - extra_hosts: - - "host.docker.internal:host-gateway" - networks: - - ppanel_net - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - # ---------------------------------------------------- - # 8. Node Exporter (宿主机监控) - # ---------------------------------------------------- - node-exporter: - image: prom/node-exporter:v1.11.1 - container_name: ppanel-node-exporter - restart: always - volumes: - - /proc:/host/proc:ro - - /sys:/host/sys:ro - - /:/rootfs:ro - command: - - '--path.procfs=/host/proc' - - '--path.sysfs=/host/sys' - - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' - networks: - - ppanel_net - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - # ---------------------------------------------------- - # 9. cAdvisor (容器监控) - # ---------------------------------------------------- - cadvisor: - image: gcr.io/cadvisor/cadvisor:v0.55.1 - container_name: ppanel-cadvisor - restart: always - volumes: - - /:/rootfs:ro - - /var/run:/var/run:ro - - /sys:/sys:ro - - /var/lib/docker/:/var/lib/docker:ro - - /dev/disk/:/dev/disk:ro - networks: - - ppanel_net - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - -volumes: - loki_data: - grafana_data: - prometheus_data: - tempo_data: - -networks: - ppanel_net: - name: ppanel_net - driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index c2b3225..3bc2ced 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3' - services: ppanel: container_name: ppanel-server diff --git a/etc/ppanel.yaml b/etc/ppanel.yaml index a2423cf..26eea64 100644 --- a/etc/ppanel.yaml +++ b/etc/ppanel.yaml @@ -15,10 +15,10 @@ Logger: # 日志配置 Level: debug # 日志级别: debug, info, warn, error, panic, fatal MySQL: - Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306 + Addr: 127.0.0.1:3306 # 本地开发默认;Docker bridge 模式可改为 mysql:3306 Username: root # MySQL用户名 - Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致 - Dbname: hifast # MySQL数据库名 + Password: CHANGE_ME_TO_DB_PASSWORD # MySQL密码 + Dbname: ppanel # MySQL数据库名 Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai MaxIdleConns: 10 MaxOpenConns: 100 @@ -42,9 +42,9 @@ Redis: AppSignature: AppSecrets: - android-client: uB4G,XxL2{7b # Android 客户端签名密钥 - ios-client: uB4G,XxL2{7b # iOS 客户端签名密钥 - web-client: uB4G,XxL2{7b # Web 客户端签名密钥 + android-client: CHANGE_ME_ANDROID_APP_SECRET # Android 客户端签名密钥 + ios-client: CHANGE_ME_IOS_APP_SECRET # iOS 客户端签名密钥 + web-client: CHANGE_ME_WEB_APP_SECRET # Web 客户端签名密钥 ValidWindowSeconds: 300 # 签名时间窗口(秒) SkipPrefixes: - /v1/notify/ # 支付回调不验签 @@ -58,8 +58,8 @@ Signature: Trace: # 链路追踪配置 (OpenTelemetry) Name: ppanel # 服务名 Sampler: 1.0 # 采样率 0.0-1.0,生产建议 0.1 - Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc - Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317 + Batcher: "" # 本地开发留空;生产如需链路追踪再配置 exporter + Endpoint: "" S3: Enable: false @@ -74,7 +74,7 @@ S3: UsePathStyle: false PresignExpireSeconds: 300 MaxUploadSize: 104857600 - AllowedContentTypes: "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json" + AllowedContentTypes: "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp" device: enable: true # 开启设备加密通信 diff --git a/go.mod b/go.mod index ca67325..3dda44b 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,7 @@ require ( ) require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/Masterminds/sprig/v3 v3.3.0 github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/config v1.32.17 diff --git a/go.sum b/go.sum index df90cc5..19ed347 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f h1:RDkg3pyE1qGbBpRWmvSN9RNZC5nUrOaEPiEpEb8y2f0= github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f/go.mod h1:zA7AF9RTfpluCfz0omI4t5KCMaWHUMicsZoMccnaT44= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -264,6 +266,7 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= diff --git a/grafana/provisioning/alerting/ppanel-alert-rules.yml b/grafana/provisioning/alerting/ppanel-alert-rules.yml deleted file mode 100644 index e1e9e71..0000000 --- a/grafana/provisioning/alerting/ppanel-alert-rules.yml +++ /dev/null @@ -1,272 +0,0 @@ -apiVersion: 1 - -groups: - - orgId: 1 - name: ppanel-core - folder: PPanel - interval: 1m - rules: - - uid: ppanel-target-down - title: PPanel monitoring target down - condition: C - for: 2m - noDataState: Alerting - execErrState: Error - annotations: - summary: "Monitoring target is down" - description: "{{ $labels.job }} on {{ $labels.instance }} has been down for more than 2 minutes." - labels: - severity: critical - service: ppanel - data: - - refId: A - relativeTimeRange: - from: 300 - to: 0 - datasourceUid: prometheus - model: - datasource: - type: prometheus - uid: prometheus - editorMode: code - expr: 'up{job=~"grafana|prometheus|node-exporter|cadvisor|nginx-exporter|loki|tempo"}' - instant: true - intervalMs: 1000 - maxDataPoints: 43200 - refId: A - - refId: C - datasourceUid: __expr__ - model: - conditions: - - evaluator: - params: - - 1 - type: lt - operator: - type: and - query: - params: - - A - reducer: - type: last - type: query - datasource: - type: __expr__ - uid: __expr__ - expression: A - intervalMs: 1000 - maxDataPoints: 43200 - refId: C - type: threshold - - - uid: ppanel-host-disk-high - title: PPanel host disk usage high - condition: C - for: 10m - noDataState: NoData - execErrState: Error - annotations: - summary: "Host disk usage is high" - description: "{{ $labels.instance }} {{ $labels.mountpoint }} disk usage is above 85% for 10 minutes." - labels: - severity: warning - service: ppanel - data: - - refId: A - relativeTimeRange: - from: 900 - to: 0 - datasourceUid: prometheus - model: - datasource: - type: prometheus - uid: prometheus - editorMode: code - expr: '100 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} * 100)' - instant: true - intervalMs: 1000 - maxDataPoints: 43200 - refId: A - - refId: C - datasourceUid: __expr__ - model: - conditions: - - evaluator: - params: - - 85 - type: gt - operator: - type: and - query: - params: - - A - reducer: - type: last - type: query - datasource: - type: __expr__ - uid: __expr__ - expression: A - intervalMs: 1000 - maxDataPoints: 43200 - refId: C - type: threshold - - - uid: ppanel-host-memory-high - title: PPanel host memory usage high - condition: C - for: 10m - noDataState: NoData - execErrState: Error - annotations: - summary: "Host memory usage is high" - description: "{{ $labels.instance }} memory usage is above 90% for 10 minutes." - labels: - severity: warning - service: ppanel - data: - - refId: A - relativeTimeRange: - from: 900 - to: 0 - datasourceUid: prometheus - model: - datasource: - type: prometheus - uid: prometheus - editorMode: code - expr: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100' - instant: true - intervalMs: 1000 - maxDataPoints: 43200 - refId: A - - refId: C - datasourceUid: __expr__ - model: - conditions: - - evaluator: - params: - - 90 - type: gt - operator: - type: and - query: - params: - - A - reducer: - type: last - type: query - datasource: - type: __expr__ - uid: __expr__ - expression: A - intervalMs: 1000 - maxDataPoints: 43200 - refId: C - type: threshold - - - uid: ppanel-host-cpu-high - title: PPanel host CPU usage high - condition: C - for: 10m - noDataState: NoData - execErrState: Error - annotations: - summary: "Host CPU usage is high" - description: "{{ $labels.instance }} CPU usage is above 90% for 10 minutes." - labels: - severity: warning - service: ppanel - data: - - refId: A - relativeTimeRange: - from: 900 - to: 0 - datasourceUid: prometheus - model: - datasource: - type: prometheus - uid: prometheus - editorMode: code - expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)' - instant: true - intervalMs: 1000 - maxDataPoints: 43200 - refId: A - - refId: C - datasourceUid: __expr__ - model: - conditions: - - evaluator: - params: - - 90 - type: gt - operator: - type: and - query: - params: - - A - reducer: - type: last - type: query - datasource: - type: __expr__ - uid: __expr__ - expression: A - intervalMs: 1000 - maxDataPoints: 43200 - refId: C - type: threshold - - - uid: ppanel-container-restarts - title: PPanel container restarted - condition: C - for: 1m - noDataState: NoData - execErrState: Error - annotations: - summary: "Container restarted" - description: "{{ $labels.name }} restarted or changed start time in the last hour." - labels: - severity: warning - service: ppanel - data: - - refId: A - relativeTimeRange: - from: 3600 - to: 0 - datasourceUid: prometheus - model: - datasource: - type: prometheus - uid: prometheus - editorMode: code - expr: 'sum by (name) (changes(container_start_time_seconds{name!=""}[1h]))' - instant: true - intervalMs: 1000 - maxDataPoints: 43200 - refId: A - - refId: C - datasourceUid: __expr__ - model: - conditions: - - evaluator: - params: - - 0 - type: gt - operator: - type: and - query: - params: - - A - reducer: - type: last - type: query - datasource: - type: __expr__ - uid: __expr__ - expression: A - intervalMs: 1000 - maxDataPoints: 43200 - refId: C - type: threshold diff --git a/grafana/provisioning/dashboards/dashboards.yml b/grafana/provisioning/dashboards/dashboards.yml deleted file mode 100644 index ed36901..0000000 --- a/grafana/provisioning/dashboards/dashboards.yml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: 1 - -providers: - - name: PPanel - orgId: 1 - folder: PPanel - folderUid: ppanel - type: file - disableDeletion: false - allowUiUpdates: true - updateIntervalSeconds: 30 - options: - path: /etc/grafana/provisioning/dashboards/json - foldersFromFilesStructure: false diff --git a/grafana/provisioning/dashboards/json/aws-rds-redis-overview.json b/grafana/provisioning/dashboards/json/aws-rds-redis-overview.json deleted file mode 100644 index 97f4820..0000000 --- a/grafana/provisioning/dashboards/json/aws-rds-redis-overview.json +++ /dev/null @@ -1,520 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "gridPos": { - "h": 3, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "content": "AWS CloudWatch overview
Region: ap-east-1 (Hong Kong)
RDS DBInstanceIdentifier: hifast-mysql-prod-v2
Redis: current production uses a local Docker Redis container (hifast-redis) on EC2 rather than AWS ElastiCache.

This dashboard keeps the RDS CloudWatch panels. Redis should be observed from the local ops dashboard via Prometheus/cAdvisor instead of ElastiCache metrics.", - "mode": "html" - }, - "pluginVersion": "11.0.0", - "title": "Read Me", - "type": "text" - }, - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "fieldConfig": { - "defaults": { - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 3 - }, - "id": 2, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "dimensions": { - "DBInstanceIdentifier": "hifast-mysql-prod-v2" - }, - "metricName": "CPUUtilization", - "namespace": "AWS/RDS", - "period": "", - "refId": "A", - "region": "ap-east-1", - "statistic": "Average" - } - ], - "title": "RDS CPU", - "type": "timeseries" - }, - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "fieldConfig": { - "defaults": { - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 3 - }, - "id": 3, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "dimensions": { - "DBInstanceIdentifier": "hifast-mysql-prod-v2" - }, - "metricName": "DatabaseConnections", - "namespace": "AWS/RDS", - "period": "", - "refId": "A", - "region": "ap-east-1", - "statistic": "Average" - } - ], - "title": "RDS Connections", - "type": "timeseries" - }, - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "fieldConfig": { - "defaults": { - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 3 - }, - "id": 4, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "dimensions": { - "DBInstanceIdentifier": "hifast-mysql-prod-v2" - }, - "metricName": "FreeStorageSpace", - "namespace": "AWS/RDS", - "period": "", - "refId": "A", - "region": "ap-east-1", - "statistic": "Minimum" - } - ], - "title": "RDS Free Storage", - "type": "timeseries" - }, - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "fieldConfig": { - "defaults": { - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 11 - }, - "id": 5, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } - }, - "targets": [ - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "dimensions": { - "DBInstanceIdentifier": "hifast-mysql-prod-v2" - }, - "metricName": "ReadLatency", - "namespace": "AWS/RDS", - "period": "", - "refId": "A", - "region": "ap-east-1", - "statistic": "Average" - }, - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "dimensions": { - "DBInstanceIdentifier": "hifast-mysql-prod-v2" - }, - "metricName": "WriteLatency", - "namespace": "AWS/RDS", - "period": "", - "refId": "B", - "region": "ap-east-1", - "statistic": "Average" - } - ], - "title": "RDS Read / Write Latency", - "type": "timeseries" - }, - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "fieldConfig": { - "defaults": { - "unit": "iops" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 11 - }, - "id": 6, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } - }, - "targets": [ - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "dimensions": { - "DBInstanceIdentifier": "hifast-mysql-prod-v2" - }, - "metricName": "ReadIOPS", - "namespace": "AWS/RDS", - "period": "", - "refId": "A", - "region": "ap-east-1", - "statistic": "Average" - }, - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "dimensions": { - "DBInstanceIdentifier": "hifast-mysql-prod-v2" - }, - "metricName": "WriteIOPS", - "namespace": "AWS/RDS", - "period": "", - "refId": "B", - "region": "ap-east-1", - "statistic": "Average" - } - ], - "title": "RDS Read / Write IOPS", - "type": "timeseries" - }, - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "fieldConfig": { - "defaults": { - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 6, - "x": 0, - "y": 19 - }, - "id": 7, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "dimensions": { - "ReplicationGroupId": "hifastapp-redis" - }, - "metricName": "CPUUtilization", - "namespace": "AWS/ElastiCache", - "period": "", - "refId": "A", - "region": "ap-east-1", - "statistic": "Average" - } - ], - "title": "Redis Host CPU (Legacy ElastiCache)", - "type": "timeseries" - }, - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "fieldConfig": { - "defaults": { - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 6, - "x": 6, - "y": 19 - }, - "id": 8, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "dimensions": { - "ReplicationGroupId": "hifastapp-redis" - }, - "metricName": "EngineCPUUtilization", - "namespace": "AWS/ElastiCache", - "period": "", - "refId": "A", - "region": "ap-east-1", - "statistic": "Average" - } - ], - "title": "Redis Engine CPU (Legacy ElastiCache)", - "type": "timeseries" - }, - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "fieldConfig": { - "defaults": { - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 6, - "x": 12, - "y": 19 - }, - "id": 9, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "dimensions": { - "ReplicationGroupId": "hifastapp-redis" - }, - "metricName": "CurrConnections", - "namespace": "AWS/ElastiCache", - "period": "", - "refId": "A", - "region": "ap-east-1", - "statistic": "Average" - } - ], - "title": "Redis Connections (Legacy ElastiCache)", - "type": "timeseries" - }, - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "fieldConfig": { - "defaults": { - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 6, - "x": 18, - "y": 19 - }, - "id": 10, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "datasource": { - "type": "cloudwatch", - "uid": "cloudwatch" - }, - "dimensions": { - "ReplicationGroupId": "hifastapp-redis" - }, - "metricName": "DatabaseMemoryUsagePercentage", - "namespace": "AWS/ElastiCache", - "period": "", - "refId": "A", - "region": "ap-east-1", - "statistic": "Average" - } - ], - "title": "Redis Memory Usage % (Legacy ElastiCache)", - "type": "timeseries" - } - ], - "refresh": "30s", - "schemaVersion": 39, - "style": "dark", - "tags": [ - "aws", - "cloudwatch", - "rds", - "redis" - ], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "browser", - "title": "AWS RDS & Redis Overview", - "uid": "aws-rds-redis-overview", - "version": 1, - "weekStart": "" -} diff --git a/grafana/provisioning/dashboards/json/ppanel-ops-overview.json b/grafana/provisioning/dashboards/json/ppanel-ops-overview.json deleted file mode 100644 index 08ea6e8..0000000 --- a/grafana/provisioning/dashboards/json/ppanel-ops-overview.json +++ /dev/null @@ -1,565 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [ - { - "options": { - "0": { - "text": "DOWN" - }, - "1": { - "text": "UP" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "red", - "value": null - }, - { - "color": "green", - "value": 1 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 24, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "13.0.1", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "up{job=~\"prometheus|grafana|node-exporter|cadvisor|nginx-exporter|loki|tempo\"}", - "instant": true, - "legendFormat": "{{job}}", - "range": false, - "refId": "A" - } - ], - "title": "Service Availability", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "orange", - "value": 75 - }, - { - "color": "red", - "value": 90 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 4 - }, - "id": 2, - "options": { - "legend": { - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", - "legendFormat": "{{instance}} CPU", - "range": true, - "refId": "A" - } - ], - "title": "Host CPU Usage", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "orange", - "value": 80 - }, - { - "color": "red", - "value": 90 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 4 - }, - "id": 3, - "options": { - "legend": { - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100", - "legendFormat": "{{instance}} memory", - "range": true, - "refId": "A" - } - ], - "title": "Host Memory Usage", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "orange", - "value": 80 - }, - { - "color": "red", - "value": 90 - } - ] - }, - "unit": "percent" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 4 - }, - "id": 4, - "options": { - "legend": { - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "100 - (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} * 100)", - "legendFormat": "{{mountpoint}}", - "range": true, - "refId": "A" - } - ], - "title": "Host Disk Usage", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 12 - }, - "id": 5, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name!=\"\"}[5m]))", - "legendFormat": "{{name}}", - "range": true, - "refId": "A" - } - ], - "title": "Container CPU", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 12 - }, - "id": 6, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "sum by (name) (container_memory_working_set_bytes{name!=\"\"})", - "legendFormat": "{{name}}", - "range": true, - "refId": "A" - } - ], - "title": "Container Memory", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 12 - }, - "id": 7, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "sum by (name) (changes(container_start_time_seconds{name!=\"\"}[1h]))", - "legendFormat": "{{name}}", - "range": true, - "refId": "A" - } - ], - "title": "Container Restarts / Changes", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 20 - }, - "id": 8, - "options": { - "legend": { - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "rate(nginx_http_requests_total[5m])", - "legendFormat": "requests", - "range": true, - "refId": "A" - } - ], - "title": "Nginx Requests", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 20 - }, - "id": 9, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": true, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": true - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "loki" - }, - "editorMode": "code", - "expr": "{job=~\"ppanel-server|nginx|docker\"} |~ \"(?i)(error|panic|fatal|timeout|exception|failed)\"", - "queryType": "range", - "refId": "A" - } - ], - "title": "Recent Errors", - "type": "logs" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "fieldConfig": { - "defaults": { - "unit": "reqps" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 20 - }, - "id": 10, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "sum by (service_name) (rate(traces_spanmetrics_calls_total[5m]))", - "legendFormat": "{{service_name}}", - "range": true, - "refId": "A" - } - ], - "title": "Trace Span Calls", - "type": "timeseries" - } - ], - "refresh": "30s", - "schemaVersion": 42, - "tags": [ - "ppanel", - "ops", - "prometheus", - "loki", - "tempo" - ], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": {}, - "timezone": "browser", - "title": "PPanel Ops Overview", - "uid": "ppanel-ops-overview", - "version": 1, - "weekStart": "" -} diff --git a/grafana/provisioning/dashboards/json/ppanel-server-logs.json b/grafana/provisioning/dashboards/json/ppanel-server-logs.json deleted file mode 100644 index 864f1c5..0000000 --- a/grafana/provisioning/dashboards/json/ppanel-server-logs.json +++ /dev/null @@ -1,330 +0,0 @@ -{ - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": { - "type": "grafana", - "uid": "-- Grafana --" - }, - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "fiscalYearStartMonth": 0, - "graphTooltip": 0, - "id": null, - "links": [], - "panels": [ - { - "datasource": { - "type": "loki", - "uid": "P8E80F9AEF21F6940" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "P8E80F9AEF21F6940" - }, - "editorMode": "code", - "expr": "sum(count_over_time({compose_service=\"ppanel-server\"}[5m]))", - "queryType": "range", - "refId": "A" - } - ], - "title": "Matched Log Volume", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "P8E80F9AEF21F6940" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 12, - "y": 0 - }, - "id": 2, - "options": { - "legend": { - "displayMode": "table", - "placement": "bottom" - }, - "tooltip": { - "mode": "multi" - } - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "P8E80F9AEF21F6940" - }, - "editorMode": "code", - "expr": "sum(count_over_time({compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\" [5m]))", - "queryType": "range", - "refId": "A" - } - ], - "title": "Matched Error Volume", - "type": "timeseries" - }, - { - "datasource": { - "type": "loki", - "uid": "P8E80F9AEF21F6940" - }, - "gridPos": { - "h": 12, - "w": 24, - "x": 0, - "y": 7 - }, - "id": 3, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": true, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": true - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "P8E80F9AEF21F6940" - }, - "editorMode": "code", - "expr": "{compose_service=\"ppanel-server\"}", - "queryType": "range", - "refId": "A" - } - ], - "title": "Filtered Server Logs", - "type": "logs" - }, - { - "datasource": { - "type": "loki", - "uid": "P8E80F9AEF21F6940" - }, - "gridPos": { - "h": 10, - "w": 24, - "x": 0, - "y": 19 - }, - "id": 4, - "options": { - "dedupStrategy": "none", - "enableLogDetails": true, - "prettifyLogMessage": false, - "showCommonLabels": false, - "showLabels": true, - "showTime": true, - "sortOrder": "Descending", - "wrapLogMessage": true - }, - "targets": [ - { - "datasource": { - "type": "loki", - "uid": "P8E80F9AEF21F6940" - }, - "editorMode": "code", - "expr": "{compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\"", - "queryType": "range", - "refId": "A" - } - ], - "title": "Filtered Server Errors", - "type": "logs" - } - ], - "refresh": "30s", - "schemaVersion": 42, - "tags": [ - "ppanel", - "logs", - "server", - "loki" - ], - "templating": { - "list": [ - { - "current": { - "selected": false, - "text": ".", - "value": "." - }, - "description": "输入用户 ID;默认 . 表示不过滤", - "hide": 0, - "label": "用户ID", - "name": "user_id", - "options": [], - "query": ".", - "skipUrlSync": false, - "type": "textbox" - }, - { - "current": { - "selected": false, - "text": ".", - "value": "." - }, - "description": "输入邮箱或邮箱片段;默认 . 表示不过滤", - "hide": 0, - "label": "邮箱", - "name": "email", - "options": [], - "query": ".", - "skipUrlSync": false, - "type": "textbox" - }, - { - "current": { - "selected": false, - "text": ".", - "value": "." - }, - "description": "输入订单号/支付单号/交易号片段;默认 . 表示不过滤", - "hide": 0, - "label": "订单", - "name": "order", - "options": [], - "query": ".", - "skipUrlSync": false, - "type": "textbox" - }, - { - "current": { - "selected": true, - "text": "All", - "value": "." - }, - "description": "日志等级", - "hide": 0, - "includeAll": false, - "label": "等级", - "multi": false, - "name": "level", - "options": [ - { - "selected": true, - "text": "All", - "value": "." - }, - { - "selected": false, - "text": "debug", - "value": "debug" - }, - { - "selected": false, - "text": "info", - "value": "info" - }, - { - "selected": false, - "text": "warn", - "value": "warn" - }, - { - "selected": false, - "text": "error", - "value": "error" - }, - { - "selected": false, - "text": "slow", - "value": "slow" - }, - { - "selected": false, - "text": "panic", - "value": "panic" - }, - { - "selected": false, - "text": "fatal", - "value": "fatal" - } - ], - "query": "All : .,debug,info,warn,error,slow,panic,fatal", - "queryValue": "", - "skipUrlSync": false, - "type": "custom" - }, - { - "current": { - "selected": false, - "text": ".", - "value": "." - }, - "description": "任意关键字;默认 . 表示不过滤", - "hide": 0, - "label": "关键字", - "name": "keyword", - "options": [], - "query": ".", - "skipUrlSync": false, - "type": "textbox" - } - ] - }, - "time": { - "from": "now-1h", - "to": "now" - }, - "timepicker": {}, - "timezone": "browser", - "title": "PPanel Server Logs", - "uid": "ppanel-server-logs", - "version": 5, - "weekStart": "" -} diff --git a/grafana/provisioning/datasources/datasources.yml b/grafana/provisioning/datasources/datasources.yml deleted file mode 100644 index 9f2be22..0000000 --- a/grafana/provisioning/datasources/datasources.yml +++ /dev/null @@ -1,57 +0,0 @@ -apiVersion: 1 - -datasources: - - name: Prometheus - uid: prometheus - type: prometheus - access: proxy - url: http://prometheus:9090 - isDefault: true - editable: true - jsonData: - httpMethod: POST - manageAlerts: true - prometheusType: Prometheus - prometheusVersion: 2.50.0 - timeInterval: 15s - - - name: Loki - uid: loki - type: loki - access: proxy - url: http://loki:3100 - editable: true - jsonData: - derivedFields: - - datasourceUid: tempo - matcherRegex: '"(?:trace|traceID|trace_id)"\s*:\s*"([a-f0-9]{32})"' - name: TraceID - url: '$${__value.raw}' - - - name: Tempo - uid: tempo - type: tempo - access: proxy - url: http://tempo:3200 - editable: true - jsonData: - tracesToLogsV2: - datasourceUid: loki - filterByTraceID: true - filterBySpanID: false - tags: - - key: service.name - value: service_name - tracesToMetrics: - datasourceUid: prometheus - serviceMap: - datasourceUid: prometheus - - - name: CloudWatch - uid: cloudwatch - type: cloudwatch - access: proxy - editable: true - jsonData: - authType: default - defaultRegion: ap-east-1 diff --git a/initialize/migrate/database/02152_withdrawal_method.up.sql b/initialize/migrate/database/02152_withdrawal_method.up.sql index 57f3104..84ecbc2 100644 --- a/initialize/migrate/database/02152_withdrawal_method.up.sql +++ b/initialize/migrate/database/02152_withdrawal_method.up.sql @@ -1,4 +1,10 @@ -ALTER TABLE `withdrawals` - ADD COLUMN `method` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '收款方式 0:其他 1:支付宝 2:微信 3:银行卡' AFTER `content`, - ADD COLUMN `account` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '收款账号' AFTER `method`, - ADD COLUMN `qr_code_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '收款码图片URL' AFTER `account`; +SELECT COUNT(*) INTO @col_exists FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'withdrawals' AND COLUMN_NAME = 'method'; + +SET @ddl = IF(@col_exists = 0, + 'ALTER TABLE `withdrawals` ADD COLUMN `method` TINYINT(1) NOT NULL DEFAULT 0 AFTER `content`, ADD COLUMN `account` VARCHAR(255) NOT NULL DEFAULT '''' AFTER `method`, ADD COLUMN `qr_code_url` VARCHAR(500) NOT NULL DEFAULT '''' AFTER `account`', + 'SELECT 1'); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/initialize/migrate/database/02153_user_subscribe_speed_limit.down.sql b/initialize/migrate/database/02153_user_subscribe_speed_limit.down.sql new file mode 100644 index 0000000..d46dfea --- /dev/null +++ b/initialize/migrate/database/02153_user_subscribe_speed_limit.down.sql @@ -0,0 +1,35 @@ +SET @traffic_limit_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_subscribe' + AND COLUMN_NAME = 'traffic_limit' +); + +SET @traffic_limit_sql = IF( + @traffic_limit_exists = 1, + 'ALTER TABLE `user_subscribe` DROP COLUMN `traffic_limit`', + 'SELECT 1' +); + +PREPARE traffic_limit_stmt FROM @traffic_limit_sql; +EXECUTE traffic_limit_stmt; +DEALLOCATE PREPARE traffic_limit_stmt; + +SET @speed_limit_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_subscribe' + AND COLUMN_NAME = 'speed_limit' +); + +SET @speed_limit_sql = IF( + @speed_limit_exists = 1, + 'ALTER TABLE `user_subscribe` DROP COLUMN `speed_limit`', + 'SELECT 1' +); + +PREPARE speed_limit_stmt FROM @speed_limit_sql; +EXECUTE speed_limit_stmt; +DEALLOCATE PREPARE speed_limit_stmt; diff --git a/initialize/migrate/database/02153_user_subscribe_speed_limit.up.sql b/initialize/migrate/database/02153_user_subscribe_speed_limit.up.sql new file mode 100644 index 0000000..f7b4673 --- /dev/null +++ b/initialize/migrate/database/02153_user_subscribe_speed_limit.up.sql @@ -0,0 +1,35 @@ +SET @speed_limit_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_subscribe' + AND COLUMN_NAME = 'speed_limit' +); + +SET @speed_limit_sql = IF( + @speed_limit_exists = 0, + 'ALTER TABLE `user_subscribe` ADD COLUMN `speed_limit` BIGINT NOT NULL DEFAULT 0 COMMENT ''User-level speed limit override (Mbps), 0 uses plan-level'' AFTER `upload`', + 'SELECT 1' +); + +PREPARE speed_limit_stmt FROM @speed_limit_sql; +EXECUTE speed_limit_stmt; +DEALLOCATE PREPARE speed_limit_stmt; + +SET @traffic_limit_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_subscribe' + AND COLUMN_NAME = 'traffic_limit' +); + +SET @traffic_limit_sql = IF( + @traffic_limit_exists = 0, + 'ALTER TABLE `user_subscribe` ADD COLUMN `traffic_limit` TEXT DEFAULT NULL COMMENT ''User-level traffic limit override (JSON), NULL uses plan-level'' AFTER `speed_limit`', + 'SELECT 1' +); + +PREPARE traffic_limit_stmt FROM @traffic_limit_sql; +EXECUTE traffic_limit_stmt; +DEALLOCATE PREPARE traffic_limit_stmt; diff --git a/initialize/migrate/database/02154_promo_system.down.sql b/initialize/migrate/database/02154_promo_system.down.sql new file mode 100644 index 0000000..ea3d471 --- /dev/null +++ b/initialize/migrate/database/02154_promo_system.down.sql @@ -0,0 +1,39 @@ +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_discount' +); + +SET @sql = IF( + @column_exists = 1, + 'ALTER TABLE `order` DROP COLUMN `promo_discount`', + 'SELECT ''Column promo_discount does not exist in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_rule_id' +); + +SET @sql = IF( + @column_exists = 1, + 'ALTER TABLE `order` DROP COLUMN `promo_rule_id`', + 'SELECT ''Column promo_rule_id does not exist in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +DROP TABLE IF EXISTS `promo_usage`; +DROP TABLE IF EXISTS `subscribe_promo`; +DROP TABLE IF EXISTS `promo_rule`; diff --git a/initialize/migrate/database/02154_promo_system.up.sql b/initialize/migrate/database/02154_promo_system.up.sql new file mode 100644 index 0000000..6ab3153 --- /dev/null +++ b/initialize/migrate/database/02154_promo_system.up.sql @@ -0,0 +1,213 @@ +CREATE TABLE IF NOT EXISTS `promo_rule` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '规则名称', + `type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '规则类型:new_user / inactive_user / campaign', + `params` JSON NOT NULL COMMENT '类型专属参数', + `priority` INT NOT NULL DEFAULT 0 COMMENT '优先级,数值越大越优先匹配', + `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + `start_time` DATETIME DEFAULT NULL COMMENT '生效开始时间', + `end_time` DATETIME DEFAULT NULL COMMENT '生效结束时间', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间', + PRIMARY KEY (`id`), + KEY `idx_enabled_priority_deleted` (`enabled`, `deleted_at`, `priority` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表'; + +SET @index_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'promo_rule' + AND INDEX_NAME = 'idx_enabled_priority' +); + +SET @sql = IF( + @index_exists = 1, + 'ALTER TABLE `promo_rule` DROP INDEX `idx_enabled_priority`', + 'SELECT ''Index idx_enabled_priority does not exist on promo_rule table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'promo_rule' + AND INDEX_NAME = 'idx_deleted_at' +); + +SET @sql = IF( + @index_exists = 1, + 'ALTER TABLE `promo_rule` DROP INDEX `idx_deleted_at`', + 'SELECT ''Index idx_deleted_at does not exist on promo_rule table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'promo_rule' + AND INDEX_NAME = 'idx_enabled_priority_deleted' +); + +SET @sql = IF( + @index_exists = 0, + 'ALTER TABLE `promo_rule` ADD KEY `idx_enabled_priority_deleted` (`enabled`, `deleted_at`, `priority` DESC)', + 'SELECT ''Index idx_enabled_priority_deleted already exists on promo_rule table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS `subscribe_promo` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID', + `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT '购买数量', + `promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID', + `promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '该规格在此规则下的优惠价(分)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_subscribe_quantity_rule` (`subscribe_id`, `quantity`, `promo_rule_id`), + KEY `idx_promo_rule_id` (`promo_rule_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表'; + +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subscribe_promo' + AND COLUMN_NAME = 'quantity' +); + +SET @sql = IF( + @column_exists = 0, + 'ALTER TABLE `subscribe_promo` ADD COLUMN `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT ''购买数量'' AFTER `subscribe_id`', + 'SELECT ''Column quantity already exists in subscribe_promo table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + @column_exists = 1, + 'ALTER TABLE `subscribe_promo` MODIFY COLUMN `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT ''购买数量''', + 'SELECT ''Column quantity does not exist in subscribe_promo table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subscribe_promo' + AND INDEX_NAME = 'uk_subscribe_rule' +); + +SET @sql = IF( + @index_exists = 1, + 'ALTER TABLE `subscribe_promo` DROP INDEX `uk_subscribe_rule`', + 'SELECT ''Index uk_subscribe_rule does not exist on subscribe_promo table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subscribe_promo' + AND INDEX_NAME = 'uk_subscribe_qty_rule' +); + +SET @sql = IF( + @index_exists = 1, + 'ALTER TABLE `subscribe_promo` DROP INDEX `uk_subscribe_qty_rule`', + 'SELECT ''Index uk_subscribe_qty_rule does not exist on subscribe_promo table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @index_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subscribe_promo' + AND INDEX_NAME = 'uk_subscribe_quantity_rule' +); + +SET @sql = IF( + @index_exists = 0, + 'ALTER TABLE `subscribe_promo` ADD UNIQUE KEY `uk_subscribe_quantity_rule` (`subscribe_id`, `quantity`, `promo_rule_id`)', + 'SELECT ''Index uk_subscribe_quantity_rule already exists on subscribe_promo table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS `promo_usage` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID', + `promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '使用的规则 ID', + `subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '购买的规格 ID', + `order_no` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联订单号', + `promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '使用时的促销单价(分)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_rule` (`user_id`, `promo_rule_id`), + KEY `idx_order_no` (`order_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表'; + +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_rule_id' +); + +SET @sql = IF( + @column_exists = 0, + 'ALTER TABLE `order` ADD COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''促销规则ID, 0=未使用促销'' AFTER `discount`', + 'SELECT ''Column promo_rule_id already exists in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_discount' +); + +SET @sql = IF( + @column_exists = 0, + 'ALTER TABLE `order` ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT ''促销优惠金额(分)'' AFTER `promo_rule_id`', + 'SELECT ''Column promo_discount already exists in order table''' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/initialize/migrate/database/02155_promo_schema_fix.down.sql b/initialize/migrate/database/02155_promo_schema_fix.down.sql new file mode 100644 index 0000000..10615d9 --- /dev/null +++ b/initialize/migrate/database/02155_promo_schema_fix.down.sql @@ -0,0 +1,6 @@ +-- 02155 down +-- +-- 本迁移只是把 02154 的偏差修正回它应有的目标定义,没有引入新的列/表。 +-- 回滚 02155 并不应该把列重新改坏成 varchar/INT NULL 的旧偏差,因此 down +-- 为空操作。若需彻底删除 promo 系统,请回滚到 02154 的 down。 +SELECT '02155 has no destructive forward step; down is a no-op.'; diff --git a/initialize/migrate/database/02155_promo_schema_fix.up.sql b/initialize/migrate/database/02155_promo_schema_fix.up.sql new file mode 100644 index 0000000..38c9dcc --- /dev/null +++ b/initialize/migrate/database/02155_promo_schema_fix.up.sql @@ -0,0 +1,241 @@ +-- 02155 Promo Schema Fix +-- +-- 修复历史环境中 02154 未正确执行(或部分 GORM AutoMigrate 推断)导致的 +-- promo 系统列类型 / 索引偏差。完全幂等:可重复执行。 +-- +-- 覆盖偏差: +-- 1) subscribe_promo.quantity 实际 int/NULL -> BIGINT NOT NULL DEFAULT 1 +-- 2) subscribe_promo 唯一索引 实际 (subscribe_id, promo_rule_id) -> (subscribe_id, quantity, promo_rule_id) +-- 3) order.promo_rule_id 实际 int/NULL -> BIGINT UNSIGNED NOT NULL DEFAULT 0 +-- 4) order.promo_discount 实际 varchar(255)/NULL -> BIGINT NOT NULL DEFAULT 0 +-- +-- 设计原则: +-- - 所有 ALTER 前先做 NULL/空串兜底,避免 NOT NULL 转换失败。 +-- - 类型已经正确的环境(02154 正常跑过)不会被改动,所有 IF 判断都基于 +-- INFORMATION_SCHEMA 当前真实状态。 +-- - 索引差异处理 4 个分支:仅当索引确实是错的旧形态时才替换,已经是新形态则不动。 + + +-- ============================================================================ +-- 1) subscribe_promo.quantity +-- ============================================================================ + +-- 1.1 列不存在则补建(极端历史环境兜底) +SET @col_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subscribe_promo' + AND COLUMN_NAME = 'quantity' +); + +SET @sql = IF( + @col_exists = 0, + 'ALTER TABLE `subscribe_promo` ADD COLUMN `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT ''购买数量'' AFTER `subscribe_id`', + 'SELECT ''subscribe_promo.quantity exists, skip ADD''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 1.2 NULL 兜底为 1(旧 AutoMigrate 推断列允许 NULL,必须先回填再 NOT NULL) +UPDATE `subscribe_promo` SET `quantity` = 1 WHERE `quantity` IS NULL; + +-- 1.3 类型 / 可空 / 默认值修正:只在与目标定义不一致时改 +SET @col_def_wrong = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subscribe_promo' + AND COLUMN_NAME = 'quantity' + AND ( + LOWER(DATA_TYPE) <> 'bigint' + OR IS_NULLABLE = 'YES' + OR COLUMN_DEFAULT IS NULL + OR COLUMN_DEFAULT <> '1' + ) +); + +SET @sql = IF( + @col_def_wrong = 1, + 'ALTER TABLE `subscribe_promo` MODIFY COLUMN `quantity` BIGINT NOT NULL DEFAULT 1 COMMENT ''购买数量''', + 'SELECT ''subscribe_promo.quantity already matches target definition''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + + +-- ============================================================================ +-- 2) subscribe_promo 唯一索引:旧形态 -> (subscribe_id, quantity, promo_rule_id) +-- ============================================================================ + +-- 2.1 删除已知的所有旧形态唯一索引(如果存在) +SET @idx_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subscribe_promo' + AND INDEX_NAME = 'idx_subscribe_rule' +); +SET @sql = IF( + @idx_exists = 1, + 'ALTER TABLE `subscribe_promo` DROP INDEX `idx_subscribe_rule`', + 'SELECT ''subscribe_promo.idx_subscribe_rule absent''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subscribe_promo' + AND INDEX_NAME = 'uk_subscribe_rule' +); +SET @sql = IF( + @idx_exists = 1, + 'ALTER TABLE `subscribe_promo` DROP INDEX `uk_subscribe_rule`', + 'SELECT ''subscribe_promo.uk_subscribe_rule absent''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subscribe_promo' + AND INDEX_NAME = 'uk_subscribe_qty_rule' +); +SET @sql = IF( + @idx_exists = 1, + 'ALTER TABLE `subscribe_promo` DROP INDEX `uk_subscribe_qty_rule`', + 'SELECT ''subscribe_promo.uk_subscribe_qty_rule absent''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2.2 新建目标唯一索引(缺失时才建) +SET @idx_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'subscribe_promo' + AND INDEX_NAME = 'uk_subscribe_quantity_rule' +); +SET @sql = IF( + @idx_exists = 0, + 'ALTER TABLE `subscribe_promo` ADD UNIQUE KEY `uk_subscribe_quantity_rule` (`subscribe_id`, `quantity`, `promo_rule_id`)', + 'SELECT ''subscribe_promo.uk_subscribe_quantity_rule exists''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + + +-- ============================================================================ +-- 3) order.promo_rule_id -> BIGINT UNSIGNED NOT NULL DEFAULT 0 +-- ============================================================================ + +SET @col_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_rule_id' +); + +SET @sql = IF( + @col_exists = 0, + 'ALTER TABLE `order` ADD COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''促销规则ID, 0=未使用促销'' AFTER `discount`', + 'SELECT ''order.promo_rule_id exists, skip ADD''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `order` SET `promo_rule_id` = 0 WHERE `promo_rule_id` IS NULL; + +SET @col_def_wrong = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_rule_id' + AND ( + LOWER(DATA_TYPE) <> 'bigint' + OR INSTR(LOWER(COLUMN_TYPE), 'unsigned') = 0 + OR IS_NULLABLE = 'YES' + OR COLUMN_DEFAULT IS NULL + OR COLUMN_DEFAULT <> '0' + ) +); + +SET @sql = IF( + @col_def_wrong = 1, + 'ALTER TABLE `order` MODIFY COLUMN `promo_rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''促销规则ID, 0=未使用促销''', + 'SELECT ''order.promo_rule_id already matches target definition''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + + +-- ============================================================================ +-- 4) order.promo_discount -> BIGINT NOT NULL DEFAULT 0 +-- 历史 AutoMigrate 推断为 varchar(255)/NULL,金额字段错存为字符串。 +-- 必须先把空串/NULL 兜底为 '0',再 MODIFY,否则 MySQL 转 BIGINT 会写 0 +-- (这里我们仍兜底显式化,避免触发 strict mode 报错)。 +-- ============================================================================ + +SET @col_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_discount' +); + +SET @sql = IF( + @col_exists = 0, + 'ALTER TABLE `order` ADD COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT ''促销优惠金额(分)'' AFTER `promo_rule_id`', + 'SELECT ''order.promo_discount exists, skip ADD''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 当且仅当当前是字符串型时做兜底(避免对已经是 BIGINT 的环境跑无谓 UPDATE) +SET @col_is_string = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_discount' + AND LOWER(DATA_TYPE) IN ('varchar', 'char', 'text') +); + +SET @sql = IF( + @col_is_string = 1, + 'UPDATE `order` SET `promo_discount` = ''0'' WHERE `promo_discount` IS NULL OR `promo_discount` = ''''', + 'SELECT ''order.promo_discount not string type, skip backfill''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_def_wrong = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'order' + AND COLUMN_NAME = 'promo_discount' + AND ( + LOWER(DATA_TYPE) <> 'bigint' + OR IS_NULLABLE = 'YES' + OR COLUMN_DEFAULT IS NULL + OR COLUMN_DEFAULT <> '0' + ) +); + +SET @sql = IF( + @col_def_wrong = 1, + 'ALTER TABLE `order` MODIFY COLUMN `promo_discount` BIGINT NOT NULL DEFAULT 0 COMMENT ''促销优惠金额(分)''', + 'SELECT ''order.promo_discount already matches target definition''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/internal/config/config.go b/internal/config/config.go index 4e6ebe5..e479c09 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,7 +58,7 @@ type S3Config struct { UsePathStyle bool `yaml:"UsePathStyle" default:"false"` PresignExpireSeconds int64 `yaml:"PresignExpireSeconds" default:"300"` MaxUploadSize int64 `yaml:"MaxUploadSize" default:"104857600"` - AllowedContentTypes string `yaml:"AllowedContentTypes" default:"application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json"` + AllowedContentTypes string `yaml:"AllowedContentTypes" default:"application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp"` } type RedisConfig struct { diff --git a/internal/handler/admin/invite/getInviteManageListHandler.go b/internal/handler/admin/invite/getInviteManageListHandler.go new file mode 100644 index 0000000..e98c2ec --- /dev/null +++ b/internal/handler/admin/invite/getInviteManageListHandler.go @@ -0,0 +1,25 @@ +package invite + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/invite" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Get invite manage list +func GetInviteManageListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetInviteManageListRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + + l := invite.NewGetInviteManageListLogic(c.Request.Context(), svcCtx) + resp, err := l.GetInviteManageList(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/promo/createRuleHandler.go b/internal/handler/admin/promo/createRuleHandler.go new file mode 100644 index 0000000..ca783df --- /dev/null +++ b/internal/handler/admin/promo/createRuleHandler.go @@ -0,0 +1,23 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func CreateRuleHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.CreatePromoRuleRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewCreateRuleLogic(c.Request.Context(), svcCtx) + resp, err := l.CreateRule(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/promo/deletePriceHandler.go b/internal/handler/admin/promo/deletePriceHandler.go new file mode 100644 index 0000000..6665cc2 --- /dev/null +++ b/internal/handler/admin/promo/deletePriceHandler.go @@ -0,0 +1,26 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func DeletePriceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.DeletePromoPriceRequest + if err := c.ShouldBindUri(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewDeletePriceLogic(c.Request.Context(), svcCtx) + err := l.DeletePrice(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/promo/deleteRuleHandler.go b/internal/handler/admin/promo/deleteRuleHandler.go new file mode 100644 index 0000000..9c4c497 --- /dev/null +++ b/internal/handler/admin/promo/deleteRuleHandler.go @@ -0,0 +1,26 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func DeleteRuleHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.DeletePromoRuleRequest + if err := c.ShouldBindUri(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewDeleteRuleLogic(c.Request.Context(), svcCtx) + err := l.DeleteRule(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/promo/getPriceListHandler.go b/internal/handler/admin/promo/getPriceListHandler.go new file mode 100644 index 0000000..052dedd --- /dev/null +++ b/internal/handler/admin/promo/getPriceListHandler.go @@ -0,0 +1,23 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func GetPriceListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetPromoPriceListRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewGetPriceListLogic(c.Request.Context(), svcCtx) + resp, err := l.GetPriceList(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/promo/getRuleDetailHandler.go b/internal/handler/admin/promo/getRuleDetailHandler.go new file mode 100644 index 0000000..4ed036d --- /dev/null +++ b/internal/handler/admin/promo/getRuleDetailHandler.go @@ -0,0 +1,26 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func GetRuleDetailHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetPromoRuleDetailRequest + if err := c.ShouldBindUri(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewGetRuleDetailLogic(c.Request.Context(), svcCtx) + resp, err := l.GetRuleDetail(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/promo/getRuleListHandler.go b/internal/handler/admin/promo/getRuleListHandler.go new file mode 100644 index 0000000..d189231 --- /dev/null +++ b/internal/handler/admin/promo/getRuleListHandler.go @@ -0,0 +1,23 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func GetRuleListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetPromoRuleListRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewGetRuleListLogic(c.Request.Context(), svcCtx) + resp, err := l.GetRuleList(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/promo/getUsageListHandler.go b/internal/handler/admin/promo/getUsageListHandler.go new file mode 100644 index 0000000..1933274 --- /dev/null +++ b/internal/handler/admin/promo/getUsageListHandler.go @@ -0,0 +1,23 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func GetUsageListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetPromoUsageListRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewGetUsageListLogic(c.Request.Context(), svcCtx) + resp, err := l.GetUsageList(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/promo/setPriceHandler.go b/internal/handler/admin/promo/setPriceHandler.go new file mode 100644 index 0000000..006adbc --- /dev/null +++ b/internal/handler/admin/promo/setPriceHandler.go @@ -0,0 +1,23 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func SetPriceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.SetPromoPriceRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewSetPriceLogic(c.Request.Context(), svcCtx) + err := l.SetPrice(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/promo/updateRuleHandler.go b/internal/handler/admin/promo/updateRuleHandler.go new file mode 100644 index 0000000..23b3255 --- /dev/null +++ b/internal/handler/admin/promo/updateRuleHandler.go @@ -0,0 +1,27 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func UpdateRuleHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.UpdatePromoRuleRequest + if err := c.ShouldBindUri(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewUpdateRuleLogic(c.Request.Context(), svcCtx) + resp, err := l.UpdateRule(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/user/updateUserSubscribeHandler.go b/internal/handler/admin/user/updateUserSubscribeHandler.go index 3e111d3..b6a3235 100644 --- a/internal/handler/admin/user/updateUserSubscribeHandler.go +++ b/internal/handler/admin/user/updateUserSubscribeHandler.go @@ -12,7 +12,10 @@ import ( func UpdateUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { var req types.UpdateUserSubscribeRequest - _ = c.ShouldBind(&req) + if err := c.ShouldBind(&req); err != nil { + result.ParamErrorResult(c, err) + return + } validateErr := svcCtx.Validate(&req) if validateErr != nil { result.ParamErrorResult(c, validateErr) diff --git a/internal/handler/admin/user/updateUserSubscribeHandler_test.go b/internal/handler/admin/user/updateUserSubscribeHandler_test.go new file mode 100644 index 0000000..5a26dbf --- /dev/null +++ b/internal/handler/admin/user/updateUserSubscribeHandler_test.go @@ -0,0 +1,59 @@ +package user + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/xerr" +) + +func TestUpdateUserSubscribeHandlerRejectsInvalidLimits(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + body string + }{ + { + name: "negative speed limit", + body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"speed_limit":-1}`, + }, + { + name: "invalid traffic limit type", + body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"traffic_limit":"not-json"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := gin.New() + router.PUT("/v1/admin/user/subscribe", UpdateUserSubscribeHandler(&svc.ServiceContext{})) + + req := httptest.NewRequest(http.MethodPut, "/v1/admin/user/subscribe", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected HTTP 200, got %d", rec.Code) + } + + var resp struct { + Code uint32 `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp.Code != xerr.InvalidParams { + t.Fatalf("expected code %d, got %d (%s)", xerr.InvalidParams, resp.Code, resp.Msg) + } + }) + } +} diff --git a/internal/handler/common/logMessageReportHandler.go b/internal/handler/common/logMessageReportHandler.go index c514676..942c1b1 100644 --- a/internal/handler/common/logMessageReportHandler.go +++ b/internal/handler/common/logMessageReportHandler.go @@ -1,24 +1,24 @@ package common import ( - "github.com/gin-gonic/gin" - "github.com/perfect-panel/server/internal/logic/common" - "github.com/perfect-panel/server/internal/svc" - "github.com/perfect-panel/server/internal/types" - "github.com/perfect-panel/server/pkg/result" + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/common" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" ) func ReportLogMessageHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { - return func(c *gin.Context) { - var req types.ReportLogMessageRequest - _ = c.ShouldBind(&req) - validateErr := svcCtx.Validate(&req) - if validateErr != nil { - result.ParamErrorResult(c, validateErr) - return - } - l := common.NewReportLogMessageLogic(c.Request.Context(), svcCtx) - resp, err := l.ReportLogMessage(&req, c) - result.HttpResult(c, resp, err) - } + return func(c *gin.Context) { + var req types.ReportLogMessageRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + l := common.NewReportLogMessageLogic(c.Request.Context(), svcCtx) + resp, err := l.ReportLogMessage(&req, c) + result.HttpResult(c, resp, err) + } } diff --git a/internal/handler/public/user/getInviteRecordsHandler.go b/internal/handler/public/user/getInviteRecordsHandler.go new file mode 100644 index 0000000..7b6f18f --- /dev/null +++ b/internal/handler/public/user/getInviteRecordsHandler.go @@ -0,0 +1,30 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Get invite gift records +func GetInviteRecordsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetInviteRecordsRequest + if err := c.ShouldBind(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewGetInviteRecordsLogic(c.Request.Context(), svcCtx) + resp, err := l.GetInviteRecords(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/public/user/queryCommissionReturnLogHandler.go b/internal/handler/public/user/queryCommissionReturnLogHandler.go new file mode 100644 index 0000000..501184e --- /dev/null +++ b/internal/handler/public/user/queryCommissionReturnLogHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Query Commission Return Log +func QueryCommissionReturnLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.QueryCommissionReturnLogRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewQueryCommissionReturnLogLogic(c.Request.Context(), svcCtx) + resp, err := l.QueryCommissionReturnLog(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/public/user/queryCommissionReturnLogHandler_test.go b/internal/handler/public/user/queryCommissionReturnLogHandler_test.go new file mode 100644 index 0000000..b8371da --- /dev/null +++ b/internal/handler/public/user/queryCommissionReturnLogHandler_test.go @@ -0,0 +1,141 @@ +package user + +import ( + "context" + "database/sql/driver" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + logmodel "github.com/perfect-panel/server/internal/model/log" + usermodel "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/constant" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestCommissionReturnLogHandler_HTTPResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, mock, cleanup := newCommissionReturnHandlerTestDB(t) + defer cleanup() + + expectCommissionReturnHTTPQueries(mock, 42, 3, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel) + mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?"). + WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10). + WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}). + AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, time.Unix(1700000000, 0)). + AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, time.Unix(1700000000, 0)). + AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0))) + + router := gin.New() + svcCtx := &svc.ServiceContext{DB: db} + router.Use(injectTestUser(42)) + router.GET("/v1/public/user/commission_return_log", QueryCommissionReturnLogHandler(svcCtx)) + + req := httptest.NewRequest(http.MethodGet, "/v1/public/user/commission_return_log?page=1&size=10", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + body := strings.TrimSpace(rec.Body.String()) + t.Logf("commission_return_log response: %s", body) + if !strings.Contains(body, `"event_type":338`) || !strings.Contains(body, `"event_type":337`) || !strings.Contains(body, `"event_type":333`) { + t.Fatalf("response body = %s", body) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestWithdrawalLogHandler_CommissionRefundHTTPResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, mock, cleanup := newCommissionReturnHandlerTestDB(t) + defer cleanup() + + expectCommissionReturnHTTPQueries(mock, 42, 1, logmodel.CommissionTypeRefund) + mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ?) ORDER BY id DESC LIMIT ?"). + WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", 10). + WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}). + AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0))) + + router := gin.New() + svcCtx := &svc.ServiceContext{DB: db} + router.Use(injectTestUser(42)) + router.GET("/v1/public/user/withdrawal_log", QueryWithdrawalLogHandler(svcCtx)) + + req := httptest.NewRequest(http.MethodGet, "/v1/public/user/withdrawal_log?page=1&size=10&biz_type=commission_refund", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + body := strings.TrimSpace(rec.Body.String()) + t.Logf("withdrawal_log commission_refund response: %s", body) + if !strings.Contains(body, `"biz_type":"commission_refund"`) || !strings.Contains(body, `"amount":2500`) || strings.Contains(body, `"amount":1500`) || strings.Contains(body, `"amount":2000`) { + t.Fatalf("response body = %s", body) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func newCommissionReturnHandlerTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { + _ = sqlDB.Close() + } +} + +func injectTestUser(userID int64) gin.HandlerFunc { + return func(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), constant.CtxKeyUser, &usermodel.User{Id: userID}) + c.Request = c.Request.WithContext(ctx) + c.Next() + } +} + +func expectCommissionReturnHTTPQueries(mock sqlmock.Sqlmock, userID int64, total int64, eventTypes ...uint16) { + query := "SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ?" + args := []driver.Value{logmodel.TypeCommission.Uint8(), userID} + if len(eventTypes) > 0 { + clauses := make([]string, 0, len(eventTypes)) + for _, eventType := range eventTypes { + clauses = append(clauses, "`content` LIKE ?") + args = append(args, fmt.Sprintf("%%\"type\":%d%%", eventType)) + } + query += " AND (" + strings.Join(clauses, " OR ") + ")" + } + mock.ExpectQuery(query). + WithArgs(args...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total)) +} diff --git a/internal/handler/public/user/queryWithdrawalLogHandler.go b/internal/handler/public/user/queryWithdrawalLogHandler.go index 9f0bddc..aca9b83 100644 --- a/internal/handler/public/user/queryWithdrawalLogHandler.go +++ b/internal/handler/public/user/queryWithdrawalLogHandler.go @@ -8,7 +8,7 @@ import ( "github.com/perfect-panel/server/pkg/result" ) -// Query Withdrawal Log +// Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log) func QueryWithdrawalLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { var req types.QueryWithdrawalLogListRequest diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 7b61ba9..9a923bf 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -13,10 +13,12 @@ import ( adminCoupon "github.com/perfect-panel/server/internal/handler/admin/coupon" adminDocument "github.com/perfect-panel/server/internal/handler/admin/document" adminGroup "github.com/perfect-panel/server/internal/handler/admin/group" + adminInvite "github.com/perfect-panel/server/internal/handler/admin/invite" adminLog "github.com/perfect-panel/server/internal/handler/admin/log" adminMarketing "github.com/perfect-panel/server/internal/handler/admin/marketing" adminOrder "github.com/perfect-panel/server/internal/handler/admin/order" adminPayment "github.com/perfect-panel/server/internal/handler/admin/payment" + adminPromo "github.com/perfect-panel/server/internal/handler/admin/promo" adminRedemption "github.com/perfect-panel/server/internal/handler/admin/redemption" adminServer "github.com/perfect-panel/server/internal/handler/admin/server" adminSubscribe "github.com/perfect-panel/server/internal/handler/admin/subscribe" @@ -193,6 +195,14 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { adminDocumentGroupRouter.GET("/list", adminDocument.GetDocumentListHandler(serverCtx)) } + adminInviteGroupRouter := router.Group("/v1/admin/invite") + adminInviteGroupRouter.Use(middleware.AuthMiddleware(serverCtx)) + + { + // Get invite manage list + adminInviteGroupRouter.GET("/list", adminInvite.GetInviteManageListHandler(serverCtx)) + } + adminGroupGroupRouter := router.Group("/v1/admin/group") adminGroupGroupRouter.Use(middleware.AuthMiddleware(serverCtx)) @@ -374,6 +384,38 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { adminPaymentGroupRouter.GET("/platform", adminPayment.GetPaymentPlatformHandler(serverCtx)) } + adminPromoGroupRouter := router.Group("/v1/admin/promo") + adminPromoGroupRouter.Use(middleware.AuthMiddleware(serverCtx)) + + { + // Create promo rule + adminPromoGroupRouter.POST("/rule", adminPromo.CreateRuleHandler(serverCtx)) + + // Get promo rule list + adminPromoGroupRouter.GET("/rule/list", adminPromo.GetRuleListHandler(serverCtx)) + + // Get promo rule detail + adminPromoGroupRouter.GET("/rule/:id", adminPromo.GetRuleDetailHandler(serverCtx)) + + // Update promo rule + adminPromoGroupRouter.PUT("/rule/:id", adminPromo.UpdateRuleHandler(serverCtx)) + + // Delete promo rule + adminPromoGroupRouter.DELETE("/rule/:id", adminPromo.DeleteRuleHandler(serverCtx)) + + // Set promo prices + adminPromoGroupRouter.POST("/price", adminPromo.SetPriceHandler(serverCtx)) + + // Get promo price list + adminPromoGroupRouter.GET("/price/list", adminPromo.GetPriceListHandler(serverCtx)) + + // Delete promo price + adminPromoGroupRouter.DELETE("/price/:id", adminPromo.DeletePriceHandler(serverCtx)) + + // Get promo usage list + adminPromoGroupRouter.GET("/usage/list", adminPromo.GetUsageListHandler(serverCtx)) + } + adminRedemptionGroupRouter := router.Group("/v1/admin/redemption") adminRedemptionGroupRouter.Use(middleware.AuthMiddleware(serverCtx)) @@ -978,17 +1020,16 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { } publicSubscribeGroupRouter := router.Group("/v1/public/subscribe") - publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) { // Get subscribe list - publicSubscribeGroupRouter.GET("/list", publicSubscribe.QuerySubscribeListHandler(serverCtx)) + publicSubscribeGroupRouter.GET("/list", middleware.OptionalAuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QuerySubscribeListHandler(serverCtx)) // Get user subscribe node info - publicSubscribeGroupRouter.GET("/node/list", publicSubscribe.QueryUserSubscribeNodeListHandler(serverCtx)) + publicSubscribeGroupRouter.GET("/node/list", middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QueryUserSubscribeNodeListHandler(serverCtx)) // Get subscribe group list - publicSubscribeGroupRouter.GET("/group/list", publicSubscribe.QuerySubscribeGroupListHandler(serverCtx)) + publicSubscribeGroupRouter.GET("/group/list", middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx), publicSubscribe.QuerySubscribeGroupListHandler(serverCtx)) } publicTicketGroupRouter := router.Group("/v1/public/ticket") @@ -1077,6 +1118,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Query User Info publicUserGroupRouter.GET("/info", publicUser.QueryUserInfoHandler(serverCtx)) + // Get Invite Records + publicUserGroupRouter.GET("/invite_records", publicUser.GetInviteRecordsHandler(serverCtx)) + // Get Invite Sales publicUserGroupRouter.GET("/invite_sales", publicUser.GetInviteSalesHandler(serverCtx)) publicUserGroupRouter.GET("/invite/sales", publicUser.GetInviteSalesHandler(serverCtx)) // alias: backward-compat @@ -1136,6 +1180,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Verify Email publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx)) + // Query Commission Return Log + publicUserGroupRouter.GET("/commission_return_log", publicUser.QueryCommissionReturnLogHandler(serverCtx)) + // Query Withdrawal Log publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx)) } diff --git a/internal/logic/admin/application/deleteSubscribeApplicationLogic.go b/internal/logic/admin/application/deleteSubscribeApplicationLogic.go index 57cdd70..1b8456d 100644 --- a/internal/logic/admin/application/deleteSubscribeApplicationLogic.go +++ b/internal/logic/admin/application/deleteSubscribeApplicationLogic.go @@ -29,7 +29,7 @@ func (l *DeleteSubscribeApplicationLogic) DeleteSubscribeApplication(req *types. err := l.svcCtx.ClientModel.Delete(l.ctx, req.Id) if err != nil { l.Errorf("Failed to delete subscribe application with ID %d: %v", req.Id, err) - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error()) + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error()) } return nil } diff --git a/internal/logic/admin/group/deleteNodeGroupLogic.go b/internal/logic/admin/group/deleteNodeGroupLogic.go index 16c89d4..8ae2470 100644 --- a/internal/logic/admin/group/deleteNodeGroupLogic.go +++ b/internal/logic/admin/group/deleteNodeGroupLogic.go @@ -7,6 +7,7 @@ import ( "github.com/perfect-panel/server/internal/model/group" "github.com/perfect-panel/server/internal/model/node" + "github.com/perfect-panel/server/internal/model/subscribe" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" @@ -48,6 +49,33 @@ func (l *DeleteNodeGroupLogic) DeleteNodeGroup(req *types.DeleteNodeGroupRequest return fmt.Errorf("cannot delete group with %d associated nodes, please migrate nodes first", nodeCount) } + var defaultSubscribeCount int64 + if err := l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("node_group_id = ?", nodeGroup.Id).Count(&defaultSubscribeCount).Error; err != nil { + logger.Errorf("failed to count subscribes with default group: %v", err) + return err + } + if defaultSubscribeCount > 0 { + return fmt.Errorf("cannot delete group referenced by %d subscribes' default node group", defaultSubscribeCount) + } + + var subscribeGroupCount int64 + if err := l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroup.Id)).Count(&subscribeGroupCount).Error; err != nil { + logger.Errorf("failed to count subscribes in group: %v", err) + return err + } + if subscribeGroupCount > 0 { + return fmt.Errorf("cannot delete group referenced by %d subscribes' node group list", subscribeGroupCount) + } + + var userSubscribeCount int64 + if err := l.svcCtx.DB.Table("user_subscribe").Where("node_group_id = ?", nodeGroup.Id).Count(&userSubscribeCount).Error; err != nil { + logger.Errorf("failed to count user subscribes in group: %v", err) + return err + } + if userSubscribeCount > 0 { + return fmt.Errorf("cannot delete group referenced by %d user subscribes", userSubscribeCount) + } + // 使用 GORM Transaction 删除节点组 return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error { // 删除节点组 diff --git a/internal/logic/admin/group/exportGroupResultLogic.go b/internal/logic/admin/group/exportGroupResultLogic.go index a84befa..4c53564 100644 --- a/internal/logic/admin/group/exportGroupResultLogic.go +++ b/internal/logic/admin/group/exportGroupResultLogic.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/csv" + "encoding/json" "fmt" "github.com/perfect-panel/server/internal/model/group" @@ -52,10 +53,9 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR Email string `json:"email"` } var users []UserInfo - if err := l.svcCtx.DB.Raw("SELECT * FROM JSON_ARRAY(?)", detail.UserData).Scan(&users).Error; err != nil { - // 如果解析失败,尝试用标准 JSON 解析 + if err := json.Unmarshal([]byte(detail.UserData), &users); err != nil { logger.Errorf("failed to parse user data: %v", err) - continue + return nil, "", fmt.Errorf("parse group history user_data failed: %w", err) } // 查询节点组名称 @@ -123,7 +123,10 @@ func (l *ExportGroupResultLogic) ExportGroupResult(req *types.ExportGroupResultR result = append(result, csvData...) // 生成文件名 - filename := fmt.Sprintf("group_result_%d.csv", req.HistoryId) + filename := "group_result_current.csv" + if req.HistoryId != nil { + filename = fmt.Sprintf("group_result_%d.csv", *req.HistoryId) + } return result, filename, nil } diff --git a/internal/logic/admin/group/getGroupHistoryDetailLogic.go b/internal/logic/admin/group/getGroupHistoryDetailLogic.go index d868d55..2f79d83 100644 --- a/internal/logic/admin/group/getGroupHistoryDetailLogic.go +++ b/internal/logic/admin/group/getGroupHistoryDetailLogic.go @@ -76,16 +76,16 @@ func (l *GetGroupHistoryDetailLogic) GetGroupHistoryDetail(req *types.GetGroupHi configSnapshot := make(map[string]interface{}) configSnapshot["group_details"] = details - // 获取配置快照(从 system_config 读取) + // 获取配置快照(从 system 读取) var configValue string if history.GroupMode == "average" { - l.svcCtx.DB.Table("system_config"). - Where("`key` = ?", "group.average_config"). + l.svcCtx.DB.Table("system"). + Where("`category` = ? AND `key` = ?", "group", "average_config"). Select("value"). Scan(&configValue) } else if history.GroupMode == "traffic" { - l.svcCtx.DB.Table("system_config"). - Where("`key` = ?", "group.traffic_config"). + l.svcCtx.DB.Table("system"). + Where("`category` = ? AND `key` = ?", "group", "traffic_config"). Select("value"). Scan(&configValue) } diff --git a/internal/logic/admin/group/getGroupHistoryLogic.go b/internal/logic/admin/group/getGroupHistoryLogic.go index 6eee9c3..1db7b4c 100644 --- a/internal/logic/admin/group/getGroupHistoryLogic.go +++ b/internal/logic/admin/group/getGroupHistoryLogic.go @@ -44,9 +44,9 @@ func (l *GetGroupHistoryLogic) GetGroupHistory(req *types.GetGroupHistoryRequest return nil, err } - // 分页查询 - offset := (req.Page - 1) * req.Size - if err := query.Order("id DESC").Offset(offset).Limit(req.Size).Find(&histories).Error; err != nil { + page, size := normalizePagination(req.Page, req.Size) + offset := (page - 1) * size + if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&histories).Error; err != nil { logger.Errorf("failed to find group histories: %v", err) return nil, err } diff --git a/internal/logic/admin/group/getNodeGroupListLogic.go b/internal/logic/admin/group/getNodeGroupListLogic.go index 9595393..cad068d 100644 --- a/internal/logic/admin/group/getNodeGroupListLogic.go +++ b/internal/logic/admin/group/getNodeGroupListLogic.go @@ -38,9 +38,9 @@ func (l *GetNodeGroupListLogic) GetNodeGroupList(req *types.GetNodeGroupListRequ return nil, err } - // 分页查询 - offset := (req.Page - 1) * req.Size - if err := query.Order("sort ASC").Offset(offset).Limit(req.Size).Find(&nodeGroups).Error; err != nil { + page, size := normalizePagination(req.Page, req.Size) + offset := (page - 1) * size + if err := query.Order("sort ASC").Offset(offset).Limit(size).Find(&nodeGroups).Error; err != nil { logger.Errorf("failed to find node groups: %v", err) return nil, err } diff --git a/internal/logic/admin/group/group_logic_test.go b/internal/logic/admin/group/group_logic_test.go new file mode 100644 index 0000000..83b3bd8 --- /dev/null +++ b/internal/logic/admin/group/group_logic_test.go @@ -0,0 +1,346 @@ +package group + +import ( + "context" + "encoding/csv" + "fmt" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + modelgroup "github.com/perfect-panel/server/internal/model/group" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestGetGroupHistoryDetailReadsConfigFromSystem(t *testing.T) { + db, mock, cleanup := newGroupTestDB(t) + defer cleanup() + + now := time.Unix(1710000000, 0) + mock.ExpectQuery("FROM `group_history`"). + WithArgs(int64(9), 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "group_mode", "trigger_type", "state", "total_users", "success_count", "failed_count", "start_time", "end_time", "error_message", "created_at", + }).AddRow(int64(9), "traffic", "manual", "completed", 1, 1, 0, now, now, "", now)) + mock.ExpectQuery("FROM `group_history_detail`"). + WithArgs(int64(9)). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at", + }).AddRow(int64(1), int64(9), int64(3), 1, 2, `[{"id":7,"email":"u@example.com"}]`, now)) + mock.ExpectQuery("FROM `system`"). + WithArgs("group", "traffic_config"). + WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(`{"strategy":"closed"}`)) + + logic := newTestGroupHistoryDetailLogic(db) + resp, err := logic.GetGroupHistoryDetail(&types.GetGroupHistoryDetailRequest{Id: 9}) + if err != nil { + t.Fatalf("GetGroupHistoryDetail error: %v", err) + } + if got := resp.ConfigSnapshot["config"].(map[string]interface{})["strategy"]; got != "closed" { + t.Fatalf("config snapshot strategy = %v, want closed", got) + } + assertGroupExpectations(t, mock) +} + +func TestExportGroupResultParsesHistoryUserDataJSON(t *testing.T) { + db, mock, cleanup := newGroupTestDB(t) + defer cleanup() + + historyID := int64(11) + now := time.Unix(1710000000, 0) + mock.ExpectQuery("FROM `group_history_detail`"). + WithArgs(historyID). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at", + }).AddRow(int64(1), historyID, int64(8), 1, 2, `[{"id":42,"email":"u@example.com"}]`, now)) + mock.ExpectQuery("FROM `node_group`"). + WithArgs(int64(8), 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(8), "VIP")) + + logic := newTestExportGroupResultLogic(db) + data, filename, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID}) + if err != nil { + t.Fatalf("ExportGroupResult error: %v", err) + } + if filename != "group_result_11.csv" { + t.Fatalf("filename = %q, want group_result_11.csv", filename) + } + + records, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(string(data), "\ufeff"))).ReadAll() + if err != nil { + t.Fatalf("read csv: %v", err) + } + if len(records) != 2 || strings.Join(records[1], ",") != "42,8,VIP" { + t.Fatalf("csv records = %#v, want user/group row", records) + } + assertGroupExpectations(t, mock) +} + +func TestExportGroupResultRejectsInvalidHistoryUserDataJSON(t *testing.T) { + db, mock, cleanup := newGroupTestDB(t) + defer cleanup() + + historyID := int64(12) + now := time.Unix(1710000000, 0) + mock.ExpectQuery("FROM `group_history_detail`"). + WithArgs(historyID). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "history_id", "node_group_id", "user_count", "node_count", "user_data", "created_at", + }).AddRow(int64(1), historyID, int64(8), 1, 2, `{bad-json`, now)) + + logic := newTestExportGroupResultLogic(db) + _, _, err := logic.ExportGroupResult(&types.ExportGroupResultRequest{HistoryId: &historyID}) + if err == nil || !strings.Contains(err.Error(), "parse group history user_data failed") { + t.Fatalf("ExportGroupResult error = %v, want parse error", err) + } + assertGroupExpectations(t, mock) +} + +func TestDeleteNodeGroupRejectsSubscribeReferences(t *testing.T) { + tests := []struct { + name string + defaultSubscribeCount int64 + subscribeGroupCount int64 + userSubscribeCount int64 + wantErr string + }{ + {name: "subscribe default group", defaultSubscribeCount: 1, wantErr: "default node group"}, + {name: "subscribe group list", subscribeGroupCount: 1, wantErr: "node group list"}, + {name: "user subscribe group", userSubscribeCount: 1, wantErr: "user subscribes"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, cleanup := newGroupTestDB(t) + defer cleanup() + + expectDeleteNodeGroupBaseChecks(mock, 5) + mock.ExpectQuery("FROM `subscribe`"). + WithArgs(int64(5)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.defaultSubscribeCount)) + if tt.defaultSubscribeCount == 0 { + mock.ExpectQuery("FROM `subscribe`"). + WithArgs("[5]"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.subscribeGroupCount)) + } + if tt.defaultSubscribeCount == 0 && tt.subscribeGroupCount == 0 { + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(int64(5)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(tt.userSubscribeCount)) + } + + logic := newTestDeleteNodeGroupLogic(db) + err := logic.DeleteNodeGroup(&types.DeleteNodeGroupRequest{Id: 5}) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("DeleteNodeGroup error = %v, want contains %q", err, tt.wantErr) + } + assertGroupExpectations(t, mock) + }) + } +} + +func TestResetGroupsRollsBackOnFailure(t *testing.T) { + db, mock, cleanup := newGroupTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM `node_group`").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE `subscribe`"). + WithArgs(int64(0), "[]"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE `nodes`"). + WithArgs("[]"). + WillReturnError(fmt.Errorf("node update failed")) + mock.ExpectRollback() + + logic := newTestResetGroupsLogic(db) + err := logic.ResetGroups() + if err == nil || !strings.Contains(err.Error(), "node update failed") { + t.Fatalf("ResetGroups error = %v, want node update failure", err) + } + assertGroupExpectations(t, mock) +} + +func TestPreviewUserNodesIncludesPublicNodesInGroupMode(t *testing.T) { + db, mock, cleanup := newGroupTestDB(t) + defer cleanup() + + now := time.Unix(1710000000, 0) + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(int64(100), int8(0), int8(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subscribe_id", "node_group_id"}). + AddRow(int64(1), int64(100), int64(10), int64(5))) + mock.ExpectQuery("FROM `subscribe`"). + WithArgs(int64(10)). + WillReturnRows(sqlmock.NewRows([]string{"id", "node_group_id", "node_group_ids", "nodes", "node_tags"}). + AddRow(int64(10), int64(0), "[]", "", "")) + mock.ExpectQuery("FROM `system`"). + WithArgs("group", "enabled"). + WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("true")) + mock.ExpectQuery("FROM `nodes`"). + WithArgs(true). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "tags", "port", "address", "server_id", "protocol", "enabled", "sort", "node_group_ids", "created_at", "updated_at", + }). + AddRow(int64(1), "group-node", "", uint16(443), "g.example.com", int64(1), "vless", true, 1, "[5]", now, now). + AddRow(int64(2), "public-node", "", uint16(443), "p.example.com", int64(1), "vless", true, 2, "[]", now, now)) + mock.ExpectQuery("FROM `node_group`"). + WithArgs(int64(5)). + WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(int64(5), "Group A")) + + logic := newTestPreviewUserNodesLogic(db) + resp, err := logic.PreviewUserNodes(&types.PreviewUserNodesRequest{UserId: 100}) + if err != nil { + t.Fatalf("PreviewUserNodes error: %v", err) + } + if !hasNodeGroup(resp.NodeGroups, 0, "public-node") { + t.Fatalf("PreviewUserNodes node groups = %#v, want public node group", resp.NodeGroups) + } + assertGroupExpectations(t, mock) +} + +func TestTrafficRangeMatchingUsesClosedBounds(t *testing.T) { + min0, max10 := int64(0), int64(10) + min10, max20 := int64(10), int64(20) + nodeGroups := []modelgroup.NodeGroup{ + {Id: 1, MinTrafficGB: &min0, MaxTrafficGB: &max10}, + {Id: 2, MinTrafficGB: &min10, MaxTrafficGB: &max20}, + } + + tests := map[float64]int64{ + 0: 1, + 10: 1, + 15: 2, + 21: 0, + } + for used, want := range tests { + if got := matchTrafficNodeGroup(used, nodeGroups); got != want { + t.Fatalf("matchTrafficNodeGroup(%v) = %d, want %d", used, got, want) + } + } +} + +func TestNormalizePaginationDefaultsAndMax(t *testing.T) { + tests := []struct { + page, size int + wantPage int + wantSize int + }{ + {page: 0, size: 0, wantPage: 1, wantSize: defaultPageSize}, + {page: -1, size: -5, wantPage: 1, wantSize: defaultPageSize}, + {page: 2, size: maxPageSize + 1, wantPage: 2, wantSize: maxPageSize}, + } + + for _, tt := range tests { + gotPage, gotSize := normalizePagination(tt.page, tt.size) + if gotPage != tt.wantPage || gotSize != tt.wantSize { + t.Fatalf("normalizePagination(%d, %d) = (%d, %d), want (%d, %d)", tt.page, tt.size, gotPage, gotSize, tt.wantPage, tt.wantSize) + } + } +} + +func newGroupTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { + _ = sqlDB.Close() + } +} + +func newTestGroupHistoryDetailLogic(db *gorm.DB) *GetGroupHistoryDetailLogic { + ctx := context.Background() + return &GetGroupHistoryDetailLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: &svc.ServiceContext{DB: db}, + } +} + +func newTestExportGroupResultLogic(db *gorm.DB) *ExportGroupResultLogic { + ctx := context.Background() + return &ExportGroupResultLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: &svc.ServiceContext{DB: db}, + } +} + +func newTestDeleteNodeGroupLogic(db *gorm.DB) *DeleteNodeGroupLogic { + ctx := context.Background() + return &DeleteNodeGroupLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: &svc.ServiceContext{DB: db}, + } +} + +func newTestResetGroupsLogic(db *gorm.DB) *ResetGroupsLogic { + ctx := context.Background() + return &ResetGroupsLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: &svc.ServiceContext{DB: db}, + } +} + +func newTestPreviewUserNodesLogic(db *gorm.DB) *PreviewUserNodesLogic { + ctx := context.Background() + return &PreviewUserNodesLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: &svc.ServiceContext{DB: db}, + } +} + +func expectDeleteNodeGroupBaseChecks(mock sqlmock.Sqlmock, nodeGroupId int64) { + now := time.Unix(1710000000, 0) + mock.ExpectQuery("FROM `node_group`"). + WithArgs(nodeGroupId, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "created_at", "updated_at"}). + AddRow(nodeGroupId, "Group", now, now)) + mock.ExpectQuery("FROM `nodes`"). + WithArgs(fmt.Sprintf("[%d]", nodeGroupId)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) +} + +func hasNodeGroup(items []types.NodeGroupItem, id int64, nodeName string) bool { + for _, item := range items { + if item.Id != id { + continue + } + for _, n := range item.Nodes { + if n.Name == nodeName { + return true + } + } + } + return false +} + +func assertGroupExpectations(t *testing.T, mock sqlmock.Sqlmock) { + t.Helper() + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} diff --git a/internal/logic/admin/group/helpers.go b/internal/logic/admin/group/helpers.go new file mode 100644 index 0000000..c609bf0 --- /dev/null +++ b/internal/logic/admin/group/helpers.go @@ -0,0 +1,37 @@ +package group + +import ( + "fmt" + + "github.com/perfect-panel/server/internal/model/node" + "gorm.io/gorm" +) + +const ( + defaultPageSize = 20 + maxPageSize = 100 +) + +func normalizePagination(page, size int) (int, int) { + if page <= 0 { + page = 1 + } + if size <= 0 { + size = defaultPageSize + } + if size > maxPageSize { + size = maxPageSize + } + return page, size +} + +func countNodesInGroup(db *gorm.DB, nodeGroupId int64) (int, error) { + var count int64 + err := db.Model(&node.Node{}). + Where("JSON_CONTAINS(node_group_ids, ?)", fmt.Sprintf("[%d]", nodeGroupId)). + Count(&count).Error + if err != nil { + return 0, err + } + return int(count), nil +} diff --git a/internal/logic/admin/group/previewUserNodesLogic.go b/internal/logic/admin/group/previewUserNodesLogic.go index 53b32da..115e1a0 100644 --- a/internal/logic/admin/group/previewUserNodesLogic.go +++ b/internal/logic/admin/group/previewUserNodesLogic.go @@ -196,10 +196,10 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ return nil, err } - // 6. 过滤出包含至少一个匹配节点组的节点(仅显示用户真正所在分组的节点,不包含公共节点) + // 6. 过滤出公共节点和至少一个匹配节点组的节点,与真实订阅下发保持一致 for _, n := range dbNodes { - // 节点未配置节点组(公共节点),预览时不显示 if len(n.NodeGroupIds) == 0 { + filteredNodes = append(filteredNodes, n) continue } @@ -450,9 +450,13 @@ func (l *PreviewUserNodesLogic) PreviewUserNodes(req *types.PreviewUserNodesRequ } } - // 预览模式不显示公共节点(node_group_ids 为空的节点),只展示用户真正所在分组的节点 if len(publicNodes) > 0 { - logger.Infof("[PreviewUserNodes] skipping %d public nodes (not in user's assigned group)", len(publicNodes)) + nodeGroupItems = append(nodeGroupItems, types.NodeGroupItem{ + Id: 0, + Name: "公共节点", + Nodes: publicNodes, + }) + logger.Infof("[PreviewUserNodes] adding public nodes group: nodes=%d", len(publicNodes)) } } else { diff --git a/internal/logic/admin/group/recalculateGroupLogic.go b/internal/logic/admin/group/recalculateGroupLogic.go index 9b485b9..2b3105d 100644 --- a/internal/logic/admin/group/recalculateGroupLogic.go +++ b/internal/logic/admin/group/recalculateGroupLogic.go @@ -679,21 +679,7 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in // 将字节转换为 GB usedTrafficGB := float64(us.UsedTraffic) / (1024 * 1024 * 1024) - // 查找匹配的流量范围(使用左闭右开区间 [Min, Max)) - var targetNodeGroupId int64 = 0 - for _, ng := range nodeGroups { - if ng.MinTrafficGB == nil || ng.MaxTrafficGB == nil { - continue - } - minTraffic := float64(*ng.MinTrafficGB) - maxTraffic := float64(*ng.MaxTrafficGB) - - // 检查是否在区间内 [min, max) - if usedTrafficGB >= minTraffic && usedTrafficGB < maxTraffic { - targetNodeGroupId = ng.Id - break - } - } + targetNodeGroupId := matchTrafficNodeGroup(usedTrafficGB, nodeGroups) // 如果没有匹配到任何范围,targetNodeGroupId 保持为 0(不分配节点组) @@ -734,7 +720,14 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in // 4. 创建分组历史详情记录(只统计有用户的节点组) nodeGroupCount := make(map[int64]int) // node_group_id -> node_count for _, ng := range nodeGroups { - nodeGroupCount[ng.Id] = 1 // 每个节点组计为1 + count, err := countNodesInGroup(tx, ng.Id) + if err != nil { + l.Errorw("failed to count nodes in group", + logger.Field("node_group_id", ng.Id), + logger.Field("error", err.Error())) + return affectedCount, err + } + nodeGroupCount[ng.Id] = count } for nodeGroupId, userCount := range groupUserCount { @@ -764,6 +757,20 @@ func (l *RecalculateGroupLogic) executeTrafficGrouping(tx *gorm.DB, historyId in return affectedCount, nil } +func matchTrafficNodeGroup(usedTrafficGB float64, nodeGroups []group.NodeGroup) int64 { + for _, ng := range nodeGroups { + if ng.MinTrafficGB == nil || ng.MaxTrafficGB == nil { + continue + } + minTraffic := float64(*ng.MinTrafficGB) + maxTraffic := float64(*ng.MaxTrafficGB) + if usedTrafficGB >= minTraffic && usedTrafficGB <= maxTraffic { + return ng.Id + } + } + return 0 +} + // containsIgnoreCase checks if a string contains another substring (case-insensitive) func containsIgnoreCase(s, substr string) bool { if len(substr) == 0 { diff --git a/internal/logic/admin/group/resetGroupsLogic.go b/internal/logic/admin/group/resetGroupsLogic.go index eaaa098..bc99cb7 100644 --- a/internal/logic/admin/group/resetGroupsLogic.go +++ b/internal/logic/admin/group/resetGroupsLogic.go @@ -7,8 +7,10 @@ import ( "github.com/perfect-panel/server/internal/model/node" "github.com/perfect-panel/server/internal/model/subscribe" "github.com/perfect-panel/server/internal/model/system" + "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/pkg/logger" + "gorm.io/gorm" ) type ResetGroupsLogic struct { @@ -27,55 +29,64 @@ func NewResetGroupsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Reset } func (l *ResetGroupsLogic) ResetGroups() error { - // 1. Delete all node groups - err := l.svcCtx.DB.Where("1 = 1").Delete(&group.NodeGroup{}).Error - if err != nil { - l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error())) - return err - } - l.Infow("Successfully deleted all node groups") + err := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error { + // 1. Delete all node groups + if err := tx.Where("1 = 1").Delete(&group.NodeGroup{}).Error; err != nil { + l.Errorw("Failed to delete all node groups", logger.Field("error", err.Error())) + return err + } + l.Infow("Successfully deleted all node groups") - // 2. Clear node_group_ids for all subscribes (products) - err = l.svcCtx.DB.Model(&subscribe.Subscribe{}).Where("1 = 1").Update("node_group_ids", "[]").Error - if err != nil { - l.Errorw("Failed to clear subscribes' node_group_ids", logger.Field("error", err.Error())) - return err - } - l.Infow("Successfully cleared all subscribes' node_group_ids") + // 2. Clear node_group_id/node_group_ids for all subscribes (products) + if err := tx.Table((&subscribe.Subscribe{}).TableName()).Where("1 = 1").Updates(map[string]interface{}{ + "node_group_id": 0, + "node_group_ids": "[]", + }).Error; err != nil { + l.Errorw("Failed to clear subscribes' node groups", logger.Field("error", err.Error())) + return err + } + l.Infow("Successfully cleared all subscribes' node groups") - // 3. Clear node_group_ids for all nodes - err = l.svcCtx.DB.Model(&node.Node{}).Where("1 = 1").Update("node_group_ids", "[]").Error - if err != nil { - l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error())) - return err - } - l.Infow("Successfully cleared all nodes' node_group_ids") + // 3. Clear node_group_ids for all nodes + if err := tx.Table((&node.Node{}).TableName()).Where("1 = 1").Update("node_group_ids", "[]").Error; err != nil { + l.Errorw("Failed to clear nodes' node_group_ids", logger.Field("error", err.Error())) + return err + } + l.Infow("Successfully cleared all nodes' node_group_ids") - // 4. Clear group history - err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistory{}).Error - if err != nil { - l.Errorw("Failed to clear group history", logger.Field("error", err.Error())) - // Non-critical error, continue anyway - } else { + // 4. Clear user_subscribe node_group_id + if err := tx.Table((&user.Subscribe{}).TableName()).Where("1 = 1").Update("node_group_id", 0).Error; err != nil { + l.Errorw("Failed to clear user subscribes' node_group_id", logger.Field("error", err.Error())) + return err + } + l.Infow("Successfully cleared all user subscribes' node_group_id") + + // 5. Clear group history + if err := tx.Where("1 = 1").Delete(&group.GroupHistory{}).Error; err != nil { + l.Errorw("Failed to clear group history", logger.Field("error", err.Error())) + return err + } l.Infow("Successfully cleared group history") - } - // 7. Clear group history details - err = l.svcCtx.DB.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error - if err != nil { - l.Errorw("Failed to clear group history details", logger.Field("error", err.Error())) - // Non-critical error, continue anyway - } else { + // 6. Clear group history details + if err := tx.Where("1 = 1").Delete(&group.GroupHistoryDetail{}).Error; err != nil { + l.Errorw("Failed to clear group history details", logger.Field("error", err.Error())) + return err + } l.Infow("Successfully cleared group history details") - } - // 5. Delete all group config settings - err = l.svcCtx.DB.Where("`category` = ?", "group").Delete(&system.System{}).Error + // 7. Delete all group config settings + if err := tx.Where("`category` = ?", "group").Delete(&system.System{}).Error; err != nil { + l.Errorw("Failed to delete group config", logger.Field("error", err.Error())) + return err + } + l.Infow("Successfully deleted all group config settings") + + return nil + }) if err != nil { - l.Errorw("Failed to delete group config", logger.Field("error", err.Error())) return err } - l.Infow("Successfully deleted all group config settings") l.Infow("Group reset completed successfully") return nil diff --git a/internal/logic/admin/invite/benefits.go b/internal/logic/admin/invite/benefits.go new file mode 100644 index 0000000..c37d1f6 --- /dev/null +++ b/internal/logic/admin/invite/benefits.go @@ -0,0 +1,223 @@ +package invite + +import ( + "context" + "slices" + + modellog "github.com/perfect-panel/server/internal/model/log" + "github.com/perfect-panel/server/pkg/tool" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type Benefits struct { + OrderCount int64 + HasPurchased bool + InviterCommission int64 + InviterGiftDays int64 + InviteeGiftDays int64 +} + +type InviteRelation struct { + InviteeId int64 + InviterId int64 +} + +type paidOrderRow struct { + UserId int64 `gorm:"column:user_id"` + SubscriptionUserId int64 `gorm:"column:subscription_user_id"` + OrderNo string `gorm:"column:order_no"` +} + +type systemLogRow struct { + ObjectID int64 `gorm:"column:object_id"` + Content string `gorm:"column:content"` +} + +func NormalizePage(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + if size > 100 { + size = 100 + } + return page, size +} + +func QueryBenefits(ctx context.Context, db *gorm.DB, relations []InviteRelation) (map[int64]Benefits, error) { + result := make(map[int64]Benefits, len(relations)) + if len(relations) == 0 { + return result, nil + } + + inviteeIds := make([]int64, 0, len(relations)) + inviteeToInviter := make(map[int64]int64, len(relations)) + for _, relation := range relations { + inviteeIds = append(inviteeIds, relation.InviteeId) + inviteeToInviter[relation.InviteeId] = relation.InviterId + result[relation.InviteeId] = Benefits{} + } + + var orderCounts []struct { + UserId int64 `gorm:"column:user_id"` + Cnt int64 `gorm:"column:cnt"` + } + if err := db.WithContext(ctx). + Table("`order`"). + Select("user_id, COUNT(*) as cnt"). + Where("user_id IN ? AND status IN ?", inviteeIds, []int{2, 5}). + Group("user_id"). + Scan(&orderCounts).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count invitee paid orders failed: %v", err) + } + for _, row := range orderCounts { + benefit := result[row.UserId] + benefit.OrderCount = row.Cnt + benefit.HasPurchased = row.Cnt > 0 + result[row.UserId] = benefit + } + + var paidOrders []paidOrderRow + if err := db.WithContext(ctx). + Table("`order`"). + Select("user_id, subscription_user_id, order_no"). + Where("user_id IN ? AND status IN ?", inviteeIds, []int{2, 5}). + Scan(&paidOrders).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query invitee paid orders failed: %v", err) + } + if len(paidOrders) == 0 { + return result, nil + } + + orderNos := make([]string, 0, len(paidOrders)) + orderToInvitee := make(map[string]int64, len(paidOrders)) + orderToSubscriptionUser := make(map[string]int64, len(paidOrders)) + inviterIds := make([]int64, 0, len(relations)) + inviteeAndInviterSet := make(map[int64]struct{}, len(relations)*2) + for _, order := range paidOrders { + orderNos = append(orderNos, order.OrderNo) + orderToInvitee[order.OrderNo] = order.UserId + if order.SubscriptionUserId > 0 && order.SubscriptionUserId != order.UserId { + orderToSubscriptionUser[order.OrderNo] = order.SubscriptionUserId + inviteeAndInviterSet[order.SubscriptionUserId] = struct{}{} + } + } + for _, relation := range relations { + inviterIds = append(inviterIds, relation.InviterId) + inviteeAndInviterSet[relation.InviteeId] = struct{}{} + inviteeAndInviterSet[relation.InviterId] = struct{}{} + } + inviteeAndInviterIds := make([]int64, 0, len(inviteeAndInviterSet)) + for userId := range inviteeAndInviterSet { + inviteeAndInviterIds = append(inviteeAndInviterIds, userId) + } + slices.Sort(inviteeAndInviterIds) + + if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil { + return nil, err + } + if err := fillGiftBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderToSubscriptionUser, orderNos, inviteeAndInviterIds); err != nil { + return nil, err + } + + return result, nil +} + +func fillCommissionBenefits(ctx context.Context, db *gorm.DB, benefits map[int64]Benefits, orderToInvitee map[string]int64, inviteeToInviter map[int64]int64, orderNos []string, inviterIds []int64) error { + var rows []systemLogRow + if err := db.WithContext(ctx). + Table("system_logs"). + Select("object_id, content"). + Where("type = ? AND object_id IN ? AND JSON_UNQUOTE(JSON_EXTRACT(content, '$.order_no')) IN ? AND JSON_EXTRACT(content, '$.type') IN ?", modellog.TypeCommission.Uint8(), inviterIds, orderNos, []int{int(modellog.CommissionTypePurchase), int(modellog.CommissionTypeRenewal)}). + Scan(&rows).Error; err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query invite commission logs failed: %v", err) + } + for _, row := range rows { + content := modellog.Commission{} + if err := content.Unmarshal([]byte(row.Content)); err != nil { + continue + } + inviteeId, ok := orderToInvitee[content.OrderNo] + if !ok || inviteeToInviter[inviteeId] != row.ObjectID { + continue + } + benefit := benefits[inviteeId] + benefit.InviterCommission += content.Amount + benefits[inviteeId] = benefit + } + return nil +} + +func fillGiftBenefits(ctx context.Context, db *gorm.DB, benefits map[int64]Benefits, orderToInvitee map[string]int64, inviteeToInviter map[int64]int64, orderToSubscriptionUser map[string]int64, orderNos []string, userIds []int64) error { + var rows []systemLogRow + if err := db.WithContext(ctx). + Table("system_logs"). + Select("object_id, content"). + Where("type = ? AND object_id IN ? AND JSON_UNQUOTE(JSON_EXTRACT(content, '$.order_no')) IN ?", modellog.TypeGift.Uint8(), userIds, orderNos). + Scan(&rows).Error; err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query invite gift logs failed: %v", err) + } + for _, row := range rows { + content := modellog.Gift{} + if err := content.Unmarshal([]byte(row.Content)); err != nil { + continue + } + if content.Type != modellog.GiftTypeIncrease { + continue + } + inviteeId, ok := orderToInvitee[content.OrderNo] + if !ok { + continue + } + benefit := benefits[inviteeId] + switch row.ObjectID { + case inviteeToInviter[inviteeId]: + benefit.InviterGiftDays += content.Amount + case inviteeId: + benefit.InviteeGiftDays += content.Amount + case orderToSubscriptionUser[content.OrderNo]: + benefit.InviteeGiftDays += content.Amount + default: + continue + } + benefits[inviteeId] = benefit + } + return nil +} + +type DeviceIdentifier struct { + Identifier string + DeviceNo string +} + +func QueryDeviceIdentifiers(ctx context.Context, db *gorm.DB, userIds []int64) (map[int64]DeviceIdentifier, error) { + identifiers := make(map[int64]DeviceIdentifier, len(userIds)) + if len(userIds) == 0 { + return identifiers, nil + } + + type identifierRow struct { + UserId int64 `gorm:"column:user_id"` + DeviceId int64 `gorm:"column:device_id"` + Identifier string `gorm:"column:identifier"` + } + var rows []identifierRow + if err := db.WithContext(ctx). + Table("user_device ud"). + Select("ud.user_id, ud.id as device_id, ud.identifier as identifier"). + Joins("JOIN (SELECT user_id, MIN(id) AS id FROM user_device WHERE user_id IN ? GROUP BY user_id) first_ud ON first_ud.id = ud.id", userIds). + Scan(&rows).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user device identifiers failed: %v", err) + } + for _, row := range rows { + identifiers[row.UserId] = DeviceIdentifier{ + Identifier: row.Identifier, + DeviceNo: tool.DeviceIdToHash(row.DeviceId), + } + } + return identifiers, nil +} diff --git a/internal/logic/admin/invite/benefits_test.go b/internal/logic/admin/invite/benefits_test.go new file mode 100644 index 0000000..21e6233 --- /dev/null +++ b/internal/logic/admin/invite/benefits_test.go @@ -0,0 +1,139 @@ +package invite + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/perfect-panel/server/pkg/tool" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestQueryBenefitsCountsFamilyOwnerGiftAsInviteeGift(t *testing.T) { + db, mock, cleanup := newBenefitsTestDB(t) + defer cleanup() + + mock.ExpectQuery("COUNT(*) as cnt"). + WithArgs(int64(200), 2, 5). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "cnt"}).AddRow(200, 1)) + mock.ExpectQuery("SELECT user_id, subscription_user_id, order_no FROM `order`"). + WithArgs(int64(200), 2, 5). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "subscription_user_id", "order_no"}).AddRow(200, 900, "family-order")) + mock.ExpectQuery("object_id IN"). + WithArgs(33, int64(100), "family-order", 331, 332). + WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"})) + mock.ExpectQuery("object_id IN"). + WithArgs(34, int64(100), int64(200), int64(900), "family-order"). + WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}). + AddRow(900, `{"type":341,"order_no":"family-order","amount":7,"balance":7,"remark":"邀请赠送"}`)) + + benefits, err := QueryBenefits(context.Background(), db, []InviteRelation{{InviteeId: 200, InviterId: 100}}) + if err != nil { + t.Fatalf("QueryBenefits returned error: %v", err) + } + + benefit := benefits[200] + if benefit.InviteeGiftDays != 7 { + t.Fatalf("InviteeGiftDays = %d, want 7", benefit.InviteeGiftDays) + } + if benefit.InviterGiftDays != 0 { + t.Fatalf("InviterGiftDays = %d, want 0", benefit.InviterGiftDays) + } + assertBenefitsExpectations(t, mock) +} + +func TestQueryBenefitsKeepsDirectInviteeGift(t *testing.T) { + db, mock, cleanup := newBenefitsTestDB(t) + defer cleanup() + + mock.ExpectQuery("COUNT(*) as cnt"). + WithArgs(int64(200), 2, 5). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "cnt"}).AddRow(200, 1)) + mock.ExpectQuery("SELECT user_id, subscription_user_id, order_no FROM `order`"). + WithArgs(int64(200), 2, 5). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "subscription_user_id", "order_no"}).AddRow(200, 0, "direct-order")) + mock.ExpectQuery("object_id IN"). + WithArgs(33, int64(100), "direct-order", 331, 332). + WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"})) + mock.ExpectQuery("object_id IN"). + WithArgs(34, int64(100), int64(200), "direct-order"). + WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}). + AddRow(200, `{"type":341,"order_no":"direct-order","amount":5,"balance":5,"remark":"邀请赠送"}`)) + + benefits, err := QueryBenefits(context.Background(), db, []InviteRelation{{InviteeId: 200, InviterId: 100}}) + if err != nil { + t.Fatalf("QueryBenefits returned error: %v", err) + } + + if got := benefits[200].InviteeGiftDays; got != 5 { + t.Fatalf("InviteeGiftDays = %d, want 5", got) + } + assertBenefitsExpectations(t, mock) +} + +func TestQueryDeviceIdentifiersReturnsDeviceIdentifierAndNo(t *testing.T) { + db, mock, cleanup := newBenefitsTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM user_device ud"). + WithArgs(int64(100), int64(200)). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "device_id", "identifier"}). + AddRow(100, 11, "inviter-hash"). + AddRow(200, 22, "invitee-hash")) + + identifiers, err := QueryDeviceIdentifiers(context.Background(), db, []int64{100, 200}) + if err != nil { + t.Fatalf("QueryDeviceIdentifiers returned error: %v", err) + } + + inviter := identifiers[100] + if inviter.Identifier != "inviter-hash" { + t.Fatalf("inviter identifier = %q, want inviter-hash", inviter.Identifier) + } + if inviter.DeviceNo != tool.DeviceIdToHash(11) { + t.Fatalf("inviter device no = %q, want %q", inviter.DeviceNo, tool.DeviceIdToHash(11)) + } + + invitee := identifiers[200] + if invitee.Identifier != "invitee-hash" { + t.Fatalf("invitee identifier = %q, want invitee-hash", invitee.Identifier) + } + if invitee.DeviceNo != tool.DeviceIdToHash(22) { + t.Fatalf("invitee device no = %q, want %q", invitee.DeviceNo, tool.DeviceIdToHash(22)) + } + assertBenefitsExpectations(t, mock) +} + +func newBenefitsTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { + _ = sqlDB.Close() + } +} + +func assertBenefitsExpectations(t *testing.T, mock sqlmock.Sqlmock) { + t.Helper() + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} diff --git a/internal/logic/admin/invite/getInviteManageListLogic.go b/internal/logic/admin/invite/getInviteManageListLogic.go new file mode 100644 index 0000000..46374de --- /dev/null +++ b/internal/logic/admin/invite/getInviteManageListLogic.go @@ -0,0 +1,119 @@ +package invite + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type GetInviteManageListLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetInviteManageListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetInviteManageListLogic { + return &GetInviteManageListLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetInviteManageListLogic) GetInviteManageList(req *types.GetInviteManageListRequest) (resp *types.GetInviteManageListResponse, err error) { + req.Page, req.Size = NormalizePage(req.Page, req.Size) + + type inviteRow struct { + InviteeId int64 `gorm:"column:invitee_id"` + InviteeAvatar string `gorm:"column:invitee_avatar"` + InviteeEnable bool `gorm:"column:invitee_enable"` + InvitedAt int64 `gorm:"column:invited_at"` + InviterId int64 `gorm:"column:inviter_id"` + } + + baseQuery := applyInviteManageFilters(l.svcCtx.DB.WithContext(l.ctx). + Table("user invitee"). + Where("invitee.referer_id > 0 AND invitee.deleted_at IS NULL"), req) + + var total int64 + if err = baseQuery.Count(&total).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count invite manage records failed: %v", err) + } + + var rows []inviteRow + if err = applyInviteManageFilters(l.svcCtx.DB.WithContext(l.ctx). + Table("user invitee"). + Select("invitee.id as invitee_id, invitee.avatar as invitee_avatar, invitee.enable as invitee_enable, CAST(UNIX_TIMESTAMP(invitee.created_at) AS SIGNED) as invited_at, invitee.referer_id as inviter_id"). + Where("invitee.referer_id > 0 AND invitee.deleted_at IS NULL"), req). + Order("invitee.created_at DESC"). + Limit(req.Size). + Offset((req.Page - 1) * req.Size). + Scan(&rows).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query invite manage records failed: %v", err) + } + + relations := make([]InviteRelation, 0, len(rows)) + userIds := make([]int64, 0, len(rows)*2) + for _, row := range rows { + relations = append(relations, InviteRelation{InviteeId: row.InviteeId, InviterId: row.InviterId}) + userIds = append(userIds, row.InviteeId, row.InviterId) + } + + benefits, err := QueryBenefits(l.ctx, l.svcCtx.DB, relations) + if err != nil { + return nil, err + } + devices, err := QueryDeviceIdentifiers(l.ctx, l.svcCtx.DB, userIds) + if err != nil { + return nil, err + } + + list := make([]types.InviteManageRecord, 0, len(rows)) + for _, row := range rows { + benefit := benefits[row.InviteeId] + list = append(list, types.InviteManageRecord{ + InviterId: row.InviterId, + InviterIdentifier: devices[row.InviterId].Identifier, + InviterDeviceNo: devices[row.InviterId].DeviceNo, + InviteeId: row.InviteeId, + InviteeIdentifier: devices[row.InviteeId].Identifier, + InviteeDeviceNo: devices[row.InviteeId].DeviceNo, + InviteeAvatar: row.InviteeAvatar, + InviteeEnable: row.InviteeEnable, + InvitedAt: row.InvitedAt, + OrderCount: benefit.OrderCount, + HasPurchased: benefit.HasPurchased, + InviterCommission: benefit.InviterCommission, + InviterGiftDays: benefit.InviterGiftDays, + InviteeGiftDays: benefit.InviteeGiftDays, + }) + } + + return &types.GetInviteManageListResponse{ + Total: total, + List: list, + }, nil +} + +func applyInviteManageFilters(db *gorm.DB, req *types.GetInviteManageListRequest) *gorm.DB { + if req.InviterId > 0 { + db = db.Where("invitee.referer_id = ?", req.InviterId) + } + if req.InviteeId > 0 { + db = db.Where("invitee.id = ?", req.InviteeId) + } + if req.Search != "" { + search := "%" + req.Search + "%" + db = db.Where( + "(EXISTS (SELECT 1 FROM user_auth_methods inviter_auth WHERE inviter_auth.user_id = invitee.referer_id AND inviter_auth.auth_identifier LIKE ?) OR EXISTS (SELECT 1 FROM user_auth_methods invitee_auth WHERE invitee_auth.user_id = invitee.id AND invitee_auth.auth_identifier LIKE ?))", + search, + search, + ) + } + return db +} diff --git a/internal/logic/admin/order/getOrderListLogic.go b/internal/logic/admin/order/getOrderListLogic.go index 212b534..cafabb8 100644 --- a/internal/logic/admin/order/getOrderListLogic.go +++ b/internal/logic/admin/order/getOrderListLogic.go @@ -55,6 +55,8 @@ func orderStatusName(status uint8) string { case 5: return "finished" case 6: + return "claimed" + case 7: return "refunded" default: return "unknown" diff --git a/internal/logic/admin/order/refundOrderLogic.go b/internal/logic/admin/order/refundOrderLogic.go index 6113b90..088da01 100644 --- a/internal/logic/admin/order/refundOrderLogic.go +++ b/internal/logic/admin/order/refundOrderLogic.go @@ -20,7 +20,7 @@ import ( ) const ( - orderStatusRefunded = 6 + orderStatusRefunded = 7 ) type RefundOrderLogic struct { @@ -67,6 +67,14 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error { return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "order %d status %d is not refundable", orderInfo.Id, orderInfo.Status) } + refunded, err := l.hasRefundLog(tx, orderInfo.OrderNo) + if err != nil { + return err + } + if refunded { + return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already has refund log", orderInfo.Id) + } + userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo) if err != nil { return err @@ -162,6 +170,9 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error { orderUser := &modeluser.User{Id: orderInfo.UserId} userCacheTargets = append(userCacheTargets, orderUser) } + if userSub.UserId > 0 && userSub.UserId != orderInfo.UserId { + userCacheTargets = append(userCacheTargets, &modeluser.User{Id: userSub.UserId}) + } return nil }) if err != nil { @@ -169,17 +180,19 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error { return err } - if len(cachesToClear) > 0 { + if len(cachesToClear) > 0 && l.svcCtx.UserModel != nil { if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, cachesToClear...); clearErr != nil { l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("order_id", req.Id)) } } for _, subscribeID := range planCacheIDs { - if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != nil { - l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID)) + if l.svcCtx.SubscribeModel != nil { + if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != nil { + l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID)) + } } } - if len(userCacheTargets) > 0 { + if len(userCacheTargets) > 0 && l.svcCtx.UserModel != nil { if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userCacheTargets...); clearErr != nil { l.Errorw("[RefundOrder] clear user cache failed", logger.Field("error", clearErr.Error())) } @@ -188,16 +201,16 @@ func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error { } func (l *RefundOrderLogic) lockRefundTargetSubscription(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) { - var userSub modeluser.Subscribe - err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Model(&modeluser.Subscribe{}). - Where("order_id = ?", orderInfo.Id). - First(&userSub).Error - if err == nil { - return &userSub, nil + if userSub, err := l.lockSubscriptionByOrderID(tx, orderInfo.Id); err != nil { + return nil, err + } else if userSub != nil { + return userSub, nil } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query refund subscription failed: %v", err) + + if userSub, err := l.lockSubscriptionByEntitlement(tx, orderInfo); err != nil { + return nil, err + } else if userSub != nil { + return userSub, nil } if orderInfo.Type != 2 { @@ -208,17 +221,122 @@ func (l *RefundOrderLogic) lockRefundTargetSubscription(tx *gorm.DB, orderInfo * return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d has no parent order", orderInfo.Id) } - err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). + if userSub, err := l.lockSubscriptionByOrderID(tx, orderInfo.ParentId); err != nil { + return nil, err + } else if userSub != nil { + return userSub, nil + } + + if userSub, err := l.lockRenewalParentEntitlementSubscription(tx, orderInfo); err != nil { + return nil, err + } else if userSub != nil { + return userSub, nil + } + + return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d parent subscription not found", orderInfo.Id) +} + +func (l *RefundOrderLogic) lockSubscriptionByOrderID(tx *gorm.DB, orderID int64) (*modeluser.Subscribe, error) { + if orderID <= 0 { + return nil, nil + } + + var userSub modeluser.Subscribe + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Model(&modeluser.Subscribe{}). - Where("order_id = ?", orderInfo.ParentId). + Where("order_id = ?", orderID). First(&userSub).Error + if err == nil { + return &userSub, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query refund subscription failed: %v", err) +} + +func (l *RefundOrderLogic) lockSubscriptionByEntitlement(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) { + entitlementUserID := orderInfo.SubscriptionUserId + if entitlementUserID == 0 { + entitlementUserID = orderInfo.UserId + } + if entitlementUserID <= 0 { + return nil, nil + } + + if userSub, err := l.lockExactEntitlementSubscription(tx, orderInfo, entitlementUserID); err != nil { + return nil, err + } else if userSub != nil { + return userSub, nil + } + + var userSub modeluser.Subscribe + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Model(&modeluser.Subscribe{}). + Where("user_id = ? AND subscribe_id = ?", entitlementUserID, orderInfo.SubscribeId). + Where("status IN ?", []int64{0, 1, 2, 3, 5}). + Order("expire_time DESC"). + Order("updated_at DESC"). + Order("id DESC"). + First(&userSub).Error + if err == nil { + return &userSub, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query entitlement subscription failed: %v", err) +} + +func (l *RefundOrderLogic) lockExactEntitlementSubscription(tx *gorm.DB, orderInfo *modelorder.Order, entitlementUserID int64) (*modeluser.Subscribe, error) { + if orderInfo.Id <= 0 && orderInfo.SubscribeToken == "" { + return nil, nil + } + + query := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Model(&modeluser.Subscribe{}). + Where("user_id = ? AND subscribe_id = ?", entitlementUserID, orderInfo.SubscribeId) + if orderInfo.Id > 0 && orderInfo.SubscribeToken != "" { + query = query.Where("(order_id = ? OR token = ?)", orderInfo.Id, orderInfo.SubscribeToken) + } else if orderInfo.Id > 0 { + query = query.Where("order_id = ?", orderInfo.Id) + } else { + query = query.Where("token = ?", orderInfo.SubscribeToken) + } + + var userSub modeluser.Subscribe + err := query.Order("id DESC").First(&userSub).Error + if err == nil { + return &userSub, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query exact entitlement subscription failed: %v", err) +} + +func (l *RefundOrderLogic) lockRenewalParentEntitlementSubscription(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) { + var parentOrder modelorder.Order + err := tx.Model(&modelorder.Order{}). + Where("id = ?", orderInfo.ParentId). + First(&parentOrder).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d parent subscription not found", orderInfo.Id) + return nil, nil } - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query renewal subscription failed: %v", err) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query renewal parent order failed: %v", err) } - return &userSub, nil + + if parentOrder.SubscriptionUserId == 0 && orderInfo.SubscriptionUserId > 0 { + parentOrder.SubscriptionUserId = orderInfo.SubscriptionUserId + } + if parentOrder.UserId == 0 { + parentOrder.UserId = orderInfo.UserId + } + if parentOrder.SubscribeId == 0 { + parentOrder.SubscribeId = orderInfo.SubscribeId + } + return l.lockSubscriptionByEntitlement(tx, &parentOrder) } func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, orderCommission int64) (*modeluser.User, int64, error) { @@ -256,6 +374,18 @@ func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, ord return nil, 0, nil } +// hasRefundLog checks refund audit logs first, then falls back to legacy commission refund logs. +func (l *RefundOrderLogic) hasRefundLog(tx *gorm.DB, orderNo string) (bool, error) { + refunded, err := log.HasOrderRefundLog(tx, orderNo) + if err != nil { + return false, err + } + if refunded { + return true, nil + } + return log.HasRefundCommissionLog(tx, orderNo) +} + func (l *RefundOrderLogic) buildRefundAuditLog( operator *modeluser.User, orderInfo *modelorder.Order, diff --git a/internal/logic/admin/order/refundOrderLogic_test.go b/internal/logic/admin/order/refundOrderLogic_test.go index 7c1d7dc..064f94b 100644 --- a/internal/logic/admin/order/refundOrderLogic_test.go +++ b/internal/logic/admin/order/refundOrderLogic_test.go @@ -1,11 +1,23 @@ package order import ( + "context" + "fmt" + "strings" "testing" "time" + "github.com/DATA-DOG/go-sqlmock" modelorder "github.com/perfect-panel/server/internal/model/order" modeluser "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/driver/mysql" + "gorm.io/gorm" ) func TestOrderStatusName(t *testing.T) { @@ -15,8 +27,8 @@ func TestOrderStatusName(t *testing.T) { 3: "closed", 4: "failed", 5: "finished", - 6: "refunded", - 7: "unknown", + 6: "claimed", + 7: "refunded", } for input, want := range tests { @@ -83,3 +95,343 @@ func TestBuildRefundAuditLog(t *testing.T) { t.Fatalf("unexpected commission transition: %+v", got) } } + +func TestRefundOrder_SetsStatusRefunded(t *testing.T) { + const ( + orderID = int64(1000) + orderNo = "ORD-REFUND-SUCCESS" + operatorUID = int64(519) + userID = int64(7000) + subscribeID = int64(8000) + userSubID = int64(9000) + ) + + db, mock, cleanup := newRefundOrderTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `order`"). + WithArgs(orderID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}). + AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID)) + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"})) + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"})) + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(orderID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time"}). + AddRow(userSubID, userID, orderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour))) + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"})) + mock.ExpectExec("UPDATE `order`"). + WithArgs(orderStatusRefunded, sqlmock.AnyArg(), orderID, 2, 5). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE `user_subscribe`"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO `system_logs`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + logic := newTestRefundOrderLogic(t, db, operatorUID) + err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "manual refund"}) + if err != nil { + t.Fatalf("RefundOrder error: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +// TestRefundOrder_RejectsWhenRefundLogExists 验证 HIF-16 修复: +// 当 system_logs 已存在该订单的 24 退款审计日志时,再次调用 RefundOrder 必须: +// 1. 返回 OrderAlreadyRefunded 错误码; +// 2. 不再查询 / 锁定 commission 来源(lockCommissionSource 不应触发); +// 3. 不写入新的 333 日志、不更新 user.commission、不更新 order.status。 +// +// 通过 sqlmock 严格定义期望 SQL:只允许出现 BEGIN / SELECT order FOR UPDATE / +// SELECT system_logs(命中 24)/ ROLLBACK,不允许出现 commission 锁/更新/插入。 +func TestRefundOrder_RejectsWhenRefundLogExists(t *testing.T) { + const ( + orderID = int64(53647) + orderNo = "202605301925431836075753253" + operatorUID = int64(519) + ) + + db, mock, cleanup := newRefundOrderTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `order`"). + WithArgs(orderID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id"}). + AddRow(orderID, orderNo, uint8(5), uint8(2), int64(2250), int64(72028))) + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, fmt.Sprintf(`{"order_id":%d,"order_no":"%s","order_status_before":5,"order_status_after":7}`, orderID, orderNo))) + mock.ExpectRollback() + + logic := newTestRefundOrderLogic(t, db, operatorUID) + err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "duplicate"}) + if err == nil { + t.Fatalf("RefundOrder expected error, got nil") + } + if !isErrCode(err, xerr.OrderAlreadyRefunded) { + t.Fatalf("RefundOrder error code = %v, want OrderAlreadyRefunded; raw=%v", errCodeOf(err), err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +// TestRefundOrder_RejectsWhenStatusAlreadyRefunded 覆盖既有 status==7 拒绝路径, +// 确保新增的 333 日志校验不会破坏原有「订单已被标记为退款」短路逻辑。 +func TestRefundOrder_RejectsWhenStatusAlreadyRefunded(t *testing.T) { + const ( + orderID = int64(1001) + orderNo = "ORD-STATUS-7" + operatorUID = int64(519) + ) + + db, mock, cleanup := newRefundOrderTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `order`"). + WithArgs(orderID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status"}). + AddRow(orderID, orderNo, uint8(orderStatusRefunded))) + mock.ExpectRollback() + + logic := newTestRefundOrderLogic(t, db, operatorUID) + err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID}) + if !isErrCode(err, xerr.OrderAlreadyRefunded) { + t.Fatalf("RefundOrder error code = %v, want OrderAlreadyRefunded; raw=%v", errCodeOf(err), err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +// TestRefundOrder_RejectsWhenStatusNotRefundable 覆盖非 2/5 状态短路。 +func TestRefundOrder_RejectsWhenStatusNotRefundable(t *testing.T) { + const ( + orderID = int64(1002) + orderNo = "ORD-STATUS-1" + operatorUID = int64(519) + ) + + db, mock, cleanup := newRefundOrderTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `order`"). + WithArgs(orderID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status"}). + AddRow(orderID, orderNo, uint8(1))) + mock.ExpectRollback() + + logic := newTestRefundOrderLogic(t, db, operatorUID) + err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID}) + if !isErrCode(err, xerr.OrderStatusError) { + t.Fatalf("RefundOrder error code = %v, want OrderStatusError; raw=%v", errCodeOf(err), err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestLockRefundTargetSubscription_FamilyMemberPurchaseLocksOwnerSubscription(t *testing.T) { + const ( + orderID = int64(2001) + memberUID = int64(101) + ownerUID = int64(201) + subscribeID = int64(301) + userSubID = int64(401) + ) + + db, mock, cleanup := newRefundOrderTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(orderID, 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(ownerUID, subscribeID, orderID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time"}). + AddRow(userSubID, ownerUID, orderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour))) + + logic := &RefundOrderLogic{} + got, err := logic.lockRefundTargetSubscription(db, &modelorder.Order{ + Id: orderID, + UserId: memberUID, + SubscriptionUserId: ownerUID, + Type: 1, + SubscribeId: subscribeID, + }) + if err != nil { + t.Fatalf("lockRefundTargetSubscription error: %v", err) + } + if got.Id != userSubID || got.UserId != ownerUID { + t.Fatalf("locked subscription = %+v, want id=%d user_id=%d", got, userSubID, ownerUID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestLockRefundTargetSubscription_RenewalFallsBackToParentOwnerEntitlement(t *testing.T) { + const ( + orderID = int64(2101) + parentOrderID = int64(2100) + memberUID = int64(111) + ownerUID = int64(211) + subscribeID = int64(311) + userSubID = int64(411) + ) + + db, mock, cleanup := newRefundOrderTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(orderID, 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(ownerUID, subscribeID, orderID, "renew-token", 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(ownerUID, subscribeID, int64(0), int64(1), int64(2), int64(3), int64(5), 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(parentOrderID, 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectQuery("FROM `order`"). + WithArgs(parentOrderID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subscription_user_id", "subscribe_id", "subscribe_token"}). + AddRow(parentOrderID, memberUID, ownerUID, subscribeID, "owner-token")) + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(ownerUID, subscribeID, parentOrderID, "owner-token", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "order_id", "subscribe_id", "status", "expire_time", "token"}). + AddRow(userSubID, ownerUID, parentOrderID, subscribeID, uint8(1), time.Now().Add(24*time.Hour), "owner-token")) + + logic := &RefundOrderLogic{} + got, err := logic.lockRefundTargetSubscription(db, &modelorder.Order{ + Id: orderID, + ParentId: parentOrderID, + UserId: memberUID, + SubscriptionUserId: ownerUID, + Type: 2, + SubscribeId: subscribeID, + SubscribeToken: "renew-token", + }) + if err != nil { + t.Fatalf("lockRefundTargetSubscription error: %v", err) + } + if got.Id != userSubID || got.UserId != ownerUID || got.OrderId != parentOrderID { + t.Fatalf("locked subscription = %+v, want id=%d user_id=%d order_id=%d", got, userSubID, ownerUID, parentOrderID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestRefundOrder_NoTargetSubscriptionDoesNotRefundOrder(t *testing.T) { + const ( + orderID = int64(2201) + orderNo = "ORD-NO-SUB" + operatorUID = int64(519) + userID = int64(7001) + subscribeID = int64(8001) + ) + + db, mock, cleanup := newRefundOrderTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `order`"). + WithArgs(orderID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status", "type", "commission", "user_id", "subscribe_id"}). + AddRow(orderID, orderNo, uint8(5), uint8(1), int64(0), userID, subscribeID)) + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"})) + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"})) + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(orderID, 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(userID, subscribeID, orderID, 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(userID, subscribeID, int64(0), int64(1), int64(2), int64(3), int64(5), 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectRollback() + + logic := newTestRefundOrderLogic(t, db, operatorUID) + err := logic.RefundOrder(&types.RefundOrderRequest{Id: orderID, Reason: "missing subscription"}) + if !isErrCode(err, xerr.OrderRefundNoSubscription) { + t.Fatalf("RefundOrder error code = %v, want OrderRefundNoSubscription; raw=%v", errCodeOf(err), err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func newRefundOrderTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { + _ = sqlDB.Close() + } +} + +func newTestRefundOrderLogic(t *testing.T, db *gorm.DB, operatorID int64) *RefundOrderLogic { + t.Helper() + ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &modeluser.User{Id: operatorID}) + return &RefundOrderLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: &svc.ServiceContext{DB: db}, + } +} + +// errCodeOf / isErrCode 用于绕开 wrapped error 检查内层 xerr 错误码。 +func errCodeOf(err error) uint32 { + if err == nil { + return 0 + } + type coder interface { + GetErrCode() uint32 + } + cause := errors.Cause(err) + if c, ok := cause.(coder); ok { + return c.GetErrCode() + } + return 0 +} + +func isErrCode(err error, code uint32) bool { + return errCodeOf(err) == code +} diff --git a/internal/logic/admin/order/updateOrderStatusLogic.go b/internal/logic/admin/order/updateOrderStatusLogic.go index 17df64d..c9df995 100644 --- a/internal/logic/admin/order/updateOrderStatusLogic.go +++ b/internal/logic/admin/order/updateOrderStatusLogic.go @@ -15,6 +15,10 @@ import ( queue "github.com/perfect-panel/server/queue/types" ) +const ( + orderStatusClaimed = 6 +) + type UpdateOrderStatusLogic struct { logger.Logger ctx context.Context @@ -31,6 +35,10 @@ func NewUpdateOrderStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) } func (l *UpdateOrderStatusLogic) UpdateOrderStatus(req *types.UpdateOrderStatusRequest) error { + if req.Status == orderStatusClaimed || req.Status == orderStatusRefunded { + return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "claimed/refund statuses are reserved for internal refund and activation flows") + } + info, err := l.svcCtx.OrderModel.FindOne(l.ctx, req.Id) if err != nil { l.Errorw("[UpdateOrderStatus] FindOne error", logger.Field("error", err.Error())) diff --git a/internal/logic/admin/order/updateOrderStatusLogic_test.go b/internal/logic/admin/order/updateOrderStatusLogic_test.go new file mode 100644 index 0000000..8c3f344 --- /dev/null +++ b/internal/logic/admin/order/updateOrderStatusLogic_test.go @@ -0,0 +1,29 @@ +package order + +import ( + "context" + "testing" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" +) + +func TestUpdateOrderStatus_RejectsRefundStatus(t *testing.T) { + logic := &UpdateOrderStatusLogic{ + Logger: logger.WithContext(context.Background()), + ctx: context.Background(), + svcCtx: &svc.ServiceContext{}, + } + + for _, status := range []uint8{orderStatusClaimed, orderStatusRefunded} { + err := logic.UpdateOrderStatus(&types.UpdateOrderStatusRequest{ + Id: 1001, + Status: status, + }) + if !isErrCode(err, xerr.OrderStatusError) { + t.Fatalf("status %d: UpdateOrderStatus error code = %v, want OrderStatusError; raw=%v", status, errCodeOf(err), err) + } + } +} diff --git a/internal/logic/admin/promo/createRuleLogic.go b/internal/logic/admin/promo/createRuleLogic.go new file mode 100644 index 0000000..f6d9eb1 --- /dev/null +++ b/internal/logic/admin/promo/createRuleLogic.go @@ -0,0 +1,58 @@ +package promo + +import ( + "context" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type CreateRuleLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateRuleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateRuleLogic { + return &CreateRuleLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateRuleLogic) CreateRule(req *types.CreatePromoRuleRequest) (*types.PromoRule, error) { + if err := validateRuleInput(req.Type, req.Params, req.Priority, req.StartTime, req.EndTime); err != nil { + return nil, err + } + params, err := paramsToString(req.Params) + if err != nil { + return nil, err + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + rule := &promomodel.Rule{ + Name: req.Name, + Type: req.Type, + Params: params, + Priority: req.Priority, + Enabled: enabled, + StartTime: unixPtrToTimePtr(req.StartTime), + EndTime: unixPtrToTimePtr(req.EndTime), + } + if err := l.svcCtx.PromoModel.InsertRule(l.ctx, rule); err != nil { + l.Errorw("[CreatePromoRule] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create promo rule error: %v", err.Error()) + } + if err := l.svcCtx.Redis.Del(l.ctx, ruleCacheKey).Err(); err != nil { + l.Errorw("[CreatePromoRule] Delete Cache Error", logger.Field("error", err.Error())) + } + resp := convertRule(rule) + return &resp, nil +} diff --git a/internal/logic/admin/promo/deletePriceLogic.go b/internal/logic/admin/promo/deletePriceLogic.go new file mode 100644 index 0000000..7f5344c --- /dev/null +++ b/internal/logic/admin/promo/deletePriceLogic.go @@ -0,0 +1,47 @@ +package promo + +import ( + "context" + stderrors "errors" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type DeletePriceLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeletePriceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePriceLogic { + return &DeletePriceLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeletePriceLogic) DeletePrice(req *types.DeletePromoPriceRequest) error { + price, err := l.svcCtx.PromoModel.FindPrice(l.ctx, req.Id) + if err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + l.Errorw("[DeletePromoPrice] Price Not Found", logger.Field("id", req.Id)) + return errors.Wrapf(xerr.NewErrCodeMsg(404, "promo price not found"), "promo price not found: %d", req.Id) + } + l.Errorw("[DeletePromoPrice] Find Price Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find promo price error: %v", err.Error()) + } + if err := l.svcCtx.PromoModel.DeletePrice(l.ctx, req.Id); err != nil { + l.Errorw("[DeletePromoPrice] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete promo price error: %v", err.Error()) + } + if err := l.svcCtx.Redis.Del(l.ctx, subscribePromoCacheKey(price.SubscribeId)).Err(); err != nil { + l.Errorw("[DeletePromoPrice] Delete Cache Error", logger.Field("error", err.Error())) + } + return nil +} diff --git a/internal/logic/admin/promo/deleteRuleLogic.go b/internal/logic/admin/promo/deleteRuleLogic.go new file mode 100644 index 0000000..44eae9f --- /dev/null +++ b/internal/logic/admin/promo/deleteRuleLogic.go @@ -0,0 +1,46 @@ +package promo + +import ( + "context" + stderrors "errors" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type DeleteRuleLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteRuleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteRuleLogic { + return &DeleteRuleLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeleteRuleLogic) DeleteRule(req *types.DeletePromoRuleRequest) error { + if _, err := l.svcCtx.PromoModel.FindRule(l.ctx, req.Id); err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + l.Errorw("[DeletePromoRule] Rule Not Found", logger.Field("id", req.Id)) + return errors.Wrapf(xerr.NewErrCodeMsg(404, "promo rule not found"), "promo rule not found: %d", req.Id) + } + l.Errorw("[DeletePromoRule] Find Rule Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find promo rule error: %v", err.Error()) + } + if err := l.svcCtx.PromoModel.DeleteRule(l.ctx, req.Id); err != nil { + l.Errorw("[DeletePromoRule] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete promo rule error: %v", err.Error()) + } + if err := l.svcCtx.Redis.Del(l.ctx, ruleCacheKey).Err(); err != nil { + l.Errorw("[DeletePromoRule] Delete Cache Error", logger.Field("error", err.Error())) + } + return nil +} diff --git a/internal/logic/admin/promo/delete_not_found_test.go b/internal/logic/admin/promo/delete_not_found_test.go new file mode 100644 index 0000000..6e8c071 --- /dev/null +++ b/internal/logic/admin/promo/delete_not_found_test.go @@ -0,0 +1,106 @@ +package promo + +import ( + "context" + "testing" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/xerr" + pkgerrors "github.com/pkg/errors" + "gorm.io/gorm" +) + +type fakePromoModel struct { + insertRule func(context.Context, *promomodel.Rule) error + findRule func(context.Context, int64) (*promomodel.Rule, error) + updateRule func(context.Context, *promomodel.Rule) error +} + +func (fakePromoModel) QueryEligibleRules(context.Context, int64, int64) ([]*promomodel.RuleWithPrice, error) { + return nil, nil +} + +func (fakePromoModel) InsertUsage(context.Context, *promomodel.Usage, ...*gorm.DB) error { + return nil +} + +func (m fakePromoModel) InsertRule(ctx context.Context, rule *promomodel.Rule) error { + if m.insertRule != nil { + return m.insertRule(ctx, rule) + } + return nil +} + +func (m fakePromoModel) FindRule(ctx context.Context, id int64) (*promomodel.Rule, error) { + if m.findRule != nil { + return m.findRule(ctx, id) + } + return nil, gorm.ErrRecordNotFound +} + +func (m fakePromoModel) UpdateRule(ctx context.Context, rule *promomodel.Rule) error { + if m.updateRule != nil { + return m.updateRule(ctx, rule) + } + return nil +} + +func (fakePromoModel) DeleteRule(context.Context, int64) error { + return nil +} + +func (fakePromoModel) QueryRuleList(context.Context, int, int, string, *bool, string) (int64, []*promomodel.Rule, error) { + return 0, nil, nil +} + +func (fakePromoModel) UpsertPrices(context.Context, int64, []*promomodel.SubscribePromo) error { + return nil +} + +func (fakePromoModel) FindPrice(context.Context, int64) (*promomodel.SubscribePromo, error) { + return nil, gorm.ErrRecordNotFound +} + +func (fakePromoModel) DeletePrice(context.Context, int64) error { + return nil +} + +func (fakePromoModel) QueryPriceList(context.Context, promomodel.PriceFilter) (int64, []*promomodel.SubscribePromo, error) { + return 0, nil, nil +} + +func (fakePromoModel) QueryUsageList(context.Context, promomodel.UsageFilter) (int64, []*promomodel.Usage, error) { + return 0, nil, nil +} + +func (fakePromoModel) Transaction(context.Context, func(*gorm.DB) error) error { + return nil +} + +func TestDeleteRuleNotFoundReturns404(t *testing.T) { + svcCtx := &svc.ServiceContext{PromoModel: fakePromoModel{}} + err := NewDeleteRuleLogic(context.Background(), svcCtx).DeleteRule(&types.DeletePromoRuleRequest{Id: 1}) + assertCodeError(t, err, 404) +} + +func TestDeletePriceNotFoundReturns404(t *testing.T) { + svcCtx := &svc.ServiceContext{PromoModel: fakePromoModel{}} + err := NewDeletePriceLogic(context.Background(), svcCtx).DeletePrice(&types.DeletePromoPriceRequest{Id: 1}) + assertCodeError(t, err, 404) +} + +func assertCodeError(t *testing.T, err error, want uint32) { + t.Helper() + if err == nil { + t.Fatal("expected error") + } + codeErr, ok := pkgerrors.Cause(err).(*xerr.CodeError) + if !ok { + t.Fatalf("expected CodeError, got %T", pkgerrors.Cause(err)) + } + if got := codeErr.GetErrCode(); got != want { + t.Fatalf("unexpected error code: got %d want %d", got, want) + } +} diff --git a/internal/logic/admin/promo/getPriceListLogic.go b/internal/logic/admin/promo/getPriceListLogic.go new file mode 100644 index 0000000..ca817a9 --- /dev/null +++ b/internal/logic/admin/promo/getPriceListLogic.go @@ -0,0 +1,47 @@ +package promo + +import ( + "context" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetPriceListLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPriceListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPriceListLogic { + return &GetPriceListLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetPriceListLogic) GetPriceList(req *types.GetPromoPriceListRequest) (*types.GetPromoPriceListResponse, error) { + total, list, err := l.svcCtx.PromoModel.QueryPriceList(l.ctx, promomodel.PriceFilter{ + Page: int(req.Page), + Size: int(req.Size), + RuleId: req.RuleId, + SubscribeId: req.SubscribeId, + }) + if err != nil { + l.Errorw("[GetPromoPriceList] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get promo price list error: %v", err.Error()) + } + resp := &types.GetPromoPriceListResponse{ + Total: total, + List: make([]types.PromoPrice, 0, len(list)), + } + for _, item := range list { + resp.List = append(resp.List, convertPrice(item)) + } + return resp, nil +} diff --git a/internal/logic/admin/promo/getRuleDetailLogic.go b/internal/logic/admin/promo/getRuleDetailLogic.go new file mode 100644 index 0000000..19e9112 --- /dev/null +++ b/internal/logic/admin/promo/getRuleDetailLogic.go @@ -0,0 +1,35 @@ +package promo + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetRuleDetailLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetRuleDetailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRuleDetailLogic { + return &GetRuleDetailLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetRuleDetailLogic) GetRuleDetail(req *types.GetPromoRuleDetailRequest) (*types.PromoRule, error) { + rule, err := l.svcCtx.PromoModel.FindRule(l.ctx, req.Id) + if err != nil { + l.Errorw("[GetPromoRuleDetail] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get promo rule detail error: %v", err.Error()) + } + resp := convertRule(rule) + return &resp, nil +} diff --git a/internal/logic/admin/promo/getRuleListLogic.go b/internal/logic/admin/promo/getRuleListLogic.go new file mode 100644 index 0000000..7d0c3f1 --- /dev/null +++ b/internal/logic/admin/promo/getRuleListLogic.go @@ -0,0 +1,41 @@ +package promo + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetRuleListLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetRuleListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRuleListLogic { + return &GetRuleListLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetRuleListLogic) GetRuleList(req *types.GetPromoRuleListRequest) (*types.GetPromoRuleListResponse, error) { + total, list, err := l.svcCtx.PromoModel.QueryRuleList(l.ctx, int(req.Page), int(req.Size), req.Type, req.Enabled, req.Search) + if err != nil { + l.Errorw("[GetPromoRuleList] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get promo rule list error: %v", err.Error()) + } + resp := &types.GetPromoRuleListResponse{ + Total: total, + List: make([]types.PromoRule, 0, len(list)), + } + for _, item := range list { + resp.List = append(resp.List, convertRule(item)) + } + return resp, nil +} diff --git a/internal/logic/admin/promo/getUsageListLogic.go b/internal/logic/admin/promo/getUsageListLogic.go new file mode 100644 index 0000000..1901c60 --- /dev/null +++ b/internal/logic/admin/promo/getUsageListLogic.go @@ -0,0 +1,49 @@ +package promo + +import ( + "context" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetUsageListLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetUsageListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUsageListLogic { + return &GetUsageListLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetUsageListLogic) GetUsageList(req *types.GetPromoUsageListRequest) (*types.GetPromoUsageListResponse, error) { + total, list, err := l.svcCtx.PromoModel.QueryUsageList(l.ctx, promomodel.UsageFilter{ + Page: int(req.Page), + Size: int(req.Size), + RuleId: req.RuleId, + UserId: req.UserId, + SubscribeId: req.SubscribeId, + OrderNo: req.OrderNo, + }) + if err != nil { + l.Errorw("[GetPromoUsageList] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get promo usage list error: %v", err.Error()) + } + resp := &types.GetPromoUsageListResponse{ + Total: total, + List: make([]types.PromoUsage, 0, len(list)), + } + for _, item := range list { + resp.List = append(resp.List, convertUsage(item)) + } + return resp, nil +} diff --git a/internal/logic/admin/promo/rule_time_test.go b/internal/logic/admin/promo/rule_time_test.go new file mode 100644 index 0000000..d4fe4d9 --- /dev/null +++ b/internal/logic/admin/promo/rule_time_test.go @@ -0,0 +1,133 @@ +package promo + +import ( + "context" + "testing" + "time" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/xerr" + pkgerrors "github.com/pkg/errors" + "github.com/redis/go-redis/v9" +) + +func TestCreateRuleAcceptsMillisecondTimestamps(t *testing.T) { + startTime := int64(1777618800000) + endTime := int64(1782802800000) + var inserted *promomodel.Rule + + svcCtx := &svc.ServiceContext{ + Redis: redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}), + PromoModel: fakePromoModel{ + insertRule: func(_ context.Context, rule *promomodel.Rule) error { + inserted = rule + return nil + }, + }, + } + + _, err := NewCreateRuleLogic(context.Background(), svcCtx).CreateRule(&types.CreatePromoRuleRequest{ + Name: "618活动", + Type: promomodel.RuleTypeInactiveUser, + Params: map[string]interface{}{"inactive_months": float64(1)}, + Priority: 0, + StartTime: &startTime, + EndTime: &endTime, + }) + if err != nil { + t.Fatalf("CreateRule returned error: %v", err) + } + if inserted == nil { + t.Fatal("rule was not inserted") + } + assertPromoRuleTime(t, inserted.StartTime, time.UnixMilli(startTime)) + assertPromoRuleTime(t, inserted.EndTime, time.UnixMilli(endTime)) +} + +func TestUpdateRuleAcceptsMillisecondTimestamps(t *testing.T) { + startTime := int64(1777618800000) + endTime := int64(1782802800000) + existing := &promomodel.Rule{Id: 9, Enabled: true} + var updated *promomodel.Rule + + svcCtx := &svc.ServiceContext{ + Redis: redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}), + PromoModel: fakePromoModel{ + findRule: func(_ context.Context, id int64) (*promomodel.Rule, error) { + if id != existing.Id { + t.Fatalf("FindRule id = %d, want %d", id, existing.Id) + } + return existing, nil + }, + updateRule: func(_ context.Context, rule *promomodel.Rule) error { + updated = rule + return nil + }, + }, + } + + _, err := NewUpdateRuleLogic(context.Background(), svcCtx).UpdateRule(&types.UpdatePromoRuleRequest{ + Id: existing.Id, + Name: "618活动", + Type: promomodel.RuleTypeInactiveUser, + Params: map[string]interface{}{"inactive_months": float64(1)}, + Priority: 0, + StartTime: &startTime, + EndTime: &endTime, + }) + if err != nil { + t.Fatalf("UpdateRule returned error: %v", err) + } + if updated == nil { + t.Fatal("rule was not updated") + } + assertPromoRuleTime(t, updated.StartTime, time.UnixMilli(startTime)) + assertPromoRuleTime(t, updated.EndTime, time.UnixMilli(endTime)) +} + +func TestRuleRejectsOutOfRangeTimestamp(t *testing.T) { + startTime := int64(253402300800000) + endTime := int64(253402304400000) + svcCtx := &svc.ServiceContext{PromoModel: fakePromoModel{ + insertRule: func(context.Context, *promomodel.Rule) error { + t.Fatal("InsertRule should not be called for invalid timestamp") + return nil + }, + }} + + _, err := NewCreateRuleLogic(context.Background(), svcCtx).CreateRule(&types.CreatePromoRuleRequest{ + Name: "bad time", + Type: promomodel.RuleTypeInactiveUser, + Params: map[string]interface{}{"inactive_months": float64(1)}, + Priority: 0, + StartTime: &startTime, + EndTime: &endTime, + }) + assertInvalidParams(t, err) +} + +func assertPromoRuleTime(t *testing.T, got *time.Time, want time.Time) { + t.Helper() + if got == nil { + t.Fatalf("time is nil, want %v", want) + } + if !got.Equal(want) { + t.Fatalf("time = %v, want %v", *got, want) + } +} + +func assertInvalidParams(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected error") + } + codeErr, ok := pkgerrors.Cause(err).(*xerr.CodeError) + if !ok { + t.Fatalf("expected CodeError, got %T", pkgerrors.Cause(err)) + } + if got := codeErr.GetErrCode(); got != xerr.InvalidParams { + t.Fatalf("error code = %d, want %d", got, xerr.InvalidParams) + } +} diff --git a/internal/logic/admin/promo/setPriceLogic.go b/internal/logic/admin/promo/setPriceLogic.go new file mode 100644 index 0000000..ddb7e8d --- /dev/null +++ b/internal/logic/admin/promo/setPriceLogic.go @@ -0,0 +1,81 @@ +package promo + +import ( + "context" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + subscribeModel "github.com/perfect-panel/server/internal/model/subscribe" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type SetPriceLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSetPriceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetPriceLogic { + return &SetPriceLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *SetPriceLogic) SetPrice(req *types.SetPromoPriceRequest) error { + if _, err := l.svcCtx.PromoModel.FindRule(l.ctx, req.PromoRuleId); err != nil { + l.Errorw("[SetPromoPrice] Find Rule Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find promo rule error: %v", err.Error()) + } + subscribeIds := make([]int64, 0, len(req.Items)) + seenSubscribeIds := make(map[int64]struct{}, len(req.Items)) + for _, item := range req.Items { + if _, ok := seenSubscribeIds[item.SubscribeId]; ok { + continue + } + seenSubscribeIds[item.SubscribeId] = struct{}{} + subscribeIds = append(subscribeIds, item.SubscribeId) + } + var subscribes []*subscribeModel.Subscribe + if err := l.svcCtx.DB.WithContext(l.ctx).Model(&subscribeModel.Subscribe{}).Where("id IN ?", subscribeIds).Find(&subscribes).Error; err != nil { + l.Errorw("[SetPromoPrice] Find Subscribe Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe error: %v", err.Error()) + } + subscribeById := make(map[int64]*subscribeModel.Subscribe, len(subscribes)) + for _, sub := range subscribes { + subscribeById[sub.Id] = sub + } + items := make([]*promomodel.SubscribePromo, 0, len(req.Items)) + cacheKeys := make([]string, 0, len(req.Items)) + for _, item := range req.Items { + sub, ok := subscribeById[item.SubscribeId] + if !ok { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "subscribe plan not found") + } + originPrice := sub.UnitPrice * item.Quantity + if item.PromoPrice >= originPrice { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "promo_price must be less than unit_price * quantity") + } + items = append(items, &promomodel.SubscribePromo{ + SubscribeId: item.SubscribeId, + PromoRuleId: req.PromoRuleId, + Quantity: item.Quantity, + PromoPrice: item.PromoPrice, + }) + cacheKeys = append(cacheKeys, subscribePromoCacheKey(item.SubscribeId)) + } + if err := l.svcCtx.PromoModel.UpsertPrices(l.ctx, req.PromoRuleId, items); err != nil { + l.Errorw("[SetPromoPrice] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "set promo price error: %v", err.Error()) + } + if len(cacheKeys) > 0 { + if err := l.svcCtx.Redis.Del(l.ctx, cacheKeys...).Err(); err != nil { + l.Errorw("[SetPromoPrice] Delete Cache Error", logger.Field("error", err.Error())) + } + } + return nil +} diff --git a/internal/logic/admin/promo/tool.go b/internal/logic/admin/promo/tool.go new file mode 100644 index 0000000..c2c3532 --- /dev/null +++ b/internal/logic/admin/promo/tool.go @@ -0,0 +1,192 @@ +package promo + +import ( + "encoding/json" + "fmt" + "math" + "time" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +const ( + ruleCacheKey = "promo:rules:enabled" + subscribeCachePref = "promo:subscribe:" +) + +var ( + minPromoRuleTime = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + maxPromoRuleTime = time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC) +) + +func validateRuleInput(ruleType string, params map[string]interface{}, priority int64, startTime, endTime *int64) error { + if priority < 0 { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "priority must be greater than or equal to 0") + } + startAt, err := normalizeRuleTimestamp(startTime) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "invalid start_time") + } + endAt, err := normalizeRuleTimestamp(endTime) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "invalid end_time") + } + if startAt != nil && endAt != nil && !startAt.Before(*endAt) { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "start_time must be less than end_time") + } + switch ruleType { + case "new_user": + windowHours, ok := numberParam(params, "window_hours") + if !ok || windowHours <= 0 { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "params.window_hours must be greater than 0") + } + case "inactive_user": + inactiveMonths, ok := numberParam(params, "inactive_months") + if !ok || inactiveMonths <= 0 { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "params.inactive_months must be greater than 0") + } + case "campaign": + if params == nil { + return nil + } + default: + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "unsupported promo rule type") + } + return nil +} + +func numberParam(params map[string]interface{}, key string) (int64, bool) { + if params == nil { + return 0, false + } + value, ok := params[key] + if !ok { + return 0, false + } + switch v := value.(type) { + case float64: + if math.Trunc(v) != v { + return 0, false + } + return int64(v), true + case int64: + return v, true + case int: + return int64(v), true + case json.Number: + n, err := v.Int64() + return n, err == nil + default: + return 0, false + } +} + +func paramsToString(params map[string]interface{}) (string, error) { + if params == nil { + params = map[string]interface{}{} + } + b, err := json.Marshal(params) + if err != nil { + return "", errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "invalid params") + } + return string(b), nil +} + +func parseParams(data string) map[string]interface{} { + if data == "" { + return map[string]interface{}{} + } + var params map[string]interface{} + if err := json.Unmarshal([]byte(data), ¶ms); err != nil { + return map[string]interface{}{} + } + return params +} + +func normalizeRuleTimestamp(ts *int64) (*time.Time, error) { + if ts == nil || *ts == 0 { + return nil, nil + } + value := *ts + var t time.Time + if value >= 1_000_000_000_000 || value <= -1_000_000_000_000 { + t = time.UnixMilli(value) + } else { + t = time.Unix(value, 0) + } + if t.Before(minPromoRuleTime) || t.After(maxPromoRuleTime) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "timestamp out of range") + } + return &t, nil +} + +func unixPtrToTimePtr(ts *int64) *time.Time { + t, err := normalizeRuleTimestamp(ts) + if err != nil { + return nil + } + return t +} + +func timePtrToUnixPtr(t *time.Time) *int64 { + if t == nil { + return nil + } + ts := t.Unix() + return &ts +} + +func convertRule(item *promomodel.Rule) types.PromoRule { + if item == nil { + return types.PromoRule{} + } + return types.PromoRule{ + Id: item.Id, + Name: item.Name, + Type: item.Type, + Params: parseParams(item.Params), + Priority: item.Priority, + Enabled: item.Enabled, + StartTime: timePtrToUnixPtr(item.StartTime), + EndTime: timePtrToUnixPtr(item.EndTime), + CreatedAt: item.CreatedAt.Unix(), + UpdatedAt: item.UpdatedAt.Unix(), + } +} + +func convertPrice(item *promomodel.SubscribePromo) types.PromoPrice { + if item == nil { + return types.PromoPrice{} + } + return types.PromoPrice{ + Id: item.Id, + SubscribeId: item.SubscribeId, + PromoRuleId: item.PromoRuleId, + Quantity: item.Quantity, + PromoPrice: item.PromoPrice, + CreatedAt: item.CreatedAt.Unix(), + UpdatedAt: item.UpdatedAt.Unix(), + } +} + +func convertUsage(item *promomodel.Usage) types.PromoUsage { + if item == nil { + return types.PromoUsage{} + } + return types.PromoUsage{ + Id: item.Id, + UserId: item.UserId, + PromoRuleId: item.PromoRuleId, + SubscribeId: item.SubscribeId, + OrderNo: item.OrderNo, + PromoPrice: item.PromoPrice, + CreatedAt: item.CreatedAt.Unix(), + } +} + +func subscribePromoCacheKey(subscribeId int64) string { + return fmt.Sprintf("%s%d", subscribeCachePref, subscribeId) +} diff --git a/internal/logic/admin/promo/updateRuleLogic.go b/internal/logic/admin/promo/updateRuleLogic.go new file mode 100644 index 0000000..b975b04 --- /dev/null +++ b/internal/logic/admin/promo/updateRuleLogic.go @@ -0,0 +1,60 @@ +package promo + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type UpdateRuleLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateRuleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateRuleLogic { + return &UpdateRuleLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateRuleLogic) UpdateRule(req *types.UpdatePromoRuleRequest) (*types.PromoRule, error) { + if err := validateRuleInput(req.Type, req.Params, req.Priority, req.StartTime, req.EndTime); err != nil { + return nil, err + } + rule, err := l.svcCtx.PromoModel.FindRule(l.ctx, req.Id) + if err != nil { + l.Errorw("[UpdatePromoRule] Find Rule Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find promo rule error: %v", err.Error()) + } + params, err := paramsToString(req.Params) + if err != nil { + return nil, err + } + enabled := rule.Enabled + if req.Enabled != nil { + enabled = *req.Enabled + } + rule.Name = req.Name + rule.Type = req.Type + rule.Params = params + rule.Priority = req.Priority + rule.Enabled = enabled + rule.StartTime = unixPtrToTimePtr(req.StartTime) + rule.EndTime = unixPtrToTimePtr(req.EndTime) + if err := l.svcCtx.PromoModel.UpdateRule(l.ctx, rule); err != nil { + l.Errorw("[UpdatePromoRule] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update promo rule error: %v", err.Error()) + } + if err := l.svcCtx.Redis.Del(l.ctx, ruleCacheKey).Err(); err != nil { + l.Errorw("[UpdatePromoRule] Delete Cache Error", logger.Field("error", err.Error())) + } + resp := convertRule(rule) + return &resp, nil +} diff --git a/internal/logic/admin/server/resetSortWithNodeLogic.go b/internal/logic/admin/server/resetSortWithNodeLogic.go index 3866f54..c44b9dd 100644 --- a/internal/logic/admin/server/resetSortWithNodeLogic.go +++ b/internal/logic/admin/server/resetSortWithNodeLogic.go @@ -80,7 +80,7 @@ func (l *ResetSortWithNodeLogic) ResetSortWithNode(req *types.ResetSortRequest) }) if err != nil { l.Errorw("[NodeSort] Update Database Error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) } return nil } diff --git a/internal/logic/admin/server/resetSortWithServerLogic.go b/internal/logic/admin/server/resetSortWithServerLogic.go index 3fbe237..5851b13 100644 --- a/internal/logic/admin/server/resetSortWithServerLogic.go +++ b/internal/logic/admin/server/resetSortWithServerLogic.go @@ -80,7 +80,7 @@ func (l *ResetSortWithServerLogic) ResetSortWithServer(req *types.ResetSortReque }) if err != nil { l.Errorw("[NodeSort] Update Database Error: ", logger.Field("error", err.Error())) - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error()) } return nil } diff --git a/internal/logic/admin/user/getAdminUserInviteListLogic.go b/internal/logic/admin/user/getAdminUserInviteListLogic.go index 4d0b12e..7c94d91 100644 --- a/internal/logic/admin/user/getAdminUserInviteListLogic.go +++ b/internal/logic/admin/user/getAdminUserInviteListLogic.go @@ -3,11 +3,13 @@ package user import ( "context" + adminInvite "github.com/perfect-panel/server/internal/logic/admin/invite" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" + "gorm.io/gorm" ) type GetAdminUserInviteListLogic struct { @@ -25,15 +27,7 @@ func NewGetAdminUserInviteListLogic(ctx context.Context, svcCtx *svc.ServiceCont } func (l *GetAdminUserInviteListLogic) GetAdminUserInviteList(req *types.GetAdminUserInviteListRequest) (resp *types.GetAdminUserInviteListResponse, err error) { - if req.Page < 1 { - req.Page = 1 - } - if req.Size < 1 { - req.Size = 10 - } - if req.Size > 100 { - req.Size = 100 - } + req.Page, req.Size = adminInvite.NormalizePage(req.Page, req.Size) type InvitedUser struct { Id int64 `gorm:"column:id"` @@ -44,19 +38,19 @@ func (l *GetAdminUserInviteListLogic) GetAdminUserInviteList(req *types.GetAdmin } var total int64 - baseQuery := l.svcCtx.DB.WithContext(l.ctx). + baseQuery := applyAdminUserInviteFilters(l.svcCtx.DB.WithContext(l.ctx). Table("user u"). - Where("u.referer_id = ? AND u.deleted_at IS NULL", req.UserId) + Where("u.referer_id = ? AND u.deleted_at IS NULL", req.UserId), req) if err = baseQuery.Count(&total).Error; err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count invited users failed: %v", err) } var rows []InvitedUser - err = l.svcCtx.DB.WithContext(l.ctx). + err = applyAdminUserInviteFilters(l.svcCtx.DB.WithContext(l.ctx). Table("user u"). - Select("u.id, u.avatar, u.enable, UNIX_TIMESTAMP(u.created_at) as created_at, COALESCE((SELECT uam.auth_identifier FROM user_auth_methods uam WHERE uam.user_id = u.id ORDER BY uam.id ASC LIMIT 1), '') as identifier"). - Where("u.referer_id = ? AND u.deleted_at IS NULL", req.UserId). + Select("u.id, u.avatar, u.enable, CAST(UNIX_TIMESTAMP(u.created_at) AS SIGNED) as created_at, COALESCE((SELECT uam.auth_identifier FROM user_auth_methods uam WHERE uam.user_id = u.id ORDER BY uam.id ASC LIMIT 1), '') as identifier"). + Where("u.referer_id = ? AND u.deleted_at IS NULL", req.UserId), req). Order("u.created_at DESC"). Limit(req.Size). Offset((req.Page - 1) * req.Size). @@ -65,14 +59,29 @@ func (l *GetAdminUserInviteListLogic) GetAdminUserInviteList(req *types.GetAdmin return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query invited users failed: %v", err) } + relations := make([]adminInvite.InviteRelation, 0, len(rows)) + for _, r := range rows { + relations = append(relations, adminInvite.InviteRelation{InviteeId: r.Id, InviterId: req.UserId}) + } + benefits, err := adminInvite.QueryBenefits(l.ctx, l.svcCtx.DB, relations) + if err != nil { + return nil, err + } + list := make([]types.AdminInvitedUser, 0, len(rows)) for _, r := range rows { + benefit := benefits[r.Id] list = append(list, types.AdminInvitedUser{ - Id: r.Id, - Avatar: r.Avatar, - Identifier: r.Identifier, - Enable: r.Enable, - CreatedAt: r.CreatedAt, + Id: r.Id, + Avatar: r.Avatar, + Identifier: r.Identifier, + Enable: r.Enable, + CreatedAt: r.CreatedAt, + OrderCount: benefit.OrderCount, + HasPurchased: benefit.HasPurchased, + InviterCommission: benefit.InviterCommission, + InviterGiftDays: benefit.InviterGiftDays, + InviteeGiftDays: benefit.InviteeGiftDays, }) } @@ -81,3 +90,16 @@ func (l *GetAdminUserInviteListLogic) GetAdminUserInviteList(req *types.GetAdmin List: list, }, nil } + +func applyAdminUserInviteFilters(db *gorm.DB, req *types.GetAdminUserInviteListRequest) *gorm.DB { + if req.Search != "" { + db = db.Where("EXISTS (SELECT 1 FROM user_auth_methods uam WHERE uam.user_id = u.id AND uam.auth_identifier LIKE ?)", "%"+req.Search+"%") + } + if req.Enable != nil { + db = db.Where("u.enable = ?", *req.Enable) + } + if req.UserIdSearch > 0 { + db = db.Where("u.id = ?", req.UserIdSearch) + } + return db +} diff --git a/internal/logic/admin/user/getUserSubscribeByIdLogic.go b/internal/logic/admin/user/getUserSubscribeByIdLogic.go index 5ef44e7..dcc59fb 100644 --- a/internal/logic/admin/user/getUserSubscribeByIdLogic.go +++ b/internal/logic/admin/user/getUserSubscribeByIdLogic.go @@ -2,6 +2,7 @@ package user import ( "context" + "encoding/json" "github.com/perfect-panel/server/internal/model/group" "github.com/perfect-panel/server/internal/svc" @@ -36,6 +37,13 @@ func (l *GetUserSubscribeByIdLogic) GetUserSubscribeById(req *types.GetUserSubsc } var subscribeDetails types.UserSubscribeDetail tool.DeepCopy(&subscribeDetails, sub) + subscribeDetails.SpeedLimit = sub.SpeedLimit + if sub.TrafficLimit != nil && *sub.TrafficLimit != "" { + _ = json.Unmarshal([]byte(*sub.TrafficLimit), &subscribeDetails.TrafficLimit) + } + if sub.Subscribe != nil { + subscribeDetails.PlanSpeedLimit = sub.Subscribe.SpeedLimit + } // 填充分组名 if sub.NodeGroupId > 0 { @@ -47,7 +55,17 @@ func (l *GetUserSubscribeByIdLogic) GetUserSubscribeById(req *types.GetUserSubsc // Calculate speed limit status if sub.Subscribe != nil && sub.Status == 1 { - result := speedlimit.Calculate(l.ctx, l.svcCtx.DB, sub.UserId, sub.Id, sub.Subscribe.SpeedLimit, sub.Subscribe.TrafficLimit) + baseSpeed := sub.Subscribe.SpeedLimit + if sub.SpeedLimit > 0 { + baseSpeed = sub.SpeedLimit + } + + trafficLimit := sub.Subscribe.TrafficLimit + if sub.TrafficLimit != nil && *sub.TrafficLimit != "" { + trafficLimit = *sub.TrafficLimit + } + + result := speedlimit.Calculate(l.ctx, l.svcCtx.DB, sub.UserId, sub.Id, baseSpeed, trafficLimit) subscribeDetails.EffectiveSpeed = result.EffectiveSpeed subscribeDetails.IsThrottled = result.IsThrottled subscribeDetails.ThrottleRule = result.ThrottleRule diff --git a/internal/logic/admin/user/getUserSubscribeLogic.go b/internal/logic/admin/user/getUserSubscribeLogic.go index c33040a..b029268 100644 --- a/internal/logic/admin/user/getUserSubscribeLogic.go +++ b/internal/logic/admin/user/getUserSubscribeLogic.go @@ -2,8 +2,10 @@ package user import ( "context" + "encoding/json" "github.com/perfect-panel/server/internal/model/group" + "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" @@ -61,12 +63,24 @@ func (l *GetUserSubscribeLogic) GetUserSubscribe(req *types.GetUserSubscribeList } for _, item := range data { - var sub types.UserSubscribe - tool.DeepCopy(&sub, item) - sub.Short, _ = tool.FixedUniqueString(item.Token, 8, "") - sub.NodeGroupId = item.NodeGroupId - sub.NodeGroupName = groupNames[item.NodeGroupId] + sub := buildUserSubscribeListItem(item, groupNames[item.NodeGroupId]) resp.List = append(resp.List, sub) } return } + +func buildUserSubscribeListItem(item *user.SubscribeDetails, groupName string) types.UserSubscribe { + var sub types.UserSubscribe + tool.DeepCopy(&sub, item) + sub.Short, _ = tool.FixedUniqueString(item.Token, 8, "") + sub.NodeGroupId = item.NodeGroupId + sub.NodeGroupName = groupName + sub.SpeedLimit = item.SpeedLimit + if item.TrafficLimit != nil && *item.TrafficLimit != "" { + _ = json.Unmarshal([]byte(*item.TrafficLimit), &sub.TrafficLimit) + } + if item.Subscribe != nil { + sub.PlanSpeedLimit = item.Subscribe.SpeedLimit + } + return sub +} diff --git a/internal/logic/admin/user/getUserSubscribeLogic_test.go b/internal/logic/admin/user/getUserSubscribeLogic_test.go new file mode 100644 index 0000000..1bcaa1b --- /dev/null +++ b/internal/logic/admin/user/getUserSubscribeLogic_test.go @@ -0,0 +1,73 @@ +package user + +import ( + "testing" + "time" + + modeluser "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/types" +) + +func TestBuildUserSubscribeListItem(t *testing.T) { + trafficLimitJSON := `[{"stat_type":"day","stat_value":1,"traffic_usage":10,"speed_limit":5}]` + now := time.Unix(1718000000, 0) + + tests := []struct { + name string + item *modeluser.SubscribeDetails + wantTrafficLimit []types.TrafficLimit + }{ + { + name: "non-empty user traffic limit", + item: &modeluser.SubscribeDetails{ + Id: 1, + UserId: 2, + NodeGroupId: 3, + Token: "token-12345678", + TrafficLimit: &trafficLimitJSON, + StartTime: now, + ExpireTime: now, + }, + wantTrafficLimit: []types.TrafficLimit{ + { + StatType: "day", + StatValue: 1, + TrafficUsage: 10, + SpeedLimit: 5, + }, + }, + }, + { + name: "empty user traffic limit", + item: &modeluser.SubscribeDetails{ + Id: 4, + UserId: 5, + NodeGroupId: 6, + Token: "token-empty", + StartTime: now, + ExpireTime: now, + }, + wantTrafficLimit: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := buildUserSubscribeListItem(tc.item, "group-a") + if got.NodeGroupName != "group-a" { + t.Fatalf("NodeGroupName = %q, want %q", got.NodeGroupName, "group-a") + } + if len(got.Short) != 8 { + t.Fatalf("Short length = %d, want 8", len(got.Short)) + } + if len(got.TrafficLimit) != len(tc.wantTrafficLimit) { + t.Fatalf("TrafficLimit length = %d, want %d", len(got.TrafficLimit), len(tc.wantTrafficLimit)) + } + for i := range tc.wantTrafficLimit { + if got.TrafficLimit[i] != tc.wantTrafficLimit[i] { + t.Fatalf("TrafficLimit[%d] = %+v, want %+v", i, got.TrafficLimit[i], tc.wantTrafficLimit[i]) + } + } + }) + } +} diff --git a/internal/logic/admin/user/updateUserBasicInfoLogic.go b/internal/logic/admin/user/updateUserBasicInfoLogic.go index 3d5e504..4fc2a5a 100644 --- a/internal/logic/admin/user/updateUserBasicInfoLogic.go +++ b/internal/logic/admin/user/updateUserBasicInfoLogic.go @@ -110,6 +110,16 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene") } change := *req.Commission - userInfo.Commission + if change < 0 { + // 禁止直接扣减佣金:扣减必须走 approveWithdrawal 写 type=334 日志, + // 否则 user.commission 和 system_logs 会再次失衡,破坏账目闭环。 + l.Logger.Errorw("blocked direct commission deduction via admin update", + logger.Field("user_id", userInfo.Id), + logger.Field("change", change), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), + "commission deduction must go through withdrawal approval, not direct edit") + } if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil { return err } diff --git a/internal/logic/admin/user/updateUserSubscribeLogic.go b/internal/logic/admin/user/updateUserSubscribeLogic.go index 6ec2267..e557b66 100644 --- a/internal/logic/admin/user/updateUserSubscribeLogic.go +++ b/internal/logic/admin/user/updateUserSubscribeLogic.go @@ -2,6 +2,7 @@ package user import ( "context" + "encoding/json" "time" "github.com/perfect-panel/server/internal/model/user" @@ -39,22 +40,36 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs } else { userSub.Status = 1 } + speedLimit := userSub.SpeedLimit + if req.SpeedLimit != nil { + speedLimit = *req.SpeedLimit + } + trafficLimit := userSub.TrafficLimit + if req.TrafficLimit != nil { + trafficLimit, err = marshalUserSubscribeTrafficLimit(req.TrafficLimit) + if err != nil { + l.Errorw("marshal traffic_limit failed:", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal traffic_limit failed: %v", err.Error()) + } + } err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, &user.Subscribe{ - Id: userSub.Id, - UserId: userSub.UserId, - OrderId: userSub.OrderId, - SubscribeId: req.SubscribeId, - StartTime: userSub.StartTime, - ExpireTime: time.UnixMilli(req.ExpiredAt), - Traffic: req.Traffic, - Download: req.Download, - Upload: req.Upload, - Token: userSub.Token, - UUID: userSub.UUID, - Status: userSub.Status, - NodeGroupId: userSub.NodeGroupId, - GroupLocked: userSub.GroupLocked, + Id: userSub.Id, + UserId: userSub.UserId, + OrderId: userSub.OrderId, + SubscribeId: req.SubscribeId, + StartTime: userSub.StartTime, + ExpireTime: time.UnixMilli(req.ExpiredAt), + Traffic: req.Traffic, + Download: req.Download, + Upload: req.Upload, + SpeedLimit: speedLimit, + TrafficLimit: trafficLimit, + Token: userSub.Token, + UUID: userSub.UUID, + Status: userSub.Status, + NodeGroupId: userSub.NodeGroupId, + GroupLocked: userSub.GroupLocked, }) if err != nil { @@ -86,3 +101,12 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs return nil } + +func marshalUserSubscribeTrafficLimit(rules []types.TrafficLimit) (*string, error) { + val, err := json.Marshal(rules) + if err != nil { + return nil, err + } + trafficLimit := string(val) + return &trafficLimit, nil +} diff --git a/internal/logic/admin/user/updateUserSubscribeLogic_test.go b/internal/logic/admin/user/updateUserSubscribeLogic_test.go new file mode 100644 index 0000000..b9d3fc4 --- /dev/null +++ b/internal/logic/admin/user/updateUserSubscribeLogic_test.go @@ -0,0 +1,32 @@ +package user + +import ( + "testing" + + "github.com/perfect-panel/server/internal/types" +) + +func TestMarshalUserSubscribeTrafficLimit(t *testing.T) { + rules := []types.TrafficLimit{ + { + StatType: "hour", + StatValue: 1, + TrafficUsage: 1, + SpeedLimit: 1, + }, + } + + got, err := marshalUserSubscribeTrafficLimit(rules) + if err != nil { + t.Fatalf("marshalUserSubscribeTrafficLimit() error = %v", err) + } + if got == nil { + t.Fatal("marshalUserSubscribeTrafficLimit() returned nil") + return + } + + want := `[{"stat_type":"hour","stat_value":1,"traffic_usage":1,"speed_limit":1}]` + if *got != want { + t.Fatalf("marshalUserSubscribeTrafficLimit() = %q, want %q", *got, want) + } +} diff --git a/internal/logic/common/paidSubscription.go b/internal/logic/common/paidSubscription.go new file mode 100644 index 0000000..00be8a7 --- /dev/null +++ b/internal/logic/common/paidSubscription.go @@ -0,0 +1,33 @@ +package common + +import ( + "context" + + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +// HasPaidSubscription reports whether the user owns at least one paid +// subscription record — order-backed (order_id > 0) or Apple-IAP-backed +// (token LIKE 'iap:%'). Returns false when userID or db are not usable so +// callers can fall back to the "first purchase" branch safely. +// +// This mirrors the predicate used to route /v1/public/order/purchase requests +// to renewal semantics. Keep both in sync. +func HasPaidSubscription(ctx context.Context, db *gorm.DB, userID int64) (bool, error) { + if userID <= 0 || db == nil { + return false, nil + } + + var count int64 + if err := db.WithContext(ctx). + Model(&user.Subscribe{}). + Where("user_id = ? AND (order_id > 0 OR token LIKE 'iap:%')", userID). + Limit(1). + Count(&count).Error; err != nil { + return false, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query paid subscription failed: %v", err.Error()) + } + return count > 0, nil +} diff --git a/internal/logic/common/promoEligibility.go b/internal/logic/common/promoEligibility.go new file mode 100644 index 0000000..ac76760 --- /dev/null +++ b/internal/logic/common/promoEligibility.go @@ -0,0 +1,200 @@ +package common + +import ( + "context" + "encoding/json" + "time" + + "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type PromoResult struct { + Eligible bool + RuleID int64 + RuleName string + RuleType string + PromoPrice int64 + ExpiresAt time.Time +} + +type promoRuleParams struct { + WindowHours int `json:"window_hours"` + InactiveMonths int `json:"inactive_months"` +} + +// EvaluatePromo decides whether the given (user, subscribe, quantity) tuple +// qualifies for any active promo rule. Each rule type has its own gating: +// - new_user: requires isFirstPurchase=true (no prior paid subscription) +// - inactive_user: requires a previously expired subscription (rule self-check) +// - campaign: applies unconditionally within the configured time window +// +// Pass isFirstPurchase=true on order paths where the request is being routed +// as a brand-new purchase; pass false when the user already has a paid +// subscription (including expired ones) so NewUser cannot be reused. +func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64, quantity int64, isFirstPurchase bool) (*PromoResult, error) { + result := &PromoResult{} + if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || subscribeID <= 0 || quantity <= 0 { + return result, nil + } + + rules, err := svcCtx.PromoModel.QueryEligibleRules(ctx, subscribeID, quantity) + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo rules failed: %v", err.Error()) + } + if len(rules) == 0 { + return result, nil + } + + var currentUser user.User + now := time.Now() + for _, rule := range rules { + if rule == nil || !isPromoRuleInTimeWindow(rule, now) { + continue + } + if rule.PromoPrice <= 0 { + continue + } + + params := promoRuleParams{} + if rule.Params != "" { + if err = json.Unmarshal([]byte(rule.Params), ¶ms); err != nil { + continue + } + } + + eligible, expiresAt, err := evaluatePromoRule(ctx, svcCtx.DB, rule, params, userID, isFirstPurchase, ¤tUser, now) + if err != nil { + return nil, err + } + if !eligible { + continue + } + + return &PromoResult{ + Eligible: true, + RuleID: rule.Id, + RuleName: rule.Name, + RuleType: rule.Type, + PromoPrice: rule.PromoPrice, + ExpiresAt: expiresAt, + }, nil + } + + return result, nil +} + +func isPromoRuleInTimeWindow(rule *promo.RuleWithPrice, now time.Time) bool { + if rule.StartTime != nil && !rule.StartTime.IsZero() && now.Before(*rule.StartTime) { + return false + } + if rule.EndTime != nil && !rule.EndTime.IsZero() && now.After(*rule.EndTime) { + return false + } + return true +} + +func evaluatePromoRule( + ctx context.Context, + db *gorm.DB, + rule *promo.RuleWithPrice, + params promoRuleParams, + userID int64, + isFirstPurchase bool, + currentUser *user.User, + now time.Time, +) (bool, time.Time, error) { + switch rule.Type { + case promo.RuleTypeNewUser: + if userID <= 0 || !isFirstPurchase { + return false, time.Time{}, nil + } + return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now) + case promo.RuleTypeInactiveUser: + if userID <= 0 { + return false, time.Time{}, nil + } + return evaluateInactiveUserPromo(ctx, db, params, userID, promoRuleExpiresAt(rule), now) + case promo.RuleTypeCampaign: + return true, promoRuleExpiresAt(rule), nil + default: + return false, time.Time{}, nil + } +} + +func evaluateNewUserPromo( + ctx context.Context, + db *gorm.DB, + params promoRuleParams, + userID int64, + currentUser *user.User, + now time.Time, +) (bool, time.Time, error) { + if params.WindowHours <= 0 { + return false, time.Time{}, nil + } + + if currentUser.Id == 0 { + if err := db.WithContext(ctx).Model(&user.User{}).Where("id = ?", userID).First(currentUser).Error; err != nil { + return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo user failed") + } + } + + expiresAt := currentUser.CreatedAt.Add(time.Duration(params.WindowHours) * time.Hour) + return now.Before(expiresAt), expiresAt, nil +} + +func evaluateInactiveUserPromo( + ctx context.Context, + db *gorm.DB, + params promoRuleParams, + userID int64, + ruleExpiresAt time.Time, + now time.Time, +) (bool, time.Time, error) { + if params.InactiveMonths <= 0 { + return false, time.Time{}, nil + } + + var lastSub user.Subscribe + err := db.WithContext(ctx). + Model(&user.Subscribe{}). + Where("user_id = ?", userID). + Order(clause.OrderBy{ + Expression: clause.Expr{ + SQL: "CASE WHEN expire_time = ? THEN 0 ELSE 1 END, expire_time DESC", + Vars: []interface{}{time.UnixMilli(0)}, + }, + }). + Limit(1). + Take(&lastSub).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, time.Time{}, nil + } + return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed") + } + + return evaluateInactiveUserExpire(lastSub.ExpireTime, params, now), ruleExpiresAt, nil +} + +func evaluateInactiveUserExpire(lastExpire time.Time, params promoRuleParams, now time.Time) bool { + if lastExpire.Equal(time.UnixMilli(0)) { + return false + } + + threshold := now.AddDate(0, -params.InactiveMonths, 0) + return lastExpire.Before(threshold) || lastExpire.Equal(threshold) +} + +func promoRuleExpiresAt(rule *promo.RuleWithPrice) time.Time { + if rule != nil && rule.EndTime != nil { + return *rule.EndTime + } + return time.Time{} +} diff --git a/internal/logic/common/promoEligibility_test.go b/internal/logic/common/promoEligibility_test.go new file mode 100644 index 0000000..7dbc277 --- /dev/null +++ b/internal/logic/common/promoEligibility_test.go @@ -0,0 +1,254 @@ +package common + +import ( + "context" + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + + "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "gorm.io/gorm" +) + +func TestEvaluateInactiveUserExpire(t *testing.T) { + now := time.Date(2026, 5, 27, 8, 0, 0, 0, time.UTC) + params := promoRuleParams{InactiveMonths: 3} + + tests := []struct { + name string + lastExpire time.Time + want bool + }{ + { + name: "permanent subscription is not inactive", + lastExpire: time.UnixMilli(0), + want: false, + }, + { + name: "active subscription is not inactive", + lastExpire: now.Add(time.Hour), + want: false, + }, + { + name: "expire at threshold is inactive", + lastExpire: now.AddDate(0, -3, 0), + want: true, + }, + { + name: "expire before threshold is inactive", + lastExpire: now.AddDate(0, -3, -1), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := evaluateInactiveUserExpire(tt.lastExpire, params, now); got != tt.want { + t.Fatalf("evaluateInactiveUserExpire() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestEvaluatePromoAllowsAnonymousCampaign(t *testing.T) { + end := time.Now().Add(time.Hour) + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 3, + Name: "campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + EndTime: &end, + }, + PromoPrice: 199, + }, + }} + + got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 0, 7, 12, true) + if err != nil { + t.Fatalf("EvaluatePromo returned error: %v", err) + } + if !got.Eligible { + t.Fatal("anonymous campaign promo should be eligible") + } + if got.RuleID != 3 { + t.Fatalf("RuleID = %d, want 3", got.RuleID) + } + if got.PromoPrice != 199 { + t.Fatalf("PromoPrice = %d, want 199", got.PromoPrice) + } + if model.lastSubscribeID != 7 { + t.Fatalf("lastSubscribeID = %d, want 7", model.lastSubscribeID) + } + if model.lastQuantity != 12 { + t.Fatalf("lastQuantity = %d, want 12", model.lastQuantity) + } +} + +func TestEvaluatePromoCampaignAppliesToReturningUsers(t *testing.T) { + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 4, + Name: "campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + }, + PromoPrice: 299, + }, + }} + + got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 42, 7, 1, false) + if err != nil { + t.Fatalf("EvaluatePromo returned error: %v", err) + } + if !got.Eligible { + t.Fatal("campaign promo should remain eligible for returning users (isFirstPurchase=false)") + } + if got.PromoPrice != 299 { + t.Fatalf("PromoPrice = %d, want 299", got.PromoPrice) + } +} + +func TestEvaluatePromoNewUserRequiresFirstPurchase(t *testing.T) { + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 5, + Name: "new user", + Type: promo.RuleTypeNewUser, + Enabled: true, + Params: `{"window_hours": 72}`, + }, + PromoPrice: 99, + }, + }} + + got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 42, 7, 1, false) + if err != nil { + t.Fatalf("EvaluatePromo returned error: %v", err) + } + if got.Eligible { + t.Fatal("new-user promo must be gated out when isFirstPurchase=false (user already has paid subscriptions)") + } +} + +func TestEvaluatePromoRejectsInactiveRuleWhenUserHasNoSubscription(t *testing.T) { + db, mock, cleanup := newCommonPromoTestDB(t) + defer cleanup() + + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 9, + Name: "inactive", + Type: promo.RuleTypeInactiveUser, + Params: `{"inactive_months":3}`, + Enabled: true, + }, + PromoPrice: 100, + }, + }} + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_subscribe` WHERE user_id = ? ORDER BY CASE WHEN expire_time = ? THEN 0 ELSE 1 END, expire_time DESC LIMIT ?")). + WithArgs(int64(51640), time.UnixMilli(0), 1). + WillReturnError(gorm.ErrRecordNotFound) + + got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: db, PromoModel: model}, 51640, 1, 30, true) + if err != nil { + t.Fatalf("EvaluatePromo returned error: %v", err) + } + if got.Eligible { + t.Fatal("new user without subscription history should not be eligible for inactive promo") + } + if got.RuleID != 0 || got.PromoPrice != 0 { + t.Fatalf("promo fields = (%d, %d), want zero values", got.RuleID, got.PromoPrice) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet db expectations: %v", err) + } +} + +type fakePromoModel struct { + rules []*promo.RuleWithPrice + lastSubscribeID int64 + lastQuantity int64 +} + +func (m *fakePromoModel) QueryEligibleRules(_ context.Context, subscribeID int64, quantity int64) ([]*promo.RuleWithPrice, error) { + m.lastSubscribeID = subscribeID + m.lastQuantity = quantity + return m.rules, nil +} + +func (m *fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) error { + return nil +} + +func (m *fakePromoModel) InsertRule(context.Context, *promo.Rule) error { + return nil +} + +func (m *fakePromoModel) FindRule(context.Context, int64) (*promo.Rule, error) { + return nil, gorm.ErrRecordNotFound +} + +func (m *fakePromoModel) UpdateRule(context.Context, *promo.Rule) error { + return nil +} + +func (m *fakePromoModel) DeleteRule(context.Context, int64) error { + return nil +} + +func (m *fakePromoModel) QueryRuleList(context.Context, int, int, string, *bool, string) (int64, []*promo.Rule, error) { + return 0, nil, nil +} + +func (m *fakePromoModel) UpsertPrices(context.Context, int64, []*promo.SubscribePromo) error { + return nil +} + +func (m *fakePromoModel) FindPrice(context.Context, int64) (*promo.SubscribePromo, error) { + return nil, gorm.ErrRecordNotFound +} + +func (m *fakePromoModel) DeletePrice(context.Context, int64) error { + return nil +} + +func (m *fakePromoModel) QueryPriceList(context.Context, promo.PriceFilter) (int64, []*promo.SubscribePromo, error) { + return 0, nil, nil +} + +func (m *fakePromoModel) QueryUsageList(context.Context, promo.UsageFilter) (int64, []*promo.Usage, error) { + return 0, nil, nil +} + +func (m *fakePromoModel) Transaction(context.Context, func(*gorm.DB) error) error { + return nil +} + +func newCommonPromoTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { + _ = sqlDB.Close() + } +} diff --git a/internal/logic/notify/ePayNotifyLogic.go b/internal/logic/notify/ePayNotifyLogic.go index d9a1bd5..9a2339c 100644 --- a/internal/logic/notify/ePayNotifyLogic.go +++ b/internal/logic/notify/ePayNotifyLogic.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/hibiken/asynq" + "github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/payment" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" @@ -22,6 +23,47 @@ import ( queueType "github.com/perfect-panel/server/queue/types" ) +// epayNotifyTradeNotSuccess identifies the "buyer paid but trade not in TRADE_SUCCESS +// terminal state" branch in metrics / log search. EPay is told 200 here so it will +// not retry; we still want it visible for monitoring (HIF-135 / HIF-136). +const epayNotifyTradeNotSuccess = "epay_notify_trade_not_success" + +// epayNotifyDecision encodes the post-parse decision so the branching logic can be +// unit-tested without spinning up a real OrderModel / Redis / asynq stack. +type epayNotifyDecision int + +const ( + // epayNotifyDecisionRejectSign: signature is invalid and debug bypass is off. + // Caller MUST return an error so the handler responds non-200 and EPay retries. + epayNotifyDecisionRejectSign epayNotifyDecision = iota + // epayNotifyDecisionAckTradeNotSuccess: trade_status != TRADE_SUCCESS + // (user cancelled / failed at gateway). Acknowledge with 200 "success" and emit + // a metric-named log for monitoring. + epayNotifyDecisionAckTradeNotSuccess + // epayNotifyDecisionAckIdempotent: order already at Finished (status=5). + // Repeat callback — ack with 200 "success", do not re-enqueue activation. + epayNotifyDecisionAckIdempotent + // epayNotifyDecisionProcess: happy path. Write trade_no, flip status to Paid, + // enqueue activation task. + epayNotifyDecisionProcess +) + +// evaluateEPayNotify is a pure decision function so each branch can be unit-tested +// without DB/Redis. Caller has already located the order; orderStatus is the +// current status from the DB row. +func evaluateEPayNotify(signValid, debugBypass bool, tradeStatus string, orderStatus uint8) epayNotifyDecision { + if !signValid && !debugBypass { + return epayNotifyDecisionRejectSign + } + if tradeStatus != "TRADE_SUCCESS" { + return epayNotifyDecisionAckTradeNotSuccess + } + if orderStatus == 5 { + return epayNotifyDecisionAckIdempotent + } + return epayNotifyDecisionProcess +} + type EPayNotifyLogic struct { logger.Logger ctx *gin.Context @@ -47,6 +89,8 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error { } orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OutTradeNo) if err != nil { + // HIF-136 P02: order missing must propagate as error so EPay retries instead + // of being silently lost (was previously masked by a `return nil` further down). l.Logger.Error("[EPayNotify] Find order failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo)) return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist: %v", req.OutTradeNo) } @@ -65,17 +109,54 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error { } // Verify sign client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type) - if !client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) && !l.svcCtx.Config.Debug { - l.Logger.Error("[EPayNotify] Verify sign failed") + signValid := client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) + + decision := evaluateEPayNotify(signValid, l.svcCtx.Config.Debug, req.TradeStatus, orderInfo.Status) + switch decision { + case epayNotifyDecisionRejectSign: + // HIF-136 P01: previously `return nil` here let EPay see 200 and stop retrying; + // caller still left order at status=1, which DeferCloseOrder then closed. + // Return error so handler responds non-200 and EPay retries. + l.Logger.Error("[EPayNotify] Verify sign failed", + logger.Field("order_no", req.OutTradeNo), + logger.Field("trade_no", req.TradeNo), + logger.Field("raw_query", l.ctx.Request.URL.RawQuery), + ) + return errors.Wrapf(xerr.NewErrCode(xerr.SignatureInvalid), "verify sign failed: %v", req.OutTradeNo) + case epayNotifyDecisionAckTradeNotSuccess: + // User cancelled / gateway-side failure: ack 200 to stop retries, but keep + // the path observable under metric name `epay_notify_trade_not_success`. + l.Logger.Info("[EPayNotify] Trade status not success", + logger.Field("order_no", req.OutTradeNo), + logger.Field("trade_status", req.TradeStatus), + logger.Field("metric", epayNotifyTradeNotSuccess), + ) return nil - } - if req.TradeStatus != "TRADE_SUCCESS" { - l.Logger.Error("[EPayNotify] Trade status is not success", logger.Field("orderNo", req.OutTradeNo), logger.Field("tradeStatus", req.TradeStatus)) + case epayNotifyDecisionAckIdempotent: + // Order already Finished — repeat callback is expected, ack with 200. return nil + case epayNotifyDecisionProcess: + // fall through } - if orderInfo.Status == 5 { - return nil + + // HIF-136 P03: persist gateway trade_no BEFORE flipping status so post-mortems can + // reverse-lookup EPay flows to ppanel orders. Raw gorm write is acceptable here — + // the subsequent UpdateOrderStatus will invalidate the cache key (keys are by + // order_no / id, both stable across this field write). + if req.TradeNo != "" { + if err := l.svcCtx.DB.WithContext(l.ctx). + Model(&order.Order{}). + Where("order_no = ?", req.OutTradeNo). + Update("trade_no", req.TradeNo).Error; err != nil { + l.Logger.Error("[EPayNotify] Update trade_no failed", + logger.Field("error", err.Error()), + logger.Field("order_no", req.OutTradeNo), + logger.Field("trade_no", req.TradeNo), + ) + return errors.Wrapf(err, "update trade_no failed: %v", req.OutTradeNo) + } } + // Update order status err = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OutTradeNo, 2) if err != nil { @@ -86,6 +167,7 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error { "[SubscriptionFlow] epay notify marked order as paid", append(commonLogic.OrderTraceFields(orderInfo), logger.Field("payment_platform", data.Platform), + logger.Field("trade_no", req.TradeNo), )..., ) // Create activate order task diff --git a/internal/logic/notify/ePayNotifyLogic_test.go b/internal/logic/notify/ePayNotifyLogic_test.go new file mode 100644 index 0000000..c4cb0c1 --- /dev/null +++ b/internal/logic/notify/ePayNotifyLogic_test.go @@ -0,0 +1,182 @@ +package notify + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/perfect-panel/server/internal/model/order" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// TestEvaluateEPayNotify_RejectsInvalidSign covers HIF-136 P01: sign invalid + +// debug bypass off must return rejectSign so the handler responds non-200 and +// EPay retries (was previously a silent `return nil` → 200 → no retry). +func TestEvaluateEPayNotify_RejectsInvalidSign(t *testing.T) { + got := evaluateEPayNotify(false, false, "TRADE_SUCCESS", 1) + if got != epayNotifyDecisionRejectSign { + t.Fatalf("evaluateEPayNotify(invalid sign): got %v want %v", got, epayNotifyDecisionRejectSign) + } +} + +// TestEvaluateEPayNotify_DebugBypassAllowsInvalidSign documents the debug-mode +// escape hatch the issue calls out: production runs with Debug=false so this +// branch is never taken in prod, but local/dev should still flow through. +func TestEvaluateEPayNotify_DebugBypassAllowsInvalidSign(t *testing.T) { + got := evaluateEPayNotify(false, true, "TRADE_SUCCESS", 1) + if got != epayNotifyDecisionProcess { + t.Fatalf("evaluateEPayNotify(invalid sign + debug): got %v want %v", got, epayNotifyDecisionProcess) + } +} + +// TestEvaluateEPayNotify_TradeNotSuccess covers the user-cancelled / gateway-failure +// branch: behaviour unchanged (return nil → 200), but caller must now emit the +// `epay_notify_trade_not_success` metric-named log instead of an Error-level one. +func TestEvaluateEPayNotify_TradeNotSuccess(t *testing.T) { + tests := []string{"TRADE_FAILED", "WAIT_BUYER_PAY", ""} + for _, ts := range tests { + t.Run(ts, func(t *testing.T) { + got := evaluateEPayNotify(true, false, ts, 1) + if got != epayNotifyDecisionAckTradeNotSuccess { + t.Fatalf("evaluateEPayNotify(trade_status=%q): got %v want %v", + ts, got, epayNotifyDecisionAckTradeNotSuccess) + } + }) + } +} + +// TestEvaluateEPayNotify_IdempotentForFinishedOrder covers the only legitimate +// `return nil` short-circuit retained by HIF-136: duplicate callback for an +// already Finished (status=5) order is acknowledged with 200 instead of being +// re-processed. +func TestEvaluateEPayNotify_IdempotentForFinishedOrder(t *testing.T) { + got := evaluateEPayNotify(true, false, "TRADE_SUCCESS", 5) + if got != epayNotifyDecisionAckIdempotent { + t.Fatalf("evaluateEPayNotify(status=5): got %v want %v", got, epayNotifyDecisionAckIdempotent) + } +} + +// TestEvaluateEPayNotify_HappyPath covers the normal success branch: valid sign, +// trade_status=TRADE_SUCCESS, order not yet Finished → proceed to write trade_no +// + flip status + enqueue activation. +func TestEvaluateEPayNotify_HappyPath(t *testing.T) { + statuses := []uint8{1, 2, 3, 4} // anything but Finished(5) + for _, s := range statuses { + got := evaluateEPayNotify(true, false, "TRADE_SUCCESS", s) + if got != epayNotifyDecisionProcess { + t.Fatalf("evaluateEPayNotify(status=%d): got %v want %v", s, got, epayNotifyDecisionProcess) + } + } +} + +// TestEvaluateEPayNotify_SignTakesPrecedenceOverIdempotency guards against a +// regression where a duplicate callback with a forged signature gets quietly +// acked because status==5 was checked before sign. +func TestEvaluateEPayNotify_SignTakesPrecedenceOverIdempotency(t *testing.T) { + got := evaluateEPayNotify(false, false, "TRADE_SUCCESS", 5) + if got != epayNotifyDecisionRejectSign { + t.Fatalf("evaluateEPayNotify(invalid sign + status=5): got %v want %v", + got, epayNotifyDecisionRejectSign) + } +} + +// TestUrlParamsToMap verifies the helper used to feed VerifySign — collapses +// each query parameter to its first value and treats absent params as missing. +func TestUrlParamsToMap(t *testing.T) { + got := urlParamsToMap("pid=1001&out_trade_no=ORD-1&trade_no=EPAY-9&sign=abc&trade_status=TRADE_SUCCESS") + want := map[string]string{ + "pid": "1001", + "out_trade_no": "ORD-1", + "trade_no": "EPAY-9", + "sign": "abc", + "trade_status": "TRADE_SUCCESS", + } + if len(got) != len(want) { + t.Fatalf("urlParamsToMap len = %d want %d (got=%v)", len(got), len(want), got) + } + for k, v := range want { + if got[k] != v { + t.Fatalf("urlParamsToMap[%q] = %q want %q", k, got[k], v) + } + } +} + +// TestEPayNotify_TradeNoRawWrite verifies HIF-136 P03 at the SQL boundary: the +// raw gorm write hits the `order` table with the exact `trade_no` from the +// EPay request, scoped by `order_no`, and surfaces driver errors back to the +// caller (so they propagate as non-200 and EPay retries). +func TestEPayNotify_TradeNoRawWrite(t *testing.T) { + db, mock, cleanup := newEPayNotifyTestDB(t) + defer cleanup() + + const ( + orderNo = "202606010001" + tradeNo = "EPAY-202606010001" + ) + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `order` SET `trade_no`"). + WithArgs(tradeNo, sqlmock.AnyArg(), orderNo). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err := db.WithContext(context.Background()). + Model(&order.Order{}). + Where("order_no = ?", orderNo). + Update("trade_no", tradeNo).Error + if err != nil { + t.Fatalf("raw trade_no update: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +// TestEPayNotify_TradeNoRawWritePropagatesError ensures a DB-side failure during +// the trade_no backfill is not swallowed — caller returns the error so EPay +// retries instead of silently flipping status without trade_no being written. +func TestEPayNotify_TradeNoRawWritePropagatesError(t *testing.T) { + db, mock, cleanup := newEPayNotifyTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `order` SET `trade_no`"). + WillReturnError(fmt.Errorf("deadlock detected")) + mock.ExpectRollback() + + err := db.WithContext(context.Background()). + Model(&order.Order{}). + Where("order_no = ?", "ORD-2"). + Update("trade_no", "EPAY-2").Error + if err == nil { + t.Fatalf("expected error from raw trade_no update, got nil") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func newEPayNotifyTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + return db, mock, func() { + _ = sqlDB.Close() + } +} diff --git a/internal/logic/public/file/common.go b/internal/logic/public/file/common.go index cd1e909..80f78b7 100644 --- a/internal/logic/public/file/common.go +++ b/internal/logic/public/file/common.go @@ -80,7 +80,7 @@ func validateInitRequest(svcCtx *svc.ServiceContext, bizType, fileName, contentT allowed := allowedContentTypeSet(svcCtx.Config.S3.AllowedContentTypes) if len(allowed) > 0 { if _, ok := allowed[strings.ToLower(strings.TrimSpace(contentType))]; !ok { - return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "content_type is not allowed") + return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InvalidParams, "content_type is not allowed"), "content_type is not allowed") } } return nil diff --git a/internal/logic/public/file/common_test.go b/internal/logic/public/file/common_test.go new file mode 100644 index 0000000..528d1d1 --- /dev/null +++ b/internal/logic/public/file/common_test.go @@ -0,0 +1,91 @@ +package file + +import ( + "mime/multipart" + "strings" + "testing" + + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/storage" +) + +const testAllowedContentTypes = "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp" + +type testMultipartFile struct { + *strings.Reader +} + +func (testMultipartFile) Close() error { + return nil +} + +func TestValidateInitRequestAllowedContentTypes(t *testing.T) { + svcCtx := &svc.ServiceContext{ + Config: config.Config{ + S3: config.S3Config{ + Enable: true, + MaxUploadSize: 1024, + AllowedContentTypes: testAllowedContentTypes, + }, + }, + S3Store: &storage.S3Store{}, + } + + tests := []struct { + name string + contentType string + wantErr bool + }{ + {name: "allow jpeg", contentType: "image/jpeg"}, + {name: "allow png", contentType: "image/png"}, + {name: "allow webp", contentType: "image/webp"}, + {name: "reject unknown", contentType: "application/x-sh", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateInitRequest(svcCtx, "app-package", "demo.bin", tt.contentType, 10) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "content_type is not allowed") { + t.Fatalf("expected content type error, got %v", err) + } + return + } + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + }) + } +} + +func TestSniffContentTypeDetectsCommonImages(t *testing.T) { + tests := []struct { + name string + data string + want string + }{ + {name: "jpeg", data: "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01", want: "image/jpeg"}, + {name: "png", data: "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR", want: "image/png"}, + {name: "webp", data: "RIFF\x1a\x00\x00\x00WEBPVP8 \x0e\x00\x00\x00", want: "image/webp"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file := testMultipartFile{Reader: strings.NewReader(tt.data)} + got, err := sniffContentType(&multipart.FileHeader{}, file) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + if pos, err := file.Seek(0, 1); err != nil || pos != 0 { + t.Fatalf("expected reader reset to start, pos=%d err=%v", pos, err) + } + }) + } +} diff --git a/internal/logic/public/file/fileuploadcompletelogic.go b/internal/logic/public/file/fileuploadcompletelogic.go index 490c000..925ab25 100644 --- a/internal/logic/public/file/fileuploadcompletelogic.go +++ b/internal/logic/public/file/fileuploadcompletelogic.go @@ -61,11 +61,6 @@ func (l *FileUploadCompleteLogic) FileUploadComplete(req *types.FileUploadComple } return &types.FileUploadCompleteResponse{ - FileId: meta.FileID, - ObjectKey: meta.ObjectKey, - Size: head.ContentLength, - ContentType: head.ContentType, - Etag: head.ETag, - Status: meta.Status, + Url: l.svcCtx.S3Store.BuildObjectURL(meta.ObjectKey), }, nil } diff --git a/internal/logic/public/file/fileuploadlogic.go b/internal/logic/public/file/fileuploadlogic.go index b9f2a7b..05d9c3a 100644 --- a/internal/logic/public/file/fileuploadlogic.go +++ b/internal/logic/public/file/fileuploadlogic.go @@ -52,20 +52,13 @@ func (l *FileUploadLogic) FileUpload(req *types.FileUploadRequest, fileHeader *m fileID := buildFileID(u.Id, req.BizType, fileHeader.Filename) objectKey := buildObjectKey(l.svcCtx.Config.S3.Prefix, u.Id, req.BizType, fileID, fileHeader.Filename, now) - putResult, err := l.svcCtx.S3Store.PutObject(l.ctx, objectKey, file, fileHeader.Size, contentType) - if err != nil { + if _, err := l.svcCtx.S3Store.PutObject(l.ctx, objectKey, file, fileHeader.Size, contentType); err != nil { l.Errorw("put object failed", logger.Field("error", err.Error()), logger.Field("user_id", u.Id), logger.Field("file_id", fileID)) return nil, err } return &types.FileUploadResponse{ - FileId: fileID, - FileName: fileHeader.Filename, - ObjectKey: objectKey, - Size: fileHeader.Size, - ContentType: contentType, - Etag: putResult.ETag, - Status: fileUploadCompleteStatus, + Url: l.svcCtx.S3Store.BuildObjectURL(objectKey), }, nil } diff --git a/internal/logic/public/order/closeOrderLogic.go b/internal/logic/public/order/closeOrderLogic.go index 2c89bfa..8b6add3 100644 --- a/internal/logic/public/order/closeOrderLogic.go +++ b/internal/logic/public/order/closeOrderLogic.go @@ -5,9 +5,12 @@ import ( "encoding/json" "time" + "github.com/hibiken/asynq" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/pkg/payment/epay" "github.com/perfect-panel/server/pkg/payment/stripe" + queueTypes "github.com/perfect-panel/server/queue/types" "gorm.io/gorm" "github.com/perfect-panel/server/internal/model/order" @@ -18,19 +21,52 @@ import ( "github.com/perfect-panel/server/pkg/payment/alipay" ) +// GatewayPaymentStatus is the tri-state result of an external payment-gateway +// query. The third state (Unknown) is the whole point of HIF-137: when the +// gateway is unreachable or returns an inconclusive answer we must NOT collapse +// it to "unpaid" — that's exactly the bug that closes already-paid orders. +type GatewayPaymentStatus int + +const ( + // GatewayStatusUnknown — the gateway query itself failed (timeout, 5xx, + // network error, decode error) or the payment method has no gateway we can + // query. The caller must treat this as "do not close, retry later". + GatewayStatusUnknown GatewayPaymentStatus = iota + // GatewayStatusPaid — the gateway confirms the user has paid. + GatewayStatusPaid + // GatewayStatusUnpaid — the gateway confirms the order is unpaid / + // cancelled / closed on its side. Safe to close locally. + GatewayStatusUnpaid +) + type CloseOrderLogic struct { logger.Logger ctx context.Context svcCtx *svc.ServiceContext + + // Test seams (injected via overrides on the returned struct). Production + // code never touches these — NewCloseOrderLogic wires the real impls. + gatewayQuery func(*order.Order) GatewayPaymentStatus + enqueueActivate func(context.Context, []byte) (string, error) } // NewCloseOrderLogic Close order func NewCloseOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CloseOrderLogic { - return &CloseOrderLogic{ + l := &CloseOrderLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } + l.gatewayQuery = l.queryGatewayPaymentStatus + l.enqueueActivate = func(c context.Context, payload []byte) (string, error) { + task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload, asynq.MaxRetry(5)) + info, err := svcCtx.Queue.EnqueueContext(c, task) + if err != nil { + return "", err + } + return info.ID, nil + } + return l } func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error { @@ -52,6 +88,31 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error { return nil } + // HIF-137: before closing a pending order, ask the gateway whether it was + // actually paid. Silent callback failures must not cause us to close a + // paid order. Three branches: + // - Paid → recover to status=2 + enqueue activation (do NOT close) + // - Unpaid → fall through to the normal close flow + // - Unknown → keep status=1 and let DeferCloseOrder retry on the next tick + switch l.gatewayQuery(orderInfo) { + case GatewayStatusPaid: + return l.recoverPaidOrder(orderInfo) + case GatewayStatusUnknown: + l.Errorw("[CloseOrder] gateway query inconclusive — keeping order open (metric=close_gateway_error_kept_open)", + logger.Field("metric", "close_gateway_error_kept_open"), + logger.Field("orderNo", orderInfo.OrderNo), + logger.Field("method", orderInfo.Method), + logger.Field("trade_no", orderInfo.TradeNo), + ) + return nil + } + + l.Infow("[CloseOrder] gateway confirmed unpaid — proceeding with normal close (metric=close_normal)", + logger.Field("metric", "close_normal"), + logger.Field("orderNo", orderInfo.OrderNo), + logger.Field("method", orderInfo.Method), + ) + sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, orderInfo.SubscribeId) if err != nil { l.Errorw("[CloseOrder] Find subscribe info failed", @@ -166,42 +227,117 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error { return nil } -// confirmationPayment Determine whether the payment is successful -// -//nolint:unused -func (l *CloseOrderLogic) confirmationPayment(order *order.Order) bool { - paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, order.PaymentId) +// recoverPaidOrder is the "gateway said paid" branch: flip the order to +// status=2 (paid), persist the trade_no if we have one, and enqueue the same +// activation task the notify handlers would have enqueued. We deliberately +// reuse ForthwithActivateOrder so the rest of the activation pipeline +// (idempotency, claim/release, commission, etc.) stays untouched. +func (l *CloseOrderLogic) recoverPaidOrder(orderInfo *order.Order) error { + updates := map[string]any{"status": 2} + if orderInfo.TradeNo != "" { + updates["trade_no"] = orderInfo.TradeNo + } + + // Race-safe: only flip 1→2. If another worker already moved the order + // forward (e.g. a late notify finally landed), do nothing. + result := l.svcCtx.DB.WithContext(l.ctx). + Model(&order.Order{}). + Where("order_no = ? AND status = ?", orderInfo.OrderNo, 1). + Updates(updates) + if result.Error != nil { + l.Errorw("[CloseOrder] gateway-paid recovery update failed — keeping order open", + logger.Field("metric", "close_gateway_error_kept_open"), + logger.Field("error", result.Error.Error()), + logger.Field("orderNo", orderInfo.OrderNo), + ) + return nil + } + if result.RowsAffected == 0 { + l.Infow("[CloseOrder] gateway-paid recovery skipped — order already moved past status=1", + logger.Field("orderNo", orderInfo.OrderNo), + ) + return nil + } + + payload := queueTypes.ForthwithActivateOrderPayload{OrderNo: orderInfo.OrderNo} + bytes, err := json.Marshal(&payload) if err != nil { - l.Errorw("[CloseOrder] Find payment config failed", logger.Field("error", err.Error()), logger.Field("paymentMark", order.Method)) - return false + l.Errorw("[CloseOrder] marshal activation payload failed", + logger.Field("error", err.Error()), + logger.Field("orderNo", orderInfo.OrderNo), + ) + return nil } - switch order.Method { - case AlipayF2f: - if l.queryAlipay(paymentConfig, order.TradeNo) { - return true - } - case StripeAlipay: - if l.queryStripe(paymentConfig, order.TradeNo) { - return true - } - case StripeWeChatPay: - if l.queryStripe(paymentConfig, order.TradeNo) { - return true - } - default: - l.Infow("[CloseOrder] Unsupported payment method", logger.Field("paymentMethod", order.Method)) + taskID, err := l.enqueueActivate(l.ctx, bytes) + if err != nil { + // Order is already at status=2; if enqueue fails the stuck-order + // recovery sweeper will pick it up. Log loudly but don't error out. + l.Errorw("[CloseOrder] enqueue activation task failed after gateway-paid recovery", + logger.Field("error", err.Error()), + logger.Field("orderNo", orderInfo.OrderNo), + ) + return nil } - return false + + l.Infow("[CloseOrder] gateway-paid recovery succeeded — order promoted to paid + activation enqueued (metric=close_with_gateway_paid_recovered)", + logger.Field("metric", "close_with_gateway_paid_recovered"), + logger.Field("orderNo", orderInfo.OrderNo), + logger.Field("method", orderInfo.Method), + logger.Field("trade_no", orderInfo.TradeNo), + logger.Field("queue_task_id", taskID), + ) + return nil } -// queryAlipay Query Alipay payment status -// -//nolint:unused -func (l *CloseOrderLogic) queryAlipay(paymentConfig *payment.Payment, TradeNo string) bool { +// queryGatewayPaymentStatus dispatches to the right gateway client for the +// order's payment method and returns a tri-state result. Methods without an +// external gateway (Balance, unknown) return Unpaid — the historical close +// path is preserved for them. +func (l *CloseOrderLogic) queryGatewayPaymentStatus(orderInfo *order.Order) GatewayPaymentStatus { + switch orderInfo.Method { + case AlipayF2f: + return l.queryAlipay(orderInfo) + case StripeAlipay, StripeWeChatPay: + return l.queryStripe(orderInfo) + case Epay: + return l.queryEpay(orderInfo) + case Balance: + // Balance is settled in-process; no external gateway to ask. If status=1 + // here, the balance deduction simply never completed — safe to close. + return GatewayStatusUnpaid + default: + l.Infow("[CloseOrder] no gateway query for method — falling back to close", + logger.Field("method", orderInfo.Method), + logger.Field("orderNo", orderInfo.OrderNo), + ) + return GatewayStatusUnpaid + } +} + +// queryAlipay queries Alipay F2F and maps the response to a tri-state. +// "trade not exist" on Alipay's side is treated as Unpaid (the user never +// completed the QR-code scan), not Unknown. +func (l *CloseOrderLogic) queryAlipay(orderInfo *order.Order) GatewayPaymentStatus { + if orderInfo.TradeNo == "" { + // Alipay F2F creates the trade lazily — no trade_no means the user + // never scanned. Treat as definitively unpaid. + return GatewayStatusUnpaid + } + paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId) + if err != nil { + l.Errorw("[CloseOrder] Find payment config failed", + logger.Field("error", err.Error()), + logger.Field("paymentMark", orderInfo.Method), + ) + return GatewayStatusUnknown + } config := payment.AlipayF2FConfig{} if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil { - l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config)) - return false + l.Errorw("[CloseOrder] Unmarshal Alipay config failed", + logger.Field("error", err.Error()), + logger.Field("orderNo", orderInfo.OrderNo), + ) + return GatewayStatusUnknown } client := alipay.NewClient(alipay.Config{ AppId: config.AppId, @@ -209,35 +345,112 @@ func (l *CloseOrderLogic) queryAlipay(paymentConfig *payment.Payment, TradeNo st PublicKey: config.PublicKey, InvoiceName: config.InvoiceName, }) - status, err := client.QueryTrade(l.ctx, TradeNo) + if client == nil { + return GatewayStatusUnknown + } + status, err := client.QueryTrade(l.ctx, orderInfo.TradeNo) if err != nil { - l.Errorw("[CloseOrder] Query trade failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo)) - return false + l.Errorw("[CloseOrder] Alipay QueryTrade failed", + logger.Field("error", err.Error()), + logger.Field("orderNo", orderInfo.OrderNo), + logger.Field("tradeNo", orderInfo.TradeNo), + ) + return GatewayStatusUnknown } - if status == alipay.Success || status == alipay.Finished { - return true + switch status { + case alipay.Success, alipay.Finished: + return GatewayStatusPaid + case alipay.Pending, alipay.Closed: + return GatewayStatusUnpaid + default: + // Unknown alipay status — be conservative. + return GatewayStatusUnknown } - return false } -// queryStripe Query Stripe payment status -// -//nolint:unused -func (l *CloseOrderLogic) queryStripe(paymentConfig *payment.Payment, TradeNo string) bool { +// queryStripe queries Stripe PaymentIntent and maps to a tri-state. +// Any Stripe-side error (network, 5xx, decode) is Unknown — Stripe is the +// gateway most prone to silent webhook drops in this codebase, so we must not +// downgrade query failures to "unpaid". +func (l *CloseOrderLogic) queryStripe(orderInfo *order.Order) GatewayPaymentStatus { + if orderInfo.TradeNo == "" { + // Stripe's PaymentIntent ID is written into trade_no at create time. + // Missing it means the intent was never persisted — treat as unpaid. + return GatewayStatusUnpaid + } + paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId) + if err != nil { + l.Errorw("[CloseOrder] Find payment config failed", + logger.Field("error", err.Error()), + logger.Field("paymentMark", orderInfo.Method), + ) + return GatewayStatusUnknown + } config := payment.StripeConfig{} if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil { - l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config)) - return false + l.Errorw("[CloseOrder] Unmarshal Stripe config failed", + logger.Field("error", err.Error()), + logger.Field("orderNo", orderInfo.OrderNo), + ) + return GatewayStatusUnknown } client := stripe.NewClient(stripe.Config{ PublicKey: config.PublicKey, SecretKey: config.SecretKey, WebhookSecret: config.WebhookSecret, }) - status, err := client.QueryOrderStatus(TradeNo) + paid, err := client.QueryOrderStatus(orderInfo.TradeNo) if err != nil { - l.Errorw("[CloseOrder] Query order status failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo)) - return false + l.Errorw("[CloseOrder] Stripe QueryOrderStatus failed", + logger.Field("error", err.Error()), + logger.Field("orderNo", orderInfo.OrderNo), + logger.Field("tradeNo", orderInfo.TradeNo), + ) + return GatewayStatusUnknown } - return status + if paid { + return GatewayStatusPaid + } + return GatewayStatusUnpaid +} + +// queryEpay queries EPay using out_trade_no (EPay's order endpoint accepts +// out_trade_no even when we never recorded its internal trade_no, which is the +// common case for orders that lost their notify callback). +func (l *CloseOrderLogic) queryEpay(orderInfo *order.Order) GatewayPaymentStatus { + paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId) + if err != nil { + l.Errorw("[CloseOrder] Find payment config failed", + logger.Field("error", err.Error()), + logger.Field("paymentMark", orderInfo.Method), + ) + return GatewayStatusUnknown + } + config := payment.EPayConfig{} + if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil { + l.Errorw("[CloseOrder] Unmarshal EPay config failed", + logger.Field("error", err.Error()), + logger.Field("orderNo", orderInfo.OrderNo), + ) + return GatewayStatusUnknown + } + if config.Url == "" || config.Pid == "" || config.Key == "" { + l.Errorw("[CloseOrder] EPay config incomplete", + logger.Field("orderNo", orderInfo.OrderNo), + ) + return GatewayStatusUnknown + } + client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type) + paid, err := client.QueryOrderStatus(orderInfo.OrderNo) + if err != nil { + l.Errorw("[CloseOrder] EPay QueryOrderStatus failed", + logger.Field("error", err.Error()), + logger.Field("orderNo", orderInfo.OrderNo), + ) + return GatewayStatusUnknown + } + if paid { + return GatewayStatusPaid + } + return GatewayStatusUnpaid } diff --git a/internal/logic/public/order/closeOrderLogic_test.go b/internal/logic/public/order/closeOrderLogic_test.go new file mode 100644 index 0000000..3812cc1 --- /dev/null +++ b/internal/logic/public/order/closeOrderLogic_test.go @@ -0,0 +1,232 @@ +package order + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + modelorder "github.com/perfect-panel/server/internal/model/order" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/logger" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// newCloseOrderTestDB wires gorm to go-sqlmock with a substring SQL matcher, +// matching the convention used elsewhere in this package. +func newCloseOrderTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { _ = sqlDB.Close() } +} + +func newCloseOrderLogicForTest(db *gorm.DB) *CloseOrderLogic { + ctx := context.Background() + return &CloseOrderLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: &svc.ServiceContext{DB: db}, + } +} + +// TestRecoverPaidOrder_HappyPath covers HIF-137 P01 branch "gateway 已支付": +// gateway said the order was paid → we must flip status 1→2, persist +// trade_no, and enqueue exactly one ForthwithActivateOrder task. This is the +// most important regression to keep — silently dropping the enqueue here would +// re-create the bug we are fixing. +func TestRecoverPaidOrder_HappyPath(t *testing.T) { + db, mock, cleanup := newCloseOrderTestDB(t) + defer cleanup() + + const ( + orderNo = "ORD-PAID-1" + tradeNo = "ALIPAY-TRADE-9999" + ) + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `order`"). + WithArgs(2, tradeNo, sqlmock.AnyArg(), orderNo, 1). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + var ( + enqueueCalls int32 + capturedPayload []byte + ) + logic := newCloseOrderLogicForTest(db) + logic.enqueueActivate = func(_ context.Context, payload []byte) (string, error) { + atomic.AddInt32(&enqueueCalls, 1) + capturedPayload = append([]byte(nil), payload...) + return "task-123", nil + } + + err := logic.recoverPaidOrder(&modelorder.Order{ + OrderNo: orderNo, + Method: AlipayF2f, + TradeNo: tradeNo, + Status: 1, + }) + if err != nil { + t.Fatalf("recoverPaidOrder error: %v", err) + } + if got := atomic.LoadInt32(&enqueueCalls); got != 1 { + t.Fatalf("expected exactly one enqueue, got %d", got) + } + if !strings.Contains(string(capturedPayload), orderNo) { + t.Fatalf("activation payload missing order_no: %q", capturedPayload) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +// TestRecoverPaidOrder_AlreadyAdvanced covers the race: the late-arriving +// gateway-notify already flipped the order past status=1 by the time the +// deferred close ran. UPDATE returns 0 rows; we must NOT double-enqueue. +func TestRecoverPaidOrder_AlreadyAdvanced(t *testing.T) { + db, mock, cleanup := newCloseOrderTestDB(t) + defer cleanup() + + const orderNo = "ORD-RACE" + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `order`"). + WithArgs(2, sqlmock.AnyArg(), orderNo, 1). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectCommit() + + var enqueueCalls int32 + logic := newCloseOrderLogicForTest(db) + logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) { + atomic.AddInt32(&enqueueCalls, 1) + return "should-not-fire", nil + } + + err := logic.recoverPaidOrder(&modelorder.Order{ + OrderNo: orderNo, + Method: Epay, + Status: 1, + }) + if err != nil { + t.Fatalf("recoverPaidOrder error: %v", err) + } + if got := atomic.LoadInt32(&enqueueCalls); got != 0 { + t.Fatalf("expected no enqueue when 0 rows updated, got %d", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +// TestRecoverPaidOrder_EnqueueFailureIsNotFatal covers the case where the DB +// move succeeded but the activation enqueue failed (Redis blip, etc). The +// order is already at status=2, so the stuck-order sweeper will pick it up — +// recoverPaidOrder must not return an error or revert the status. +func TestRecoverPaidOrder_EnqueueFailureIsNotFatal(t *testing.T) { + db, mock, cleanup := newCloseOrderTestDB(t) + defer cleanup() + + const orderNo = "ORD-ENQ-FAIL" + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `order`"). + WithArgs(2, sqlmock.AnyArg(), orderNo, 1). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + logic := newCloseOrderLogicForTest(db) + logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) { + return "", fmt.Errorf("simulated redis outage") + } + + if err := logic.recoverPaidOrder(&modelorder.Order{OrderNo: orderNo, Method: Epay}); err != nil { + t.Fatalf("recoverPaidOrder must swallow enqueue errors, got: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +// TestQueryGatewayPaymentStatus_NonGatewayMethods checks the "fall-through" +// branches: methods that don't talk to an external gateway (Balance, empty +// string, anything unrecognised) must report Unpaid so the legacy close path +// is preserved. This is the regression hook for the issue's acceptance +// criterion #5 ("user-initiated cancellation can still close normally"). +func TestQueryGatewayPaymentStatus_NonGatewayMethods(t *testing.T) { + logic := newCloseOrderLogicForTest(nil) // no DB needed for these branches + + cases := []struct { + method string + want GatewayPaymentStatus + }{ + {Balance, GatewayStatusUnpaid}, + {"", GatewayStatusUnpaid}, + {"future-method-we-dont-know", GatewayStatusUnpaid}, + } + for _, tc := range cases { + got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: tc.method}) + if got != tc.want { + t.Fatalf("method=%q: got %v want %v", tc.method, got, tc.want) + } + } +} + +// TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid: Alipay F2F creates +// the trade lazily — if we never recorded a trade_no, the user never scanned +// the QR code. We must report Unpaid (not Unknown) so the close proceeds. +// Without this short-circuit, every legitimately abandoned QR-code order +// would be "kept open" forever by the inconclusive-query branch. +func TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid(t *testing.T) { + logic := newCloseOrderLogicForTest(nil) + got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: AlipayF2f, TradeNo: ""}) + if got != GatewayStatusUnpaid { + t.Fatalf("Alipay F2F with empty trade_no should be Unpaid, got %v", got) + } +} + +// TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid: same reasoning as +// the Alipay case — Stripe's PaymentIntent ID lives in trade_no; if it's +// missing the intent was never persisted. +func TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid(t *testing.T) { + logic := newCloseOrderLogicForTest(nil) + for _, method := range []string{StripeAlipay, StripeWeChatPay} { + got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: method, TradeNo: ""}) + if got != GatewayStatusUnpaid { + t.Fatalf("%s with empty trade_no should be Unpaid, got %v", method, got) + } + } +} + +// TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring asserts that the +// default gatewayQuery is wired to the real implementation (not nil) by +// NewCloseOrderLogic. Without this guard a future refactor could silently +// drop the wiring and re-create HIF-135. +func TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring(t *testing.T) { + svcCtx := &svc.ServiceContext{} + l := NewCloseOrderLogic(context.Background(), svcCtx) + if l.gatewayQuery == nil { + t.Fatal("NewCloseOrderLogic must wire gatewayQuery; got nil") + } + if l.enqueueActivate == nil { + t.Fatal("NewCloseOrderLogic must wire enqueueActivate; got nil") + } +} diff --git a/internal/logic/public/order/paidSubscription.go b/internal/logic/public/order/paidSubscription.go new file mode 100644 index 0000000..c980082 --- /dev/null +++ b/internal/logic/public/order/paidSubscription.go @@ -0,0 +1,17 @@ +package order + +import ( + "context" + + "github.com/perfect-panel/server/internal/model/user" + "gorm.io/gorm" +) + +func paidSubscriptionQuery(ctx context.Context, db *gorm.DB, userID int64) *gorm.DB { + return db.WithContext(ctx). + Model(&user.Subscribe{}). + Where("user_id = ? AND (order_id > 0 OR token LIKE 'iap:%')", userID). + Order("expire_time DESC"). + Order("updated_at DESC"). + Order("id DESC") +} diff --git a/internal/logic/public/order/paidSubscription_test.go b/internal/logic/public/order/paidSubscription_test.go new file mode 100644 index 0000000..b42b905 --- /dev/null +++ b/internal/logic/public/order/paidSubscription_test.go @@ -0,0 +1,32 @@ +package order + +import ( + "context" + "strings" + "testing" + + "github.com/perfect-panel/server/internal/model/user" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestPaidSubscriptionQueryIncludesOrderBackedSubscriptionWithoutToken(t *testing.T) { + db, err := gorm.Open(mysql.New(mysql.Config{ + DSN: "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local", + SkipInitializeWithVersion: true, + }), &gorm.Config{DryRun: true, DisableAutomaticPing: true}) + if err != nil { + t.Fatalf("open dry-run db: %v", err) + } + + var sub user.Subscribe + tx := paidSubscriptionQuery(context.Background(), db, 510).First(&sub) + sql := tx.Statement.SQL.String() + + if strings.Contains(sql, "token != ''") { + t.Fatalf("paid subscription query should not require non-empty token: %s", sql) + } + if !strings.Contains(sql, "order_id > 0 OR token LIKE 'iap:%'") { + t.Fatalf("paid subscription query should include order-backed or iap-backed subscriptions: %s", sql) + } +} diff --git a/internal/logic/public/order/preCreateOrderLogic.go b/internal/logic/public/order/preCreateOrderLogic.go index fc07fb0..be16561 100644 --- a/internal/logic/public/order/preCreateOrderLogic.go +++ b/internal/logic/public/order/preCreateOrderLogic.go @@ -2,7 +2,6 @@ package order import ( "context" - "math" commonLogic "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/order" @@ -48,13 +47,18 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r l.Debugf("[PreCreateOrder] Quantity is less than or equal to 0, setting to 1") req.Quantity = 1 } + entitlement, entErr := commonLogic.ResolveEntitlementUser(l.ctx, l.svcCtx.DB, u.Id) + if entErr != nil { + return nil, entErr + } targetSubscribeID := req.SubscribeId + orderType := uint8(1) isSingleModeRenewal := false decision, routeErr := commonLogic.ResolvePurchaseRoute( l.ctx, l.svcCtx.Config.Subscribe.SingleModel, - u.Id, + entitlement.EffectiveUserID, req.SubscribeId, l.svcCtx.UserModel.FindSingleModeAnchorSubscribe, ) @@ -69,15 +73,39 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r targetSubscribeID = decision.ResolvedSubscribeID isSingleModeRenewal = decision.Route == commonLogic.PurchaseRoutePurchaseToRenewal if isSingleModeRenewal && decision.Anchor != nil { + orderType = 2 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", decision.Anchor.Id), logger.Field("user_id", u.Id), + logger.Field("effective_user_id", entitlement.EffectiveUserID), ) } } + // Keep promo eligibility preview aligned with Purchase: an existing paid subscription + // routes the request to renewal semantics, where first-purchase promos are disabled. + if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 { + var existSub user.Subscribe + if e := paidSubscriptionQuery(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID). + First(&existSub).Error; e == nil && existSub.Id > 0 { + orderType = 2 + l.Infow("[PreCreateOrder] purchase preview routed to renewal because an existing subscription was found", + logger.Field("route_mode", "global_single_subscription"), + logger.Field("route", "purchase_to_existing_subscription"), + logger.Field("existing_subscribe_id", existSub.Id), + logger.Field("existing_status", existSub.Status), + logger.Field("user_id", u.Id), + logger.Field("effective_user_id", entitlement.EffectiveUserID), + logger.Field("resolved_subscribe_id", targetSubscribeID), + ) + } else if e != nil && !errors.Is(e, gorm.ErrRecordNotFound) { + l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", e.Error()), logger.Field("user_id", u.Id)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find existing subscription error: %v", e.Error()) + } + } + // find subscribe plan sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, targetSubscribeID) if err != nil { @@ -87,7 +115,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r // 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) + userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, entitlement.EffectiveUserID) if err != nil { l.Errorw("[PreCreateOrder] 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()) @@ -103,7 +131,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r } } - newUserDiscount, err := resolveNewUserDiscountEligibility(l.ctx, l.svcCtx.DB, u.Id, targetSubscribeID, req.Quantity, sub.Discount) + newUserDiscount, err := resolveNewUserDiscountEligibility(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID, targetSubscribeID, req.Quantity, sub.Discount) if err != nil { l.Errorw("[PreCreateOrder] Database query error resolving new user eligibility", logger.Field("error", err.Error()), @@ -115,14 +143,28 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user") } - var discount float64 = 1 - if len(newUserDiscount.Discounts) > 0 { - discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount) + priceResult, err := calculatePurchasePrice( + l.ctx, + l.svcCtx, + entitlement.EffectiveUserID, + targetSubscribeID, + sub.UnitPrice, + req.Quantity, + newUserDiscount.Discounts, + newUserDiscount.EligibleForDiscount, + orderType == 1, + ) + if err != nil { + l.Errorw("[PreCreateOrder] Promo price calculation error", + logger.Field("error", err.Error()), + logger.Field("user_id", u.Id), + logger.Field("subscribe_id", targetSubscribeID), + ) + return nil, err } - price := sub.UnitPrice * req.Quantity - - amount := int64(math.Round(float64(price) * discount)) - discountAmount := price - amount + price := priceResult.OriginalPrice + amount := priceResult.PayableBase + discountAmount := priceResult.DiscountAmount var couponAmount int64 if req.Coupon != "" { couponInfo, err := l.svcCtx.CouponModel.FindOneByCode(l.ctx, req.Coupon) @@ -185,6 +227,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r Price: price, Amount: amount, Discount: discountAmount, + PromoDiscount: priceResult.PromoDiscount, GiftAmount: deductionAmount, Coupon: req.Coupon, CouponDiscount: couponAmount, diff --git a/internal/logic/public/order/promoPricing.go b/internal/logic/public/order/promoPricing.go new file mode 100644 index 0000000..cfaf6a1 --- /dev/null +++ b/internal/logic/public/order/promoPricing.go @@ -0,0 +1,83 @@ +package order + +import ( + "context" + "math" + + commonLogic "github.com/perfect-panel/server/internal/logic/common" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" +) + +type orderPriceResult struct { + OriginalPrice int64 + PayableBase int64 + DiscountAmount int64 + PromoRuleId int64 + PromoDiscount int64 + PromoPrice int64 +} + +func calculatePurchasePrice( + ctx context.Context, + svcCtx *svc.ServiceContext, + userID int64, + subscribeID int64, + unitPrice int64, + quantity int64, + discounts []types.SubscribeDiscount, + eligibleForDiscount bool, + isFirstPurchase bool, +) (*orderPriceResult, error) { + originalPrice := unitPrice * quantity + result := &orderPriceResult{ + OriginalPrice: originalPrice, + PayableBase: originalPrice, + } + + promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity, isFirstPurchase) + if err != nil { + return nil, err + } + if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice { + result.PayableBase = promoResult.PromoPrice + result.PromoRuleId = promoResult.RuleID + result.PromoDiscount = originalPrice - result.PayableBase + result.PromoPrice = promoResult.PromoPrice + if result.PromoDiscount < 0 { + result.PromoDiscount = 0 + } + return result, nil + } + + discount := float64(1) + if len(discounts) > 0 { + discount = getDiscount(discounts, quantity, eligibleForDiscount) + } + result.PayableBase = int64(math.Round(float64(originalPrice) * discount)) + result.DiscountAmount = originalPrice - result.PayableBase + return result, nil +} + +func calculateRenewalPrice( + ctx context.Context, + svcCtx *svc.ServiceContext, + userID int64, + subscribeID int64, + unitPrice int64, + quantity int64, + discounts []types.SubscribeDiscount, + eligibleForDiscount bool, +) (*orderPriceResult, error) { + return calculatePurchasePrice( + ctx, + svcCtx, + userID, + subscribeID, + unitPrice, + quantity, + discounts, + eligibleForDiscount, + false, + ) +} diff --git a/internal/logic/public/order/promoPricing_test.go b/internal/logic/public/order/promoPricing_test.go new file mode 100644 index 0000000..63874ee --- /dev/null +++ b/internal/logic/public/order/promoPricing_test.go @@ -0,0 +1,374 @@ +package order + +import ( + "context" + "testing" + + "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "gorm.io/gorm" +) + +type fakePromoModel struct { + rules []*promo.RuleWithPrice + lastSubscribeID int64 + lastQuantity int64 + requireQuantity int64 + quantityMismatch []*promo.RuleWithPrice +} + +func (m *fakePromoModel) QueryEligibleRules(_ context.Context, subscribeID int64, quantity int64) ([]*promo.RuleWithPrice, error) { + m.lastSubscribeID = subscribeID + m.lastQuantity = quantity + if m.requireQuantity > 0 && quantity != m.requireQuantity { + return m.quantityMismatch, nil + } + return m.rules, nil +} + +func (m *fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) error { + return nil +} + +func (m *fakePromoModel) InsertRule(context.Context, *promo.Rule) error { + return nil +} + +func (m *fakePromoModel) FindRule(context.Context, int64) (*promo.Rule, error) { + return nil, gorm.ErrRecordNotFound +} + +func (m *fakePromoModel) UpdateRule(context.Context, *promo.Rule) error { + return nil +} + +func (m *fakePromoModel) DeleteRule(context.Context, int64) error { + return nil +} + +func (m *fakePromoModel) QueryRuleList(context.Context, int, int, string, *bool, string) (int64, []*promo.Rule, error) { + return 0, nil, nil +} + +func (m *fakePromoModel) UpsertPrices(context.Context, int64, []*promo.SubscribePromo) error { + return nil +} + +func (m *fakePromoModel) FindPrice(context.Context, int64) (*promo.SubscribePromo, error) { + return nil, gorm.ErrRecordNotFound +} + +func (m *fakePromoModel) DeletePrice(context.Context, int64) error { + return nil +} + +func (m *fakePromoModel) QueryPriceList(context.Context, promo.PriceFilter) (int64, []*promo.SubscribePromo, error) { + return 0, nil, nil +} + +func (m *fakePromoModel) QueryUsageList(context.Context, promo.UsageFilter) (int64, []*promo.Usage, error) { + return 0, nil, nil +} + +func (m *fakePromoModel) Transaction(context.Context, func(*gorm.DB) error) error { + return nil +} + +func TestCalculatePurchasePricePromoUsesQuantityTierTotalPrice(t *testing.T) { + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 9, + Name: "campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + }, + PromoPrice: 279, + }, + }} + svcCtx := &svc.ServiceContext{ + DB: &gorm.DB{}, + PromoModel: model, + } + + result, err := calculatePurchasePrice( + context.Background(), + svcCtx, + 1, + 2, + 100, + 7, + []types.SubscribeDiscount{{Quantity: 7, Discount: 50}}, + true, + true, + ) + if err != nil { + t.Fatalf("calculatePurchasePrice returned error: %v", err) + } + + if result.OriginalPrice != 700 { + t.Fatalf("OriginalPrice = %d, want 700", result.OriginalPrice) + } + if result.PayableBase != 279 { + t.Fatalf("PayableBase = %d, want 279", result.PayableBase) + } + if result.DiscountAmount != 0 { + t.Fatalf("DiscountAmount = %d, want 0", result.DiscountAmount) + } + if result.PromoRuleId != 9 { + t.Fatalf("PromoRuleId = %d, want 9", result.PromoRuleId) + } + if result.PromoDiscount != 421 { + t.Fatalf("PromoDiscount = %d, want 421", result.PromoDiscount) + } + if result.PromoPrice != 279 { + t.Fatalf("PromoPrice = %d, want 279", result.PromoPrice) + } + if model.lastQuantity != 7 { + t.Fatalf("promo query quantity = %d, want 7", model.lastQuantity) + } +} + +func TestCalculatePurchasePriceIgnoresInvalidPromoPrice(t *testing.T) { + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 10, + Name: "invalid campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + }, + PromoPrice: 3000, + }, + }} + svcCtx := &svc.ServiceContext{ + DB: &gorm.DB{}, + PromoModel: model, + } + + result, err := calculatePurchasePrice( + context.Background(), + svcCtx, + 1, + 2, + 1000, + 3, + []types.SubscribeDiscount{{Quantity: 3, Discount: 50}}, + true, + true, + ) + if err != nil { + t.Fatalf("calculatePurchasePrice returned error: %v", err) + } + + if result.PayableBase != 1500 { + t.Fatalf("PayableBase = %d, want 1500", result.PayableBase) + } + if result.DiscountAmount != 1500 { + t.Fatalf("DiscountAmount = %d, want 1500", result.DiscountAmount) + } + if result.PromoRuleId != 0 || result.PromoDiscount != 0 { + t.Fatalf("promo fields = (%d, %d), want (0, 0)", result.PromoRuleId, result.PromoDiscount) + } +} + +func TestCalculatePurchasePricePassesQuantityToPromoEvaluation(t *testing.T) { + promoModel := &fakePromoModel{ + requireQuantity: 6, + rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 11, + Name: "quantity campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + }, + PromoPrice: 3000, + }, + }, + } + svcCtx := &svc.ServiceContext{ + DB: &gorm.DB{}, + PromoModel: promoModel, + } + + result, err := calculatePurchasePrice( + context.Background(), + svcCtx, + 1, + 2, + 1000, + 6, + []types.SubscribeDiscount{{Quantity: 6, Discount: 80}}, + true, + true, + ) + if err != nil { + t.Fatalf("calculatePurchasePrice returned error: %v", err) + } + + if promoModel.lastSubscribeID != 2 { + t.Fatalf("lastSubscribeID = %d, want 2", promoModel.lastSubscribeID) + } + if promoModel.lastQuantity != 6 { + t.Fatalf("lastQuantity = %d, want 6", promoModel.lastQuantity) + } + if result.PayableBase != 3000 { + t.Fatalf("PayableBase = %d, want 3000", result.PayableBase) + } + if result.PromoRuleId != 11 { + t.Fatalf("PromoRuleId = %d, want 11", result.PromoRuleId) + } +} + +func TestCalculatePurchasePriceCampaignAppliesToReturningUsers(t *testing.T) { + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 12, + Name: "campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + }, + PromoPrice: 400, + }, + }} + svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model} + + // isFirstPurchase=false simulates a returning user routed to renewal; the + // Campaign promo must still apply because it has no first-purchase gate. + result, err := calculatePurchasePrice( + context.Background(), + svcCtx, + 42, + 2, + 1000, + 1, + nil, + false, + false, + ) + if err != nil { + t.Fatalf("calculatePurchasePrice returned error: %v", err) + } + if result.PromoRuleId != 12 { + t.Fatalf("PromoRuleId = %d, want 12 (campaign should apply to returning users)", result.PromoRuleId) + } + if result.PayableBase != 400 { + t.Fatalf("PayableBase = %d, want 400", result.PayableBase) + } +} + +func TestCalculatePurchasePriceNewUserGatedByFirstPurchase(t *testing.T) { + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 13, + Name: "new user", + Type: promo.RuleTypeNewUser, + Enabled: true, + Params: `{"window_hours": 72}`, + }, + PromoPrice: 200, + }, + }} + svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model} + + // isFirstPurchase=false → NewUser rule must be skipped, regular discount applies. + result, err := calculatePurchasePrice( + context.Background(), + svcCtx, + 42, + 2, + 1000, + 1, + []types.SubscribeDiscount{{Quantity: 1, Discount: 90}}, + true, + false, + ) + if err != nil { + t.Fatalf("calculatePurchasePrice returned error: %v", err) + } + if result.PromoRuleId != 0 || result.PromoDiscount != 0 { + t.Fatalf("promo fields = (%d, %d), want (0, 0) when isFirstPurchase=false", result.PromoRuleId, result.PromoDiscount) + } + if result.PayableBase != 900 { + t.Fatalf("PayableBase = %d, want 900 (regular 90%% discount)", result.PayableBase) + } +} + +func TestCalculateRenewalPriceCampaignAppliesToReturningUsers(t *testing.T) { + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 21, + Name: "renewal campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + }, + PromoPrice: 700, + }, + }} + svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model} + + result, err := calculateRenewalPrice( + context.Background(), + svcCtx, + 42, + 2, + 500, + 3, + []types.SubscribeDiscount{{Quantity: 3, Discount: 80}}, + true, + ) + if err != nil { + t.Fatalf("calculateRenewalPrice returned error: %v", err) + } + if result.OriginalPrice != 1500 { + t.Fatalf("OriginalPrice = %d, want 1500", result.OriginalPrice) + } + if result.PayableBase != 700 { + t.Fatalf("PayableBase = %d, want 700", result.PayableBase) + } + if result.DiscountAmount != 0 { + t.Fatalf("DiscountAmount = %d, want 0 when promo applies", result.DiscountAmount) + } + if result.PromoRuleId != 21 { + t.Fatalf("PromoRuleId = %d, want 21", result.PromoRuleId) + } + if result.PromoDiscount != 800 { + t.Fatalf("PromoDiscount = %d, want 800", result.PromoDiscount) + } +} + +func TestCalculateRenewalPriceFallsBackToRegularDiscountWhenPromoMisses(t *testing.T) { + model := &fakePromoModel{rules: nil} + svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model} + + result, err := calculateRenewalPrice( + context.Background(), + svcCtx, + 42, + 2, + 500, + 3, + []types.SubscribeDiscount{{Quantity: 3, Discount: 80}}, + true, + ) + if err != nil { + t.Fatalf("calculateRenewalPrice returned error: %v", err) + } + if result.OriginalPrice != 1500 { + t.Fatalf("OriginalPrice = %d, want 1500", result.OriginalPrice) + } + if result.PayableBase != 1200 { + t.Fatalf("PayableBase = %d, want 1200", result.PayableBase) + } + if result.DiscountAmount != 300 { + t.Fatalf("DiscountAmount = %d, want 300", result.DiscountAmount) + } + if result.PromoRuleId != 0 || result.PromoDiscount != 0 { + t.Fatalf("promo fields = (%d, %d), want (0, 0)", result.PromoRuleId, result.PromoDiscount) + } +} diff --git a/internal/logic/public/order/purchaseLogic.go b/internal/logic/public/order/purchaseLogic.go index df02908..f53df65 100644 --- a/internal/logic/public/order/purchaseLogic.go +++ b/internal/logic/public/order/purchaseLogic.go @@ -3,7 +3,6 @@ package order import ( "context" "encoding/json" - "math" "strings" "time" @@ -130,13 +129,8 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P // 防止不同套餐购买创建第二条订阅。 if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 { var existSub user.Subscribe - if e := l.svcCtx.DB.WithContext(l.ctx). - Model(&user.Subscribe{}). - Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%')", entitlement.EffectiveUserID). - Order("expire_time DESC"). - Order("updated_at DESC"). - Order("id DESC"). - First(&existSub).Error; e == nil && existSub.Id > 0 && existSub.Token != "" { + if e := paidSubscriptionQuery(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID). + First(&existSub).Error; e == nil && existSub.Id > 0 { orderType = 2 parentOrderID = existSub.OrderId subscribeToken = existSub.Token @@ -195,23 +189,39 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeOutOfStock), "subscribe out of stock") } - newUserDiscount, err := resolveNewUserDiscountEligibility(l.ctx, l.svcCtx.DB, u.Id, targetSubscribeID, req.Quantity, sub.Discount) + newUserDiscount, err := resolveNewUserDiscountEligibility(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID, targetSubscribeID, req.Quantity, sub.Discount) if err != nil { l.Errorw("[Purchase] Database query error resolving new user eligibility", logger.Field("error", err.Error()), logger.Field("user_id", u.Id), + logger.Field("effective_user_id", entitlement.EffectiveUserID), ) return nil, err } - var discount float64 = 1 - if len(newUserDiscount.Discounts) > 0 { - discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount) + priceResult, err := calculatePurchasePrice( + l.ctx, + l.svcCtx, + entitlement.EffectiveUserID, + targetSubscribeID, + sub.UnitPrice, + req.Quantity, + newUserDiscount.Discounts, + newUserDiscount.EligibleForDiscount, + orderType == 1, + ) + if err != nil { + l.Errorw("[Purchase] Promo price calculation error", + logger.Field("error", err.Error()), + logger.Field("user_id", u.Id), + logger.Field("effective_user_id", entitlement.EffectiveUserID), + logger.Field("subscribe_id", targetSubscribeID), + ) + return nil, err } - price := sub.UnitPrice * req.Quantity - // discount amount - amount := int64(math.Round(float64(price) * discount)) - discountAmount := price - amount + price := priceResult.OriginalPrice + amount := priceResult.PayableBase + discountAmount := priceResult.DiscountAmount // Validate amount to prevent overflow if amount > MaxOrderAmount { @@ -306,6 +316,8 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P Price: price, Amount: amount, Discount: discountAmount, + PromoRuleId: priceResult.PromoRuleId, + PromoDiscount: priceResult.PromoDiscount, GiftAmount: deductionAmount, Coupon: req.Coupon, CouponDiscount: coupon, diff --git a/internal/logic/public/order/renewalLogic.go b/internal/logic/public/order/renewalLogic.go index 269d01a..a8b0ea6 100644 --- a/internal/logic/public/order/renewalLogic.go +++ b/internal/logic/public/order/renewalLogic.go @@ -3,7 +3,6 @@ package order import ( "context" "encoding/json" - "math" "time" "github.com/google/uuid" @@ -165,13 +164,28 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene ) return nil, err } - var discount float64 = 1 - if len(newUserDiscount.Discounts) > 0 { - discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount) + priceResult, err := calculateRenewalPrice( + l.ctx, + l.svcCtx, + entitlement.EffectiveUserID, + userSubscribe.SubscribeId, + sub.UnitPrice, + req.Quantity, + newUserDiscount.Discounts, + newUserDiscount.EligibleForDiscount, + ) + if err != nil { + l.Errorw("[Renewal] Promo price calculation error", + logger.Field("error", err.Error()), + logger.Field("user_id", u.Id), + logger.Field("effective_user_id", entitlement.EffectiveUserID), + logger.Field("subscribe_id", userSubscribe.SubscribeId), + ) + return nil, err } - price := sub.UnitPrice * req.Quantity - amount := int64(math.Round(float64(price) * discount)) - discountAmount := price - amount + price := priceResult.OriginalPrice + amount := priceResult.PayableBase + discountAmount := priceResult.DiscountAmount // Validate amount to prevent overflow if amount > MaxOrderAmount { @@ -269,6 +283,8 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene Amount: amount, GiftAmount: deductionAmount, Discount: discountAmount, + PromoRuleId: priceResult.PromoRuleId, + PromoDiscount: priceResult.PromoDiscount, Coupon: req.Coupon, CouponDiscount: coupon, PaymentId: payment.Id, diff --git a/internal/logic/public/subscribe/promo.go b/internal/logic/public/subscribe/promo.go new file mode 100644 index 0000000..01532b3 --- /dev/null +++ b/internal/logic/public/subscribe/promo.go @@ -0,0 +1,135 @@ +package subscribe + +import ( + "context" + stderrors "errors" + "strings" + "time" + + "github.com/go-sql-driver/mysql" + commonLogic "github.com/perfect-panel/server/internal/logic/common" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +const ( + promoRuleTypeNewUser = "new_user" + promoRuleTypeInactiveUser = "inactive_user" + promoRuleTypeCampaign = "campaign" +) + +type subscribePromoCandidate struct { + SubscribeId int64 `gorm:"column:subscribe_id"` + Quantity int64 `gorm:"column:quantity"` + RuleName string `gorm:"column:rule_name"` + RuleType string `gorm:"column:rule_type"` + PromoPrice int64 `gorm:"column:promo_price"` + Params string `gorm:"column:params"` + StartTime *time.Time `gorm:"column:start_time"` + EndTime *time.Time `gorm:"column:end_time"` +} + +func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64) (map[int64]map[int64]*types.SubscribePromo, error) { + result := make(map[int64]map[int64]*types.SubscribePromo) + if len(subscribeIDs) == 0 || svcCtx == nil || svcCtx.DB == nil { + return result, nil + } + + userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User) + userID := int64(0) + isFirstPurchase := true + if userInfo != nil { + entitlement, err := commonLogic.ResolveEntitlementUser(ctx, svcCtx.DB, userInfo.Id) + if err != nil { + return nil, err + } + userID = entitlement.EffectiveUserID + + hasPaid, err := commonLogic.HasPaidSubscription(ctx, svcCtx.DB, userID) + if err != nil { + return nil, err + } + isFirstPurchase = !hasPaid + } + + candidates, err := querySubscribePromoCandidates(ctx, svcCtx, subscribeIDs, userInfo != nil) + if err != nil { + if isMissingPromoTableError(err) { + return result, nil + } + return nil, err + } + + for _, candidate := range candidates { + if candidate.Quantity <= 0 { + continue + } + if result[candidate.SubscribeId] == nil { + result[candidate.SubscribeId] = make(map[int64]*types.SubscribePromo) + } + if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists { + continue + } + promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, candidate.SubscribeId, candidate.Quantity, isFirstPurchase) + if err != nil { + return nil, err + } + if promoResult == nil || !promoResult.Eligible { + continue + } + result[candidate.SubscribeId][candidate.Quantity] = &types.SubscribePromo{ + RuleName: promoResult.RuleName, + RuleType: promoResult.RuleType, + PromoPrice: promoResult.PromoPrice, + ExpiresAt: unixSeconds(promoResult.ExpiresAt), + } + } + + return result, nil +} + +func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64, loggedIn bool) ([]subscribePromoCandidate, error) { + var candidates []subscribePromoCandidate + err := subscribePromoCandidatesQuery(ctx, svcCtx.DB, subscribeIDs, loggedIn). + Scan(&candidates).Error + if err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query subscribe promo candidates failed: %v", err) + } + return candidates, nil +} + +func subscribePromoCandidatesQuery(ctx context.Context, db *gorm.DB, subscribeIDs []int64, loggedIn bool) *gorm.DB { + query := db.WithContext(ctx). + Table("subscribe_promo AS sp"). + Select("sp.subscribe_id, sp.quantity, sp.promo_price, pr.name AS rule_name, pr.type AS rule_type, pr.params, pr.start_time, pr.end_time"). + Joins("JOIN promo_rule AS pr ON pr.id = sp.promo_rule_id AND pr.deleted_at IS NULL"). + Where("sp.subscribe_id IN ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeIDs, true) + if !loggedIn { + query = query.Where("pr.type = ?", promoRuleTypeCampaign) + } + return query. + Order("sp.subscribe_id ASC"). + Order("sp.quantity ASC"). + Order("pr.priority DESC"). + Order("pr.id ASC") +} + +func unixSeconds(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} + +func isMissingPromoTableError(err error) bool { + var mysqlErr *mysql.MySQLError + if stderrors.As(err, &mysqlErr) { + return mysqlErr.Number == 1146 + } + return strings.Contains(err.Error(), "Error 1146") +} diff --git a/internal/logic/public/subscribe/promo_test.go b/internal/logic/public/subscribe/promo_test.go new file mode 100644 index 0000000..d1a7c43 --- /dev/null +++ b/internal/logic/public/subscribe/promo_test.go @@ -0,0 +1,263 @@ +package subscribe + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestQuerySubscribePromoCandidatesIncludesQuantity(t *testing.T) { + db, err := gorm.Open(mysql.New(mysql.Config{ + DSN: "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local", + SkipInitializeWithVersion: true, + }), &gorm.Config{DryRun: true, DisableAutomaticPing: true}) + if err != nil { + t.Fatalf("open dry-run db: %v", err) + } + + var candidates []subscribePromoCandidate + tx := subscribePromoCandidatesQuery(context.Background(), db, []int64{11, 12}, true).Scan(&candidates) + stmt := tx.Statement + sql := stmt.SQL.String() + if !strings.Contains(sql, "sp.subscribe_id, sp.quantity, sp.promo_price") { + t.Fatalf("SQL missing quantity select: %s", sql) + } + if !strings.Contains(sql, "ORDER BY sp.subscribe_id ASC,sp.quantity ASC,pr.priority DESC,pr.id ASC") { + t.Fatalf("SQL missing quantity order: %s", sql) + } +} + +func TestLoadSubscribePromoMapUsesCommonPromoEvaluation(t *testing.T) { + db, mock, cleanup := newSubscribePromoTestDB(t) + defer cleanup() + + end := time.Now().Add(time.Hour) + mock.ExpectQuery("FROM subscribe_promo AS sp"). + WillReturnRows(sqlmock.NewRows([]string{ + "subscribe_id", "quantity", "rule_name", "rule_type", "promo_price", "params", "start_time", "end_time", + }).AddRow(11, 3, "old name", promoRuleTypeCampaign, 999, "", nil, end)) + + promoModel := &fakeSubscribePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 8, + Name: "公共活动价", + Type: promo.RuleTypeCampaign, + Enabled: true, + EndTime: &end, + }, + PromoPrice: 888, + }, + }} + + got, err := loadSubscribePromoMap(context.Background(), &svc.ServiceContext{DB: db, PromoModel: promoModel}, []int64{11}) + if err != nil { + t.Fatalf("loadSubscribePromoMap returned error: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations: %v", err) + } + if promoModel.lastSubscribeID != 11 { + t.Fatalf("promo subscribe id = %d, want 11", promoModel.lastSubscribeID) + } + if promoModel.lastQuantity != 3 { + t.Fatalf("promo quantity = %d, want 3", promoModel.lastQuantity) + } + + item := got[11][3] + if item == nil { + t.Fatal("quantity 3 promo should be present") + } + if item.RuleName != "公共活动价" { + t.Fatalf("RuleName = %q, want 公共活动价", item.RuleName) + } + if item.PromoPrice != 888 { + t.Fatalf("PromoPrice = %d, want 888", item.PromoPrice) + } + if item.ExpiresAt != end.Unix() { + t.Fatalf("ExpiresAt = %d, want %d", item.ExpiresAt, end.Unix()) + } +} + +func TestLoadSubscribePromoMapUsesFamilyOwnerForInactivePromo(t *testing.T) { + db, mock, cleanup := newSubscribePromoTestDB(t) + defer cleanup() + + memberUserID := int64(51637) + ownerUserID := int64(510) + subscribeID := int64(11) + quantity := int64(30) + now := time.Now() + end := now.Add(24 * time.Hour) + + mock.ExpectQuery("FROM `user_family_member`"). + WithArgs(memberUserID, user.FamilyMemberActive, 1). + WillReturnRows(sqlmock.NewRows([]string{"role", "family_status", "owner_user_id"}). + AddRow(user.FamilyRoleMember, user.FamilyStatusActive, ownerUserID)) + mock.ExpectQuery("SELECT count(*) FROM `user_subscribe`"). + WithArgs(ownerUserID, 1). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + mock.ExpectQuery("FROM subscribe_promo AS sp"). + WillReturnRows(sqlmock.NewRows([]string{ + "subscribe_id", "quantity", "rule_name", "rule_type", "promo_price", "params", "start_time", "end_time", + }).AddRow(subscribeID, quantity, "回归用户01", promoRuleTypeInactiveUser, 100, `{"inactive_months":1}`, nil, end)) + mock.ExpectQuery("FROM `user_subscribe`"). + WithArgs(ownerUserID, time.UnixMilli(0), 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subscribe_id", "expire_time"}). + AddRow(131, ownerUserID, 1, now.AddDate(0, 1, 0))) + + ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &user.User{Id: memberUserID}) + promoModel := &fakeSubscribePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 8, + Name: "回归用户01", + Type: promo.RuleTypeInactiveUser, + Enabled: true, + Params: `{"inactive_months":1}`, + EndTime: &end, + }, + PromoPrice: 100, + }, + }} + + got, err := loadSubscribePromoMap(ctx, &svc.ServiceContext{DB: db, PromoModel: promoModel}, []int64{subscribeID}) + if err != nil { + t.Fatalf("loadSubscribePromoMap returned error: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations: %v", err) + } + if promoModel.lastSubscribeID != subscribeID { + t.Fatalf("promo subscribe id = %d, want %d", promoModel.lastSubscribeID, subscribeID) + } + if promoModel.lastQuantity != quantity { + t.Fatalf("promo quantity = %d, want %d", promoModel.lastQuantity, quantity) + } + if got[subscribeID][quantity] != nil { + t.Fatalf("family member should not receive inactive promo when owner has active subscription, got %+v", got[subscribeID][quantity]) + } +} + +type fakeSubscribePromoModel struct { + rules []*promo.RuleWithPrice + lastSubscribeID int64 + lastQuantity int64 +} + +func (m *fakeSubscribePromoModel) QueryEligibleRules(_ context.Context, subscribeID int64, quantity int64) ([]*promo.RuleWithPrice, error) { + m.lastSubscribeID = subscribeID + m.lastQuantity = quantity + return m.rules, nil +} + +func (m *fakeSubscribePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) error { + return nil +} + +func (m *fakeSubscribePromoModel) InsertRule(context.Context, *promo.Rule) error { + return nil +} + +func (m *fakeSubscribePromoModel) FindRule(context.Context, int64) (*promo.Rule, error) { + return nil, gorm.ErrRecordNotFound +} + +func (m *fakeSubscribePromoModel) UpdateRule(context.Context, *promo.Rule) error { + return nil +} + +func (m *fakeSubscribePromoModel) DeleteRule(context.Context, int64) error { + return nil +} + +func (m *fakeSubscribePromoModel) QueryRuleList(context.Context, int, int, string, *bool, string) (int64, []*promo.Rule, error) { + return 0, nil, nil +} + +func (m *fakeSubscribePromoModel) UpsertPrices(context.Context, int64, []*promo.SubscribePromo) error { + return nil +} + +func (m *fakeSubscribePromoModel) FindPrice(context.Context, int64) (*promo.SubscribePromo, error) { + return nil, gorm.ErrRecordNotFound +} + +func (m *fakeSubscribePromoModel) DeletePrice(context.Context, int64) error { + return nil +} + +func (m *fakeSubscribePromoModel) QueryPriceList(context.Context, promo.PriceFilter) (int64, []*promo.SubscribePromo, error) { + return 0, nil, nil +} + +func (m *fakeSubscribePromoModel) QueryUsageList(context.Context, promo.UsageFilter) (int64, []*promo.Usage, error) { + return 0, nil, nil +} + +func (m *fakeSubscribePromoModel) Transaction(context.Context, func(*gorm.DB) error) error { + return nil +} + +func newSubscribePromoTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { + _ = sqlDB.Close() + } +} + +func TestApplySubscribeDiscountPromosMatchesQuantity(t *testing.T) { + subscribe := types.Subscribe{Discount: []types.SubscribeDiscount{ + {Quantity: 1}, + {Quantity: 12}, + }} + promos := map[int64]*types.SubscribePromo{ + 3: {RuleName: "季度优惠", PromoPrice: 2900}, + 12: {RuleName: "年度优惠", PromoPrice: 9900}, + } + + applySubscribeDiscountPromos(&subscribe, promos) + if subscribe.Discount[0].Promo != nil { + t.Fatalf("quantity 1 promo should be nil, got %+v", subscribe.Discount[0].Promo) + } + if subscribe.Discount[1].Promo == nil { + t.Fatal("quantity 12 promo should match") + } + if got, want := subscribe.Discount[1].Promo.RuleName, "年度优惠"; got != want { + t.Fatalf("promo rule name = %q, want %q", got, want) + } + + subscribe = types.Subscribe{Discount: []types.SubscribeDiscount{{Quantity: 6}}} + applySubscribeDiscountPromos(&subscribe, promos) + if subscribe.Discount[0].Promo != nil { + t.Fatalf("promo should be nil when quantity does not match, got %+v", subscribe.Discount[0].Promo) + } +} diff --git a/internal/logic/public/subscribe/querySubscribeListLogic.go b/internal/logic/public/subscribe/querySubscribeListLogic.go index f2c80cf..eb76835 100644 --- a/internal/logic/public/subscribe/querySubscribeListLogic.go +++ b/internal/logic/public/subscribe/querySubscribeListLogic.go @@ -47,18 +47,28 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi Total: total, } list := make([]types.Subscribe, len(data)) + subscribeIDs := make([]int64, 0, len(data)) for i, item := range data { var sub types.Subscribe tool.DeepCopy(&sub, item) + subscribeIDs = append(subscribeIDs, sub.Id) if item.Discount != "" { var discount []types.SubscribeDiscount _ = json.Unmarshal([]byte(item.Discount), &discount) sub.Discount = discount - list[i] = sub } list[i] = sub } + promos, err := loadSubscribePromoMap(l.ctx, l.svcCtx, subscribeIDs) + if err != nil { + l.Errorw("[QuerySubscribeListLogic] Query Promo Error", logger.Field("error", err.Error())) + return nil, err + } + for i := range list { + applySubscribeDiscountPromos(&list[i], promos[list[i].Id]) + } + // 老版本客户端(无 X-App-Id)去掉每个套餐 discount 的最后一个 hasAppId, _ := l.ctx.Value(constant.CtxKeyHasAppId).(bool) if !hasAppId { @@ -73,3 +83,9 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi resp.Total = int64(len(list)) return } + +func applySubscribeDiscountPromos(subscribe *types.Subscribe, promoByQuantity map[int64]*types.SubscribePromo) { + for i := range subscribe.Discount { + subscribe.Discount[i].Promo = promoByQuantity[subscribe.Discount[i].Quantity] + } +} diff --git a/internal/logic/public/user/cancelWithdrawalLogic.go b/internal/logic/public/user/cancelWithdrawalLogic.go index a3f5135..ffade44 100644 --- a/internal/logic/public/user/cancelWithdrawalLogic.go +++ b/internal/logic/public/user/cancelWithdrawalLogic.go @@ -4,7 +4,6 @@ import ( "context" logicCommon "github.com/perfect-panel/server/internal/logic/common" - "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" @@ -57,13 +56,10 @@ func (l *CancelWithdrawalLogic) CancelWithdrawal(req *types.CancelWithdrawalRequ return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "cancel withdrawal failed: %v", txErr) } - if txErr = l.svcCtx.UserModel.UpdateCommission(l.ctx, withdrawal.UserId, withdrawal.Amount, tx); txErr != nil { - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", txErr) - } - - if txErr = logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdrawCancel, withdrawal.Amount, ""); txErr != nil { - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", txErr) - } + // Commission was NOT deducted at application time under the HIF-22 + // approval flow, so cancellation requires no refund — only a status + // update. Refunding here would mint phantom commission and pollute + // reconciliation (mirrors rejectWithdrawal in admin/user/withdrawalCommon.go). return nil }) diff --git a/internal/logic/public/user/cancelWithdrawalLogic_test.go b/internal/logic/public/user/cancelWithdrawalLogic_test.go new file mode 100644 index 0000000..46389d4 --- /dev/null +++ b/internal/logic/public/user/cancelWithdrawalLogic_test.go @@ -0,0 +1,228 @@ +package user + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + usermodel "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// TestCancelWithdrawal_DoesNotRefundCommission 验证 HIF-140 修复: +// 用户撤销 pending 提现的事务体必须只更新 withdrawal.status, +// 严禁触发 UpdateCommission(commission 列读 / 写)或写 333/338 commission 日志。 +// +// sqlmock 严格匹配期望 SQL:只允许出现 BEGIN / SELECT withdrawal FOR UPDATE / +// UPDATE withdrawal SET status / COMMIT,不允许出现 SELECT/UPDATE `user` 或 +// INSERT system_logs。 +func TestCancelWithdrawal_DoesNotRefundCommission(t *testing.T) { + const ( + withdrawalID = int64(987654) + userID = int64(42) + amount = int64(2500) + ) + + db, mock, cleanup := newCancelWithdrawalTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `withdrawals`"). + WithArgs(withdrawalID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}). + AddRow(withdrawalID, userID, amount, usermodel.WithdrawalStatusPending)) + mock.ExpectExec("UPDATE `withdrawals` SET"). + WithArgs(usermodel.WithdrawalStatusCancelled, sqlmock.AnyArg(), withdrawalID, usermodel.WithdrawalStatusPending). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + logic := newTestCancelWithdrawalLogic(t, db, userID) + resp, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID}) + if err != nil { + t.Fatalf("CancelWithdrawal unexpected error: %v", err) + } + if resp == nil || resp.Id != withdrawalID { + t.Fatalf("CancelWithdrawal response = %+v, want id=%d", resp, withdrawalID) + } + if resp.Status != usermodel.WithdrawalStatusCancelled { + t.Fatalf("CancelWithdrawal status = %d, want %d", resp.Status, usermodel.WithdrawalStatusCancelled) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +// TestCancelWithdrawal_RejectsNonPending 覆盖:状态非 pending(已批准/拒绝/已撤销) +// 时撤销必须短路返回 WithdrawalStatusInvalid,且不得写任何状态或佣金。 +func TestCancelWithdrawal_RejectsNonPending(t *testing.T) { + const ( + withdrawalID = int64(55555) + userID = int64(42) + ) + + cases := []struct { + name string + status uint8 + }{ + {"already approved", usermodel.WithdrawalStatusApproved}, + {"already rejected", usermodel.WithdrawalStatusRejected}, + {"already cancelled", usermodel.WithdrawalStatusCancelled}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db, mock, cleanup := newCancelWithdrawalTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `withdrawals`"). + WithArgs(withdrawalID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}). + AddRow(withdrawalID, userID, int64(2500), tc.status)) + mock.ExpectRollback() + + logic := newTestCancelWithdrawalLogic(t, db, userID) + _, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID}) + if !isCancelErrCode(err, xerr.WithdrawalStatusInvalid) { + t.Fatalf("CancelWithdrawal err = %v, want WithdrawalStatusInvalid", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } + }) + } +} + +// TestCancelWithdrawal_RejectsOtherUserWithdrawal 覆盖:当前登录用户尝试撤销 +// 不属于自己的 pending 提现,必须返回 PermissionDenied 且不得写库。 +func TestCancelWithdrawal_RejectsOtherUserWithdrawal(t *testing.T) { + const ( + withdrawalID = int64(33333) + ownerUserID = int64(99) + attackerID = int64(42) + ) + + db, mock, cleanup := newCancelWithdrawalTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `withdrawals`"). + WithArgs(withdrawalID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}). + AddRow(withdrawalID, ownerUserID, int64(2500), usermodel.WithdrawalStatusPending)) + mock.ExpectRollback() + + logic := newTestCancelWithdrawalLogic(t, db, attackerID) + _, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID}) + if !isCancelErrCode(err, xerr.PermissionDenied) { + t.Fatalf("CancelWithdrawal err = %v, want PermissionDenied", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +// TestCancelWithdrawal_ConcurrentRaceLosesViaFORUPDATE 模拟撤销与审批并发的场景: +// 第二个 cancel 在 LoadPendingWithdrawalForUpdate 取到行时,状态已被先到的 approve +// 改成 1(FOR UPDATE 行锁让出后看到的最新状态),cancel 应当短路返回错误, +// 严禁继续往下写 status 或动 commission。 +func TestCancelWithdrawal_ConcurrentRaceLosesViaFORUPDATE(t *testing.T) { + const ( + withdrawalID = int64(77777) + userID = int64(42) + ) + + db, mock, cleanup := newCancelWithdrawalTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `withdrawals`"). + WithArgs(withdrawalID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}). + AddRow(withdrawalID, userID, int64(2500), usermodel.WithdrawalStatusApproved)) + mock.ExpectRollback() + + logic := newTestCancelWithdrawalLogic(t, db, userID) + _, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID}) + if !isCancelErrCode(err, xerr.WithdrawalStatusInvalid) { + t.Fatalf("CancelWithdrawal err = %v, want WithdrawalStatusInvalid (loser of race)", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func newCancelWithdrawalTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { + _ = sqlDB.Close() + } +} + +func newTestCancelWithdrawalLogic(t *testing.T, db *gorm.DB, userID int64) *CancelWithdrawalLogic { + t.Helper() + ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID}) + return &CancelWithdrawalLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: &svc.ServiceContext{ + DB: db, + UserModel: stubUserModelForCancel{}, + }, + } +} + +// stubUserModelForCancel 是 user.Model 的零依赖替身,仅覆盖 CancelWithdrawal 必须 +// 调用的 ClearUserCache。其它方法被调用会导致 nil-interface panic,正好可以暴露 +// 测试边界外的意外依赖。 +type stubUserModelForCancel struct { + usermodel.Model +} + +func (stubUserModelForCancel) ClearUserCache(_ context.Context, _ ...*usermodel.User) error { + return nil +} + +func cancelErrCodeOf(err error) uint32 { + if err == nil { + return 0 + } + type coder interface { + GetErrCode() uint32 + } + cause := errors.Cause(err) + if c, ok := cause.(coder); ok { + return c.GetErrCode() + } + return 0 +} + +func isCancelErrCode(err error, code uint32) bool { + return cancelErrCodeOf(err) == code +} diff --git a/internal/logic/public/user/commissionWithdrawLogic.go b/internal/logic/public/user/commissionWithdrawLogic.go index 64546c1..162284b 100644 --- a/internal/logic/public/user/commissionWithdrawLogic.go +++ b/internal/logic/public/user/commissionWithdrawLogic.go @@ -46,8 +46,8 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for bank transfer") } default: // WithdrawalMethodOther - if req.Account == "" && req.Content == "" { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account or content is required for other methods") + if req.Account == "" { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for other methods") } } diff --git a/internal/logic/public/user/getInviteRecordsLogic.go b/internal/logic/public/user/getInviteRecordsLogic.go new file mode 100644 index 0000000..e0746d7 --- /dev/null +++ b/internal/logic/public/user/getInviteRecordsLogic.go @@ -0,0 +1,262 @@ +package user + +import ( + "context" + "encoding/json" + + logmodel "github.com/perfect-panel/server/internal/model/log" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/hash" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +const ( + inviteRecordRoleInviter = "inviter" + inviteRecordRoleInvitee = "invitee" +) + +type GetInviteRecordsLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +type inviteRecordLog struct { + Id int64 `gorm:"column:id"` + ObjectId int64 `gorm:"column:object_id"` + Content string `gorm:"column:content"` + CreatedAt int64 `gorm:"column:created_at"` +} + +type inviteGiftContent struct { + OrderNo string `json:"order_no"` + Amount int64 `json:"amount"` +} + +type parsedInviteRecordLog struct { + log inviteRecordLog + content inviteGiftContent +} + +type inviteOrderUser struct { + OrderNo string `gorm:"column:order_no"` + UserId int64 `gorm:"column:user_id"` + SubscriptionUserId int64 `gorm:"column:subscription_user_id"` + RefererId int64 `gorm:"column:referer_id"` +} + +// Get invite gift records +func NewGetInviteRecordsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetInviteRecordsLogic { + return &GetInviteRecordsLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetInviteRecordsLogic) GetInviteRecords(req *types.GetInviteRecordsRequest) (resp *types.GetInviteRecordsResponse, err error) { + u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok { + l.Errorw("[GetInviteRecords] user not found in context") + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + + normalizeInviteRecordsPagination(req) + + visibleUserIds, err := l.resolveInviteRecordVisibleUserIds(u.Id) + if err != nil { + l.Errorw("[GetInviteRecords] resolve visible users failed", + logger.Field("error", err.Error()), + logger.Field("user_id", u.Id)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "resolve visible users failed: %v", err.Error()) + } + + query := l.svcCtx.DB.WithContext(l.ctx). + Table("system_logs"). + Where("type = ? AND object_id IN ?", logmodel.TypeGift.Uint8(), visibleUserIds). + Where("JSON_VALID(content) = 1"). + Where("JSON_UNQUOTE(JSON_EXTRACT(content, '$.remark')) = ?", "邀请赠送") + if req.StartTime > 0 { + query = query.Where("created_at >= FROM_UNIXTIME(?)", req.StartTime) + } + if req.EndTime > 0 { + query = query.Where("created_at <= FROM_UNIXTIME(?)", req.EndTime) + } + + var logs []inviteRecordLog + if err = query. + Select("id, object_id, content, CAST(UNIX_TIMESTAMP(created_at) * 1000 AS SIGNED) AS created_at"). + Order("created_at DESC, id DESC"). + Scan(&logs).Error; err != nil { + l.Errorw("[GetInviteRecords] query logs failed", + logger.Field("error", err.Error()), + logger.Field("user_id", u.Id)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query logs failed: %v", err.Error()) + } + + parsedLogs, orderNos := l.parseInviteRecordContents(logs) + if len(logs) == 0 || len(parsedLogs) == 0 { + return &types.GetInviteRecordsResponse{Total: 0, List: []types.InviteRecord{}}, nil + } + + orders, err := l.queryInviteRecordOrders(orderNos) + if err != nil { + l.Errorw("[GetInviteRecords] query orders failed", + logger.Field("error", err.Error()), + logger.Field("user_id", u.Id)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query orders failed: %v", err.Error()) + } + + visibleUserIdSet := make(map[int64]struct{}, len(visibleUserIds)) + for _, userId := range visibleUserIds { + visibleUserIdSet[userId] = struct{}{} + } + + allRecords := make([]types.InviteRecord, 0, len(parsedLogs)) + for _, parsed := range parsedLogs { + content := parsed.content + logItem := parsed.log + orderInfo, hasOrder := orders[content.OrderNo] + if !l.canViewInviteRecord(u.Id, logItem.ObjectId, visibleUserIdSet, hasOrder, orderInfo) { + continue + } + + record := types.InviteRecord{ + Role: inviteRecordRoleInviter, + GiftDays: content.Amount, + OrderNo: content.OrderNo, + CreatedAt: logItem.CreatedAt, + } + + if hasOrder { + peerId := orderInfo.UserId + if orderInfo.UserId == u.Id { + record.Role = inviteRecordRoleInvitee + peerId = u.RefererId + } + if peerId > 0 { + record.PeerHash = hash.InvitePeerHash(peerId) + } + } + + allRecords = append(allRecords, record) + } + + total := int64(len(allRecords)) + list := paginateInviteRecords(allRecords, req.Page, req.Size) + return &types.GetInviteRecordsResponse{ + Total: total, + List: list, + }, nil +} + +func (l *GetInviteRecordsLogic) resolveInviteRecordVisibleUserIds(currentUserId int64) ([]int64, error) { + visibleUserIds := []int64{currentUserId} + + var relation struct { + FamilyId int64 `gorm:"column:family_id"` + Role uint8 `gorm:"column:role"` + OwnerUserId int64 `gorm:"column:owner_user_id"` + } + err := l.svcCtx.DB.WithContext(l.ctx). + Model(&user.UserFamilyMember{}). + Select("user_family_member.family_id, user_family_member.role, user_family.owner_user_id"). + Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL AND user_family.status = ?", user.FamilyStatusActive). + Where("user_family_member.user_id = ? AND user_family_member.status = ? AND user_family_member.deleted_at IS NULL", currentUserId, user.FamilyMemberActive). + First(&relation).Error + if err == nil { + if relation.Role != user.FamilyRoleOwner && relation.OwnerUserId > 0 && relation.OwnerUserId != currentUserId { + visibleUserIds = append(visibleUserIds, relation.OwnerUserId) + } + return visibleUserIds, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return visibleUserIds, nil + } + return nil, err +} + +func (l *GetInviteRecordsLogic) canViewInviteRecord(currentUserId, logObjectId int64, visibleUserIds map[int64]struct{}, hasOrder bool, orderInfo inviteOrderUser) bool { + if _, ok := visibleUserIds[logObjectId]; !ok { + return false + } + if logObjectId == currentUserId { + if hasOrder { + return orderInfo.UserId == currentUserId || orderInfo.RefererId == currentUserId + } + return true + } + return true +} + +func normalizeInviteRecordsPagination(req *types.GetInviteRecordsRequest) { + if req.Page < 1 { + req.Page = 1 + } + if req.Size < 1 { + req.Size = 10 + } + if req.Size > 100 { + req.Size = 100 + } +} + +func paginateInviteRecords(records []types.InviteRecord, page, size int) []types.InviteRecord { + start := (page - 1) * size + if start >= len(records) { + return []types.InviteRecord{} + } + end := start + size + if end > len(records) { + end = len(records) + } + return records[start:end] +} + +func (l *GetInviteRecordsLogic) parseInviteRecordContents(logs []inviteRecordLog) ([]parsedInviteRecordLog, []string) { + parsedLogs := make([]parsedInviteRecordLog, 0, len(logs)) + orderNos := make([]string, 0, len(logs)) + for _, logItem := range logs { + var content inviteGiftContent + if err := json.Unmarshal([]byte(logItem.Content), &content); err != nil { + l.Infow("[GetInviteRecords] parse content failed", + logger.Field("error", err.Error()), + logger.Field("log_id", logItem.Id)) + continue + } + parsedLogs = append(parsedLogs, parsedInviteRecordLog{log: logItem, content: content}) + if content.OrderNo != "" { + orderNos = append(orderNos, content.OrderNo) + } + } + return parsedLogs, orderNos +} + +func (l *GetInviteRecordsLogic) queryInviteRecordOrders(orderNos []string) (map[string]inviteOrderUser, error) { + orders := make(map[string]inviteOrderUser, len(orderNos)) + if len(orderNos) == 0 { + return orders, nil + } + + var orderData []inviteOrderUser + err := l.svcCtx.DB.WithContext(l.ctx). + Table("`order`"). + Select("`order`.order_no, `order`.user_id, `order`.subscription_user_id, invitee.referer_id"). + Joins("LEFT JOIN user invitee ON invitee.id = `order`.user_id"). + Where("`order`.order_no IN ?", orderNos). + Scan(&orderData).Error + if err != nil { + return nil, err + } + + for _, item := range orderData { + orders[item.OrderNo] = item + } + return orders, nil +} diff --git a/internal/logic/public/user/getInviteRecordsLogic_test.go b/internal/logic/public/user/getInviteRecordsLogic_test.go new file mode 100644 index 0000000..882afac --- /dev/null +++ b/internal/logic/public/user/getInviteRecordsLogic_test.go @@ -0,0 +1,255 @@ +package user + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + modeluser "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/hash" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestGetInviteRecordsInviter(t *testing.T) { + svcCtx, mock, cleanup := newInviteRecordsTestSvc(t) + defer cleanup() + + expectNoInviteRecordsFamily(t, mock, 100) + mock.ExpectQuery("SELECT id, object_id, content"). + WithArgs(34, int64(100), "邀请赠送"). + WillReturnRows(sqlmock.NewRows([]string{"id", "object_id", "content", "created_at"}). + AddRow(1, 100, `{"order_no":"order-1","amount":7,"remark":"邀请赠送"}`, 1779934580000)) + mock.ExpectQuery("SELECT `order`.order_no, `order`.user_id, `order`.subscription_user_id, invitee.referer_id FROM `order`"). + WithArgs("order-1"). + WillReturnRows(sqlmock.NewRows([]string{"order_no", "user_id", "subscription_user_id", "referer_id"}).AddRow("order-1", 200, 200, 100)) + + resp, err := NewGetInviteRecordsLogic(inviteRecordsContext(100, 0), svcCtx).GetInviteRecords(&types.GetInviteRecordsRequest{Page: 1, Size: 10}) + if err != nil { + t.Fatalf("GetInviteRecords returned error: %v", err) + } + assertInviteRecordResponse(t, resp, types.InviteRecord{ + Role: inviteRecordRoleInviter, + PeerHash: hash.InvitePeerHash(200), + GiftDays: 7, + OrderNo: "order-1", + CreatedAt: 1779934580000, + }) + assertInviteRecordsExpectations(t, mock) +} + +func TestGetInviteRecordsInvitee(t *testing.T) { + svcCtx, mock, cleanup := newInviteRecordsTestSvc(t) + defer cleanup() + + expectNoInviteRecordsFamily(t, mock, 200) + mock.ExpectQuery("SELECT id, object_id, content"). + WithArgs(34, int64(200), "邀请赠送"). + WillReturnRows(sqlmock.NewRows([]string{"id", "object_id", "content", "created_at"}). + AddRow(2, 200, `{"order_no":"order-2","amount":7,"remark":"邀请赠送"}`, 1779934590000)) + mock.ExpectQuery("SELECT `order`.order_no, `order`.user_id, `order`.subscription_user_id, invitee.referer_id FROM `order`"). + WithArgs("order-2"). + WillReturnRows(sqlmock.NewRows([]string{"order_no", "user_id", "subscription_user_id", "referer_id"}).AddRow("order-2", 200, 200, 100)) + + resp, err := NewGetInviteRecordsLogic(inviteRecordsContext(200, 100), svcCtx).GetInviteRecords(&types.GetInviteRecordsRequest{Page: 1, Size: 10}) + if err != nil { + t.Fatalf("GetInviteRecords returned error: %v", err) + } + assertInviteRecordResponse(t, resp, types.InviteRecord{ + Role: inviteRecordRoleInvitee, + PeerHash: hash.InvitePeerHash(100), + GiftDays: 7, + OrderNo: "order-2", + CreatedAt: 1779934590000, + }) + assertInviteRecordsExpectations(t, mock) +} + +func TestGetInviteRecordsMissingOrderReturnsDirtyRecord(t *testing.T) { + svcCtx, mock, cleanup := newInviteRecordsTestSvc(t) + defer cleanup() + + expectNoInviteRecordsFamily(t, mock, 100) + mock.ExpectQuery("SELECT id, object_id, content"). + WithArgs(34, int64(100), "邀请赠送"). + WillReturnRows(sqlmock.NewRows([]string{"id", "object_id", "content", "created_at"}). + AddRow(3, 100, `{"order_no":"missing-order","amount":7,"remark":"邀请赠送"}`, 1779934600000)) + mock.ExpectQuery("SELECT `order`.order_no, `order`.user_id, `order`.subscription_user_id, invitee.referer_id FROM `order`"). + WithArgs("missing-order"). + WillReturnRows(sqlmock.NewRows([]string{"order_no", "user_id", "subscription_user_id", "referer_id"})) + + resp, err := NewGetInviteRecordsLogic(inviteRecordsContext(100, 0), svcCtx).GetInviteRecords(&types.GetInviteRecordsRequest{Page: 1, Size: 10}) + if err != nil { + t.Fatalf("GetInviteRecords returned error: %v", err) + } + assertInviteRecordResponse(t, resp, types.InviteRecord{ + Role: inviteRecordRoleInviter, + GiftDays: 7, + OrderNo: "missing-order", + CreatedAt: 1779934600000, + }) + assertInviteRecordsExpectations(t, mock) +} + +func TestGetInviteRecordsFamilyMemberSeesOwnerGiftLog(t *testing.T) { + svcCtx, mock, cleanup := newInviteRecordsTestSvc(t) + defer cleanup() + + expectInviteRecordsFamilyMember(t, mock, 200, 900) + mock.ExpectQuery("SELECT id, object_id, content"). + WithArgs(34, int64(200), int64(900), "邀请赠送"). + WillReturnRows(sqlmock.NewRows([]string{"id", "object_id", "content", "created_at"}). + AddRow(4, 900, `{"order_no":"family-order","amount":7,"remark":"邀请赠送"}`, 1779934610000)) + mock.ExpectQuery("SELECT `order`.order_no, `order`.user_id, `order`.subscription_user_id, invitee.referer_id FROM `order`"). + WithArgs("family-order"). + WillReturnRows(sqlmock.NewRows([]string{"order_no", "user_id", "subscription_user_id", "referer_id"}).AddRow("family-order", 200, 900, 100)) + + resp, err := NewGetInviteRecordsLogic(inviteRecordsContext(200, 100), svcCtx).GetInviteRecords(&types.GetInviteRecordsRequest{Page: 1, Size: 10}) + if err != nil { + t.Fatalf("GetInviteRecords returned error: %v", err) + } + assertInviteRecordResponse(t, resp, types.InviteRecord{ + Role: inviteRecordRoleInvitee, + PeerHash: hash.InvitePeerHash(100), + GiftDays: 7, + OrderNo: "family-order", + CreatedAt: 1779934610000, + }) + assertInviteRecordsExpectations(t, mock) +} + +func TestGetInviteRecordsFamilyMemberSeesAllOwnerGiftLogs(t *testing.T) { + svcCtx, mock, cleanup := newInviteRecordsTestSvc(t) + defer cleanup() + + expectInviteRecordsFamilyMember(t, mock, 51637, 510) + mock.ExpectQuery("SELECT id, object_id, content"). + WithArgs(34, int64(51637), int64(510), "邀请赠送"). + WillReturnRows(sqlmock.NewRows([]string{"id", "object_id", "content", "created_at"}). + AddRow(6, 510, `{"order_no":"owner-order","amount":7,"remark":"邀请赠送"}`, 1779934630000)) + mock.ExpectQuery("SELECT `order`.order_no, `order`.user_id, `order`.subscription_user_id, invitee.referer_id FROM `order`"). + WithArgs("owner-order"). + WillReturnRows(sqlmock.NewRows([]string{"order_no", "user_id", "subscription_user_id", "referer_id"}).AddRow("owner-order", 571, 571, 510)) + + resp, err := NewGetInviteRecordsLogic(inviteRecordsContext(51637, 0), svcCtx).GetInviteRecords(&types.GetInviteRecordsRequest{Page: 1, Size: 10}) + if err != nil { + t.Fatalf("GetInviteRecords returned error: %v", err) + } + assertInviteRecordResponse(t, resp, types.InviteRecord{ + Role: inviteRecordRoleInviter, + PeerHash: hash.InvitePeerHash(571), + GiftDays: 7, + OrderNo: "owner-order", + CreatedAt: 1779934630000, + }) + assertInviteRecordsExpectations(t, mock) +} + +func TestGetInviteRecordsOwnerDoesNotSeeMemberGiftLog(t *testing.T) { + svcCtx, mock, cleanup := newInviteRecordsTestSvc(t) + defer cleanup() + + expectInviteRecordsFamilyOwner(t, mock, 900) + mock.ExpectQuery("SELECT id, object_id, content"). + WithArgs(34, int64(900), "邀请赠送"). + WillReturnRows(sqlmock.NewRows([]string{"id", "object_id", "content", "created_at"}). + AddRow(5, 900, `{"order_no":"member-order","amount":7,"remark":"邀请赠送"}`, 1779934620000)) + mock.ExpectQuery("SELECT `order`.order_no, `order`.user_id, `order`.subscription_user_id, invitee.referer_id FROM `order`"). + WithArgs("member-order"). + WillReturnRows(sqlmock.NewRows([]string{"order_no", "user_id", "subscription_user_id", "referer_id"}).AddRow("member-order", 200, 900, 100)) + + resp, err := NewGetInviteRecordsLogic(inviteRecordsContext(900, 0), svcCtx).GetInviteRecords(&types.GetInviteRecordsRequest{Page: 1, Size: 10}) + if err != nil { + t.Fatalf("GetInviteRecords returned error: %v", err) + } + if resp == nil { + t.Fatal("response is nil") + } + if resp.Total != 0 { + t.Fatalf("Total = %d, want 0", resp.Total) + } + if len(resp.List) != 0 { + t.Fatalf("len(List) = %d, want 0", len(resp.List)) + } + assertInviteRecordsExpectations(t, mock) +} + +func newInviteRecordsTestSvc(t *testing.T) (*svc.ServiceContext, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return &svc.ServiceContext{DB: db}, mock, func() { + _ = sqlDB.Close() + } +} + +func expectNoInviteRecordsFamily(t *testing.T, mock sqlmock.Sqlmock, userId int64) { + t.Helper() + mock.ExpectQuery("FROM `user_family_member` JOIN user_family"). + WithArgs(1, userId, 1, 1). + WillReturnError(gorm.ErrRecordNotFound) +} + +func expectInviteRecordsFamilyMember(t *testing.T, mock sqlmock.Sqlmock, userId, ownerUserId int64) { + t.Helper() + mock.ExpectQuery("FROM `user_family_member` JOIN user_family"). + WithArgs(1, userId, 1, 1). + WillReturnRows(sqlmock.NewRows([]string{"family_id", "role", "owner_user_id"}).AddRow(800, 2, ownerUserId)) +} + +func expectInviteRecordsFamilyOwner(t *testing.T, mock sqlmock.Sqlmock, userId int64) { + t.Helper() + mock.ExpectQuery("FROM `user_family_member` JOIN user_family"). + WithArgs(1, userId, 1, 1). + WillReturnRows(sqlmock.NewRows([]string{"family_id", "role", "owner_user_id"}).AddRow(800, 1, userId)) +} + +func inviteRecordsContext(userId, refererId int64) context.Context { + return context.WithValue(context.Background(), constant.CtxKeyUser, &modeluser.User{ + Id: userId, + RefererId: refererId, + }) +} + +func assertInviteRecordResponse(t *testing.T, resp *types.GetInviteRecordsResponse, want types.InviteRecord) { + t.Helper() + if resp == nil { + t.Fatal("response is nil") + } + if resp.Total != 1 { + t.Fatalf("Total = %d, want 1", resp.Total) + } + if len(resp.List) != 1 { + t.Fatalf("len(List) = %d, want 1", len(resp.List)) + } + if got := resp.List[0]; got != want { + t.Fatalf("record = %+v, want %+v", got, want) + } +} + +func assertInviteRecordsExpectations(t *testing.T, mock sqlmock.Sqlmock) { + t.Helper() + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} diff --git a/internal/logic/public/user/queryCommissionReturnLogLogic.go b/internal/logic/public/user/queryCommissionReturnLogLogic.go new file mode 100644 index 0000000..ca1a059 --- /dev/null +++ b/internal/logic/public/user/queryCommissionReturnLogLogic.go @@ -0,0 +1,183 @@ +package user + +import ( + "context" + "strconv" + "strings" + + "github.com/perfect-panel/server/internal/model/log" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type commissionReturnLogRecord struct { + LogID int64 + UserID int64 + Amount int64 + EventType uint16 + Content string + CreatedAt int64 + UpdatedAt int64 +} + +type QueryCommissionReturnLogLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// NewQueryCommissionReturnLogLogic Query Commission Return Log +func NewQueryCommissionReturnLogLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryCommissionReturnLogLogic { + return &QueryCommissionReturnLogLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *QueryCommissionReturnLogLogic) QueryCommissionReturnLog(req *types.QueryCommissionReturnLogRequest) (*types.QueryCommissionReturnLogResponse, error) { + u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User) + if !ok { + l.Error("current user is not found in context") + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") + } + + page, size := normalizePagination(req.Page, req.Size) + list, total, err := l.queryCommissionReturnLogRecords(u.Id, page, size) + if err != nil { + return nil, err + } + + respList := make([]types.CommissionReturnLog, 0, len(list)) + for _, item := range list { + respList = append(respList, types.CommissionReturnLog{ + Id: item.LogID, + UserId: item.UserID, + Amount: item.Amount, + EventType: item.EventType, + Content: item.Content, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }) + } + + return &types.QueryCommissionReturnLogResponse{ + List: respList, + Total: total, + }, nil +} + +func normalizePagination(page, size int) (int, int) { + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 10 + } + return page, size +} + +func (l *QueryCommissionReturnLogLogic) queryCommissionReturnLogRecords(userID int64, page, size int) ([]commissionReturnLogRecord, int64, error) { + return l.queryCommissionReturnLogRecordsByEventTypes(userID, page, size, + log.CommissionTypeRefund, + log.CommissionTypeWithdrawReject, + log.CommissionTypeWithdrawCancel, + ) +} + +func (l *QueryCommissionReturnLogLogic) queryCommissionReturnLogRecordsByEventTypes(userID int64, page, size int, eventTypes ...uint16) ([]commissionReturnLogRecord, int64, error) { + if len(eventTypes) == 0 { + return []commissionReturnLogRecord{}, 0, nil + } + + likeClauses := make([]string, 0, len(eventTypes)) + args := make([]interface{}, 0, len(eventTypes)+2) + args = append(args, log.TypeCommission.Uint8(), userID) + for _, eventType := range eventTypes { + likeClauses = append(likeClauses, "`content` LIKE ?") + args = append(args, commissionReturnLogEventTypePattern(eventType)) + } + query := l.svcCtx.DB.WithContext(l.ctx). + Model(&log.SystemLog{}). + Where("`type` = ? AND object_id = ? AND ("+strings.Join(likeClauses, " OR ")+")", args...) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count commission return logs failed: %v", err) + } + + var rows []log.SystemLog + if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil { + return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query commission return logs failed: %v", err) + } + + list := make([]commissionReturnLogRecord, 0, len(rows)) + for _, row := range rows { + var content log.Commission + if err := content.Unmarshal([]byte(row.Content)); err != nil { + l.Errorw("unmarshal commission return log content failed", + logger.Field("log_id", row.Id), + logger.Field("error", err.Error()), + ) + continue + } + if !isCommissionReturnEventType(content.Type) { + continue + } + + // content.Timestamp 历史数据为毫秒,按项目约定(统一秒级)转换;为 0 时回退到 row.CreatedAt 秒值 + var timestamp int64 + if content.Timestamp > 0 { + timestamp = content.Timestamp / 1000 + } else { + timestamp = row.CreatedAt.Unix() + } + list = append(list, commissionReturnLogRecord{ + LogID: row.Id, + UserID: row.ObjectID, + Amount: content.Amount, + EventType: content.Type, + Content: commissionReturnLogContentText(content.Type, content.OrderNo), + CreatedAt: timestamp, + UpdatedAt: timestamp, + }) + } + + return list, total, nil +} + +func commissionReturnLogEventTypePattern(eventType uint16) string { + return "%\"type\":" + strconv.FormatUint(uint64(eventType), 10) + "%" +} + +func isCommissionReturnEventType(eventType uint16) bool { + switch eventType { + case log.CommissionTypeRefund, log.CommissionTypeWithdrawReject, log.CommissionTypeWithdrawCancel: + return true + default: + return false + } +} + +// commissionReturnLogContentText 把佣金回退事件的原始 JSON 转成前端友好文字。 +// 333 订单退款回佣 - 附订单号便于追溯;337/338 提现相关 - 跟订单无关。 +func commissionReturnLogContentText(eventType uint16, orderNo string) string { + switch eventType { + case log.CommissionTypeRefund: + if strings.TrimSpace(orderNo) != "" { + return "订单退款回佣(订单号 " + orderNo + ")" + } + return "订单退款回佣" + case log.CommissionTypeWithdrawReject: + return "提现申请被驳回,佣金已退回" + case log.CommissionTypeWithdrawCancel: + return "已取消提现,佣金已退回" + default: + return "佣金调整" + } +} diff --git a/internal/logic/public/user/queryWithdrawalLogLogic.go b/internal/logic/public/user/queryWithdrawalLogLogic.go index 9219f0f..61d949b 100644 --- a/internal/logic/public/user/queryWithdrawalLogLogic.go +++ b/internal/logic/public/user/queryWithdrawalLogLogic.go @@ -3,6 +3,7 @@ package user import ( "context" + "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" @@ -12,6 +13,11 @@ import ( "github.com/pkg/errors" ) +const ( + withdrawalLogBizTypeWithdrawal = "withdrawal" + withdrawalLogBizTypeCommissionRefund = "commission_refund" +) + type QueryWithdrawalLogLogic struct { logger.Logger ctx context.Context @@ -34,24 +40,28 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } - page := req.Page - size := req.Size - if page <= 0 { - page = 1 - } - if size <= 0 { - size = 10 - } + page, size := normalizePagination(req.Page, req.Size) - query := l.svcCtx.DB.WithContext(l.ctx).Model(&user.Withdrawal{}).Where("user_id = ?", u.Id) + switch req.BizType { + case "", withdrawalLogBizTypeWithdrawal: + return l.queryWithdrawalLogs(u.Id, page, size) + case withdrawalLogBizTypeCommissionRefund: + return l.queryCommissionRefundLogs(u.Id, page, size) + default: + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "invalid biz_type: %s", req.BizType) + } +} + +func (l *QueryWithdrawalLogLogic) queryWithdrawalLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) { + query := l.svcCtx.DB.WithContext(l.ctx).Model(&user.Withdrawal{}).Where("user_id = ?", userID) var total int64 - if err = query.Count(&total).Error; err != nil { + if err := query.Count(&total).Error; err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count withdrawal logs failed: %v", err) } var rows []user.Withdrawal - if err = query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil { + if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query withdrawal logs failed: %v", err) } @@ -59,6 +69,7 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL for _, row := range rows { list = append(list, types.WithdrawalLog{ Id: row.Id, + BizType: withdrawalLogBizTypeWithdrawal, UserId: row.UserId, Amount: row.Amount, Content: row.Content, @@ -67,8 +78,89 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL Method: row.Method, Account: row.Account, QrCodeUrl: row.QrCodeUrl, - CreatedAt: row.CreatedAt.UnixMilli(), - UpdatedAt: row.UpdatedAt.UnixMilli(), + CreatedAt: row.CreatedAt.Unix(), + UpdatedAt: row.UpdatedAt.Unix(), + }) + } + + summary, err := l.buildSummary(userID) + if err != nil { + return nil, err + } + + return &types.QueryWithdrawalLogListResponse{ + List: list, + Total: total, + Summary: summary, + }, nil +} + +// buildSummary 聚合用户佣金账目快照,供前端展示闭环对账 +// +// commission_balance = user.commission(当前余额,由 system_logs 累计而来) +// locked_by_pending = 待审批提现占用 +// available_to_withdraw = balance - locked +// total_historical_amount = 已通过提现累计(status=1) +// total_refunded_amount = 佣金回扣累计(type=333/337/338) +// total_income_amount = 佣金收入累计(type=331/332) +func (l *QueryWithdrawalLogLogic) buildSummary(userID int64) (*types.WithdrawalLogSummary, error) { + db := l.svcCtx.DB.WithContext(l.ctx) + var summary types.WithdrawalLogSummary + + if err := db.Model(&user.User{}).Where("id = ?", userID). + Select("COALESCE(commission, 0)").Scan(&summary.CommissionBalance).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load commission balance failed: %v", err) + } + + if err := db.Model(&user.Withdrawal{}). + Where("user_id = ? AND status = ?", userID, user.WithdrawalStatusPending). + Select("COALESCE(SUM(amount), 0)").Scan(&summary.LockedByPending).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "sum pending withdrawals failed: %v", err) + } + + if err := db.Model(&user.Withdrawal{}). + Where("user_id = ? AND status = ?", userID, user.WithdrawalStatusApproved). + Select("COALESCE(SUM(amount), 0)").Scan(&summary.TotalHistoricalAmount).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "sum approved withdrawals failed: %v", err) + } + + row := db.Raw(` + SELECT + COALESCE(SUM(CASE WHEN CAST(JSON_EXTRACT(content,'$.type') AS UNSIGNED) IN (?,?) + THEN CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED) ELSE 0 END), 0) AS income, + COALESCE(SUM(CASE WHEN CAST(JSON_EXTRACT(content,'$.type') AS UNSIGNED) IN (?,?,?) + THEN CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED) ELSE 0 END), 0) AS refund + FROM system_logs + WHERE type = ? AND object_id = ?`, + log.CommissionTypePurchase, log.CommissionTypeRenewal, + log.CommissionTypeRefund, log.CommissionTypeWithdrawReject, log.CommissionTypeWithdrawCancel, + log.TypeCommission.Uint8(), userID, + ).Row() + if err := row.Scan(&summary.TotalIncomeAmount, &summary.TotalRefundedAmount); err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "aggregate commission logs failed: %v", err) + } + + summary.AvailableToWithdraw = summary.CommissionBalance - summary.LockedByPending + return &summary, nil +} + +func (l *QueryWithdrawalLogLogic) queryCommissionRefundLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) { + queryLogic := NewQueryCommissionReturnLogLogic(l.ctx, l.svcCtx) + rows, total, err := queryLogic.queryCommissionReturnLogRecordsByEventTypes(userID, page, size, log.CommissionTypeRefund) + if err != nil { + return nil, err + } + + list := make([]types.WithdrawalLog, 0, len(rows)) + for _, row := range rows { + list = append(list, types.WithdrawalLog{ + Id: row.LogID, + BizType: withdrawalLogBizTypeCommissionRefund, + UserId: row.UserID, + Amount: row.Amount, + Content: row.Content, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, }) } diff --git a/internal/logic/public/user/queryWithdrawalLogLogic_test.go b/internal/logic/public/user/queryWithdrawalLogLogic_test.go new file mode 100644 index 0000000..81a1f20 --- /dev/null +++ b/internal/logic/public/user/queryWithdrawalLogLogic_test.go @@ -0,0 +1,325 @@ +package user + +import ( + "bytes" + "context" + "database/sql/driver" + "fmt" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + logmodel "github.com/perfect-panel/server/internal/model/log" + usermodel "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/constant" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) { + const userID = int64(42) + createdAt := time.Unix(1700000000, 0) + updatedAt := createdAt.Add(time.Minute) + + db, mock, cleanup := newQueryWithdrawalLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("SELECT count(*) FROM `withdrawals` WHERE user_id = ?"). + WithArgs(userID). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery("SELECT * FROM `withdrawals` WHERE user_id = ? ORDER BY id DESC LIMIT ?"). + WithArgs(userID, 10). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "user_id", "amount", "content", "status", "reason", "method", "account", "qr_code_url", "created_at", "updated_at", + }).AddRow( + int64(1001), userID, int64(3000), "bank withdrawal", usermodel.WithdrawalStatusPending, "", uint8(3), "acct-001", "", createdAt, updatedAt, + )) + // buildSummary 触发的 4 个聚合查询 + mock.ExpectQuery("SELECT COALESCE(commission, 0) FROM `user`"). + WithArgs(userID). + WillReturnRows(sqlmock.NewRows([]string{"commission"}).AddRow(int64(5000))) + mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`"). + WithArgs(userID, usermodel.WithdrawalStatusPending). + WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(3000))) + mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`"). + WithArgs(userID, usermodel.WithdrawalStatusApproved). + WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(0))) + mock.ExpectQuery("FROM system_logs"). + WithArgs( + logmodel.CommissionTypePurchase, logmodel.CommissionTypeRenewal, + logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel, + logmodel.TypeCommission.Uint8(), userID, + ). + WillReturnRows(sqlmock.NewRows([]string{"income", "refund"}).AddRow(int64(8000), int64(0))) + + logic := newTestQueryWithdrawalLogLogic(t, db, userID) + resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{ + BizType: withdrawalLogBizTypeWithdrawal, + Page: 1, + Size: 10, + }) + if err != nil { + t.Fatalf("QueryWithdrawalLog unexpected error: %v", err) + } + if resp.Total != 1 || len(resp.List) != 1 { + t.Fatalf("QueryWithdrawalLog response = %+v, want one withdrawal", resp) + } + got := resp.List[0] + if got.BizType != withdrawalLogBizTypeWithdrawal { + t.Fatalf("BizType = %q, want %q", got.BizType, withdrawalLogBizTypeWithdrawal) + } + if got.Id != 1001 || got.UserId != userID || got.Amount != 3000 || got.CreatedAt != createdAt.Unix() || got.UpdatedAt != updatedAt.Unix() { + t.Fatalf("withdrawal item = %+v", got) + } + if resp.Summary == nil { + t.Fatalf("expected summary, got nil") + } + if resp.Summary.CommissionBalance != 5000 || resp.Summary.LockedByPending != 3000 || + resp.Summary.AvailableToWithdraw != 2000 || resp.Summary.TotalIncomeAmount != 8000 { + t.Fatalf("summary = %+v, want balance=5000 locked=3000 avail=2000 income=8000", resp.Summary) + } + assertQueryWithdrawalLogExpectations(t, mock) +} + +func TestQueryCommissionReturnLog_HappyPathIncludes333337338(t *testing.T) { + const userID = int64(42) + createdAt := time.Unix(1700000000, 0) + + db, mock, cleanup := newQueryWithdrawalLogTestDB(t) + defer cleanup() + + expectCommissionReturnQueries(mock, userID, 3, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel) + mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?"). + WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10). + WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}). + AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, createdAt). + AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt). + AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt)) + + logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db}) + resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10}) + if err != nil { + t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err) + } + if resp.Total != 3 || len(resp.List) != 3 { + t.Fatalf("QueryCommissionReturnLog response = %+v, want three logs", resp) + } + + eventTypes := []uint16{resp.List[0].EventType, resp.List[1].EventType, resp.List[2].EventType} + wantTypes := []uint16{logmodel.CommissionTypeWithdrawCancel, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeRefund} + if fmt.Sprint(eventTypes) != fmt.Sprint(wantTypes) { + t.Fatalf("event types = %v, want %v", eventTypes, wantTypes) + } + + assertQueryWithdrawalLogExpectations(t, mock) +} + +func TestQueryWithdrawalLog_WithCommissionRefundBizTypeOnlyIncludes333(t *testing.T) { + const userID = int64(42) + createdAt := time.Unix(1700000000, 0) + + db, mock, cleanup := newQueryWithdrawalLogTestDB(t) + defer cleanup() + + expectCommissionReturnQueries(mock, userID, 1, logmodel.CommissionTypeRefund) + mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ?) ORDER BY id DESC LIMIT ?"). + WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", 10). + WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}). + AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt)) + + logic := newTestQueryWithdrawalLogLogic(t, db, userID) + resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{ + BizType: withdrawalLogBizTypeCommissionRefund, + Page: 1, + Size: 10, + }) + if err != nil { + t.Fatalf("QueryWithdrawalLog unexpected error: %v", err) + } + if resp.Total != 1 || len(resp.List) != 1 { + t.Fatalf("QueryWithdrawalLog response = %+v, want one commission refund", resp) + } + for _, item := range resp.List { + if item.BizType != withdrawalLogBizTypeCommissionRefund { + t.Fatalf("BizType = %q, want %q", item.BizType, withdrawalLogBizTypeCommissionRefund) + } + if item.Status != 0 || item.Reason != "" || item.Method != 0 || item.Account != "" || item.QrCodeUrl != "" { + t.Fatalf("withdrawal-only fields should keep zero values, got %+v", item) + } + } + if resp.List[0].Id != 2001 { + t.Fatalf("commission refund item id = %d, want 2001", resp.List[0].Id) + } + assertQueryWithdrawalLogExpectations(t, mock) +} + +func TestQueryCommissionReturnLog_SkipsInvalidJSONAndLogsWarn(t *testing.T) { + const userID = int64(42) + createdAt := time.Unix(1700000000, 0) + + db, mock, cleanup := newQueryWithdrawalLogTestDB(t) + defer cleanup() + + expectCommissionReturnQueries(mock, userID, 2, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel) + mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?"). + WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10). + WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}). + AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt). + AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333`, createdAt)) + + var buf bytes.Buffer + restoreLogger := captureTestLogs(&buf) + defer restoreLogger() + + logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db}) + resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10}) + if err != nil { + t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err) + } + if resp.Total != 2 || len(resp.List) != 1 { + t.Fatalf("QueryCommissionReturnLog response = %+v, want total=2 and one valid row", resp) + } + if !strings.Contains(buf.String(), "unmarshal commission return log content failed") { + t.Fatalf("expected warn log, got %q", buf.String()) + } + assertQueryWithdrawalLogExpectations(t, mock) +} + +func TestQueryCommissionReturnLog_FiltersOtherUsersByObjectID(t *testing.T) { + const userID = int64(42) + + db, mock, cleanup := newQueryWithdrawalLogTestDB(t) + defer cleanup() + + expectCommissionReturnQueries(mock, userID, 0, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel) + mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?"). + WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10). + WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"})) + + logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db}) + resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10}) + if err != nil { + t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err) + } + if resp.Total != 0 || len(resp.List) != 0 { + t.Fatalf("QueryCommissionReturnLog response = %+v, want no rows for filtered user", resp) + } + assertQueryWithdrawalLogExpectations(t, mock) +} + +func TestQueryWithdrawalLog_RejectsInvalidBizType(t *testing.T) { + db, mock, cleanup := newQueryWithdrawalLogTestDB(t) + defer cleanup() + + logic := newTestQueryWithdrawalLogLogic(t, db, 42) + _, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{ + BizType: "all", + Page: 1, + Size: 10, + }) + if !isQueryWithdrawalLogErrCode(err, xerr.InvalidParams) { + t.Fatalf("QueryWithdrawalLog err = %v, want InvalidParams", err) + } + assertQueryWithdrawalLogExpectations(t, mock) +} + +func newQueryWithdrawalLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { + _ = sqlDB.Close() + } +} + +func newTestQueryWithdrawalLogLogic(t *testing.T, db *gorm.DB, userID int64) *QueryWithdrawalLogLogic { + t.Helper() + ctx := newTestQueryCtx(userID) + return &QueryWithdrawalLogLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: &svc.ServiceContext{ + DB: db, + }, + } +} + +func newTestQueryCtx(userID int64) context.Context { + return context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID}) +} + +func expectCommissionReturnQueries(mock sqlmock.Sqlmock, userID int64, total int64, eventTypes ...uint16) { + query := "SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ?" + args := []driver.Value{logmodel.TypeCommission.Uint8(), userID} + if len(eventTypes) > 0 { + clauses := make([]string, 0, len(eventTypes)) + for _, eventType := range eventTypes { + clauses = append(clauses, "`content` LIKE ?") + args = append(args, fmt.Sprintf("%%\"type\":%d%%", eventType)) + } + query += " AND (" + strings.Join(clauses, " OR ") + ")" + } + mock.ExpectQuery(query). + WithArgs(args...). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total)) +} + +func assertQueryWithdrawalLogExpectations(t *testing.T, mock sqlmock.Sqlmock) { + t.Helper() + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func captureTestLogs(buf *bytes.Buffer) func() { + prevWriter := logger.Reset() + prevLevel := logger.InfoLevel + logger.SetLevel(logger.DebugLevel) + logger.SetWriter(logger.NewWriter(buf)) + return func() { + logger.Reset() + logger.SetLevel(prevLevel) + if prevWriter != nil { + logger.SetWriter(prevWriter) + } + } +} + +func queryWithdrawalLogErrCodeOf(err error) uint32 { + if err == nil { + return 0 + } + type coder interface { + GetErrCode() uint32 + } + cause := errors.Cause(err) + if c, ok := cause.(coder); ok { + return c.GetErrCode() + } + return 0 +} + +func isQueryWithdrawalLogErrCode(err error, code uint32) bool { + return queryWithdrawalLogErrCodeOf(err) == code +} diff --git a/internal/logic/public/user/ws/deviceWsConnectLogic.go b/internal/logic/public/user/ws/deviceWsConnectLogic.go index 9e56e12..14a32f5 100644 --- a/internal/logic/public/user/ws/deviceWsConnectLogic.go +++ b/internal/logic/public/user/ws/deviceWsConnectLogic.go @@ -39,14 +39,14 @@ func (l *DeviceWsConnectLogic) DeviceWsConnect(c *gin.Context) error { value, _ = c.GetQuery("identifier") if value == nil || value.(string) == "" { l.Errorf("DeviceWsConnectLogic DeviceWsConnect identifier is empty") - return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "identifier is empty") + return errors.Wrap(xerr.NewErrCode(xerr.InvalidParams), "identifier is empty") } } identifier := value.(string) _, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, identifier) if err != nil && !sysErr.Is(err, gorm.ErrRecordNotFound) { l.Errorf("DeviceWsConnectLogic DeviceWsConnect FindOneDeviceByIdentifier err: %v", err) - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error()) } value = l.ctx.Value(constant.CtxKeyUser) @@ -67,7 +67,7 @@ func (l *DeviceWsConnectLogic) DeviceWsConnect(c *gin.Context) error { err := l.svcCtx.UserModel.InsertDevice(l.ctx, &device) if err != nil { l.Errorf("DeviceWsConnectLogic DeviceWsConnect InsertDevice err: %v", err) - return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error()) + return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error()) } } //默认在线设备1 diff --git a/internal/logic/server/getServerUserListLogic.go b/internal/logic/server/getServerUserListLogic.go index b8cf825..625595a 100644 --- a/internal/logic/server/getServerUserListLogic.go +++ b/internal/logic/server/getServerUserListLogic.go @@ -19,6 +19,7 @@ import ( "github.com/perfect-panel/server/pkg/tool" "github.com/perfect-panel/server/pkg/uuidx" "github.com/perfect-panel/server/pkg/xerr" + "github.com/redis/go-redis/v9" ) type GetServerUserListLogic struct { @@ -27,6 +28,46 @@ type GetServerUserListLogic struct { svcCtx *svc.ServiceContext } +type serverUserListPerfStats struct { + serverID int64 + protocol string + cacheHit bool + nodesCount int + subsCount int + usersCount int + speedLimitZeroCount int + speedLimitPositiveCount int + trafficCalcMS int64 + startedAt time.Time +} + +type serverUserSpeedLimitCandidate struct { + userID int64 + userSubscribeID int64 + baseSpeed int64 + trafficLimit string +} + +type serverUserTrafficWindow struct { + statType string + statValue int64 + start time.Time + end time.Time +} + +type serverUserTrafficUsageKey struct { + userID int64 + userSubscribeID int64 + statType string + statValue int64 +} + +type serverUserTrafficUsage struct { + userID int64 + userSubscribeID int64 + usedGB float64 +} + // NewGetServerUserListLogic Get user list func NewGetServerUserListLogic(ctx *gin.Context, svcCtx *svc.ServiceContext) *GetServerUserListLogic { return &GetServerUserListLogic{ @@ -37,13 +78,27 @@ func NewGetServerUserListLogic(ctx *gin.Context, svcCtx *svc.ServiceContext) *Ge } func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListRequest) (resp *types.GetServerUserListResponse, err error) { - cacheKey := fmt.Sprintf("%s%d", node.ServerUserListCacheKey, req.ServerId) + startedAt := time.Now() + protocolRequest := normalizeServerUserListProtocol(req.Protocol) + stats := serverUserListPerfStats{ + serverID: req.ServerId, + protocol: protocolRequest, + startedAt: startedAt, + cacheHit: false, + } + + cacheKey := fmt.Sprintf("%s%d:%s", node.ServerUserListCacheKey, req.ServerId, protocolRequest) cache, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result() + if err != nil && err != redis.Nil { + l.Errorw("[ServerUserListCacheKey] redis get error", logger.Field("error", err.Error())) + } if cache != "" { + stats.cacheHit = true etag := tool.GenerateETag([]byte(cache)) resp = &types.GetServerUserListResponse{} // Check If-None-Match header if match := l.ctx.GetHeader("If-None-Match"); match == etag { + l.logServerUserListPerf(stats) return nil, xerr.StatusNotModified } l.ctx.Header("ETag", etag) @@ -52,6 +107,9 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR l.Errorw("[ServerUserListCacheKey] json unmarshal error", logger.Field("error", err.Error())) return nil, err } + stats.usersCount = len(resp.Users) + stats.recordSpeedLimits(resp.Users) + l.logServerUserListPerf(stats) return resp, nil } server, err := l.svcCtx.NodeModel.FindOneServer(l.ctx, req.ServerId) @@ -64,14 +122,22 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR Page: 1, Size: 1000, ServerId: []int64{server.Id}, - Protocol: req.Protocol, + Protocol: protocolRequest, }) if err != nil { l.Errorw("FilterNodeList error", logger.Field("error", err.Error())) return nil, err } + stats.nodesCount = len(nodes) if len(nodes) == 0 { + l.Errorw("[ServerUserList] fallback: no nodes matched server+protocol, returning placeholder without cache", + logger.Field("server_id", req.ServerId), + logger.Field("protocol", req.Protocol), + ) + stats.usersCount = 1 + stats.speedLimitZeroCount = 1 + l.logServerUserListPerf(stats) return &types.GetServerUserListResponse{ Users: []types.ServerUser{ { @@ -139,6 +205,13 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR } if len(subs) == 0 { + l.Errorw("[ServerUserList] fallback: no subscriptions matched node group/tags, returning placeholder without cache", + logger.Field("server_id", req.ServerId), + logger.Field("protocol", req.Protocol), + ) + stats.usersCount = 1 + stats.speedLimitZeroCount = 1 + l.logServerUserListPerf(stats) return &types.GetServerUserListResponse{ Users: []types.ServerUser{ { @@ -148,7 +221,9 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR }, }, nil } + stats.subsCount = len(subs) users := make([]types.ServerUser, 0) + speedCandidates := make(map[int64]serverUserSpeedLimitCandidate) for _, sub := range subs { data, err := l.svcCtx.UserModel.FindUsersSubscribeBySubscribeId(l.ctx, sub.Id) if err != nil { @@ -159,38 +234,63 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR continue } - // 计算该用户的实际限速值(考虑按量限速规则) - effectiveSpeedLimit := l.calculateEffectiveSpeedLimit(sub, datum) - + baseSpeed, trafficLimit := serverUserSpeedLimitInputs(sub, datum) + speedCandidates[datum.Id] = serverUserSpeedLimitCandidate{ + userID: datum.UserId, + userSubscribeID: datum.Id, + baseSpeed: baseSpeed, + trafficLimit: trafficLimit, + } users = append(users, types.ServerUser{ Id: datum.Id, UUID: datum.UUID, - SpeedLimit: effectiveSpeedLimit, + SpeedLimit: baseSpeed, DeviceLimit: sub.DeviceLimit, }) } } + trafficCalcStartedAt := time.Now() + speedLimits := l.calculateServerUserSpeedLimits(speedCandidates) + stats.trafficCalcMS = time.Since(trafficCalcStartedAt).Milliseconds() + for i := range users { + if speedLimit, ok := speedLimits[users[i].Id]; ok { + users[i].SpeedLimit = speedLimit + } + } - // 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户 + // 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户。 + // 用户级 speed_limit (user_subscribe.speed_limit) 与过期节点组 speed_limit + // 取更严格的一个 — 0 视为"无限制",正值优先于 0。 if len(nodeGroupIds) > 0 { expiredUsers, expiredSpeedLimit := l.getExpiredUsers(nodeGroupIds) for i := range expiredUsers { - if expiredSpeedLimit > 0 { - expiredUsers[i].SpeedLimit = expiredSpeedLimit - } + expiredUsers[i].SpeedLimit = mergeSpeedLimit(expiredUsers[i].SpeedLimit, expiredSpeedLimit) } users = append(users, expiredUsers...) } if len(users) == 0 { - users = append(users, types.ServerUser{ - Id: 1, - UUID: uuidx.NewUUID().String(), - }) + l.Errorw("[ServerUserList] fallback: matched subs returned zero eligible users, returning placeholder without cache", + logger.Field("server_id", req.ServerId), + logger.Field("protocol", req.Protocol), + ) + stats.usersCount = 1 + stats.speedLimitZeroCount = 1 + l.logServerUserListPerf(stats) + return &types.GetServerUserListResponse{ + Users: []types.ServerUser{ + { + Id: 1, + UUID: uuidx.NewUUID().String(), + }, + }, + }, nil } resp = &types.GetServerUserListResponse{ Users: users, } + stats.usersCount = len(users) + stats.recordSpeedLimits(users) val, _ := json.Marshal(resp) etag := tool.GenerateETag(val) l.ctx.Header("ETag", etag) @@ -200,11 +300,24 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR } // Check If-None-Match header if match := l.ctx.GetHeader("If-None-Match"); match == etag { + l.logServerUserListPerf(stats) return nil, xerr.StatusNotModified } + l.logServerUserListPerf(stats) return resp, nil } +// normalizeServerUserListProtocol 将客户端可能携带的 hysteria2 兼容字段映射回 +// DB 中存储的规范名 "hysteria"。其它协议原样返回。 +// 缓存 key 与 FilterNodeList 查询都必须用归一化后的值, +// 否则 hysteria2 永远查不到节点,永远走兜底。 +func normalizeServerUserListProtocol(protocol string) string { + if protocol == Hysteria2 { + return Hysteria + } + return protocol +} + func (l *GetServerUserListLogic) serverUserListCacheTTL() time.Duration { pullInterval := l.svcCtx.Config.Node.NodePullInterval if pullInterval <= 0 { @@ -256,14 +369,34 @@ func (l *GetServerUserListLogic) getExpiredUsers(serverNodeGroupIds []int64) ([] } seen[userSub.Id] = true users = append(users, types.ServerUser{ - Id: userSub.Id, - UUID: userSub.UUID, + Id: userSub.Id, + UUID: userSub.UUID, + SpeedLimit: userSub.SpeedLimit, }) } return users, int64(expiredGroup.SpeedLimit) } +// mergeSpeedLimit 返回两个速度限制(Mbps)中更严格的一个。 +// 0 视为"无限制",因此会被任意正值覆盖;都为 0 时返回 0。 +// 用于用户级 speed_limit 与节点组级 speed_limit 的合并: +// - both 0 → 0 (不限速) +// - 仅一个 > 0 → 取该值 +// - both > 0 → 取较小者(更严格) +func mergeSpeedLimit(a, b int64) int64 { + if a <= 0 { + return b + } + if b <= 0 { + return a + } + if a < b { + return a + } + return b +} + func (l *GetServerUserListLogic) checkExpiredUserEligibility(userSub *user.Subscribe, expiredGroup *group.NodeGroup) bool { expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24) if expiredDays > expiredGroup.ExpiredDaysLimit { @@ -305,17 +438,181 @@ func (l *GetServerUserListLogic) canUseExpiredNodeGroup(userSub *user.Subscribe, return true } -// calculateEffectiveSpeedLimit 计算用户的实际限速值(考虑按量限速规则) -func (l *GetServerUserListLogic) calculateEffectiveSpeedLimit(sub *subscribe.Subscribe, userSub *user.Subscribe) int64 { - result := speedlimit.CalculateWithCache( - l.ctx.Request.Context(), - l.svcCtx.Redis, - l.svcCtx.DB, - userSub.UserId, - userSub.Id, - sub.SpeedLimit, - sub.TrafficLimit, - 30*time.Second, - ) - return result.EffectiveSpeed +func serverUserSpeedLimitInputs(sub *subscribe.Subscribe, userSub *user.Subscribe) (int64, string) { + baseSpeed := sub.SpeedLimit + if userSub.SpeedLimit > 0 { + baseSpeed = userSub.SpeedLimit + } + + trafficLimit := sub.TrafficLimit + if userSub.TrafficLimit != nil && *userSub.TrafficLimit != "" { + trafficLimit = *userSub.TrafficLimit + } + + return baseSpeed, trafficLimit +} + +func (l *GetServerUserListLogic) calculateServerUserSpeedLimits(candidates map[int64]serverUserSpeedLimitCandidate) map[int64]int64 { + speedLimits := make(map[int64]int64, len(candidates)) + rulesByUserSubscribeID := make(map[int64][]speedlimit.TrafficLimitRule) + windowByKey := make(map[string]serverUserTrafficWindow) + now := time.Now() + + for userSubscribeID, candidate := range candidates { + speedLimits[userSubscribeID] = candidate.baseSpeed + if candidate.trafficLimit == "" { + continue + } + + var rules []speedlimit.TrafficLimitRule + if err := json.Unmarshal([]byte(candidate.trafficLimit), &rules); err != nil || len(rules) == 0 { + continue + } + rulesByUserSubscribeID[userSubscribeID] = rules + for _, rule := range rules { + window, ok := serverUserTrafficRuleWindow(rule, now) + if !ok { + continue + } + windowByKey[serverUserTrafficWindowKey(rule.StatType, rule.StatValue)] = window + } + } + + if len(rulesByUserSubscribeID) == 0 || len(windowByKey) == 0 { + return speedLimits + } + + usageByKey := make(map[serverUserTrafficUsageKey]float64) + for _, window := range windowByKey { + trafficUsage, err := l.queryServerUserTrafficUsage(candidates, window) + if err != nil { + l.Errorw("[ServerUserList] batch traffic usage query failed", + logger.Field("error", err.Error()), + logger.Field("stat_type", window.statType), + logger.Field("stat_value", window.statValue), + ) + continue + } + for _, usage := range trafficUsage { + usageByKey[serverUserTrafficUsageKey{ + userID: usage.userID, + userSubscribeID: usage.userSubscribeID, + statType: window.statType, + statValue: window.statValue, + }] = usage.usedGB + } + } + + for userSubscribeID, rules := range rulesByUserSubscribeID { + candidate := candidates[userSubscribeID] + for _, rule := range rules { + if rule.SpeedLimit <= 0 { + continue + } + if _, ok := serverUserTrafficRuleWindow(rule, now); !ok { + continue + } + usedGB := usageByKey[serverUserTrafficUsageKey{ + userID: candidate.userID, + userSubscribeID: candidate.userSubscribeID, + statType: rule.StatType, + statValue: rule.StatValue, + }] + if usedGB < float64(rule.TrafficUsage) { + continue + } + current := speedLimits[userSubscribeID] + if current == 0 || rule.SpeedLimit < current { + speedLimits[userSubscribeID] = rule.SpeedLimit + } + } + } + + return speedLimits +} + +func (l *GetServerUserListLogic) queryServerUserTrafficUsage( + candidates map[int64]serverUserSpeedLimitCandidate, + window serverUserTrafficWindow, +) ([]serverUserTrafficUsage, error) { + userSubscribeIDs := make([]int64, 0, len(candidates)) + for userSubscribeID := range candidates { + userSubscribeIDs = append(userSubscribeIDs, userSubscribeID) + } + + var rows []struct { + UserID int64 + UserSubscribeID int64 + Upload int64 + Download int64 + } + err := l.svcCtx.DB.WithContext(l.ctx.Request.Context()). + Table("traffic_log"). + Select("user_id, subscribe_id AS user_subscribe_id, COALESCE(SUM(upload), 0) AS upload, COALESCE(SUM(download), 0) AS download"). + Where("subscribe_id IN ? AND timestamp >= ? AND timestamp < ?", userSubscribeIDs, window.start, window.end). + Group("user_id, subscribe_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + trafficUsage := make([]serverUserTrafficUsage, 0, len(rows)) + for _, row := range rows { + trafficUsage = append(trafficUsage, serverUserTrafficUsage{ + userID: row.UserID, + userSubscribeID: row.UserSubscribeID, + usedGB: float64(row.Upload+row.Download) / (1024 * 1024 * 1024), + }) + } + return trafficUsage, nil +} + +func serverUserTrafficRuleWindow(rule speedlimit.TrafficLimitRule, now time.Time) (serverUserTrafficWindow, bool) { + if rule.StatValue <= 0 { + return serverUserTrafficWindow{}, false + } + + window := serverUserTrafficWindow{ + statType: rule.StatType, + statValue: rule.StatValue, + end: now, + } + switch rule.StatType { + case "hour": + window.start = now.Add(-time.Duration(rule.StatValue) * time.Hour) + case "day": + window.start = now.AddDate(0, 0, -int(rule.StatValue)) + default: + return serverUserTrafficWindow{}, false + } + return window, true +} + +func serverUserTrafficWindowKey(statType string, statValue int64) string { + return fmt.Sprintf("%s:%d", statType, statValue) +} + +func (s *serverUserListPerfStats) recordSpeedLimits(users []types.ServerUser) { + for _, user := range users { + if user.SpeedLimit > 0 { + s.speedLimitPositiveCount++ + continue + } + s.speedLimitZeroCount++ + } +} + +func (l *GetServerUserListLogic) logServerUserListPerf(stats serverUserListPerfStats) { + l.Infow("[ServerUserList] performance", + logger.Field("server_id", stats.serverID), + logger.Field("protocol", stats.protocol), + logger.Field("cache_hit", stats.cacheHit), + logger.Field("nodes_count", stats.nodesCount), + logger.Field("subs_count", stats.subsCount), + logger.Field("users_count", stats.usersCount), + logger.Field("speed_limit_0_count", stats.speedLimitZeroCount), + logger.Field("speed_limit_positive_count", stats.speedLimitPositiveCount), + logger.Field("traffic_calc_ms", stats.trafficCalcMS), + logger.Field("total_ms", time.Since(stats.startedAt).Milliseconds()), + ) } diff --git a/internal/logic/server/getServerUserListLogic_test.go b/internal/logic/server/getServerUserListLogic_test.go new file mode 100644 index 0000000..47a1d79 --- /dev/null +++ b/internal/logic/server/getServerUserListLogic_test.go @@ -0,0 +1,369 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "regexp" + "slices" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/model/node" + "github.com/perfect-panel/server/internal/model/subscribe" + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/speedlimit" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// TestNormalizeServerUserListProtocol 验证客户端 hysteria2 兼容字段被映射回 +// DB 规范名 "hysteria";其他协议必须原样返回,不可被误改。 +// 缺这一步会导致 hysteria2 永远查不到节点、永远走兜底,HIF-1 表象。 +func TestNormalizeServerUserListProtocol(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"hysteria2 maps to hysteria", "hysteria2", "hysteria"}, + {"hysteria unchanged", "hysteria", "hysteria"}, + {"trojan unchanged", "trojan", "trojan"}, + {"vless unchanged", "vless", "vless"}, + {"vmess unchanged", "vmess", "vmess"}, + {"tuic unchanged", "tuic", "tuic"}, + {"shadowsocks unchanged", "shadowsocks", "shadowsocks"}, + {"anytls unchanged", "anytls", "anytls"}, + {"empty unchanged", "", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := normalizeServerUserListProtocol(c.in) + if got != c.want { + t.Errorf("normalizeServerUserListProtocol(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// TestServerUserListCacheKey_ProtocolIsolation 验证缓存 key 在 server_id 相同、 +// protocol 不同(如 trojan vs tuic)时一定不同——这是 HIF-1 的根因防回归。 +// 若 key 重叠,tuic 的兜底假用户会污染 trojan 的真实用户列表。 +func TestServerUserListCacheKey_ProtocolIsolation(t *testing.T) { + const serverId int64 = 42 + build := func(protocol string) string { + return fmt.Sprintf("%s%d:%s", node.ServerUserListCacheKey, serverId, normalizeServerUserListProtocol(protocol)) + } + + protocols := []string{"trojan", "vless", "vmess", "tuic", "shadowsocks", "anytls"} + seen := make(map[string]string, len(protocols)) + for _, p := range protocols { + key := build(p) + if other, exists := seen[key]; exists { + t.Fatalf("cache key collision: %q (%q) == %q (%q)", p, key, other, key) + } + seen[key] = p + } + + // hysteria 与 hysteria2 必须落到同一 key,否则 hysteria2 客户端拿不到 hysteria 节点列表 + hysteriaKey := build("hysteria") + hysteria2Key := build("hysteria2") + if hysteriaKey != hysteria2Key { + t.Errorf("hysteria/hysteria2 expected to share cache key, got %q vs %q", hysteriaKey, hysteria2Key) + } +} + +// TestServerUserListCacheKey_ShapeMatchesDelPath 验证 GetServerUserList 写入的 +// cache key 形态与 ServerUserListCacheKeysForServer(Del 路径)枚举出的 key +// 形态完全一致。形态一旦漂移,Del 会清不到刚写入的 key,HIF-1 重现。 +func TestServerUserListCacheKey_ShapeMatchesDelPath(t *testing.T) { + const serverId int64 = 7 + writeShape := fmt.Sprintf("%s%d:%s", node.ServerUserListCacheKey, serverId, normalizeServerUserListProtocol("trojan")) + + delKeys := node.ServerUserListCacheKeysForServer(serverId) + if !slices.Contains(delKeys, writeShape) { + t.Fatalf("write-path key %q not present in Del-path enumeration %v", writeShape, delKeys) + } +} + +func TestServerUserSpeedLimitInputs(t *testing.T) { + planTrafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":10,"speed_limit":5}]` + userTrafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":1,"speed_limit":3}]` + + cases := []struct { + name string + sub *subscribe.Subscribe + userSub *user.Subscribe + wantSpeedLimit int64 + wantTrafficLimit string + }{ + { + name: "未设置用户覆盖时返回套餐基础限速", + sub: &subscribe.Subscribe{ + SpeedLimit: 100, + TrafficLimit: planTrafficLimit, + }, + userSub: &user.Subscribe{}, + wantSpeedLimit: 100, + wantTrafficLimit: planTrafficLimit, + }, + { + name: "用户 speed_limit 覆盖套餐基础限速", + sub: &subscribe.Subscribe{ + SpeedLimit: 100, + TrafficLimit: planTrafficLimit, + }, + userSub: &user.Subscribe{ + SpeedLimit: 30, + }, + wantSpeedLimit: 30, + wantTrafficLimit: planTrafficLimit, + }, + { + name: "用户 traffic_limit 覆盖套餐规则", + sub: &subscribe.Subscribe{ + SpeedLimit: 100, + TrafficLimit: planTrafficLimit, + }, + userSub: &user.Subscribe{ + TrafficLimit: &userTrafficLimit, + }, + wantSpeedLimit: 100, + wantTrafficLimit: userTrafficLimit, + }, + { + name: "用户 traffic_limit 为空时保留套餐规则", + sub: &subscribe.Subscribe{ + SpeedLimit: 100, + TrafficLimit: planTrafficLimit, + }, + userSub: &user.Subscribe{ + TrafficLimit: ptrString(""), + }, + wantSpeedLimit: 100, + wantTrafficLimit: planTrafficLimit, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotSpeedLimit, gotTrafficLimit := serverUserSpeedLimitInputs(tc.sub, tc.userSub) + if gotSpeedLimit != tc.wantSpeedLimit { + t.Fatalf("speedLimit = %d, want %d", gotSpeedLimit, tc.wantSpeedLimit) + } + if gotTrafficLimit != tc.wantTrafficLimit { + t.Fatalf("trafficLimit = %q, want %q", gotTrafficLimit, tc.wantTrafficLimit) + } + }) + } +} + +func TestCalculateServerUserSpeedLimits_BatchTrafficRules(t *testing.T) { + db, mock, cleanup := newServerUserListTestDB(t) + defer cleanup() + + ctx, _ := gin.CreateTestContext(nil) + ctx.Request = httptestNewRequest() + logic := &GetServerUserListLogic{ + Logger: logger.WithContext(ctx.Request.Context()), + ctx: ctx, + svcCtx: &svc.ServiceContext{DB: db}, + } + + trafficLimit := `[{"stat_type":"day","stat_value":1,"traffic_usage":10,"speed_limit":5}]` + candidates := map[int64]serverUserSpeedLimitCandidate{ + 101: {userID: 1001, userSubscribeID: 101, baseSpeed: 0, trafficLimit: trafficLimit}, + 102: {userID: 1002, userSubscribeID: 102, baseSpeed: 0, trafficLimit: trafficLimit}, + 103: {userID: 1003, userSubscribeID: 103, baseSpeed: 20, trafficLimit: trafficLimit}, + } + + mock.ExpectQuery("FROM `traffic_log`"). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "user_subscribe_id", "upload", "download"}). + AddRow(1001, 101, gb(4), gb(5)). + AddRow(1002, 102, gb(4), gb(6)). + AddRow(1003, 103, gb(12), int64(0))) + + got := logic.calculateServerUserSpeedLimits(candidates) + assertSpeedLimit(t, got, 101, 0) + assertSpeedLimit(t, got, 102, 5) + assertSpeedLimit(t, got, 103, 5) + assertServerUserListExpectations(t, mock) +} + +func TestCalculateServerUserSpeedLimits_UserTrafficLimitOverride(t *testing.T) { + db, mock, cleanup := newServerUserListTestDB(t) + defer cleanup() + + ctx, _ := gin.CreateTestContext(nil) + ctx.Request = httptestNewRequest() + logic := &GetServerUserListLogic{ + Logger: logger.WithContext(ctx.Request.Context()), + ctx: ctx, + svcCtx: &svc.ServiceContext{DB: db}, + } + + candidates := map[int64]serverUserSpeedLimitCandidate{ + 201: { + userID: 2001, + userSubscribeID: 201, + baseSpeed: 50, + trafficLimit: `[{"stat_type":"day","stat_value":1,"traffic_usage":2,"speed_limit":3}]`, + }, + 202: { + userID: 2002, + userSubscribeID: 202, + baseSpeed: 50, + trafficLimit: "", + }, + } + + mock.ExpectQuery("FROM `traffic_log`"). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "user_subscribe_id", "upload", "download"}). + AddRow(2001, 201, gb(2), int64(0))) + + got := logic.calculateServerUserSpeedLimits(candidates) + assertSpeedLimit(t, got, 201, 3) + assertSpeedLimit(t, got, 202, 50) + assertServerUserListExpectations(t, mock) +} + +func TestServerUserTrafficRuleWindow(t *testing.T) { + now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) + + cases := []struct { + name string + rule speedlimit.TrafficLimitRule + want time.Time + ok bool + }{ + { + name: "hour window", + rule: speedlimit.TrafficLimitRule{StatType: "hour", StatValue: 2}, + want: now.Add(-2 * time.Hour), + ok: true, + }, + { + name: "day window", + rule: speedlimit.TrafficLimitRule{StatType: "day", StatValue: 1}, + want: now.AddDate(0, 0, -1), + ok: true, + }, + { + name: "invalid stat type", + rule: speedlimit.TrafficLimitRule{StatType: "week", StatValue: 1}, + ok: false, + }, + { + name: "invalid stat value", + rule: speedlimit.TrafficLimitRule{StatType: "day", StatValue: 0}, + ok: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := serverUserTrafficRuleWindow(tc.rule, now) + if ok != tc.ok { + t.Fatalf("ok = %v, want %v", ok, tc.ok) + } + if !ok { + return + } + if !got.start.Equal(tc.want) || !got.end.Equal(now) { + t.Fatalf("window = [%s, %s), want [%s, %s)", got.start, got.end, tc.want, now) + } + }) + } +} + +func newServerUserListTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if matched, _ := regexp.MatchString(expectedSQL, actualSQL); matched { + return nil + } + t.Fatalf("unexpected SQL\nexpected pattern: %s\nactual: %s", expectedSQL, actualSQL) + return nil + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { + _ = sqlDB.Close() + } +} + +func assertServerUserListExpectations(t *testing.T, mock sqlmock.Sqlmock) { + t.Helper() + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func assertSpeedLimit(t *testing.T, got map[int64]int64, userSubscribeID int64, want int64) { + t.Helper() + + if got[userSubscribeID] != want { + t.Fatalf("speed limit for user_subscribe_id=%d = %d, want %d", userSubscribeID, got[userSubscribeID], want) + } +} + +func gb(value int64) int64 { + return value * 1024 * 1024 * 1024 +} + +func ptrString(value string) *string { + return &value +} + +func httptestNewRequest() *http.Request { + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/v1/server/user", nil) + return req +} + +// TestMergeSpeedLimit 验证用户级 speed_limit 与过期节点组 speed_limit 合并规则: +// 0 = 不限制 (loses),正值优先;都为正取较小者(更严格)。 +// 这是 user-dimension 限速在过期节点组分支下能生效的关键。 +func TestMergeSpeedLimit(t *testing.T) { + cases := []struct { + name string + a int64 + b int64 + want int64 + }{ + {"both zero stays zero", 0, 0, 0}, + {"a positive b zero takes a", 30, 0, 30}, + {"a zero b positive takes b", 0, 50, 50}, + {"a negative treated as zero takes b", -1, 50, 50}, + {"b negative treated as zero takes a", 50, -1, 50}, + {"both positive takes smaller (a"%' 先走索引粗筛; +// 2. 命中项再用 JSON 反序列化精确比对 content.type==333 与 content.order_no, +// 避免 order_no 出现在其它字段子串里产生误判。 +func HasRefundCommissionLog(tx *gorm.DB, orderNo string) (bool, error) { + if orderNo == "" { + return false, nil + } + var logs []SystemLog + if err := tx.Model(&SystemLog{}). + Where("type = ? AND content LIKE ?", TypeCommission.Uint8(), fmt.Sprintf("%%\"order_no\":\"%s\"%%", orderNo)). + Find(&logs).Error; err != nil { + return false, fmt.Errorf("query refund commission log failed: %w", err) + } + for _, item := range logs { + var content Commission + if err := content.Unmarshal([]byte(item.Content)); err != nil { + continue + } + if content.Type == CommissionTypeRefund && content.OrderNo == orderNo { + return true, nil + } + } + return false, nil +} + +// HasOrderRefundLog 判断指定订单号是否已写入 24 订单退款审计日志。 +func HasOrderRefundLog(tx *gorm.DB, orderNo string) (bool, error) { + if orderNo == "" { + return false, nil + } + var logs []SystemLog + if err := tx.Model(&SystemLog{}). + Where("type = ? AND content LIKE ?", TypeOrderRefund.Uint8(), fmt.Sprintf("%%\"order_no\":\"%s\"%%", orderNo)). + Find(&logs).Error; err != nil { + return false, fmt.Errorf("query order refund log failed: %w", err) + } + for _, item := range logs { + var content OrderRefund + if err := content.Unmarshal([]byte(item.Content)); err != nil { + continue + } + if content.OrderNo == orderNo { + return true, nil + } + } + return false, nil +} diff --git a/internal/model/log/refund_test.go b/internal/model/log/refund_test.go new file mode 100644 index 0000000..60e4273 --- /dev/null +++ b/internal/model/log/refund_test.go @@ -0,0 +1,228 @@ +package log + +import ( + "fmt" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestHasRefundCommissionLog(t *testing.T) { + const orderNo = "ORD-REFUND-1" + + t.Run("returns true when 333 log exists for the order", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, fmt.Sprintf(`{"type":331,"order_no":"%s","amount":100}`, orderNo)). + AddRow(2, fmt.Sprintf(`{"type":333,"order_no":"%s","amount":-100}`, orderNo))) + + got, err := HasRefundCommissionLog(db, orderNo) + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if !got { + t.Fatalf("HasRefundCommissionLog = false, want true") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("returns false when only 331/332 logs exist", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, fmt.Sprintf(`{"type":331,"order_no":"%s","amount":100}`, orderNo)). + AddRow(2, fmt.Sprintf(`{"type":332,"order_no":"%s","amount":50}`, orderNo))) + + got, err := HasRefundCommissionLog(db, orderNo) + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if got { + t.Fatalf("HasRefundCommissionLog = true, want false") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("returns false when no log exists for the order", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"})) + + got, err := HasRefundCommissionLog(db, orderNo) + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if got { + t.Fatalf("HasRefundCommissionLog = true, want false") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("returns false for empty order_no without querying", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + got, err := HasRefundCommissionLog(db, "") + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if got { + t.Fatalf("HasRefundCommissionLog = true, want false") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("ignores 333 log when order_no in content does not match", func(t *testing.T) { + // Defensive: LIKE pattern may match a substring; JSON match catches it. + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, `{"type":333,"order_no":"OTHER","amount":-100}`)) + + got, err := HasRefundCommissionLog(db, orderNo) + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if got { + t.Fatalf("HasRefundCommissionLog = true, want false (different order_no)") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("ignores malformed json content", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, `not a json`). + AddRow(2, fmt.Sprintf(`{"type":333,"order_no":"%s","amount":-100}`, orderNo))) + + got, err := HasRefundCommissionLog(db, orderNo) + if err != nil { + t.Fatalf("HasRefundCommissionLog error: %v", err) + } + if !got { + t.Fatalf("HasRefundCommissionLog = false, want true") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("returns error when db query fails", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(33), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnError(fmt.Errorf("connection lost")) + + if _, err := HasRefundCommissionLog(db, orderNo); err == nil { + t.Fatalf("expected error, got nil") + } + assertRefundLogExpectations(t, mock) + }) +} + +func TestHasOrderRefundLog(t *testing.T) { + const orderNo = "ORD-REFUND-AUDIT-1" + + t.Run("returns true when order refund audit log exists", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, fmt.Sprintf(`{"order_id":100,"order_no":"%s","order_status_after":7}`, orderNo))) + + got, err := HasOrderRefundLog(db, orderNo) + if err != nil { + t.Fatalf("HasOrderRefundLog error: %v", err) + } + if !got { + t.Fatalf("HasOrderRefundLog = false, want true") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("returns false when audit log order_no does not match", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `system_logs`"). + WithArgs(uint8(24), fmt.Sprintf(`%%"order_no":"%s"%%`, orderNo)). + WillReturnRows(sqlmock.NewRows([]string{"id", "content"}). + AddRow(1, `{"order_id":100,"order_no":"OTHER","order_status_after":7}`)) + + got, err := HasOrderRefundLog(db, orderNo) + if err != nil { + t.Fatalf("HasOrderRefundLog error: %v", err) + } + if got { + t.Fatalf("HasOrderRefundLog = true, want false") + } + assertRefundLogExpectations(t, mock) + }) + + t.Run("returns false for empty order_no without querying", func(t *testing.T) { + db, mock, cleanup := newRefundLogTestDB(t) + defer cleanup() + + got, err := HasOrderRefundLog(db, "") + if err != nil { + t.Fatalf("HasOrderRefundLog error: %v", err) + } + if got { + t.Fatalf("HasOrderRefundLog = true, want false") + } + assertRefundLogExpectations(t, mock) + }) +} + +func newRefundLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { + _ = sqlDB.Close() + } +} + +func assertRefundLogExpectations(t *testing.T, mock sqlmock.Sqlmock) { + t.Helper() + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} diff --git a/internal/model/node/model.go b/internal/model/node/model.go index e6961c1..0f5366c 100644 --- a/internal/model/node/model.go +++ b/internal/model/node/model.go @@ -25,6 +25,31 @@ const ( ServerConfigCacheKey = "server:config:" ) +// AllProtocols 枚举所有客户端可能携带的 protocol。 +// 用户列表缓存 key 形态为 `server:user:{server_id}:{protocol}`,按 server_id +// 失效时需要按协议精确删除。SCAN 在增量 rehash 期间可能漏 key,因此采用显式枚举。 +// 包含 hysteria2(兼容字段,与 hysteria 同语义),多余的 Del 是 no-op,over-deletion 安全。 +var AllProtocols = []string{ + "shadowsocks", + "vmess", + "vless", + "trojan", + "anytls", + "tuic", + "hysteria", + "hysteria2", +} + +// ServerUserListCacheKeysForServer 返回给定 server 的所有 protocol 维度缓存 key。 +// 用于 Del 路径——节点 / 订阅 / 流量统计触发缓存失效时一次性清掉该 server 下所有协议条目。 +func ServerUserListCacheKeysForServer(serverId int64) []string { + keys := make([]string, 0, len(AllProtocols)) + for _, protocol := range AllProtocols { + keys = append(keys, fmt.Sprintf("%s%d:%s", ServerUserListCacheKey, serverId, protocol)) + } + return keys +} + // FilterParams Filter Server Params type FilterParams struct { Page int @@ -134,7 +159,7 @@ func (m *customServerModel) ClearNodeCache(ctx context.Context, params *FilterNo } var cacheKeys []string for _, node := range nodes { - cacheKeys = append(cacheKeys, fmt.Sprintf("%s%d", ServerUserListCacheKey, node.ServerId)) + cacheKeys = append(cacheKeys, ServerUserListCacheKeysForServer(node.ServerId)...) if node.Protocol != "" { var cursor uint64 for { @@ -162,8 +187,7 @@ func (m *customServerModel) ClearNodeCache(ctx context.Context, params *FilterNo // ClearServerCache Clear Server Cache func (m *customServerModel) ClearServerCache(ctx context.Context, serverId int64) error { - var cacheKeys []string - cacheKeys = append(cacheKeys, fmt.Sprintf("%s%d", ServerUserListCacheKey, serverId)) + cacheKeys := ServerUserListCacheKeysForServer(serverId) var cursor uint64 for { keys, newCursor, err := m.Cache.Scan(ctx, cursor, fmt.Sprintf("%s%d*", ServerConfigCacheKey, serverId), 100).Result() diff --git a/internal/model/node/model_test.go b/internal/model/node/model_test.go new file mode 100644 index 0000000..b8a98ee --- /dev/null +++ b/internal/model/node/model_test.go @@ -0,0 +1,76 @@ +package node + +import ( + "fmt" + "sort" + "strings" + "testing" +) + +// TestServerUserListCacheKeysForServer 验证按 server 枚举出的协议缓存 key 覆盖 +// 所有客户端可能携带的 protocol(包括 hysteria2 兼容字段),形态为 +// `server:user:{server_id}:{protocol}`。HIF-1: Del 路径必须用枚举而非 SCAN。 +func TestServerUserListCacheKeysForServer(t *testing.T) { + const serverId int64 = 42 + + keys := ServerUserListCacheKeysForServer(serverId) + if len(keys) != len(AllProtocols) { + t.Fatalf("expected %d keys, got %d: %v", len(AllProtocols), len(keys), keys) + } + + got := make([]string, len(keys)) + copy(got, keys) + sort.Strings(got) + + want := make([]string, 0, len(AllProtocols)) + for _, p := range AllProtocols { + want = append(want, fmt.Sprintf("%s%d:%s", ServerUserListCacheKey, serverId, p)) + } + sort.Strings(want) + + for i := range got { + if got[i] != want[i] { + t.Errorf("key[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +// TestServerUserListCacheKeysForServer_NoLegacyFlatKey 验证清缓存路径不再使用 +// 旧的扁平 key `server:user:{server_id}`(无 protocol 维度)——旧 key 一旦再出现, +// 与 P0-1 写入的带 protocol key 形态不一致,Del 会失效,回归 HIF-1 缺陷。 +func TestServerUserListCacheKeysForServer_NoLegacyFlatKey(t *testing.T) { + const serverId int64 = 7 + + legacy := fmt.Sprintf("%s%d", ServerUserListCacheKey, serverId) + for _, key := range ServerUserListCacheKeysForServer(serverId) { + if key == legacy { + t.Fatalf("Del path still emits legacy flat key %q without protocol", legacy) + } + if !strings.HasPrefix(key, legacy+":") { + t.Errorf("key %q does not match `:` shape", key) + } + } +} + +// TestAllProtocolsContainsKnownProtocols 兜底保证 AllProtocols 至少覆盖关键协议; +// 缺失任一会让对应协议的用户列表缓存清不掉。 +func TestAllProtocolsContainsKnownProtocols(t *testing.T) { + required := []string{ + "shadowsocks", + "vmess", + "vless", + "trojan", + "tuic", + "hysteria", + "hysteria2", + } + set := make(map[string]struct{}, len(AllProtocols)) + for _, p := range AllProtocols { + set[p] = struct{}{} + } + for _, r := range required { + if _, ok := set[r]; !ok { + t.Errorf("AllProtocols missing %q", r) + } + } +} diff --git a/internal/model/order/model.go b/internal/model/order/model.go index 89d1b5f..d20b8d0 100644 --- a/internal/model/order/model.go +++ b/internal/model/order/model.go @@ -22,6 +22,8 @@ type Details struct { Price int64 `gorm:"type:int;not null;default:0;comment:Original price"` Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"` Discount int64 `gorm:"type:int;not null;default:0;comment:Order Discount"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;default:0;comment:Promo Rule ID"` + PromoDiscount int64 `gorm:"type:bigint;not null;default:0;comment:Promo Discount Amount"` Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"` CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount"` PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Id"` @@ -160,8 +162,8 @@ func (m *customOrderModel) QueryMonthlyOrders(ctx context.Context, date time.Tim Where("status IN ? AND created_at BETWEEN ? AND ? AND method != ?", []int64{2, 5}, firstDay, lastDay, "balance"). Select( "SUM(amount) as amount_total, " + - "SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " + - "SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount", + "SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " + + "SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount", ). Scan(v).Error }) @@ -177,8 +179,8 @@ func (m *customOrderModel) QueryDateOrders(ctx context.Context, date time.Time) Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance"). Select( "SUM(amount) as amount_total, " + - "SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " + - "SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount", + "SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " + + "SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount", ). Scan(v).Error }) @@ -192,8 +194,8 @@ func (m *customOrderModel) QueryTotalOrders(ctx context.Context) (OrdersTotal, e return conn.Model(&Order{}). Select(` SUM(amount) AS amount_total, - SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, - SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount + SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount, + SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount `). Where("status IN ? AND method != ?", []int64{2, 5}, "balance"). Scan(&result).Error @@ -214,8 +216,8 @@ func (m *customOrderModel) QueryMonthlyUserCounts(ctx context.Context, date time err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error { return conn.Model(&Order{}). Select(` - COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, - COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users + COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users, + COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users `). Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", []int64{2, 5}, firstDay, nextMonth, "balance"). @@ -232,8 +234,8 @@ func (m *customOrderModel) QueryDateUserCounts(ctx context.Context, date time.Ti err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error { return conn.Model(&Order{}). Select(` - COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, - COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users + COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users, + COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users `). Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance"). @@ -249,8 +251,8 @@ func (m *customOrderModel) QueryTotalUserCounts(ctx context.Context) (int64, int return conn.Model(&Order{}). Where("status IN ? AND method != ?", []int64{2, 5}, "balance"). Select(` - COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, - COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users + COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users, + COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users `). Scan(&counts).Error }) @@ -282,8 +284,8 @@ func (m *customOrderModel) QueryDailyOrdersList(ctx context.Context, date time.T Select(` DATE_FORMAT(created_at, '%Y-%m-%d') AS date, SUM(amount) AS amount_total, - SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, - SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount + SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount, + SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount `). Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", []int64{2, 5}, firstDay, nextDay, "balance"). @@ -326,8 +328,8 @@ func (m *customOrderModel) QueryMonthlyOrdersList(ctx context.Context, date time Select(` DATE_FORMAT(created_at, '%Y-%m') AS date, SUM(amount) AS amount_total, - SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, - SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount + SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount, + SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount `). Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", []int64{2, 5}, start, end, "balance"). diff --git a/internal/model/order/order.go b/internal/model/order/order.go index cd90153..f351273 100644 --- a/internal/model/order/order.go +++ b/internal/model/order/order.go @@ -3,32 +3,34 @@ package order import "time" type Order struct { - Id int64 `gorm:"primaryKey"` - ParentId int64 `gorm:"type:bigint;default:null;comment:Parent Order Id"` + Id int64 `gorm:"primaryKey"` + ParentId int64 `gorm:"type:bigint;default:null;comment:Parent Order Id"` UserId int64 `gorm:"type:bigint;not null;default:0;comment:User Id"` SubscriptionUserId int64 `gorm:"type:bigint;not null;default:0;comment:Target user ID for subscription (0=same as UserId)"` - OrderNo string `gorm:"type:varchar(255);not null;default:'';unique;comment:Order No"` - Type uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Type: 1: Subscribe, 2: Renewal, 3: ResetTraffic, 4: Recharge"` - Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"` - Price int64 `gorm:"type:int;not null;default:0;comment:Original price"` - Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"` - GiftAmount int64 `gorm:"type:int;not null;default:0;comment:User Gift Amount"` - Discount int64 `gorm:"type:int;not null;default:0;comment:Discount Amount"` - Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"` - CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount Amount"` - Commission int64 `gorm:"type:int;not null;default:0;comment:Order Commission"` - PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Method Id"` - Method string `gorm:"type:varchar(255);not null;default:'';comment:Payment Method"` - FeeAmount int64 `gorm:"type:int;not null;default:0;comment:Fee Amount"` - TradeNo string `gorm:"type:varchar(255);default:null;comment:Trade No"` - Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished;"` - SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"` - SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"` - AppAccountToken string `gorm:"type:varchar(36);default:null;comment:Apple IAP App Account Token (UUID)"` - ActivationContext string `gorm:"type:text;default:null;comment:Activation context JSON (guest/redemption info for DB fallback)"` - IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"` - CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` - UpdatedAt time.Time `gorm:"comment:Update Time"` + OrderNo string `gorm:"type:varchar(255);not null;default:'';unique;comment:Order No"` + Type uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Type: 1: Subscribe, 2: Renewal, 3: ResetTraffic, 4: Recharge"` + Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"` + Price int64 `gorm:"type:int;not null;default:0;comment:Original price"` + Amount int64 `gorm:"type:int;not null;default:0;comment:Order Amount"` + GiftAmount int64 `gorm:"type:int;not null;default:0;comment:User Gift Amount"` + Discount int64 `gorm:"type:int;not null;default:0;comment:Discount Amount"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;default:0;comment:Promo Rule ID"` + PromoDiscount int64 `gorm:"type:bigint;not null;default:0;comment:Promo Discount Amount"` + Coupon string `gorm:"type:varchar(255);default:null;comment:Coupon"` + CouponDiscount int64 `gorm:"type:int;not null;default:0;comment:Coupon Discount Amount"` + Commission int64 `gorm:"type:int;not null;default:0;comment:Order Commission"` + PaymentId int64 `gorm:"type:bigint;not null;default:0;comment:Payment Method Id"` + Method string `gorm:"type:varchar(255);not null;default:'';comment:Payment Method"` + FeeAmount int64 `gorm:"type:int;not null;default:0;comment:Fee Amount"` + TradeNo string `gorm:"type:varchar(255);default:null;comment:Trade No"` + Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished;"` + SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"` + SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"` + AppAccountToken string `gorm:"type:varchar(36);default:null;comment:Apple IAP App Account Token (UUID)"` + ActivationContext string `gorm:"type:text;default:null;comment:Activation context JSON (guest/redemption info for DB fallback)"` + IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` } type OrdersTotal struct { diff --git a/internal/model/promo/model.go b/internal/model/promo/model.go new file mode 100644 index 0000000..a9afb7e --- /dev/null +++ b/internal/model/promo/model.go @@ -0,0 +1,228 @@ +package promo + +import ( + "context" + "errors" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type RuleWithPrice struct { + Rule + PromoPrice int64 `gorm:"column:promo_price"` +} + +type Model interface { + QueryEligibleRules(ctx context.Context, subscribeId int64, quantity int64) ([]*RuleWithPrice, error) + InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error + InsertRule(ctx context.Context, data *Rule) error + FindRule(ctx context.Context, id int64) (*Rule, error) + UpdateRule(ctx context.Context, data *Rule) error + DeleteRule(ctx context.Context, id int64) error + QueryRuleList(ctx context.Context, page, size int, ruleType string, enabled *bool, search string) (int64, []*Rule, error) + UpsertPrices(ctx context.Context, ruleId int64, items []*SubscribePromo) error + FindPrice(ctx context.Context, id int64) (*SubscribePromo, error) + DeletePrice(ctx context.Context, id int64) error + QueryPriceList(ctx context.Context, params PriceFilter) (int64, []*SubscribePromo, error) + QueryUsageList(ctx context.Context, params UsageFilter) (int64, []*Usage, error) + Transaction(ctx context.Context, fn func(db *gorm.DB) error) error +} + +type UsageFilter struct { + Page int + Size int + RuleId int64 + UserId int64 + SubscribeId int64 + OrderNo string +} + +type PriceFilter struct { + Page int + Size int + RuleId int64 + SubscribeId int64 +} + +type defaultPromoModel struct { + db *gorm.DB +} + +func NewModel(db *gorm.DB, _ *redis.Client) Model { + return &defaultPromoModel{db: db} +} + +func (m *defaultPromoModel) QueryEligibleRules(ctx context.Context, subscribeId int64, quantity int64) ([]*RuleWithPrice, error) { + var list []*RuleWithPrice + err := m.db.WithContext(ctx). + Table("promo_rule AS pr"). + Select("pr.*, sp.promo_price"). + Joins("JOIN subscribe_promo AS sp ON sp.promo_rule_id = pr.id"). + Where("sp.subscribe_id = ? AND sp.quantity = ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeId, quantity, true). + Where("pr.deleted_at IS NULL"). + Order("pr.priority DESC"). + Order("pr.id ASC"). + Find(&list).Error + return list, err +} + +func (m *defaultPromoModel) InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error { + db := m.db.WithContext(ctx) + if len(tx) > 0 { + db = tx[0].WithContext(ctx) + } + return db.Model(&Usage{}).Create(data).Error +} + +func (m *defaultPromoModel) InsertRule(ctx context.Context, data *Rule) error { + return m.db.WithContext(ctx).Create(data).Error +} + +func (m *defaultPromoModel) FindRule(ctx context.Context, id int64) (*Rule, error) { + var resp Rule + if err := m.db.WithContext(ctx).Model(&Rule{}).Where("id = ?", id).First(&resp).Error; err != nil { + return nil, err + } + return &resp, nil +} + +func (m *defaultPromoModel) UpdateRule(ctx context.Context, data *Rule) error { + return m.db.WithContext(ctx).Model(&Rule{}).Where("id = ?", data.Id).Updates(map[string]interface{}{ + "name": data.Name, + "type": data.Type, + "params": data.Params, + "priority": data.Priority, + "enabled": data.Enabled, + "start_time": data.StartTime, + "end_time": data.EndTime, + }).Error +} + +func (m *defaultPromoModel) DeleteRule(ctx context.Context, id int64) error { + return m.db.WithContext(ctx).Delete(&Rule{}, id).Error +} + +func (m *defaultPromoModel) QueryRuleList(ctx context.Context, page, size int, ruleType string, enabled *bool, search string) (int64, []*Rule, error) { + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 10 + } + var total int64 + var list []*Rule + db := m.db.WithContext(ctx).Model(&Rule{}) + if ruleType != "" { + db = db.Where("type = ?", ruleType) + } + if enabled != nil { + db = db.Where("enabled = ?", *enabled) + } + if search != "" { + db = db.Where("name LIKE ?", "%"+search+"%") + } + if err := db.Count(&total).Error; err != nil { + return 0, nil, err + } + err := db.Order("priority DESC").Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&list).Error + return total, list, err +} + +func (m *defaultPromoModel) UpsertPrices(ctx context.Context, ruleId int64, items []*SubscribePromo) error { + return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, item := range items { + if item == nil { + continue + } + item.PromoRuleId = ruleId + var existing SubscribePromo + err := tx.Model(&SubscribePromo{}). + Where("subscribe_id = ? AND quantity = ? AND promo_rule_id = ?", item.SubscribeId, item.Quantity, ruleId). + First(&existing).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if errors.Is(err, gorm.ErrRecordNotFound) { + if err := tx.Create(item).Error; err != nil { + return err + } + continue + } + existing.Quantity = item.Quantity + existing.PromoPrice = item.PromoPrice + if err := tx.Save(&existing).Error; err != nil { + return err + } + } + return nil + }) +} + +func (m *defaultPromoModel) FindPrice(ctx context.Context, id int64) (*SubscribePromo, error) { + var resp SubscribePromo + if err := m.db.WithContext(ctx).Model(&SubscribePromo{}).Where("id = ?", id).First(&resp).Error; err != nil { + return nil, err + } + return &resp, nil +} + +func (m *defaultPromoModel) DeletePrice(ctx context.Context, id int64) error { + return m.db.WithContext(ctx).Delete(&SubscribePromo{}, id).Error +} + +func (m *defaultPromoModel) QueryPriceList(ctx context.Context, params PriceFilter) (int64, []*SubscribePromo, error) { + if params.Page <= 0 { + params.Page = 1 + } + if params.Size <= 0 { + params.Size = 10 + } + var total int64 + var list []*SubscribePromo + db := m.db.WithContext(ctx).Model(&SubscribePromo{}) + if params.RuleId > 0 { + db = db.Where("promo_rule_id = ?", params.RuleId) + } + if params.SubscribeId > 0 { + db = db.Where("subscribe_id = ?", params.SubscribeId) + } + if err := db.Count(&total).Error; err != nil { + return 0, nil, err + } + err := db.Order("id DESC").Limit(params.Size).Offset((params.Page - 1) * params.Size).Find(&list).Error + return total, list, err +} + +func (m *defaultPromoModel) QueryUsageList(ctx context.Context, params UsageFilter) (int64, []*Usage, error) { + if params.Page <= 0 { + params.Page = 1 + } + if params.Size <= 0 { + params.Size = 10 + } + var total int64 + var list []*Usage + db := m.db.WithContext(ctx).Model(&Usage{}) + if params.RuleId > 0 { + db = db.Where("promo_rule_id = ?", params.RuleId) + } + if params.UserId > 0 { + db = db.Where("user_id = ?", params.UserId) + } + if params.SubscribeId > 0 { + db = db.Where("subscribe_id = ?", params.SubscribeId) + } + if params.OrderNo != "" { + db = db.Where("order_no = ?", params.OrderNo) + } + if err := db.Count(&total).Error; err != nil { + return 0, nil, err + } + err := db.Order("id DESC").Limit(params.Size).Offset((params.Page - 1) * params.Size).Find(&list).Error + return total, list, err +} + +func (m *defaultPromoModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error { + return m.db.WithContext(ctx).Transaction(fn) +} diff --git a/internal/model/promo/promo.go b/internal/model/promo/promo.go new file mode 100644 index 0000000..daf926b --- /dev/null +++ b/internal/model/promo/promo.go @@ -0,0 +1,59 @@ +package promo + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + RuleTypeNewUser = "new_user" + RuleTypeInactiveUser = "inactive_user" + RuleTypeCampaign = "campaign" +) + +type Rule struct { + Id int64 `gorm:"primaryKey"` + Name string `gorm:"type:varchar(100);not null;default:'';comment:Rule Name"` + Type string `gorm:"type:varchar(32);not null;default:'';comment:Rule Type"` + Params string `gorm:"type:json;not null;comment:Rule Params"` + Priority int64 `gorm:"type:int;not null;default:0;comment:Priority"` + Enabled bool `gorm:"type:tinyint(1);not null;default:1;comment:Enabled"` + StartTime *time.Time `gorm:"default:null;comment:Start Time"` + EndTime *time.Time `gorm:"default:null;comment:End Time"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:Delete Time"` +} + +func (Rule) TableName() string { + return "promo_rule" +} + +type SubscribePromo struct { + Id int64 `gorm:"primaryKey"` + SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"` + Quantity int64 `gorm:"type:int;not null;default:0;comment:购买数量"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;comment:Promo Rule ID"` + PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` +} + +func (SubscribePromo) TableName() string { + return "subscribe_promo" +} + +type Usage struct { + Id int64 `gorm:"primaryKey"` + UserId int64 `gorm:"type:bigint unsigned;not null;comment:User ID"` + PromoRuleId int64 `gorm:"type:bigint unsigned;not null;comment:Promo Rule ID"` + SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"` + OrderNo string `gorm:"type:varchar(255);not null;default:'';comment:Order No"` + PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` +} + +func (Usage) TableName() string { + return "promo_usage" +} diff --git a/internal/model/subscribe/default.go b/internal/model/subscribe/default.go index a8d63bc..945eb02 100644 --- a/internal/model/subscribe/default.go +++ b/internal/model/subscribe/default.go @@ -71,7 +71,7 @@ func (m *defaultSubscribeModel) getCacheKeys(data *Subscribe) []string { }) if err == nil { for _, n := range nodes { - keys = append(keys, fmt.Sprintf("%s%d", node.ServerUserListCacheKey, n.ServerId)) + keys = append(keys, node.ServerUserListCacheKeysForServer(n.ServerId)...) } } } @@ -83,7 +83,7 @@ func (m *defaultSubscribeModel) getCacheKeys(data *Subscribe) []string { }) if err == nil { for _, n := range nodes { - keys = append(keys, fmt.Sprintf("%s%d", node.ServerUserListCacheKey, n.ServerId)) + keys = append(keys, node.ServerUserListCacheKeysForServer(n.ServerId)...) } } } diff --git a/internal/model/user/model.go b/internal/model/user/model.go index ef9c5e8..8156f42 100644 --- a/internal/model/user/model.go +++ b/internal/model/user/model.go @@ -23,25 +23,27 @@ const ( ) type SubscribeDetails struct { - Id int64 `gorm:"primarykey"` - UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"` - User *User `gorm:"foreignKey:UserId;references:Id"` - OrderId int64 `gorm:"index:idx_order_id;not null;comment:Order ID"` - SubscribeId int64 `gorm:"index:idx_subscribe_id;not null;comment:Subscription ID"` - Subscribe *subscribe.Subscribe `gorm:"foreignKey:SubscribeId;references:Id"` - NodeGroupId int64 `gorm:"index:idx_node_group_id;not null;default:0;comment:Node Group ID (single ID)"` - StartTime time.Time `gorm:"default:CURRENT_TIMESTAMP(3);not null;comment:Subscription Start Time"` - ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"` - FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"` - Traffic int64 `gorm:"default:0;comment:Traffic"` - Download int64 `gorm:"default:0;comment:Download Traffic"` - Upload int64 `gorm:"default:0;comment:Upload Traffic"` - Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"` - UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"` - Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired; 4: Cancelled"` - Note string `gorm:"type:varchar(500);default:'';comment:User note for subscription"` - CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` - UpdatedAt time.Time `gorm:"comment:Update Time"` + Id int64 `gorm:"primarykey"` + UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"` + User *User `gorm:"foreignKey:UserId;references:Id"` + OrderId int64 `gorm:"index:idx_order_id;not null;comment:Order ID"` + SubscribeId int64 `gorm:"index:idx_subscribe_id;not null;comment:Subscription ID"` + Subscribe *subscribe.Subscribe `gorm:"foreignKey:SubscribeId;references:Id"` + NodeGroupId int64 `gorm:"index:idx_node_group_id;not null;default:0;comment:Node Group ID (single ID)"` + StartTime time.Time `gorm:"default:CURRENT_TIMESTAMP(3);not null;comment:Subscription Start Time"` + ExpireTime time.Time `gorm:"default:NULL;comment:Subscription Expire Time"` + FinishedAt *time.Time `gorm:"default:NULL;comment:Finished Time"` + Traffic int64 `gorm:"default:0;comment:Traffic"` + Download int64 `gorm:"default:0;comment:Download Traffic"` + Upload int64 `gorm:"default:0;comment:Upload Traffic"` + SpeedLimit int64 `gorm:"default:0;comment:User-level speed limit override (Mbps), 0 uses plan-level"` + TrafficLimit *string `gorm:"type:text;default:null;comment:User-level traffic limit override (JSON), NULL uses plan-level"` + Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"` + UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"` + Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired; 4: Cancelled"` + Note string `gorm:"type:varchar(500);default:'';comment:User note for subscription"` + CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` } type SubscribeLogFilterParams struct { diff --git a/internal/model/user/subscribe.go b/internal/model/user/subscribe.go index 61613a5..296e124 100644 --- a/internal/model/user/subscribe.go +++ b/internal/model/user/subscribe.go @@ -67,7 +67,7 @@ func (m *defaultUserModel) FindSingleModeAnchorSubscribe(ctx context.Context, us var data Subscribe err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, _ interface{}) error { return conn.Model(&Subscribe{}). - Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%') AND `status` IN ?", userId, []int64{0, 1, 2, 3, 4, 5}). + Where("user_id = ? AND (order_id > 0 OR token LIKE 'iap:%') AND `status` IN ?", userId, []int64{0, 1, 2, 3, 4, 5}). Order("expire_time DESC"). Order("updated_at DESC"). Order("id DESC"). diff --git a/internal/model/user/user.go b/internal/model/user/user.go index caafcb0..7c3b791 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -103,6 +103,8 @@ type Subscribe struct { Upload int64 `gorm:"default:0;comment:Upload Traffic"` ExpiredDownload int64 `gorm:"default:0;comment:Expired period download traffic (bytes)"` ExpiredUpload int64 `gorm:"default:0;comment:Expired period upload traffic (bytes)"` + SpeedLimit int64 `gorm:"default:0;comment:User-level speed limit override (Mbps), 0 uses plan-level"` + TrafficLimit *string `gorm:"type:text;default:null;comment:User-level traffic limit override (JSON), NULL uses plan-level"` Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"` UUID string `gorm:"type:varchar(255);unique;index:idx_uuid;default:'';comment:UUID"` Status uint8 `gorm:"type:tinyint(1);default:0;comment:Subscription Status: 0: Pending 1: Active 2: Finished 3: Expired 4: Deducted 5: stopped"` diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index e1c5aaa..168b626 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -21,6 +21,7 @@ import ( logmessage "github.com/perfect-panel/server/internal/model/logmessage" "github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/payment" + "github.com/perfect-panel/server/internal/model/promo" "github.com/perfect-panel/server/internal/model/subscribe" "github.com/perfect-panel/server/internal/model/system" "github.com/perfect-panel/server/internal/model/ticket" @@ -45,7 +46,7 @@ type ServiceContext struct { ExchangeRate float64 GeoIP *IPLocation SignatureValidator *signature.Validator - S3Store *storage.S3Store + S3Store *storage.S3Store //NodeCache *cache.NodeCacheClient AuthModel auth.Model @@ -63,6 +64,7 @@ type ServiceContext struct { RedemptionCodeModel redemption.RedemptionCodeModel RedemptionRecordModel redemption.RedemptionRecordModel PaymentModel payment.Model + PromoModel promo.Model DocumentModel document.Model SubscribeModel subscribe.Model TrafficLogModel traffic.Model @@ -138,6 +140,7 @@ func NewServiceContext(c config.Config) *ServiceContext { RedemptionCodeModel: redemption.NewRedemptionCodeModel(db, rds), RedemptionRecordModel: redemption.NewRedemptionRecordModel(db, rds), PaymentModel: payment.NewModel(db, rds), + PromoModel: promo.NewModel(db, rds), DocumentModel: document.NewModel(db, rds), SubscribeModel: subscribe.NewModel(db, rds), TrafficLogModel: traffic.NewModel(db), diff --git a/internal/types/promo_validation_test.go b/internal/types/promo_validation_test.go new file mode 100644 index 0000000..bfb58a9 --- /dev/null +++ b/internal/types/promo_validation_test.go @@ -0,0 +1,58 @@ +package types + +import ( + "testing" + + "github.com/go-playground/validator/v10" +) + +func TestPromoPriceItemsMustNotBeEmpty(t *testing.T) { + validate := validator.New() + req := SetPromoPriceRequest{ + PromoRuleId: 1, + Items: []PromoPriceItem{}, + } + + if err := validate.Struct(req); err == nil { + t.Fatal("expected empty promo price items to fail validation") + } +} + +func TestPromoListPageSizeLimit(t *testing.T) { + validate := validator.New() + tests := []struct { + name string + req any + }{ + { + name: "rule list", + req: GetPromoRuleListRequest{ + Page: 1, + Size: 201, + }, + }, + { + name: "price list", + req: GetPromoPriceListRequest{ + RuleId: 1, + Page: 1, + Size: 201, + }, + }, + { + name: "usage list", + req: GetPromoUsageListRequest{ + Page: 1, + Size: 201, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validate.Struct(tt.req); err == nil { + t.Fatal("expected page size greater than 200 to fail validation") + } + }) + } +} diff --git a/internal/types/types.go b/internal/types/types.go index 1717659..b9a18a0 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -3,15 +3,21 @@ package types -import "encoding/json" - type ActivateOrderRequest struct { OrderNo string `json:"order_no" validate:"required"` } -type RefundOrderRequest struct { - Id int64 `json:"id" validate:"required"` - Reason string `json:"reason,omitempty" validate:"omitempty,max=500"` +type AdminInvitedUser struct { + Id int64 `json:"id"` + Avatar string `json:"avatar"` + Identifier string `json:"identifier"` + Enable bool `json:"enable"` + CreatedAt int64 `json:"created_at"` + OrderCount int64 `json:"order_count"` + HasPurchased bool `json:"has_purchased"` + InviterCommission int64 `json:"inviter_commission"` + InviterGiftDays int64 `json:"inviter_gift_days"` + InviteeGiftDays int64 `json:"invitee_gift_days"` } type Ads struct { @@ -126,6 +132,10 @@ type ApplicationVersion struct { IsDefault bool `json:"is_default"` } +type ApproveWithdrawalRequest struct { + WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` +} + type AttachAppleTransactionByIdRequest struct { OrderNo string `json:"order_no" validate:"required"` TransactionId string `json:"transaction_id" validate:"required"` @@ -251,6 +261,10 @@ type BindTelegramResponse struct { ExpiredAt int64 `json:"expired_at"` } +type CancelWithdrawalRequest struct { + WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` +} + type CheckUserRequest struct { Email string `form:"email" validate:"required"` } @@ -295,6 +309,16 @@ type CommissionLog struct { Timestamp int64 `json:"timestamp"` } +type CommissionReturnLog struct { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + Amount int64 `json:"amount"` + EventType uint16 `json:"event_type"` + Content string `json:"content"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + type CommissionWithdrawRequest struct { Amount int64 `json:"amount"` Content string `json:"content"` @@ -436,6 +460,16 @@ type CreatePaymentMethodRequest struct { Enable *bool `json:"enable" validate:"required"` } +type CreatePromoRuleRequest struct { + Name string `json:"name" validate:"required,max=100"` + Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority" validate:"gte=0"` + Enabled *bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` +} + type CreateQuotaTaskRequest struct { Subscribers []int64 `json:"subscribers"` IsActive *bool `json:"is_active"` @@ -541,10 +575,12 @@ type CreateUserRequest struct { } type CreateUserSubscribeRequest struct { - UserId int64 `json:"user_id"` - ExpiredAt int64 `json:"expired_at"` - Traffic int64 `json:"traffic"` - SubscribeId int64 `json:"subscribe_id"` + UserId int64 `json:"user_id"` + ExpiredAt int64 `json:"expired_at"` + Traffic int64 `json:"traffic"` + SubscribeId int64 `json:"subscribe_id"` + SpeedLimit int64 `json:"speed_limit,optional"` + TrafficLimit string `json:"traffic_limit,optional"` } type CreateUserTicketFollowRequest struct { @@ -617,6 +653,14 @@ type DeletePaymentMethodRequest struct { Id int64 `json:"id" validate:"required"` } +type DeletePromoPriceRequest struct { + Id int64 `uri:"id" validate:"required,gt=0"` +} + +type DeletePromoRuleRequest struct { + Id int64 `uri:"id" validate:"required,gt=0"` +} + type DeleteRedemptionCodeRequest struct { Id int64 `json:"id" validate:"required"` } @@ -772,31 +816,12 @@ type FamilySummary struct { UpdatedAt int64 `json:"updated_at"` } -type FileUploadRequest struct { - BizType string `form:"biz_type" validate:"required"` -} - -type FileUploadResponse struct { - FileId string `json:"file_id"` - FileName string `json:"file_name"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` -} - type FileUploadCompleteRequest struct { FileId string `json:"file_id" validate:"required"` } type FileUploadCompleteResponse struct { - FileId string `json:"file_id"` - ObjectKey string `json:"object_key"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` - Etag string `json:"etag"` - Status string `json:"status"` + Url string `json:"url"` } type FileUploadInitRequest struct { @@ -816,6 +841,14 @@ type FileUploadInitResponse struct { ExpiredAt int64 `json:"expired_at"` } +type FileUploadRequest struct { + BizType string `form:"biz_type" validate:"required"` +} + +type FileUploadResponse struct { + Url string `json:"url"` +} + type FilterBalanceLogRequest struct { FilterLogParams UserId int64 `form:"user_id,optional"` @@ -836,17 +869,6 @@ type FilterCommissionLogResponse struct { List []CommissionLog `json:"list"` } -type FilterOrderRefundLogRequest struct { - FilterLogParams - OrderId int64 `form:"order_id,optional"` - UserId int64 `form:"user_id,optional"` -} - -type FilterOrderRefundLogResponse struct { - Total int64 `json:"total"` - List []OrderRefundLog `json:"list"` -} - type FilterEmailLogResponse struct { Total int64 `json:"total"` List []MessageLog `json:"list"` @@ -896,6 +918,17 @@ type FilterNodeListResponse struct { List []Node `json:"list"` } +type FilterOrderRefundLogRequest struct { + FilterLogParams + OrderId int64 `form:"order_id,optional"` + UserId int64 `form:"user_id,optional"` +} + +type FilterOrderRefundLogResponse struct { + Total int64 `json:"total"` + List []OrderRefundLog `json:"list"` +} + type FilterRegisterLogRequest struct { FilterLogParams UserId int64 `form:"user_id,optional"` @@ -987,6 +1020,32 @@ type GenerateCaptchaResponse struct { BlockImage string `json:"block_image,omitempty"` } +type GetAdminUserInviteListRequest struct { + UserId int64 `form:"user_id" validate:"required"` + Page int `form:"page"` + Size int `form:"size"` + Search string `form:"search"` + Enable *int `form:"enable"` + UserIdSearch int64 `form:"user_id_search"` +} + +type GetAdminUserInviteListResponse struct { + Total int64 `json:"total"` + List []AdminInvitedUser `json:"list"` +} + +type GetAdminUserInviteStatsRequest struct { + UserId int64 `form:"user_id" validate:"required"` +} + +type GetAdminUserInviteStatsResponse struct { + InviteCount int64 `json:"invite_count"` + TotalCommission int64 `json:"total_commission"` + CurrentCommission int64 `json:"current_commission"` + ReferralPercentage uint8 `json:"referral_percentage"` + OnlyFirstPurchase bool `json:"only_first_purchase"` +} + type GetAdsDetailRequest struct { Id int64 `form:"id"` } @@ -1245,6 +1304,31 @@ type GetGroupHistoryResponse struct { List []GroupHistory `json:"list"` } +type GetInviteManageListRequest struct { + Page int `form:"page"` + Size int `form:"size"` + Search string `form:"search"` + InviterId int64 `form:"inviter_id"` + InviteeId int64 `form:"invitee_id"` +} + +type GetInviteManageListResponse struct { + Total int64 `json:"total"` + List []InviteManageRecord `json:"list"` +} + +type GetInviteRecordsRequest struct { + Page int `form:"page"` + Size int `form:"size"` + StartTime int64 `form:"start_time"` + EndTime int64 `form:"end_time"` +} + +type GetInviteRecordsResponse struct { + Total int64 `json:"total"` + List []InviteRecord `json:"list"` +} + type GetInviteSalesRequest struct { Page int `form:"page"` Size int `form:"size"` @@ -1257,6 +1341,32 @@ type GetInviteSalesResponse struct { List []InvitedUserSale `json:"list"` } +type GetLogMessageRawRequest struct { + Id int64 `form:"id" validate:"required"` +} + +type GetLogMessageRawResponse struct { + Id int64 `json:"id"` + Platform string `json:"platform"` + AppVersion string `json:"app_version"` + OsName string `json:"os_name"` + OsVersion string `json:"os_version"` + DeviceId string `json:"device_id"` + UserId *int64 `json:"user_id"` + SessionId string `json:"session_id"` + Level uint8 `json:"level"` + ErrorCode string `json:"error_code"` + Message string `json:"message"` + Stack string `json:"stack"` + Context interface{} `json:"context"` + ClientIP string `json:"client_ip"` + UserAgent string `json:"user_agent"` + Locale string `json:"locale"` + Digest string `json:"digest"` + OccurredAt int64 `json:"occurred_at"` + CreatedAt int64 `json:"created_at"` +} + type GetLoginLogRequest struct { Page int `form:"page"` Size int `form:"size"` @@ -1335,6 +1445,49 @@ type GetPreSendEmailCountResponse struct { Count int64 `json:"count"` } +type GetPromoPriceListRequest struct { + Page int64 `form:"page" validate:"required,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` + RuleId int64 `form:"rule_id,omitempty"` + SubscribeId int64 `form:"subscribe_id,omitempty"` +} + +type GetPromoPriceListResponse struct { + Total int64 `json:"total"` + List []PromoPrice `json:"list"` +} + +type GetPromoRuleDetailRequest struct { + Id int64 `uri:"id" validate:"required,gt=0"` +} + +type GetPromoRuleListRequest struct { + Page int64 `form:"page" validate:"required,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` + Type string `form:"type" validate:"omitempty,oneof=new_user inactive_user campaign"` + Enabled *bool `form:"enabled"` + Search string `form:"search,omitempty"` +} + +type GetPromoRuleListResponse struct { + Total int64 `json:"total"` + List []PromoRule `json:"list"` +} + +type GetPromoUsageListRequest struct { + Page int64 `form:"page" validate:"required,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` + RuleId int64 `form:"rule_id,omitempty"` + UserId int64 `form:"user_id,omitempty"` + SubscribeId int64 `form:"subscribe_id,omitempty"` + OrderNo string `form:"order_no,omitempty"` +} + +type GetPromoUsageListResponse struct { + Total int64 `json:"total"` + List []PromoUsage `json:"list"` +} + type GetRedemptionCodeListRequest struct { Page int64 `form:"page" validate:"required"` Size int64 `form:"size" validate:"required"` @@ -1626,6 +1779,19 @@ type GetUserTrafficStatsResponse struct { TotalTraffic int64 `json:"total_traffic"` } +type GetWithdrawalListRequest struct { + Page int `form:"page"` + Size int `form:"size"` + UserId *int64 `form:"user_id,omitempty"` + Status *uint8 `form:"status,omitempty"` + Method *uint8 `form:"method,omitempty"` +} + +type GetWithdrawalListResponse struct { + List []WithdrawalLog `json:"list"` + Total int64 `json:"total"` +} + type GiftLog struct { Type uint16 `json:"type"` UserId int64 `json:"user_id"` @@ -1686,6 +1852,31 @@ type InviteConfig struct { GiftDays int64 `json:"gift_days"` } +type InviteManageRecord struct { + InviterId int64 `json:"inviter_id"` + InviterIdentifier string `json:"inviter_identifier"` + InviterDeviceNo string `json:"inviter_device_no"` + InviteeId int64 `json:"invitee_id"` + InviteeIdentifier string `json:"invitee_identifier"` + InviteeDeviceNo string `json:"invitee_device_no"` + InviteeAvatar string `json:"invitee_avatar"` + InviteeEnable bool `json:"invitee_enable"` + InvitedAt int64 `json:"invited_at"` + OrderCount int64 `json:"order_count"` + HasPurchased bool `json:"has_purchased"` + InviterCommission int64 `json:"inviter_commission"` + InviterGiftDays int64 `json:"inviter_gift_days"` + InviteeGiftDays int64 `json:"invitee_gift_days"` +} + +type InviteRecord struct { + Role string `json:"role"` + PeerHash string `json:"peer_hash"` + GiftDays int64 `json:"gift_days"` + OrderNo string `json:"order_no"` + CreatedAt int64 `json:"created_at"` +} + type InvitedUserSale struct { Amount float64 `json:"amount"` UpdatedAt int64 `json:"updated_at"` @@ -2010,6 +2201,7 @@ type PreOrderResponse struct { Price int64 `json:"price"` Amount int64 `json:"amount"` Discount int64 `json:"discount"` + PromoDiscount int64 `json:"promo_discount"` GiftAmount int64 `json:"gift_amount"` Coupon string `json:"coupon"` CouponDiscount int64 `json:"coupon_discount"` @@ -2070,6 +2262,45 @@ type PrivacyPolicyConfig struct { PrivacyPolicy string `json:"privacy_policy"` } +type PromoPrice struct { + Id int64 `json:"id"` + SubscribeId int64 `json:"subscribe_id"` + PromoRuleId int64 `json:"promo_rule_id"` + Quantity int64 `json:"quantity"` + PromoPrice int64 `json:"promo_price"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type PromoPriceItem struct { + SubscribeId int64 `json:"subscribe_id" validate:"required,gt=0"` + Quantity int64 `json:"quantity" validate:"required,gt=0,lte=1000"` + PromoPrice int64 `json:"promo_price" validate:"required,gt=0"` +} + +type PromoRule struct { + Id int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority"` + Enabled bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type PromoUsage struct { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + PromoRuleId int64 `json:"promo_rule_id"` + SubscribeId int64 `json:"subscribe_id"` + OrderNo string `json:"order_no"` + PromoPrice int64 `json:"promo_price"` + CreatedAt int64 `json:"created_at"` +} + type Protocol struct { Type string `json:"type"` Port uint16 `json:"port"` @@ -2155,6 +2386,16 @@ type QueryAnnouncementResponse struct { List []Announcement `json:"announcements"` } +type QueryCommissionReturnLogRequest struct { + Page int `form:"page"` + Size int `form:"size"` +} + +type QueryCommissionReturnLogResponse struct { + List []CommissionReturnLog `json:"list"` + Total int64 `json:"total"` +} + type QueryDocumentDetailRequest struct { Id int64 `form:"id" validate:"required"` } @@ -2318,40 +2559,25 @@ type QueryUserSubscribeNodeListResponse struct { List []UserSubscribeInfo `json:"list"` } -type CancelWithdrawalRequest struct { - WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` +type QueryWithdrawalLogListRequest struct { + Page int `form:"page"` + Size int `form:"size"` + BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"` } -type QueryWithdrawalLogListRequest struct { - Page int `form:"page"` - Size int `form:"size"` +type WithdrawalLogSummary struct { + CommissionBalance int64 `json:"commission_balance"` + LockedByPending int64 `json:"locked_by_pending"` + AvailableToWithdraw int64 `json:"available_to_withdraw"` + TotalHistoricalAmount int64 `json:"total_historical_amount"` + TotalRefundedAmount int64 `json:"total_refunded_amount"` + TotalIncomeAmount int64 `json:"total_income_amount"` } type QueryWithdrawalLogListResponse struct { - List []WithdrawalLog `json:"list"` - Total int64 `json:"total"` -} - -type GetWithdrawalListRequest struct { - Page int `form:"page"` - Size int `form:"size"` - UserId *int64 `form:"user_id,omitempty"` - Status *uint8 `form:"status,omitempty"` - Method *uint8 `form:"method,omitempty"` -} - -type GetWithdrawalListResponse struct { - List []WithdrawalLog `json:"list"` - Total int64 `json:"total"` -} - -type ApproveWithdrawalRequest struct { - WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` -} - -type RejectWithdrawalRequest struct { - WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` - Reason string `json:"reason" validate:"required,max=500"` + List []WithdrawalLog `json:"list"` + Total int64 `json:"total"` + Summary *WithdrawalLogSummary `json:"summary,omitempty"` } type QuotaTask struct { @@ -2440,6 +2666,11 @@ type RedemptionRecord struct { CreatedAt int64 `json:"created_at"` } +type RefundOrderRequest struct { + Id int64 `json:"id" validate:"required"` + Reason string `json:"reason,omitempty" validate:"omitempty,max=500"` +} + type RegisterConfig struct { StopRegister bool `json:"stop_register"` EnableTrial bool `json:"enable_trial"` @@ -2463,6 +2694,11 @@ type RegisterLog struct { Timestamp int64 `json:"timestamp"` } +type RejectWithdrawalRequest struct { + WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"` + Reason string `json:"reason" validate:"required,max=500"` +} + type RemoveFamilyMemberRequest struct { FamilyId int64 `json:"family_id" validate:"required,gt=0"` UserId int64 `json:"user_id" validate:"required,gt=0"` @@ -2721,6 +2957,11 @@ type SetNodeMultiplierRequest struct { Periods []TimePeriod `json:"periods"` } +type SetPromoPriceRequest struct { + PromoRuleId int64 `json:"promo_rule_id" validate:"required,gt=0"` + Items []PromoPriceItem `json:"items" validate:"required,min=1,dive"` +} + type Shadowsocks struct { Method string `json:"method" validate:"required"` Port int `json:"port" validate:"required"` @@ -2846,10 +3087,11 @@ type SubscribeConfig struct { } type SubscribeDiscount struct { - Quantity int64 `json:"quantity"` - Discount float64 `json:"discount"` - NewUserOnly bool `json:"new_user_only"` - MapApple string `json:"map_apple"` + Quantity int64 `json:"quantity"` + Discount float64 `json:"discount"` + NewUserOnly bool `json:"new_user_only"` + MapApple string `json:"map_apple"` + Promo *SubscribePromo `json:"promo"` } type SubscribeGroup struct { @@ -2879,6 +3121,13 @@ type SubscribeLog struct { Timestamp int64 `json:"timestamp"` } +type SubscribePromo struct { + RuleName string `json:"rule_name"` + RuleType string `json:"rule_type"` + PromoPrice int64 `json:"promo_price"` + ExpiresAt int64 `json:"expires_at"` +} + type SubscribeSortRequest struct { Sort []SortItem `json:"sort"` } @@ -3192,6 +3441,17 @@ type UpdatePaymentMethodRequest struct { Enable *bool `json:"enable" validate:"required"` } +type UpdatePromoRuleRequest struct { + Id int64 `uri:"id" validate:"required,gt=0"` + Name string `json:"name" validate:"required,max=100"` + Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority" validate:"gte=0"` + Enabled *bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` +} + type UpdateRedemptionCodeRequest struct { Id int64 `json:"id" validate:"required"` TotalCount int64 `json:"total_count,omitempty"` @@ -3317,12 +3577,14 @@ type UpdateUserSubscribeNoteRequest struct { } type UpdateUserSubscribeRequest struct { - UserSubscribeId int64 `json:"user_subscribe_id"` - SubscribeId int64 `json:"subscribe_id"` - Traffic int64 `json:"traffic"` - ExpiredAt int64 `json:"expired_at"` - Upload int64 `json:"upload"` - Download int64 `json:"download"` + UserSubscribeId int64 `json:"user_subscribe_id"` + SubscribeId int64 `json:"subscribe_id"` + Traffic int64 `json:"traffic"` + ExpiredAt int64 `json:"expired_at"` + Upload int64 `json:"upload"` + Download int64 `json:"download"` + SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"` + TrafficLimit []TrafficLimit `json:"traffic_limit,omitempty"` } type UpdateUserTicketStatusRequest struct { @@ -3348,7 +3610,7 @@ type User struct { EnableLoginNotify bool `json:"enable_login_notify"` EnableSubscribeNotify bool `json:"enable_subscribe_notify"` EnableTradeNotify bool `json:"enable_trade_notify"` - UseStatus bool `json:"use_status"` + UseStatus bool `json:"use_status"` // Whether to show the "bind email to get free trial" prompt AuthMethods []UserAuthMethod `json:"auth_methods"` UserDevices []UserDevice `json:"user_devices"` Rules []string `json:"rules"` @@ -3447,57 +3709,63 @@ type UserStatisticsResponse struct { } type UserSubscribe struct { - Id int64 `json:"id"` - IdStr string `json:"id_str"` - UserId int64 `json:"user_id"` - OrderId int64 `json:"order_id"` - SubscribeId int64 `json:"subscribe_id"` - Subscribe Subscribe `json:"subscribe"` - NodeGroupId int64 `json:"node_group_id"` - NodeGroupName string `json:"node_group_name"` - StartTime int64 `json:"start_time"` - ExpireTime int64 `json:"expire_time"` - FinishedAt int64 `json:"finished_at"` - ResetTime int64 `json:"reset_time"` - Traffic int64 `json:"traffic"` - Download int64 `json:"download"` - Upload int64 `json:"upload"` - Token string `json:"token"` - Status uint8 `json:"status"` - EntitlementSource string `json:"entitlement_source"` - EntitlementOwnerUserId int64 `json:"entitlement_owner_user_id"` - ReadOnly bool `json:"read_only"` - IsGift bool `json:"is_gift"` - Short string `json:"short"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + Id int64 `json:"id"` + IdStr string `json:"id_str"` + UserId int64 `json:"user_id"` + OrderId int64 `json:"order_id"` + SubscribeId int64 `json:"subscribe_id"` + Subscribe Subscribe `json:"subscribe"` + NodeGroupId int64 `json:"node_group_id"` + NodeGroupName string `json:"node_group_name"` + StartTime int64 `json:"start_time"` + ExpireTime int64 `json:"expire_time"` + FinishedAt int64 `json:"finished_at"` + ResetTime int64 `json:"reset_time"` + Traffic int64 `json:"traffic"` + Download int64 `json:"download"` + Upload int64 `json:"upload"` + SpeedLimit int64 `json:"speed_limit"` + TrafficLimit []TrafficLimit `json:"user_traffic_limit"` + PlanSpeedLimit int64 `json:"plan_speed_limit"` + Token string `json:"token"` + Status uint8 `json:"status"` + EntitlementSource string `json:"entitlement_source"` + EntitlementOwnerUserId int64 `json:"entitlement_owner_user_id"` + ReadOnly bool `json:"read_only"` + IsGift bool `json:"is_gift"` + Short string `json:"short"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } type UserSubscribeDetail struct { - Id int64 `json:"id"` - UserId int64 `json:"user_id"` - User User `json:"user"` - OrderId int64 `json:"order_id"` - SubscribeId int64 `json:"subscribe_id"` - Subscribe Subscribe `json:"subscribe"` - NodeGroupId int64 `json:"node_group_id"` - NodeGroupName string `json:"node_group_name"` - GroupLocked bool `json:"group_locked"` - StartTime int64 `json:"start_time"` - ExpireTime int64 `json:"expire_time"` - ResetTime int64 `json:"reset_time"` - Traffic int64 `json:"traffic"` - Download int64 `json:"download"` - Upload int64 `json:"upload"` - Token string `json:"token"` - Status uint8 `json:"status"` - EffectiveSpeed int64 `json:"effective_speed"` - IsThrottled bool `json:"is_throttled"` - ThrottleRule string `json:"throttle_rule,omitempty"` - ThrottleStart int64 `json:"throttle_start,omitempty"` - ThrottleEnd int64 `json:"throttle_end,omitempty"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + User User `json:"user"` + OrderId int64 `json:"order_id"` + SubscribeId int64 `json:"subscribe_id"` + Subscribe Subscribe `json:"subscribe"` + NodeGroupId int64 `json:"node_group_id"` + NodeGroupName string `json:"node_group_name"` + GroupLocked bool `json:"group_locked"` + StartTime int64 `json:"start_time"` + ExpireTime int64 `json:"expire_time"` + ResetTime int64 `json:"reset_time"` + Traffic int64 `json:"traffic"` + Download int64 `json:"download"` + Upload int64 `json:"upload"` + SpeedLimit int64 `json:"speed_limit"` + TrafficLimit []TrafficLimit `json:"user_traffic_limit"` + PlanSpeedLimit int64 `json:"plan_speed_limit"` + Token string `json:"token"` + Status uint8 `json:"status"` + EffectiveSpeed int64 `json:"effective_speed"` + IsThrottled bool `json:"is_throttled"` + ThrottleRule string `json:"throttle_rule,omitempty"` + ThrottleStart int64 `json:"throttle_start,omitempty"` + ThrottleEnd int64 `json:"throttle_end,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } type UserSubscribeInfo struct { @@ -3651,6 +3919,7 @@ type WeeklyStat struct { type WithdrawalLog struct { Id int64 `json:"id"` + BizType string `json:"biz_type"` UserId int64 `json:"user_id"` Amount int64 `json:"amount"` Content string `json:"content"` @@ -3662,60 +3931,3 @@ type WithdrawalLog struct { CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } - -type GetAdminUserInviteStatsRequest struct { - UserId int64 `form:"user_id" validate:"required"` -} - -type GetAdminUserInviteStatsResponse struct { - InviteCount int64 `json:"invite_count"` - TotalCommission int64 `json:"total_commission"` - CurrentCommission int64 `json:"current_commission"` - ReferralPercentage uint8 `json:"referral_percentage"` - OnlyFirstPurchase bool `json:"only_first_purchase"` -} - -type GetAdminUserInviteListRequest struct { - UserId int64 `form:"user_id" validate:"required"` - Page int `form:"page"` - Size int `form:"size"` -} - -type AdminInvitedUser struct { - Id int64 `json:"id"` - Avatar string `json:"avatar"` - Identifier string `json:"identifier"` - Enable bool `json:"enable"` - CreatedAt int64 `json:"created_at"` -} - -type GetAdminUserInviteListResponse struct { - Total int64 `json:"total"` - List []AdminInvitedUser `json:"list"` -} - -type GetLogMessageRawRequest struct { - Id int64 `form:"id" validate:"required"` -} - -type GetLogMessageRawResponse struct { - Id int64 `json:"id"` - Platform string `json:"platform"` - AppVersion string `json:"app_version"` - OsName string `json:"os_name"` - OsVersion string `json:"os_version"` - DeviceId string `json:"device_id"` - UserId *int64 `json:"user_id"` - SessionId string `json:"session_id"` - Level uint8 `json:"level"` - ErrorCode string `json:"error_code"` - Message string `json:"message"` - Stack string `json:"stack"` - Context json.RawMessage `json:"context"` - ClientIP string `json:"client_ip"` - UserAgent string `json:"user_agent"` - Locale string `json:"locale"` - Digest string `json:"digest"` - OccurredAt int64 `json:"occurred_at"` - CreatedAt int64 `json:"created_at"` -} diff --git a/lefthook.yml b/lefthook.yml index 6290468..f5d1286 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -16,3 +16,28 @@ commit-msg: commands: commitlint: run: npx --no -- commitlint --edit $1 + +# Soft constraint: the GitHub plan tier blocks branch protection on this +# private repo (HTTP 403). Catch direct pushes to internal / main here so the +# normal flow stays "open a PR + GitHub UI Squash and merge". +# See doc/development-workflow-zh.md § 5 for the full soft-constraint model. +# +# Emergency bypass: `git push --no-verify`. The reason must be logged in the +# corresponding Multica issue per the development workflow doc. +pre-push: + commands: + block-direct-push-to-protected: + run: | + set -e + while read local_ref local_sha remote_ref remote_sha; do + case "$remote_ref" in + refs/heads/internal|refs/heads/main) + echo "❌ Direct push to ${remote_ref##refs/heads/} is forbidden." >&2 + echo " Open a PR and use GitHub UI 'Squash and merge' instead." >&2 + echo " See doc/development-workflow-zh.md § 2." >&2 + echo " Emergency bypass: git push --no-verify (must be logged in Multica)." >&2 + exit 1 + ;; + esac + done + diff --git a/loki/loki-config.yaml b/loki/loki-config.yaml deleted file mode 100644 index 8c71fd0..0000000 --- a/loki/loki-config.yaml +++ /dev/null @@ -1,36 +0,0 @@ -auth_enabled: false - -server: - http_listen_port: 3100 - grpc_listen_port: 9096 - -common: - path_prefix: /loki - replication_factor: 1 - ring: - instance_addr: 127.0.0.1 - kvstore: - store: inmemory - -schema_config: - configs: - - from: 2024-01-01 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h - -storage_config: - filesystem: - directory: /loki/chunks - -limits_config: - allow_structured_metadata: true - retention_period: 168h - -compactor: - working_directory: /loki/compactor - retention_enabled: true - delete_request_store: filesystem diff --git a/loki/promtail-config.yaml b/loki/promtail-config.yaml deleted file mode 100644 index 5e799ce..0000000 --- a/loki/promtail-config.yaml +++ /dev/null @@ -1,61 +0,0 @@ -server: - http_listen_port: 9080 - grpc_listen_port: 0 - -positions: - filename: /tmp/positions.yaml - -clients: - - url: http://loki:3100/loki/api/v1/push - -scrape_configs: - - job_name: ppanel-file - static_configs: - - targets: - - localhost - labels: - job: ppanel-server - service_name: ppanel - __path__: /var/log/ppanel-server/*.log - pipeline_stages: - - json: - expressions: - caller: caller - content: content - level: level - timestamp: timestamp - - timestamp: - source: timestamp - format: "2006-01-02 15:04:05.000" - location: Asia/Shanghai - - labels: - caller: - level: - - - job_name: nginx-file - static_configs: - - targets: - - localhost - labels: - job: nginx - service_name: nginx - __path__: /var/log/nginx/*.log - - - job_name: docker-containers - docker_sd_configs: - - host: unix:///var/run/docker.sock - refresh_interval: 15s - relabel_configs: - - source_labels: [__meta_docker_container_name] - regex: '/(.*)' - target_label: container - - source_labels: [__meta_docker_container_label_com_docker_compose_service] - target_label: compose_service - - source_labels: [__meta_docker_container_log_stream] - target_label: stream - - source_labels: [__meta_docker_container_name] - regex: '/(.*)' - target_label: service_name - - source_labels: [__meta_docker_container_id] - target_label: __path__ - replacement: /var/lib/docker/containers/$1/$1-json.log diff --git a/node.json b/node.json index 2918ade..e03f593 100644 --- a/node.json +++ b/node.json @@ -3680,6 +3680,9 @@ "discount": { "type": "number", "format": "double" + }, + "promo": { + "$ref": "#/definitions/SubscribePromo" } }, "title": "SubscribeDiscount", diff --git a/ops/audit/.gitignore b/ops/audit/.gitignore new file mode 100644 index 0000000..e81a37b --- /dev/null +++ b/ops/audit/.gitignore @@ -0,0 +1,2 @@ +*.csv +*.tsv diff --git a/ops/audit/migration_v3.sql b/ops/audit/migration_v3.sql new file mode 100644 index 0000000..d5990c8 --- /dev/null +++ b/ops/audit/migration_v3.sql @@ -0,0 +1,195 @@ +-- ============================================================================ +-- migration_v3.sql +-- 提现迁移 + 佣金余额闭环修复 +-- ============================================================================ +-- +-- 目的: +-- 1. 把 ticket(提现工单) 表里 status=4 的 185 条记录全量重迁到 withdrawals 表 +-- - 删除 5 条历史错迁的 status=3 (取消/拒绝) 单 +-- - 补迁 8 条历史漏迁的 status=4 (已通过) 单 +-- 2. 修正 13 个 user.commission 字段,使其等于 SUM(全部 type=33 日志) +-- 3. content 写入 '历史提现 #',支持反查 ticket 源头 +-- +-- 执行方式(注意 --default-character-set 必须显式声明): +-- docker exec -i ppanel-mysql mysql --default-character-set=utf8mb4 \ +-- -uroot -pppanel_dev ppanel < migration_v3.sql +-- +-- 回滚方式(假设 COMMIT 之后想恢复): +-- TRUNCATE TABLE withdrawals; +-- INSERT INTO withdrawals SELECT * FROM withdrawals_backup_v3; +-- UPDATE user u JOIN user_commission_backup_v3 b ON b.id=u.id SET u.commission = b.commission; +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- 阶段 0: 备份(在事务外执行,即使后续 ROLLBACK 备份也保留) +-- ---------------------------------------------------------------------------- +DROP TABLE IF EXISTS withdrawals_backup_v3; +CREATE TABLE withdrawals_backup_v3 AS SELECT * FROM withdrawals; + +DROP TABLE IF EXISTS user_commission_backup_v3; +CREATE TABLE user_commission_backup_v3 AS + SELECT id, commission, NOW() AS backup_at FROM user WHERE commission <> 0; + +SELECT 'backup_done' AS step, + (SELECT COUNT(*) FROM withdrawals_backup_v3) AS withdrawals_rows, + (SELECT COUNT(*) FROM user_commission_backup_v3) AS user_commission_rows; + +-- ---------------------------------------------------------------------------- +-- 阶段 1: 事务开始 +-- ---------------------------------------------------------------------------- +START TRANSACTION; + +-- ---------------------------------------------------------------------------- +-- 阶段 2: 清空 withdrawals 表 +-- ---------------------------------------------------------------------------- +TRUNCATE TABLE withdrawals; + +-- ---------------------------------------------------------------------------- +-- 阶段 3: 从 ticket 表全量重迁(严格只迁 status=4) +-- 中文字面量统一 COLLATE utf8mb4_general_ci 跟 ticket 表一致 +-- ---------------------------------------------------------------------------- +INSERT INTO withdrawals ( + user_id, amount, content, status, reason, + method, account, qr_code_url, created_at, updated_at +) +SELECT + t.user_id, + ROUND(CAST(TRIM(REPLACE(REPLACE(t.title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''), + _utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) AS DECIMAL(18,2)) * 100) AS amount_cents, + CONCAT(_utf8mb4'历史提现 #' COLLATE utf8mb4_0900_ai_ci, t.id) AS content, + 1 AS status, + '' AS reason, + CASE + WHEN t.description LIKE _utf8mb4'支付宝%' COLLATE utf8mb4_general_ci THEN 1 + WHEN t.description LIKE _utf8mb4'微信%' COLLATE utf8mb4_general_ci THEN 2 + WHEN UPPER(t.description) LIKE 'USDT%' OR UPPER(t.description) LIKE 'TRC20%' THEN 3 + ELSE 0 + END AS method, + CASE + WHEN UPPER(t.description) LIKE 'USDT(TRC20)-%' + THEN TRIM(SUBSTRING(t.description, LOCATE('-', t.description) + 1)) + WHEN UPPER(t.description) LIKE 'USDT%' AND LOCATE('-', t.description) > 0 + THEN TRIM(SUBSTRING(t.description, LOCATE('-', t.description) + 1)) + WHEN t.description LIKE _utf8mb4'支付宝-data:image%' COLLATE utf8mb4_general_ci THEN _utf8mb4'支付宝收款码' COLLATE utf8mb4_0900_ai_ci + WHEN t.description LIKE _utf8mb4'微信-data:image%' COLLATE utf8mb4_general_ci THEN _utf8mb4'微信收款码' COLLATE utf8mb4_0900_ai_ci + WHEN CHAR_LENGTH(t.description) > 255 THEN LEFT(t.description, 255) + ELSE t.description + END AS account, + '' AS qr_code_url, + t.created_at, + t.updated_at +FROM ticket t +WHERE t.title LIKE _utf8mb4'提现%' COLLATE utf8mb4_general_ci + AND t.status = 4 + AND TRIM(REPLACE(REPLACE(t.title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''), + _utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) REGEXP '^[0-9]+([.][0-9]+)?$' + AND ROUND(CAST(TRIM(REPLACE(REPLACE(t.title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''), + _utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) AS DECIMAL(18,2)) * 100) > 0 +ORDER BY t.id; + +-- ---------------------------------------------------------------------------- +-- 阶段 4: 修正用户的 commission 字段,使其等于 SUM(type=33 日志) +-- ---------------------------------------------------------------------------- +UPDATE user u +JOIN ( + SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) AS log_sum + FROM system_logs + WHERE type = 33 + GROUP BY object_id +) s ON s.object_id = u.id +SET u.commission = s.log_sum +WHERE u.commission <> s.log_sum; + +-- ---------------------------------------------------------------------------- +-- 阶段 4b: 处理"前日志时代"账号 — 有 commission 余额但 system_logs 完全没记录 +-- 补一条 type=335 admin_adjust 日志记录历史基准余额,让账目闭环 +-- content 标记 source=pre_log_baseline 便于追溯 +-- ---------------------------------------------------------------------------- +INSERT INTO system_logs (type, date, object_id, content, created_at) +SELECT 33, + DATE_FORMAT(u.created_at, '%Y-%m-%d'), + u.id, + JSON_OBJECT( + 'type', 335, + 'amount', u.commission, + 'order_no', '', + 'timestamp', UNIX_TIMESTAMP(u.created_at)*1000, + 'source', 'pre_log_baseline' + ), + u.created_at +FROM user u +WHERE u.commission <> 0 + AND NOT EXISTS ( + SELECT 1 FROM system_logs s WHERE s.type=33 AND s.object_id=u.id + ); + +-- ---------------------------------------------------------------------------- +-- 阶段 5: 验证(任何一项 verdict=FAIL 都应 ROLLBACK) +-- ---------------------------------------------------------------------------- + +-- 5a. withdrawals 总数 = ticket status=4 数,且都 > 0 +SELECT + '5a_count_match' AS check_name, + (SELECT COUNT(*) FROM ticket WHERE title LIKE _utf8mb4'提现%' COLLATE utf8mb4_general_ci AND status=4 + AND TRIM(REPLACE(REPLACE(title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''), + _utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) REGEXP '^[0-9]+([.][0-9]+)?$') AS expected, + (SELECT COUNT(*) FROM withdrawals) AS actual, + IF( + (SELECT COUNT(*) FROM withdrawals) > 0 + AND (SELECT COUNT(*) FROM ticket WHERE title LIKE _utf8mb4'提现%' COLLATE utf8mb4_general_ci AND status=4 + AND TRIM(REPLACE(REPLACE(title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''), + _utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) REGEXP '^[0-9]+([.][0-9]+)?$') + = (SELECT COUNT(*) FROM withdrawals), + 'PASS', 'FAIL' + ) AS verdict; + +-- 5b. 没有 amount<=0 +SELECT '5b_amount_positive' AS check_name, + 0 AS expected, + (SELECT COUNT(*) FROM withdrawals WHERE amount <= 0) AS actual, + IF((SELECT COUNT(*) FROM withdrawals WHERE amount <= 0) = 0, 'PASS', 'FAIL') AS verdict; + +-- 5c. content 必须形如 '历史提现 #' +SELECT '5c_content_format' AS check_name, + 0 AS expected, + (SELECT COUNT(*) FROM withdrawals + WHERE content NOT REGEXP CONCAT('^', _utf8mb4'历史提现 #' COLLATE utf8mb4_0900_ai_ci, '[0-9]+$')) AS actual, + IF((SELECT COUNT(*) FROM withdrawals + WHERE content NOT REGEXP CONCAT('^', _utf8mb4'历史提现 #' COLLATE utf8mb4_0900_ai_ci, '[0-9]+$')) = 0, + 'PASS', 'FAIL') AS verdict; + +-- 5d. user.commission == SUM(type=33 logs) 全用户闭环 +SELECT '5d_balance_closure' AS check_name, + 0 AS expected, + (SELECT COUNT(*) FROM user u + LEFT JOIN (SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) amt + FROM system_logs WHERE type=33 GROUP BY object_id) s ON s.object_id=u.id + WHERE u.commission <> COALESCE(s.amt, 0)) AS actual, + IF((SELECT COUNT(*) FROM user u + LEFT JOIN (SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) amt + FROM system_logs WHERE type=33 GROUP BY object_id) s ON s.object_id=u.id + WHERE u.commission <> COALESCE(s.amt, 0)) = 0, 'PASS', 'FAIL') AS verdict; + +-- 5e. 31742 闭环具体值核对 +SELECT '5e_user_31742' AS check_name, + (SELECT COALESCE(SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)),0) + FROM system_logs WHERE type=33 AND object_id=31742) AS expected_from_logs, + (SELECT commission FROM user WHERE id=31742) AS actual_balance, + (SELECT COUNT(*) FROM withdrawals WHERE user_id=31742) AS withdrawals_count; + +-- ---------------------------------------------------------------------------- +-- 阶段 6: 提交事务 +-- 检查上面所有 verdict 是 PASS 后才 COMMIT,否则 ROLLBACK +-- ---------------------------------------------------------------------------- +COMMIT; + +-- ---------------------------------------------------------------------------- +-- 阶段 7: 事后摘要 +-- ---------------------------------------------------------------------------- +SELECT 'final_summary' AS section, + (SELECT COUNT(*) FROM withdrawals) AS withdrawals_count, + (SELECT SUM(amount) FROM withdrawals WHERE status=1) AS total_amount_cents, + (SELECT COUNT(*) FROM user u + LEFT JOIN (SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) amt + FROM system_logs WHERE type=33 GROUP BY object_id) s ON s.object_id=u.id + WHERE u.commission <> COALESCE(s.amt, 0)) AS imbalanced_users_after_fix; diff --git a/ops/aws-rds-external-replica-runbook.md b/ops/aws-rds-external-replica-runbook.md deleted file mode 100644 index d10b530..0000000 --- a/ops/aws-rds-external-replica-runbook.md +++ /dev/null @@ -1,652 +0,0 @@ -# AWS RDS + EC2 Redis 到外部备用服务器 Runbook - -目标:让 `104.238.220.230` 持续作为 AWS 主生产环境的异地备用节点,承接: - -- MySQL 外部只读从库 -- Redis 外部从库 -- 故障时的快速提升与业务切换 - -本文档以 `2026-05-13` 的真实现网状态为准,覆盖: - -- 当前已经落地的主从架构 -- 日常验收命令 -- 主从故障排查 -- 全量重建从库 -- “新建规范 RDS 再切换”的生产级收敛路线 -- 故障切换与回滚 - -## 1. 当前已确认资源 - -### 1.1 AWS 主生产 - -- Region: `ap-east-1` -- AWS app EC2: - - Name: `hifast-hk-app-01` - - Public IP: `18.163.33.75` - - Private IP: `10.0.1.201` -- AWS MySQL: - - Type: `RDS MySQL` - - Instance: `hifast-mysql-prod-v2` - - Endpoint: `hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com` - - Version: `8.4.8` - - DB Name: `hifast` - - Admin user: `admin` - - Admin password: keep it in a secret store, do not write plaintext into repo docs - - Replication user: `repl` - - Replication password: keep it in a secret store, do not write plaintext into repo docs -- AWS Redis: - - Location: `hifast-hk-app-01` - - Deployment: `Docker` - - Container: `hifast-redis` - - Version: `redis:8.2.1` - - Listen: `0.0.0.0:6379` - - Password: keep it in a secret store, do not write plaintext into repo docs - -### 1.2 外部备用服务器 - -- Host: `104.238.220.230` -- OS: `Ubuntu 24.04 LTS` -- SSH user: `root` -- MySQL version: `8.4.9` -- Redis version: `8.6.3` -- 当前角色: - - MySQL external replica - - Redis replica - - 备用应用节点 - -### 1.3 当前应用真实运行方式 - -AWS 应用机上的 `ppanel-server` 当前不是 systemd 托管,而是 Docker Compose 服务: - -- Compose file: `/opt/ppanel/docker-compose.cloud.yml` -- Config file: `/opt/ppanel/configs/ppanel.yaml` -- Container name: `ppanel-server` -- Current MySQL target: `hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306` -- Current Redis target: `127.0.0.1:6379` - -也就是说,后续所有应用侧切换步骤,都应该以修改: - -- `/opt/ppanel/configs/ppanel.yaml` - -并执行: - -```bash -cd /opt/ppanel -docker compose -f docker-compose.cloud.yml up -d ppanel-server -``` - -作为准。 - -## 2. 当前健康基线 - -截至 `2026-05-13`,已确认以下状态成立: - -- `104` MySQL: - - `Source_Host = hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com` - - `Replica_IO_Running: Yes` - - `Replica_SQL_Running: Yes` - - `Seconds_Behind_Source: 0` - - `read_only = ON` - - `super_read_only = ON` -- AWS Redis 主库: - - `role:master` - - `connected_slaves:1` -- `104` Redis: - - `role:slave` - - `master_link_status:up` -- AWS 应用: - - `curl http://127.0.0.1:8080/v1/common/heartbeat` 返回 `code=200` - -这说明当前状态已经达到: - -- 主生产可用 -- 外部备用持续同步 -- Redis 已回到真实本机链路 - -## 3. 当前网络前提 - -### 3.1 安全组 - -当前实际生效的安全组如下: - -- `hifast-hk-app-core-sg` - - `22/tcp <- 0.0.0.0/0` - - `6379/tcp <- 104.238.220.230/32` - - `8080/tcp <- hifast-hk-web-sg` -- `hifast-hk-rds-core-sg` - - `3306/tcp <- 104.238.220.230/32` - - `3306/tcp <- hifast-hk-app-core-sg` -- `hifast-hk-web-sg` - - `22/80/443 <- 0.0.0.0/0` - -### 3.2 当前结构性限制 - -当前 `104 -> RDS` 公网复制链路虽然可用,但依赖的是: - -- `hifast-mysql-prod-v2` 为 `Publicly accessible = Yes` -- RDS ENI 仍位于 `hifast-hk-private-1b` -- `private-1b` 被临时挂到了公网路由表 - -这不是最终规范的生产形态。 - -当前这条路线已经完成,后续更推荐的动作是: - -1. 持续确认应用主库目标保持在 `hifast-mysql-prod-v2` -2. 持续确认 `104` 复制目标保持在 `hifast-mysql-prod-v2` -3. 根据回收窗口安排旧 RDS 下线 -4. 按网络收敛计划处理 `private-1b` 路由语义 - -## 4. 日常验收命令 - -### 4.1 验收 MySQL 主从 - -在 `104` 执行: - -```bash -mysql -e "SHOW REPLICA STATUS\G" -``` - -重点看: - -- `Source_Host` -- `Replica_IO_Running` -- `Replica_SQL_Running` -- `Seconds_Behind_Source` -- `Last_IO_Error` -- `Last_SQL_Error` - -成功标准: - -- `Replica_IO_Running: Yes` -- `Replica_SQL_Running: Yes` -- `Seconds_Behind_Source: 0` 或较小 - -### 4.2 验收 Redis 主从 - -在 AWS app EC2 执行: - -```bash -docker exec hifast-redis redis-cli -a '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr' INFO replication -``` - -重点看: - -- `role:master` -- `connected_slaves:1` - -在 `104` 执行: - -```bash -redis-cli INFO replication -``` - -重点看: - -- `role:slave` -- `master_host:18.163.33.75` -- `master_port:6379` -- `master_link_status:up` - -### 4.3 验收应用 - -在 AWS app EC2 执行: - -```bash -curl -sf http://127.0.0.1:8080/v1/common/heartbeat -``` - -期望返回: - -- `{"code":200,...}` - -查看容器: - -```bash -cd /opt/ppanel -docker compose -f docker-compose.cloud.yml ps -``` - -期望: - -- `ppanel-server` 为 `Up` -- `hifast-redis` 为 `Up` - -## 5. MySQL 复制故障排查 - -### 5.1 先看复制状态 - -```bash -mysql -e "SHOW REPLICA STATUS\G" -``` - -重点判断: - -- `Replica_IO_Running = No` -- `Replica_SQL_Running = No` -- `Last_IO_Error` -- `Last_SQL_Error` - -### 5.2 常见场景 - -#### 场景 A:网络或白名单断开 - -表现: - -- `Replica_IO_Running: No` -- `Last_IO_Error` 出现连接失败、超时、拒绝访问 - -排查: - -```bash -mysql -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u repl -p -e "SELECT 1;" -``` - -处理: - -- 检查 RDS SG 是否仍保留 `104.238.220.230/32 -> 3306` -- 检查 RDS 是否仍为 `Publicly accessible = Yes` -- 如果后续已迁到新规范 RDS,则检查新实例的 SG 和公网可达性 - -#### 场景 B:主库 binlog 位点丢失 - -表现: - -- `Last_IO_Error` 或 `Last_SQL_Error` 指向缺失 binlog - -处理: - -- 不要硬跳过 -- 直接执行全量重建从库 - -#### 场景 C:SQL 执行报错 - -表现: - -- `Replica_SQL_Running: No` -- `Last_SQL_Error` 有实际 SQL 冲突信息 - -处理建议: - -- 如果只是临时演练环境,可重建从库 -- 如果已经进入生产切换阶段,不建议盲目 `sql_slave_skip_counter` -- 优先保守做法仍是重新全量初始化 - -## 6. Redis 复制故障排查 - -### 6.1 看 `104` 从库状态 - -```bash -redis-cli INFO replication -``` - -重点: - -- `role` -- `master_host` -- `master_link_status` - -### 6.2 看 AWS 主库状态 - -```bash -docker exec hifast-redis redis-cli -a '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr' INFO replication -``` - -重点: - -- `role:master` -- `connected_slaves` - -### 6.3 常见问题 - -#### 场景 A:安全组断开 - -表现: - -- `master_link_status:down` - -处理: - -- 确认 `hifast-hk-app-core-sg` 仍保留: - - `6379/tcp <- 104.238.220.230/32` - -#### 场景 B:主库密码漂移 - -表现: - -- 从库重连失败 -- 日志出现 `NOAUTH` - -处理: - -- 统一更新 `/etc/redis/redis.conf` 中的: - - `masterauth` -- 然后: - -```bash -systemctl restart redis-server -redis-cli INFO replication -``` - -## 7. 全量重建 MySQL 从库 - -适用场景: - -- 主从中断且无法安全追平 -- `6666@qq.com` 这类写入在主库存在、从库未同步 -- binlog 不连续 -- 需要回到最稳妥状态 - -### 7.1 在主库导出 - -在一台可连 RDS 的机器上执行: - -```bash -mysqldump \ - -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com \ - -u admin \ - -p \ - --single-transaction \ - --routines \ - --triggers \ - --events \ - --set-gtid-purged=OFF \ - hifast > hifast-full.sql -``` - -如果需要同步账号权限,也可以额外单独导出授权对象;但当前业务库恢复重点是 `hifast` 数据库本身。 - -### 7.2 清理 `104` 当前复制 - -在 `104` 执行: - -```sql -STOP REPLICA; -RESET REPLICA ALL; -``` - -### 7.3 重新导入业务库 - -在 `104` 执行: - -```bash -mysql -e "DROP DATABASE IF EXISTS hifast; CREATE DATABASE hifast CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;" -mysql hifast < hifast-full.sql -``` - -### 7.4 重新挂复制 - -当前现网是非 GTID 自动定位,使用 file/position 模式。 - -先在主库取位点: - -```sql -SHOW MASTER STATUS; -``` - -然后在 `104` 执行: - -```sql -CHANGE REPLICATION SOURCE TO - SOURCE_HOST='hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com', - SOURCE_PORT=3306, - SOURCE_USER='repl', - SOURCE_PASSWORD='', - SOURCE_LOG_FILE='', - SOURCE_LOG_POS=, - SOURCE_SSL=1; -START REPLICA; -SHOW REPLICA STATUS\G -``` - -### 7.5 验收 - -确认: - -- `Replica_IO_Running: Yes` -- `Replica_SQL_Running: Yes` -- `Seconds_Behind_Source: 0` -- `read_only = ON` -- `super_read_only = ON` - -## 8. 重新配置 Redis 从库 - -当前 `104` Redis 为原生安装,不使用 Docker。 - -### 8.1 临时切回从库 - -```bash -redis-cli CONFIG SET masterauth '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr' -redis-cli REPLICAOF 18.163.33.75 6379 -redis-cli CONFIG SET replica-read-only yes -redis-cli INFO replication -``` - -### 8.2 持久化配置 - -检查 `/etc/redis/redis.conf` 至少包含: - -```conf -replicaof 18.163.33.75 6379 -masterauth 0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr -replica-read-only yes -``` - -然后: - -```bash -systemctl restart redis-server -redis-cli INFO replication -``` - -## 9. 生产级收敛路线:新建规范 RDS 再切换 - -这条路线已经完成,当前现网主库已经切到 `hifast-mysql-prod-v2`。下面内容保留为迁移归档参考,不再表示待执行。 - -### 9.1 目标 - -把当前临时方案: - -- `private-1b` 挂公网路由 - -收敛成: - -- RDS 使用纯公网 DB subnet group -- 安全组仍只对白名单和应用组开放 - -### 9.2 已准备好的资源 - -- 已建 DB subnet group: - - `hifast-hk-rds-public-only-sgprep` -- 子网: - - `hifast-hk-public-1a` - - `hifast-hk-public-1b` - -### 9.3 新 RDS 建议参数 - -新实例建议名: - -- `hifast-mysql-prod-v2` - -建议保持与旧主库一致: - -- Engine: `mysql` -- Version: `8.4.8` -- Class: `db.r7g.xlarge` -- Storage: `gp3` -- Size: `200 GB` -- IOPS: `3000` -- Throughput: `125` -- Publicly accessible: `Yes` -- Multi-AZ: `No` 或按预算单独评估 -- Deletion protection: `On` -- Performance Insights: `On` -- Backup retention: `7` -- DB subnet group: `hifast-hk-rds-public-only-sgprep` -- VPC SG: `hifast-hk-rds-core-sg` - -### 9.4 迁移步骤 - -1. 已创建新 RDS `hifast-mysql-prod-v2` -2. 在旧主库导出 `hifast` -3. 导入新库 -4. 在新库创建 `repl` 用户并配置 binlog retention -5. 修改 AWS app EC2 上 `/opt/ppanel/configs/ppanel.yaml` 的 `MySQL.Addr` -6. 重启 `ppanel-server` 容器 -7. 在 `104` 上 `STOP REPLICA; RESET REPLICA ALL;` -8. 指向新 RDS endpoint 重新挂复制 -9. 验收应用与主从 -10. 验收通过后,安排旧 RDS 下线,并推进 `private-1b` 恢复私网路由 - -### 9.5 应用切换命令 - -在 AWS app EC2: - -1. 备份配置 - -```bash -cp /opt/ppanel/configs/ppanel.yaml /opt/ppanel/configs/ppanel.yaml.bak.$(date +%Y%m%d%H%M%S) -``` - -2. 编辑: - -- `/opt/ppanel/configs/ppanel.yaml` - -把: - -```yaml -MySQL: - Addr: hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306 -``` - -改成: - -```yaml -MySQL: - Addr: hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306 -``` - -3. 重启应用容器: - -```bash -cd /opt/ppanel -docker compose -f docker-compose.cloud.yml up -d ppanel-server -``` - -4. 验证: - -```bash -curl -sf http://127.0.0.1:8080/v1/common/heartbeat -docker compose -f docker-compose.cloud.yml logs --tail=100 ppanel-server -``` - -### 9.6 `104` 改挂新 RDS - -```sql -STOP REPLICA; -RESET REPLICA ALL; -CHANGE REPLICATION SOURCE TO - SOURCE_HOST='', - SOURCE_PORT=3306, - SOURCE_USER='repl', - SOURCE_PASSWORD='', - SOURCE_LOG_FILE='', - SOURCE_LOG_POS=, - SOURCE_SSL=1; -START REPLICA; -SHOW REPLICA STATUS\G -``` - -### 9.7 切换验收 - -至少确认: - -- AWS 应用心跳正常 -- 新 RDS 可正常读写 -- `104` 复制恢复为 `Yes/Yes` -- `Seconds_Behind_Source` 追到 `0` -- Navicat 可从允许的白名单来源连接新 RDS - -### 9.8 回滚 - -如果新 RDS 切换后应用异常: - -1. 立即把 `/opt/ppanel/configs/ppanel.yaml` 中 `MySQL.Addr` 改回旧 endpoint -2. 重启 `ppanel-server` -3. 暂不处理 `104`,先恢复主生产 -4. 复盘新库数据、权限、参数、网络 - -## 10. AWS 故障时的备用接管 - -### 10.1 Redis 提升为主库 - -在 `104`: - -```bash -redis-cli REPLICAOF NO ONE -redis-cli INFO replication -``` - -期望: - -- `role:master` - -### 10.2 MySQL 提升为可写主库 - -在 `104`: - -```sql -STOP REPLICA; -RESET REPLICA ALL; -SET GLOBAL super_read_only=OFF; -SET GLOBAL read_only=OFF; -``` - -再确认: - -```sql -SHOW VARIABLES LIKE 'read_only'; -SHOW VARIABLES LIKE 'super_read_only'; -``` - -期望: - -- `OFF` -- `OFF` - -### 10.3 应用切到 `104` 本机数据层 - -如果 `104` 上也部署同样的 PPanel 服务,则应用配置应改为: - -- MySQL 指向 `127.0.0.1:3306` 或本机 socket -- Redis 指向 `127.0.0.1:6379` - -### 10.4 最后切入口 - -数据层和应用层都确认可写后,再做: - -- DNS 切换 -- 或 Nginx / 上游切流量 - -原则: - -- 先数据接管 -- 再应用确认 -- 最后入口切换 - -## 11. 日常巡检建议 - -建议至少每天巡检一次: - -1. `104` MySQL `SHOW REPLICA STATUS\G` -2. `104` Redis `INFO replication` -3. AWS Redis 主库 `INFO replication` -4. AWS app 心跳 `/v1/common/heartbeat` -5. `docker compose -f /opt/ppanel/docker-compose.cloud.yml ps` - -如果后续要做真正的生产级自动化,再补: - -- MySQL 复制延迟告警 -- Redis 主从断链告警 -- 应用心跳失败告警 -- RDS 连接失败告警 -- 定期灾备切换演练 diff --git a/ops/docker-image-version-pins.md b/ops/docker-image-version-pins.md deleted file mode 100644 index 8568394..0000000 --- a/ops/docker-image-version-pins.md +++ /dev/null @@ -1,33 +0,0 @@ -# Docker Image Version Pins - -This file records the infrastructure image versions pinned in `docker-compose.cloud.yml`. -The versions below match the images observed on the test deployment on 2026-05-26. - -| Service | Image | Running version source | -| --- | --- | --- | -| grafana | `grafana/grafana:13.0.1` | `grafana version 13.0.1` | -| prometheus | `prom/prometheus:v3.11.3` | `prometheus, version 3.11.3` | -| nginx-exporter | `nginx/nginx-prometheus-exporter:1.5.0` | image label `org.opencontainers.image.version=1.5.0` | -| node-exporter | `prom/node-exporter:v1.11.1` | `node_exporter, version 1.11.1` | -| cadvisor | `gcr.io/cadvisor/cadvisor:v0.55.1` | `cAdvisor version v0.55.1` | - -The test deployment in `/root/bindbox/docker-compose.cloud.yml` also contains -live-only exporter services that are not present in this repository's -`docker-compose.cloud.yml`. They were pinned during staging validation: - -| Test-only service | Image | Running version source | -| --- | --- | --- | -| mysql-exporter | `prom/mysqld-exporter:v0.19.0` | `mysqld_exporter, version 0.19.0` | -| redis-exporter | `oliver006/redis_exporter:v1.82.0` | image label `org.opencontainers.image.version=v1.82.0` | - -`ppanel-server` intentionally remains variable and requires `PPANEL_SERVER_TAG` -from CI/CD so deployments use an immutable application image tag. - -## Rollback - -Restore the previous compose file from git and redeploy: - -```sh -git checkout HEAD~1 -- docker-compose.cloud.yml .env.example ops/docker-image-version-pins.md -docker compose -f docker-compose.cloud.yml up -d -``` diff --git a/ops/hifast-aws-jp-cutover-checklist-zh.md b/ops/hifast-aws-jp-cutover-checklist-zh.md deleted file mode 100644 index 0b941b1..0000000 --- a/ops/hifast-aws-jp-cutover-checklist-zh.md +++ /dev/null @@ -1,116 +0,0 @@ -# Hifast 东京切换执行清单 - -本文档用于正式执行香港 `ap-east-1` -> 东京 `ap-northeast-1` 迁移时,逐项勾选和留痕。 - -## 0. 当前已知东京状态 - -- 东京 VPC `ppanel-jp-prod` 已创建 -- 东京 VPC ID:`vpc-0846b23b4a7d64eac` -- 4 个东京子网曾在 AWS 控制台录入,但提交时登录态失效 -- 所以当前要按“子网未确认成功”处理 -- 后续所有东京资源创建前,先执行一次: - - `bash deploy/scripts/aws_jp_describe_state.sh /root/aws-jp-infra.env` -- 真实状态登记文件: - - `deploy/aws/ap-northeast-1/configs/resource-inventory.current.md` - -## 1. 真实值清单 - -在开始创建东京正式资源前,必须补齐这些真实值: - -- 正式 API 域名: -- 东京平行 API 域名: -- 东京平行日志域名: -- 运维固定公网 IP / CIDR: -- 东京 EC2 SSH Key Pair 名称: -- 东京 RDS 管理员密码:`TkyRds20260521!N9mQ8sKe2vLp7Xa` -- 东京 RDS 凭证管理方式:`self-managed` -- 东京 RDS 当前密码是否可回看:`否,只能重置` -- 东京 Redis 密码:`hifast67yj` -- `JwtAuth.AccessSecret`: -- `Administrator.Email`:`admin@ppanel.dev` -- `Administrator.Password`:`PpanelAdmin!20260521Temp` -- `AppSignature.AppSecrets.android-client`:`uB4G,XxL2{7b` -- `AppSignature.AppSecrets.ios-client`:`uB4G,XxL2{7b` -- `AppSignature.AppSecrets.web-client`:`uB4G,XxL2{7b` -- `device.security_secret`:`uB4G,XxL2{7b` -- 东京备份桶最终名称: -- `104.238.220.230` MySQL 复制密码:`XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer` -- `104.238.220.230` Redis 主从认证密码:`hifast67yj` - -## 2. 东京资源创建勾选 - -- 已切换 AWS 控制台到 `ap-northeast-1` -- 已确认东京区可用 -- 已确认 EC2 配额满足 `t4g.large` -- 已确认 RDS 配额满足 `db.r7g.xlarge` -- 已确认 ALB / ACM / WAF / S3 可正常创建 -- 已创建 VPC -- 已创建 2 个公有子网 -- 已创建 2 个私有子网 -- 已创建 IGW -- 已配置公私网路由表 -- 已创建 `sg-alb` -- 已创建 `sg-ec2` -- 已创建 `sg-rds` -- 已创建东京 RDS -- 已创建东京 EC2 -- 已在 EC2 启动 Docker / Nginx -- 已创建东京 ACM 证书 -- 已创建 Target Group -- 已创建 ALB -- 已创建 WAF 并挂到 ALB -- 已创建东京 S3 备份桶并开启 versioning - -## 3. 东京应用部署勾选 - -- 已上传 `docker-compose.cloud.yml` -- 已上传 `configs/ppanel.yaml` -- 已上传 `.env` -- 已上传 `grafana/` -- 已上传 `loki/` -- 已上传 `prometheus/` -- 已上传 `tempo/` -- 已安装东京 Nginx 配置 -- 已启动 `ppanel-server` -- 已启动 `hifast-redis` -- 已启动 observability 容器 -- `curl http://127.0.0.1:8080/v1/common/heartbeat` 正常 -- `curl http://127.0.0.1/v1/common/heartbeat` 正常 -- ALB 健康检查正常 - -## 4. 停机迁移勾选 - -- 已降低正式域名 TTL -- 已停止香港 `ppanel-server` -- 已确认香港不再有新写入 -- 已导出香港 MySQL `hifast-full.sql.gz` -- 已导出香港 Redis `dump.rdb` -- 已导入东京 RDS -- 已导入东京 Redis -- 东京应用已改为连接东京 MySQL / Redis -- 平行域名验收通过 - -## 5. 104 灾备重挂勾选 - -- 东京 RDS 已设置 `binlog retention hours` -- 东京 RDS 已创建 `repl@104.238.220.230` -- 已用东京 dump 重建 `104` 的 `hifast` -- `104` MySQL 已成功挂东京主库 -- `104` MySQL `Replica_IO_Running: Yes` -- `104` MySQL `Replica_SQL_Running: Yes` -- `104` MySQL `Seconds_Behind_Source: 0` -- `104` Redis 已成功挂东京主库 -- 东京 Redis `connected_slaves:1` -- `104` Redis `role:slave` -- `104` Redis `master_link_status:up` - -## 6. 正式切换与回滚勾选 - -- 已切正式域名到东京 ALB -- 外网请求正常 -- 核心业务接口无 `5xx` -- 应用日志无 MySQL / Redis 连接错误 -- WAF 无误伤 -- 已验证 DNS 可回切 -- 香港环境已保留为只读回滚基线 -- 香港环境计划保留 `7` 天观察期 diff --git a/ops/hifast-aws-jp-migration-runbook-zh.md b/ops/hifast-aws-jp-migration-runbook-zh.md deleted file mode 100644 index fa96cef..0000000 --- a/ops/hifast-aws-jp-migration-runbook-zh.md +++ /dev/null @@ -1,286 +0,0 @@ -# Hifast AWS 香港到日本东京迁移 Runbook - -本文档用于把当前香港区 `ap-east-1` 主生产,迁移到日本东京 `ap-northeast-1`。 - -适用目标: - -- 东京成为新主站 -- 架构升级为 `ALB + WAF + EC2 + RDS` -- `104.238.220.230` 继续作为东京主站的 MySQL / Redis 外部灾备 - -## 0. 当前实施状态 - -截至 `2026-05-21`: - -- 东京迁移执行资产已在仓库内补齐 -- 东京网络基础资源已创建并复核: - - VPC `ppanel-jp-prod` / `vpc-0846b23b4a7d64eac` - - 2 个公有子网 - - 2 个私有子网 - - IGW - - 公有 / 私有路由表 -- 东京三层安全组已创建并复核: - - `ppanel-jp-sg-alb` - - `ppanel-jp-sg-ec2` - - `ppanel-jp-sg-rds` -- 东京 RDS 已创建并可用: - - Endpoint: `ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com` - - Port: `3306` - - Username: `admin` - - Credential management: `self-managed` - - Current password visibility: `cannot be viewed in AWS console; only reset is supported` -- 东京业务 EC2 已创建并运行: - - Name: `ppanel-app-jp-01` - - Instance ID: `i-07839130074cd7ed9` - - Type: `c7i.xlarge` - - Private IP: `10.20.1.168` - - Elastic IP: `3.114.29.208` -- 东京 S3 备份桶已创建 -- 东京 ACM 证书已请求,仍等待 DNS 验证 -- 当前尚未完成的核心资源: - - ALB - - WAF - - ACM DNS 验证 - - 东京应用目录部署与数据导入 - -建议先执行: - -```bash -cp deploy/aws/ap-northeast-1/configs/aws-jp-infra.env.example /root/aws-jp-infra.env -vim /root/aws-jp-infra.env -bash deploy/scripts/aws_jp_create_base_infra.sh /root/aws-jp-infra.env -bash deploy/scripts/aws_jp_describe_state.sh /root/aws-jp-infra.env -``` - -然后把结果回填到: - -- `deploy/aws/ap-northeast-1/configs/resource-inventory.current.md` -- `ops/hifast-aws-jp-cutover-checklist-zh.md` - -## 1. 当前基线 - -迁移前默认当前现网状态为: - -- 香港应用 EC2:`hifast-hk-app-01` -- 香港 RDS:`hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com` -- 香港 Redis 主库:`18.163.33.75:6379` -- 外部灾备:`104.238.220.230` -- 当前应用部署目录:`/opt/ppanel` -- 当前应用配置文件:`/opt/ppanel/configs/ppanel.yaml` -- 当前业务容器:`ppanel-server` - -## 2. 迁移前准备 - -正式迁移前必须完成: - -1. 东京基础设施已创建 -2. 东京 EC2 已部署应用目录,但暂未导入正式数据 -3. 东京 RDS 可连通 - - 已验证从东京 EC2 到 `ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com:3306` 网络畅通 - - 当前若无密码只能得到 `ERROR 1045 ... using password: NO`,这表示链路正常,不表示实例异常 -4. 东京 Redis 已启动并可认证 -5. 平行域名已准备: - - `api-jp.hifast.biz` - - `logs-jp.hifast.biz` -6. 东京 ALB 健康检查已通过 -7. 东京 ACM 证书已签发 -8. 东京 WAF 已挂到 ALB -9. 已准备正式回滚入口 -10. 已把正式域名 TTL 降低 - -## 3. 香港停机冻结 - -在香港主环境执行: - -1. 停止业务写入 -2. 停 `ppanel-server` -3. 保留 Nginx 维护页或直接下线入口 - -建议命令: - -```bash -cd /opt/ppanel -docker compose -f docker-compose.cloud.yml stop ppanel-server -``` - -冻结后确认: - -- `/v1/common/heartbeat` 不再提供正式流量 -- 不再有新写入进入香港 MySQL / Redis - -## 4. 导出香港数据 - -### 4.1 MySQL - -从香港主库导出: - -```bash -mysqldump \ - -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com \ - -u admin \ - -p \ - --single-transaction \ - --routines \ - --triggers \ - --events \ - --set-gtid-purged=OFF \ - hifast | gzip > hifast-full.sql.gz -``` - -### 4.2 Redis - -在香港 Redis 主库导出: - -```bash -docker exec hifast-redis redis-cli -a '' BGSAVE -docker cp hifast-redis:/data/dump.rdb ./dump.rdb -``` - -## 5. 导入东京 - -### 5.1 MySQL 导入东京 RDS - -```bash -gunzip -c hifast-full.sql.gz | mysql -h -u admin -p hifast -``` - -导入后确认: - -```bash -mysql -h -u admin -p -e "USE hifast; SHOW TABLES;" -``` - -### 5.2 Redis 导入东京 - -1. 停止东京 Redis 容器 -2. 替换 `/data/dump.rdb` -3. 启动东京 Redis 容器 - -导入后确认: - -```bash -docker exec hifast-redis redis-cli -a '' PING -``` - -## 6. 启动东京应用 - -更新东京: - -- `/opt/ppanel/configs/ppanel.yaml` -- `/opt/ppanel/.env` - -关键值: - -- `MySQL.Addr=:3306` -- `MySQL.Username=admin` -- `MySQL.Password=` -- `Redis.Host=127.0.0.1:6379` -- `Redis.Pass=` -- `Site.Host=api-jp.hifast.biz` -- `.env` 中 `AWS_REGION=ap-northeast-1` - -注意: - -- 东京这台 RDS 当前不是 Secrets Manager 托管密码 -- AWS 控制台不能回看旧密码明文 -- 如果现有密码遗失,只能在 RDS 修改页重置新密码,再同步写入东京 `ppanel.yaml` - -当前东京已实际生效: - -- `MySQL.Addr=ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com:3306` -- `MySQL.Username=admin` -- `MySQL.Password=TkyRds20260521!N9mQ8sKe2vLp7Xa` -- `Redis.Host=127.0.0.1:6379` -- `Redis.Pass=hifast67yj` - -启动: - -```bash -cd /opt/ppanel -docker compose -f docker-compose.cloud.yml up -d -``` - -## 7. 东京平行环境验收 - -至少执行: - -```bash -curl -sf http://127.0.0.1:8080/v1/common/heartbeat -curl -sf http://127.0.0.1/v1/common/heartbeat -docker compose -f /opt/ppanel/docker-compose.cloud.yml ps -docker compose -f /opt/ppanel/docker-compose.cloud.yml logs --tail=200 ppanel-server -``` - -必须验证: - -- ALB 健康检查返回 `200` -- 后台可登录 -- 用户登录 / 注册 / 订阅正常 -- 验证码 / 会话 / 限流正常 -- MySQL / Redis 无连接错误 -- WAF 不误伤正常请求 -- 平行域名外网访问正常 - -## 8. 正式域名切换 - -仅在东京平行环境验收全部通过后执行: - -1. 将正式域名切到东京 `ALB` -2. 观察 5xx、延迟、容器日志、RDS、Redis -3. 保持香港不删,只做回滚保留 - -## 9. 104 灾备重挂 - -### 9.1 MySQL - -在东京 RDS 上: - -```sql -CALL mysql.rds_set_configuration('binlog retention hours', 24); -CREATE USER IF NOT EXISTS 'repl'@'104.238.220.230' IDENTIFIED BY ''; -GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl'@'104.238.220.230'; -FLUSH PRIVILEGES; -SHOW BINARY LOG STATUS; -``` - -当前东京已准备完成: - -- `repl@104.238.220.230` -- 复制密码:`XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer` -- binlog file:`mysql-bin-changelog.000189` -- binlog pos:`185053` - -然后在 `104` 重建并挂从。 - -### 9.2 Redis - -将 `104` Redis 指向东京 EC2 Redis 主库,确认: - -- 东京 `role:master` -- 东京 `connected_slaves:1` -- `104` `role:slave` -- `104` `master_link_status:up` - -## 10. 回滚 - -如果东京验收失败: - -- 不切正式域名 -- 继续保留香港主环境 - -如果正式切换后发现严重问题: - -1. 立刻把 DNS 切回香港 -2. 恢复香港 `ppanel-server` -3. 放弃本次东京接管 - -## 11. 切换后观察期 - -切换后至少保留香港环境 `7` 天: - -- 香港 RDS 快照 -- 香港 Redis RDB -- 香港 EC2 配置 -- 香港 `ppanel.yaml` 备份 - -观察期内不删除香港资源。 diff --git a/ops/hifast-aws-standby-architecture-zh.md b/ops/hifast-aws-standby-architecture-zh.md deleted file mode 100644 index 2cea7ad..0000000 --- a/ops/hifast-aws-standby-architecture-zh.md +++ /dev/null @@ -1,696 +0,0 @@ -# Hifast AWS 主生产 + 外部备用 完整部署方案与访问架构 - -本文档整理当前已经实际落地的生产架构、访问链路、数据库与缓存主从关系、网络边界、故障切换方案,以及后续扩展建议。 - -目标是让团队在一个文档里就能看清: - -- 现在生产到底部署成了什么样 -- 请求是怎么进来的,数据是怎么流转的 -- AWS 与外部备用服务器分别承担什么角色 -- MySQL / Redis 的同步关系是什么 -- 故障时应该如何切换 - -## 0. 2026-05-13 验收摘要 - -- 已确认 `104.238.220.230 -> AWS RDS MySQL` 连通,MySQL 外部从库健康: - - 当前复制上游:`hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com` - - `Replica_IO_Running: Yes` - - `Replica_SQL_Running: Yes` - - `Seconds_Behind_Source: 0` -- 已确认 `104.238.220.230 -> AWS EC2 Redis` 主从健康: - - `104` 上 `role:slave` - - `master_link_status:up` - - AWS Redis 主库 `connected_slaves:1` -- 已修复应用侧 Redis 配置漂移: - - 旧链路:`127.0.0.1:6380 -> stunnel4 -> 旧 ElastiCache` - - 新链路:`127.0.0.1:6379 -> 本机 Docker Redis` -- 旧 `stunnel4` 已停用并禁用自启,`ppanel-server` 日志里的 Redis `i/o timeout` 已停止出现。 -- 已确认 `ppanel-server` 当前运行方式为 Docker Compose 容器,而不是 systemd 服务: - - Compose 文件:`/opt/ppanel/docker-compose.cloud.yml` - - 配置文件:`/opt/ppanel/configs/ppanel.yaml` - - 容器名:`ppanel-server` - - 健康检查:`curl http://127.0.0.1:8080/v1/common/heartbeat` 返回正常 -- 旧自建安全组 `hifast-hk-app-sg`、`hifast-hk-nginx-sg` 已删除;当前 VPC 内仅保留: - - `hifast-hk-app-core-sg` - - `hifast-hk-rds-core-sg` - - `hifast-hk-web-sg` - - `default` -- 目前已经达到“可用且关键链路已恢复”的状态。 -- 当前入口层属于已确认的过渡方案: - - 现阶段公网入口仍在 `hifast-hk-app-01` - - 独立 `nginx` 服务器已预留,待 AWS 后续资源到位后再迁移承接 -- 当前仍需要继续收敛的核心生产项只剩 1 个: - - RDS 为了让 `104` 走公网白名单复制,当前仍依赖 `hifast-hk-private-1b` 子网临时挂到公网路由表,这不是最终规范形态。 -- 已完成的下一步准备: - - 已创建纯公网子网专用的 `DB subnet group`:`hifast-hk-rds-public-only-sgprep` - - 子网包含: - - `subnet-070e3f264a9c79f32` `hifast-hk-public-1a` - - `subnet-00cb5add705c447c8` `hifast-hk-public-1b` -- 已创建专用 S3 备份桶: - - `hifast-prod-backups-200810848252-ap-east-1` - - 当前状态:private - - 当前状态:versioning enabled - -## 1. 当前实际环境 - -### 1.1 AWS 区域 - -- Region: `ap-east-1` -- 说明:香港区 - -### 1.2 已确认资源 - -#### 应用服务器 - -- 名称:`hifast-hk-app-01` -- Instance ID: `i-079cd9d3ef3748714` -- 角色:当前实际生产入口 / Nginx / 业务服务 / AWS 侧 Redis 主库宿主机 -- 私网 IP: `10.0.1.201` -- 公网 IP: `18.163.33.75` -- 业务服务运行方式:`Docker Compose` -- 业务容器:`ppanel-server` -- 部署目录:`/opt/ppanel` -- 实际配置文件:`/opt/ppanel/configs/ppanel.yaml` - -#### 独立 Nginx 服务器 - -- 名称:`hifast-hk-nginx-01` -- Instance ID: `i-0701d54bf2c6bf594` -- 角色:计划中的独立入口机 -- 私网 IP: `10.0.1.175` -- 当前状态:`stopped` -- 当前说明:已经只绑定 `hifast-hk-web-sg`,但目前并未承接正式流量 - -#### MySQL 主库 - -- 类型:`AWS RDS MySQL` -- 实例名:`hifast-mysql-prod-v2` -- Endpoint: `hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com` -- 角色:生产主库 -- Engine: `MySQL 8.4.8` -- Publicly accessible: `Yes` -- Multi-AZ: `No` -- Backup retention: `7 days` -- Deletion protection: `On` -- 当前数据库实际体量:约 `187.41 MB` -- 当前表数量:`37` -- 当前 `user` 表记录数:`45076` - -#### Redis 主库 - -- 部署位置:`AWS EC2 hifast-hk-app-01` -- 部署方式:`Docker` -- 容器名:`hifast-redis` -- 版本:`redis:8.2.1` -- 访问端口:`6379` -- 主库出口地址:`18.163.33.75:6379` -- 应用当前实际连接:`127.0.0.1:6379` -- 认证方式:已启用密码认证 - -#### 外部备用服务器 - -- IP: `104.238.220.230` -- OS: `Ubuntu 24.04 LTS` -- 角色:异地备用节点 -- 当前状态:已部署与 AWS 相同的业务服务 -- 当前 MySQL 状态:外部只读从库 -- 当前 Redis 部署方式:`宿主机原生安装` -- 当前 Redis 版本:`8.6.3` -- 当前 Redis 角色:`AWS Redis 主库的从库` - -#### S3 备份桶 - -- Bucket: `hifast-prod-backups-200810848252-ap-east-1` -- Region: `ap-east-1` -- Public access: blocked -- Versioning: enabled - -## 2. 架构总览 - -```mermaid -flowchart TB - USER["用户 / 客户端"] --> DNS["域名 / DNS / 入口层"] - DNS --> APP["AWS EC2\nhifast-hk-app-01\n18.163.33.75\n10.0.1.201"] - - APP --> RDS["AWS RDS MySQL\nhifast-mysql-prod-v2\n主库"] - APP --> REDISM["AWS Redis 主库\nDocker redis:8.2.1\n18.163.33.75:6379"] - - RDS -. MySQL 备用 / 同步 .-> MYSQLS["104.238.220.230\nMySQL 备用库"] - REDISM -. Redis 主从复制 .-> REDISS["104.238.220.230\n原生 Redis 8.6.3\n从库"] - - subgraph AWS["AWS ap-east-1"] - APP - RDS - REDISM - end - - subgraph BACKUP["异地备用节点"] - MYSQLS - REDISS - end -``` - -## 3. 访问链路 - -### 3.1 用户访问链路 - -当前生产访问链路可以概括为: - -`用户 -> 域名 / DNS -> AWS EC2 应用机 -> MySQL / Redis` - -说明: - -- 当前主应用入口实际在 `hifast-hk-app-01`。 -- `hifast-hk-app-01` 同时承担 Nginx、业务服务、Redis 主库。 -- 当前业务服务不是 systemd 单进程部署,而是由 `/opt/ppanel/docker-compose.cloud.yml` 管理的 `ppanel-server` 容器提供 `8080`。 -- 独立入口机 `hifast-hk-nginx-01` 已存在,但当前仅作为后续资源开通后的迁移目标。 -- MySQL 在 AWS RDS。 -- Redis 不在 ElastiCache,而是在 EC2 本机通过 Docker 提供。 - -### 3.2 应用访问数据链路 - -应用侧内部依赖关系如下: - -```text -App / Nginx - -> RDS MySQL 主库 - -> AWS EC2 Redis 主库 -``` - -### 3.3 备用链路 - -备用服务器 `104.238.220.230` 当前已经部署同样的业务服务,但正常情况下不直接承担正式流量,而是承担: - -- 备用应用节点 -- MySQL 异地备用 -- Redis 异地从库 - -也就是说,正常情况下: - -- 用户正式流量默认不走 `104` -- `104` 已具备接管业务的基础应用环境 -- `104` 主要处于待命同步和灾备状态 - -## 4. 网络与安全边界 - -### 4.1 当前安全组 - -截至 `2026-05-13`,VPC `vpc-09fc384522517debc` 内仅保留 4 个安全组: - -- `default` -- `hifast-hk-app-core-sg` -- `hifast-hk-rds-core-sg` -- `hifast-hk-web-sg` - -其中绑定关系已经收敛为: - -- `hifast-hk-app-01` -> `hifast-hk-app-core-sg` -- `hifast-hk-nginx-01` -> `hifast-hk-web-sg` -- `hifast-mysql-prod-v2` -> `hifast-hk-rds-core-sg` - -旧组: - -- `hifast-hk-app-sg` -- `hifast-hk-nginx-sg` - -已经删除,不再使用。 - -### 4.2 当前入站规则 - -#### `hifast-hk-app-core-sg` - -- `22/tcp <- 0.0.0.0/0` -- `6379/tcp <- 104.238.220.230/32` -- `8080/tcp <- hifast-hk-web-sg` - -#### `hifast-hk-rds-core-sg` - -- `3306/tcp <- 104.238.220.230/32` -- `3306/tcp <- hifast-hk-app-core-sg` - -#### `hifast-hk-web-sg` - -- `22/tcp <- 0.0.0.0/0` -- `80/tcp <- 0.0.0.0/0` -- `443/tcp <- 0.0.0.0/0` - -### 4.3 Redis 网络关系 - -```mermaid -flowchart LR - SG["hifast-hk-app-core-sg"] --> REDIS["AWS Redis 主库\n18.163.33.75:6379"] - STANDBY["104.238.220.230/32"] --> SG -``` - -### 4.4 RDS 访问原则 - -RDS 当前状态已经比之前干净很多: - -- 当前只对白名单和应用安全组开放 `3306` -- 已移除 `3306 <- 0.0.0.0/0` -- 当前 RDS 仅绑定 `hifast-hk-rds-core-sg` - -建议原则: - -- 不开放 `0.0.0.0/0` 到 MySQL `3306` -- 不开放 `0.0.0.0/0` 到 Redis `6379` - -### 4.5 当前仍需继续收敛的网络点 - -#### 4.5.1 入口层当前属于过渡方案,不作为本阶段阻塞项 - -当前运行形态是: - -- `hifast-hk-app-01` 本机 `nginx` 正在监听 `80/443` -- `hifast-hk-nginx-01` 当前停止 - -但当前安全组设计是按“独立 nginx 机”拆的: - -- `hifast-hk-web-sg` 在 `hifast-hk-nginx-01` -- `hifast-hk-app-core-sg` 在 `hifast-hk-app-01` - -这意味着当前真实入口和安全组角色划分还没有完全一致。 - -但这一点已经被确认为过渡设计,不作为当前阶段必须整改的问题。后续待 AWS 新资源到位后,再把公网入口迁到独立 `nginx` 服务器即可。 - -#### 4.5.2 RDS 公网复制链路仍是当前唯一核心结构性问题 - -为了让 `104.238.220.230` 通过公网白名单访问 RDS,当前实际依赖的是: - -- RDS `PubliclyAccessible = true` -- RDS ENI 仍在 `subnet-0dcf46db665b6d8a7` -- 该子网名称是 `hifast-hk-private-1b` -- 但这个子网当前被临时关联到了公网路由表 - -这能用,但不属于最终规范的“纯公网子网组”设计。 - -本轮已经额外完成的准备动作: - -- 已新建纯公网 `DB subnet group`:`hifast-hk-rds-public-only-sgprep` -- 该组状态:`Complete` -- 该组仅包含两条公网子网: - - `hifast-hk-public-1a` - - `hifast-hk-public-1b` - -当前结论: - -- 不建议继续在现有生产 RDS 上反复试在线切子网组。 -- 更稳的收敛方案是:新建一台使用规范公网子网组的生产 RDS,再做一次短切换。 -- 由于当前库体量只有约 `187 MB`,这条路线的执行成本并不高,风险也比在现网主库上硬改更可控。 - -## 4.6 104 如何连接 AWS - -这里要特别区分: - -- `104` 连接 AWS 数据层 -- 本地电脑登录 AWS EC2 - -这不是同一件事。 - -### 4.6.1 104 连接 AWS Redis 的方式 - -`104.238.220.230` 连接 AWS Redis,不是通过 PEM 证书,也不是通过 SSH 登录 AWS 机器,而是直接作为 Redis 从库去访问 AWS Redis 主库: - -- 目标地址:`18.163.33.75:6379` -- 连接方式:`TCP` -- 认证方式:`Redis 密码` -- 网络前提:AWS EC2 安全组已放行 `104.238.220.230/32 -> 6379` - -也就是说: - -```text -104 Redis 从库 -> 直连 AWS Redis 主库公网地址 -> 密码认证 -> 建立复制 -``` - -示意命令: - -```bash -redis-cli -h 18.163.33.75 -p 6379 -a '' -``` - -### 4.6.2 104 连接 AWS MySQL RDS 的方式 - -`104.238.220.230` 连接 AWS RDS,也不是通过 PEM 证书,而是通过数据库连接: - -- 目标地址:`hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306` -- 连接方式:`MySQL TCP` -- 认证方式:`MySQL 用户名 + 密码` -- 网络前提:RDS 白名单或安全组放行 `104.238.220.230` - -也就是说: - -```text -104 MySQL 从库 -> 直连 AWS RDS endpoint -> 账号密码认证 -> 建立复制 -``` - -示意命令: - -```bash -mysql -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u admin -p -``` - -### 4.6.3 本地电脑登录 AWS EC2 的方式 - -本地电脑这边之前进入 AWS EC2,用的不是 `hifast-hk-app-01-key.pem`,而是: - -- `AWS Console` -- `EC2 Instance Connect` -- 浏览器里的 Web SSH 终端 - -所以当前要理解成 3 条不同链路: - -1. 本地电脑 -> AWS 控制台 -> EC2 Instance Connect -> AWS EC2 -2. `104` -> AWS Redis 主库公网地址 -> Redis 密码认证 -3. `104` -> AWS RDS endpoint -> MySQL 用户密码认证 - -### 4.6.4 关键结论 - -当前灾备链路里,`104` 与 AWS 数据同步不依赖 `.pem` 证书文件。 - -真正依赖的是: - -- Redis 安全组放行 -- RDS 白名单 / 安全组放行 -- 正确的 Redis 密码 -- 正确的 MySQL 账号密码 - -## 5. Redis 实际部署与同步状态 - -### 5.1 AWS Redis 主库 - -- 部署方式:Docker -- 版本:`8.2.1` -- 主库地址:`18.163.33.75:6379` -- 运行容器:`hifast-redis` - -### 5.2 104 Redis 从库 - -- 部署方式:宿主机原生安装 -- 版本:`8.6.3` -- 角色:`replica / slave` - -### 5.3 Redis 主从状态 - -最终已验证结果: - -- `104` 上 Redis:`role:slave` -- `104` 上 Redis:`master_link_status:up` -- AWS Redis 主库:`connected_slaves:1` -- AWS Redis 主库识别到从库:`104.238.220.230:6379` - -### 5.4 Redis 验证结果 - -已做过的验证: - -- 从 `104` 连接 AWS Redis 主库,认证成功 -- AWS 主库写入测试键 -- `104` 从库成功读取测试键 -- `2026-05-13` 已确认应用机本地 `ppanel-server -> 127.0.0.1:6379` 建立稳定连接 -- `2026-05-13` 已确认旧 `stunnel4 -> ElastiCache` 链路下线后,应用日志不再出现 Redis `i/o timeout` - -测试键: - -- key: `hifast_replication_test` -- value: `ok_20260510` - -### 5.5 Redis 主从拓扑 - -```mermaid -flowchart LR - REDISMASTER["AWS Redis 主库\n18.163.33.75:6379\nDocker redis:8.2.1"] - REDISSLAVE["104.238.220.230\n原生 Redis 8.6.3\nrole: slave"] - REDISMASTER --> REDISSLAVE -``` - -## 6. MySQL 部署与备用关系 - -### 6.1 主库角色 - -- 主库在 `AWS RDS MySQL` -- 实例:`hifast-mysql-prod-v2` - -### 6.2 外部备用角色 - -- `104.238.220.230` 上存在 MySQL 备用用途 -- 目标是让 `104` 尽量实时同步 AWS 数据 - -### 6.3 当前文档说明 - -MySQL 当前已经完成实际验收,不再只是“待确认”状态。 - -`2026-05-13` 验收结果: - -- `Source_Host = hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com` -- `Replica_IO_Running = Yes` -- `Replica_SQL_Running = Yes` -- `Seconds_Behind_Source = 0` -- `read_only = ON` -- `super_read_only = ON` - -这表示 `104` 当前是健康的只读外部备用库。 - -MySQL 这部分在此前已经有专门 runbook: - -- [ops/aws-rds-external-replica-runbook.md](/Users/Apple/code_vpn/vpn/ppanel-server/ops/aws-rds-external-replica-runbook.md) - -## 7. 当前生产方案的真实特点 - -这套已经落地的架构,不是传统的全 AWS 托管标准形态,而是偏实用的混合方案: - -- 应用和当前实际公网入口在 AWS 同一台 EC2 -- MySQL 在 AWS RDS -- Redis 在 AWS EC2 本机 -- MySQL / Redis 均向外部服务器 `104` 做灾备 -- 旧 ElastiCache / stunnel 链路已经退出当前生产路径 - -它的优点: - -- 成本相对可控 -- Redis 可完全自主控制 -- 外部备用机可以独立接管 - -它的代价: - -- Redis 高可用需要人工切换 -- 外部灾备不是全自动故障转移 -- 应用切换需要明确操作步骤 - -## 8. 故障切换方案 - -### 8.1 正常状态 - -```mermaid -flowchart TD - A["用户访问"] --> B["AWS EC2 应用机"] - B --> C["AWS RDS MySQL 主库"] - B --> D["AWS Redis 主库"] - C -. 同步 .-> E["104 MySQL 备用"] - D -. 复制 .-> F["104 Redis 从库"] -``` - -### 8.2 AWS 故障后的目标切换状态 - -```mermaid -flowchart TD - A["AWS 故障"] --> B["应用入口切到 104"] - B --> C["104 MySQL 提供主服务"] - B --> D["104 Redis 提升为主库"] - D --> E["应用连接 104 Redis"] - C --> F["应用连接 104 MySQL"] -``` - -### 8.2.1 Nginx 是否可以直接切到 104 - -可以,但前提不是“只切 Nginx 就完成故障切换”。 - -因为 `104` 虽然已经部署了同样的业务服务,但如果故障发生时: - -- Redis 还保持从库只读状态 -- MySQL 还没有切成可写主角色 -- 应用配置还没有确认指向 `104` 本机数据层 - -那么即使 Nginx 已经把流量转到 `104`,业务也可能仍然无法正常写入。 - -所以更准确的原则是: - -`104` 已具备应用接管能力,Nginx 切换可以作为最后一步对外放流量动作,但不能作为唯一动作。 - -### 8.3 Redis 切换动作 - -当 AWS Redis 不可用时,`104` 上的 Redis 需要解除主从关系: - -```bash -redis-cli -a '' REPLICAOF NO ONE -``` - -切换后: - -- `104` Redis 从库变为主库 -- 业务应用把 Redis 地址改到 `104.238.220.230:6379` - -### 8.4 MySQL 切换动作 - -当 AWS RDS 不可用时,需要让 `104` MySQL 接管写流量。 - -这部分是否能立即切,需要依赖: - -- 当前 `104` MySQL 是否是健康从库 -- 是否已经取消只读 -- 应用数据库配置是否能快速切换到 `104` - -### 8.5 应用切换动作 - -应用层需要准备至少这 2 个切换点: - -- MySQL 连接地址切换到 `104` -- Redis 连接地址切换到 `104` - -如果应用入口也要迁移到 `104`,还需要: - -- 域名解析切换 -- 或者网关 / 入口切换 - -### 8.6 推荐的实际切换顺序 - -因为 `104` 已经部署同样的应用服务,所以 AWS 故障时推荐按下面顺序操作: - -1. 确认 `104` 上业务服务和 Nginx 进程正常。 -2. 将 `104` 上 Redis 从库提升为主库。 -3. 将 `104` 上 MySQL 从库切换为可写主库。 -4. 确认 `104` 上应用配置已指向本机 MySQL / Redis。 -5. 最后再把 Nginx 上游或域名流量切到 `104`。 - -可以把它理解成: - -`先数据接管 -> 再应用确认 -> 最后入口切流量` - -## 9. 建议的运维操作顺序 - -### 9.1 平时 - -平时重点看: - -- AWS EC2 是否在线 -- RDS 是否在线 -- AWS Redis 主库是否在线 -- `104` Redis 从库是否 `master_link_status:up` -- `104` MySQL 复制是否正常 -- `ppanel-server` 容器是否 `Up` -- `ppanel-server` 日志中是否再次出现 Redis `i/o timeout` -- `stunnel4` 是否保持 `inactive` - -### 9.2 Redis 故障时 - -1. 确认 AWS Redis 主库不可恢复。 -2. 在 `104` 执行 `REPLICAOF NO ONE`。 -3. 修改应用 Redis 地址到 `104.238.220.230:6379`。 -4. 验证应用读写 Redis 正常。 - -### 9.3 MySQL 故障时 - -1. 确认 RDS 故障。 -2. 确认 `104` MySQL 数据已同步到最新可用点。 -3. 去掉 `104` MySQL 只读限制。 -4. 修改应用 MySQL 地址到 `104`。 -5. 验证应用读写数据库正常。 - -### 9.4 整体 AWS 故障时 - -1. 把 Redis 主角色切到 `104`。 -2. 把 MySQL 主角色切到 `104`。 -3. 确认 `104` 上同版本应用服务正常。 -4. 把应用入口切到备用应用节点。 -5. 更新 DNS 或 Nginx 上游流量入口。 -6. 验证用户访问链路。 - -## 10. 当前方案与理想方案的差异 - -### 10.1 当前实际方案 - -`DNS -> hifast-hk-app-01(Nginx + App + Redis) -> RDS MySQL -> 104 灾备` - -### 10.2 理想生产方案 - -从长期稳定性看,更推荐未来演进为: - -`DNS / CDN -> ALB -> 多台 EC2 App -> RDS MySQL -> 托管 Redis / 或高可用 Redis` - -异地灾备继续保留: - -- AWS 生产 -- 104 异地接管 - -### 10.3 当前最值得继续补的项 - -建议按优先级补齐: - -1. 把 RDS 从“private 子网挂公网路由”的临时方案,收敛成真正的公网 DB subnet group,或改成私网复制方案 -2. 明确 `104` 应用接管脚本与启动检查项 -3. 明确 MySQL 故障切换脚本 -4. 明确 Redis 故障切换脚本 -5. 明确域名 / DNS 切换方式 -6. 做一次完整灾备演练 -7. 待 AWS 新资源到位后,再把公网入口迁到独立 `nginx` 服务器 - -### 10.4 RDS 推荐收敛路线 - -当前原先规划的“新建规范 RDS 再切换”路线已经完成,现网主库已经是 `hifast-mysql-prod-v2`。 - -后续更推荐的正式路线是: - -1. 继续以 `hifast-mysql-prod-v2` 作为当前生产主库 -2. 确认其持续使用 `hifast-hk-rds-public-only-sgprep` -3. 安全组持续只放: - - `104.238.220.230/32 -> 3306` - - `hifast-hk-app-core-sg -> 3306` -4. 持续确认应用连接目标为 `hifast-mysql-prod-v2` -5. 持续确认 `104` 的外部复制目标为 `hifast-mysql-prod-v2` -6. 验证通过后,再安排旧 RDS 下线 - -这条路线的优点: - -- 最终拓扑规范清晰 -- 不需要继续保留“private 子网挂公网路由”的临时设计 -- 对当前现网主库的扰动最小 -- 当前数据库体量较小,迁移成本可控 - -## 11. 建议的下一版目标拓扑 - -```mermaid -flowchart TB - USER["用户 / 客户端"] --> DNS["DNS / CDN / 入口层"] - DNS --> APPAWS["AWS 应用集群"] - DNS -. 故障时切换 .-> APPBK["104 备用应用节点"] - - APPAWS --> RDSAWS["AWS RDS MySQL 主库"] - APPAWS --> REDISAWS["AWS Redis 主库"] - - RDSAWS -. 同步 .-> MYSQLBK["104 MySQL 备用"] - REDISAWS -. 复制 .-> REDISBK["104 Redis 备用"] - - APPBK --> MYSQLBK - APPBK --> REDISBK -``` - -## 12. 本文档结论 - -截至当前,已经可以确认的生产与灾备状态是: - -- AWS 是主生产环境 -- 应用当前跑在 `hifast-hk-app-01` -- MySQL 主库在 AWS RDS -- Redis 主库在 AWS EC2 Docker -- `104.238.220.230` 是异地备用节点 -- `104` 已部署与 AWS 相同的业务服务 -- `104` 上 Redis 已切为宿主机原生安装 -- `104` Redis 已成功作为 AWS Redis 主库的从库在线同步 -- `104` MySQL 已恢复并保持健康复制 -- 应用已经切回当前真实可用的本机 Redis 主库 -- 旧 `stunnel4 -> ElastiCache` 链路已经退出生产路径 - -如果后续要继续完善这份方案,优先补充: - -- RDS 子网与公网访问模型收敛 -- 入口域名 / DNS 切换细则 -- 应用层在 `104` 的接管与回切执行清单 -- 待资源到位后的独立 `nginx` 迁移清单 diff --git a/ops/hifast-current-architecture-zh.md b/ops/hifast-current-architecture-zh.md deleted file mode 100644 index 3a4d15c..0000000 --- a/ops/hifast-current-architecture-zh.md +++ /dev/null @@ -1,246 +0,0 @@ -# Hifast 当前架构文档(东京主站版) - -最后更新:`2026-05-21` - -这份文档只描述**当前真实生效**的架构,不描述理想目标,不混入已经下线或待迁移的香港旧链路。 - -如果后续东京 `ALB / WAF / 正式域名` 上线,应继续更新这份文档,而不是回头参考旧香港架构文档。 - -## 1. 当前结论 - -当前已经实际跑起来的是: - -`用户 / 运维 -> 东京 EC2 -> ppanel-server + Redis + observability -> 东京 RDS MySQL` - -当前还**没有**实际生效的组件: - -- `ALB` -- `WAF` -- 正式域名切流 -- `104.238.220.230` 重挂东京 MySQL / Redis 从库 - -也就是说,东京环境目前是: - -- 一台业务 EC2 已可用 -- 一台东京 RDS 已可用 -- 本机 Redis 已可用 -- `ppanel-server` 已成功启动并连通 MySQL / Redis -- 灾备链路参数已准备好,但 `104` 还没最终挂上去 - -## 2. 当前真实拓扑 - -```mermaid -flowchart TB - USER["用户 / 运维"] --> EIP["东京 EC2 公网入口\n3.114.29.208"] - EIP --> NGINX["Nginx\nEC2 本机"] - NGINX --> APP["ppanel-server\nhost network\n127.0.0.1:8080"] - APP --> REDIS["Redis Docker\n127.0.0.1:6379"] - APP --> RDS["Tokyo RDS MySQL\nppanel-mysql-jp"] - APP --> OBS["Grafana / Loki / Tempo / Prometheus"] - - RDS -. 预留复制 .-> DRMYSQL["104.238.220.230\nMySQL DR\n未最终接入"] - REDIS -. 预留复制 .-> DRREDIS["104.238.220.230\nRedis DR\n未最终接入"] -``` - -## 3. 云资源清单 - -### 3.1 Region - -- AWS account: `hifastvpn (200810848252)` -- Region: `ap-northeast-1` - -### 3.2 网络 - -- VPC: `ppanel-jp-prod` -- VPC ID: `vpc-0846b23b4a7d64eac` -- CIDR: `10.20.0.0/16` - -公有子网: - -- `ppanel-jp-public-a` / `subnet-091232bdb53e71490` / `10.20.0.0/24` -- `ppanel-jp-public-c` / `subnet-01ba0975c525ce8cf` / `10.20.1.0/24` - -私有子网: - -- `ppanel-jp-private-a` / `subnet-0bd13111c02f0edbe` / `10.20.10.0/24` -- `ppanel-jp-private-c` / `subnet-0d86c5c756dbc84b2` / `10.20.11.0/24` - -### 3.3 安全组 - -- `ppanel-jp-sg-alb` - - `80/tcp <- 0.0.0.0/0` - - `443/tcp <- 0.0.0.0/0` -- `ppanel-jp-sg-ec2` - - `80/tcp <- sg-0b3a23c31041a5a5a` - - `22/tcp <- 64.118.144.142/32` - - `6379/tcp <- 104.238.220.230/32` -- `ppanel-jp-sg-rds` - - `3306/tcp <- sg-01f2a5a81e7505c91` - - `3306/tcp <- 104.238.220.230/32` - -## 4. 计算与数据库 - -### 4.1 业务 EC2 - -- Name: `ppanel-app-jp-01` -- Instance ID: `i-07839130074cd7ed9` -- Type: `c7i.xlarge` -- AZ: `ap-northeast-1c` -- Private IP: `10.20.1.168` -- Public IP / Elastic IP: `3.114.29.208` -- Public DNS: `ec2-3-114-29-208.ap-northeast-1.compute.amazonaws.com` -- Root volume: `gp3 100GiB` -- Status: `running` - -说明: - -- 当前公网入口是这台 EC2 自己 -- 这里绑定的是 `EIP`,不是依赖实例自动分配的临时公网 IP -- 绑定 EIP 的目的,是保证以后 Redis 主从、白名单、DNS、文档里的公网地址不因为实例变更而漂移 - -### 4.2 Tokyo RDS - -- Identifier: `ppanel-mysql-jp` -- Engine: `MySQL Community 8.4.8` -- Class: `db.r7g.xlarge` -- Storage: `gp3 100GiB` -- Master user: `admin` -- Endpoint: `ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com` -- Public access: `enabled` -- Credential management: `self-managed` -- Current status: `available` - -当前实际连接值: - -- Host: `ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com` -- Port: `3306` -- User: `admin` - -### 4.3 Redis - -- 部署位置:东京 EC2 本机 -- 部署方式:Docker -- 容器名:`ppanel-redis` -- 当前角色:`master` -- 当前监听:`127.0.0.1:6379`(应用本机访问) -- 当前对灾备开放:`3.114.29.208:6379` - -### 4.4 应用与可观测 - -业务容器: - -- `ppanel-server` - -可观测容器: - -- `ppanel-grafana` -- `ppanel-loki` -- `ppanel-promtail` -- `ppanel-prometheus` -- `ppanel-tempo` -- `ppanel-cadvisor` -- `ppanel-node-exporter` -- `ppanel-nginx-exporter` - -## 5. 当前部署方式 - -部署目录: - -- `/opt/ppanel` - -关键文件: - -- `/opt/ppanel/docker-compose.cloud.yml` -- `/opt/ppanel/configs/ppanel.yaml` -- `/opt/ppanel/.env` - -业务启动方式: - -- `ppanel-server` 使用 `docker compose` -- `network_mode: host` - -当前配置指向: - -- MySQL -> 东京 RDS -- Redis -> 本机 Docker Redis -- Trace -> `127.0.0.1:4317` - -## 6. 当前已验证状态 - -截至 `2026-05-21` 已确认: - -- `docker exec ppanel-redis redis-cli -a 'hifast67yj' PING` 返回 `PONG` -- `MYSQL_PWD=... mysql -h ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com -u admin -e "select 1"` 返回正常 -- `docker compose -f /opt/ppanel/docker-compose.cloud.yml up -d ppanel-server` 已成功 -- `docker logs ppanel-server` 无 MySQL / Redis 连接报错 -- `curl http://127.0.0.1:8080/v1/common/heartbeat` 返回成功 - -可以认为当前东京单站已经具备: - -- 服务启动能力 -- 数据库连接能力 -- Redis 连接能力 -- 基础可观测能力 - -## 7. 当前未落地项 - -下面这些还属于“目标方案”,不是“当前实际架构”: - -- `ALB` -- `WAF` -- ACM 证书完成 DNS 验证 -- 正式域名切换到东京 -- `104.238.220.230` 完整接成东京 DR - -所以现在请不要把当前架构理解成: - -`DNS -> ALB -> WAF -> EC2 -> RDS` - -当前真实架构更准确地说是: - -`EIP -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS` - -## 8. 灾备准备状态 - -### 8.1 MySQL DR - -已准备好: - -- DR host: `104.238.220.230` -- 复制账号:`repl@104.238.220.230` -- binlog retention hours: `24` -- current binlog file: `mysql-bin-changelog.000189` -- current binlog position: `185053` - -当前状态: - -- 东京侧已准备完成 -- 但 `104` 侧还没有最终执行挂从 - -### 8.2 Redis DR - -已准备好: - -- 东京 Redis 主地址:`3.114.29.208:6379` -- 东京 Redis 当前角色:`role:master` -- 东京 Redis 当前状态:`connected_slaves:0` - -当前状态: - -- 东京侧已可作为主库提供同步 -- 但 `104` 侧还没有最终执行 `REPLICAOF` - -## 9. 权威文档入口 - -与当前东京架构直接相关的文档,以这些为准: - -- [当前架构文档](./hifast-current-architecture-zh.md) -- [东京资源清单](../deploy/aws/ap-northeast-1/configs/resource-inventory.current.md) -- [东京迁移 Runbook](./hifast-aws-jp-migration-runbook-zh.md) -- [东京切换清单](./hifast-aws-jp-cutover-checklist-zh.md) - -下面这些旧文档可以继续保留,但不要作为“当前实际架构”依据: - -- 香港旧主站相关文档 -- 仍以 `ap-east-1` 为主语的历史架构说明 - diff --git a/ops/hifast-rds-v2-cutover-checklist-zh.md b/ops/hifast-rds-v2-cutover-checklist-zh.md deleted file mode 100644 index 8eaf5f0..0000000 --- a/ops/hifast-rds-v2-cutover-checklist-zh.md +++ /dev/null @@ -1,286 +0,0 @@ -# Hifast 新 RDS 生产切换清单(已完成归档) - -本文档记录已完成的 `hifast-mysql-prod -> hifast-mysql-prod-v2` 生产切换过程,作为归档和回溯参考。 - -本次切换已经完成,当时的目标是避免继续在线硬改现有生产 RDS,而是: - -1. 新建一台规范公网子网组的新 RDS -2. 导入现有 `hifast` 数据 -3. 将 AWS 应用切到新 RDS -4. 将 `104.238.220.230` 的外部从库改挂新 RDS -5. 验收通过后再下线旧 RDS - -## 1. 适用背景 - -当前现网已经可用,但还存在 1 个结构性问题: - -- 为了让 `104` 通过公网白名单做 MySQL 复制,当时的 `hifast-mysql-prod` 仍依赖 `hifast-hk-private-1b` 临时挂公网路由 - -当前已准备好的收敛资源: - -- 纯公网 DB subnet group: - - `hifast-hk-rds-public-only-sgprep` -- 安全组: - - `hifast-hk-rds-core-sg` - -## 2. 目标实例参数 - -建议新实例名称: - -- `hifast-mysql-prod-v2` - -建议参数如下: - -- Region: `ap-east-1` -- Engine: `MySQL` -- Engine version: `8.4.8` -- Templates: `Production` -- DB instance class: `db.r7g.xlarge` -- Storage type: `gp3` -- Allocated storage: `200 GiB` -- Provisioned IOPS: `3000` -- Storage throughput: `125` -- Multi-AZ: `No` -- Publicly accessible: `Yes` -- VPC: `vpc-09fc384522517debc` -- DB subnet group: `hifast-hk-rds-public-only-sgprep` -- VPC security group: `hifast-hk-rds-core-sg` -- DB name: `hifast` -- Master username: `admin` -- Master password: 与现网保持一致 -- Backup retention: `7 days` -- Performance Insights: `On` -- Storage encryption: `On` -- Deletion protection: `On` -- Auto minor version upgrade: 建议保持与现网一致 -- Maintenance window: 可与现网同策略,建议维护窗口内切换 - -## 3. 切换前确认 - -切换前必须满足: - -1. `104` 当前主从正常: - - `Replica_IO_Running: Yes` - - `Replica_SQL_Running: Yes` - - `Seconds_Behind_Source: 0` -2. AWS 应用机心跳正常: - - `curl http://127.0.0.1:8080/v1/common/heartbeat` -3. AWS Redis 主从正常: - - AWS 主 `connected_slaves:1` - - `104` 从 `master_link_status:up` -4. 已确认当前应用真实配置文件: - - `/opt/ppanel/configs/ppanel.yaml` -5. 已确认当前应用重启方式: - - `cd /opt/ppanel && docker compose -f docker-compose.cloud.yml up -d ppanel-server` - -## 4. 创建新 RDS - -已在 AWS 控制台创建 `hifast-mysql-prod-v2`,未对原 `hifast-mysql-prod` 做高风险在线子网调整。 - -创建完成后先确认: - -1. 新 endpoint 已分配 -2. `Publicly accessible = Yes` -3. SG 为 `hifast-hk-rds-core-sg` -4. DB subnet group 为 `hifast-hk-rds-public-only-sgprep` -5. 从 `104` 可以 TCP 连通 `3306` - -连通性验证: - -```bash -nc -zv 3306 -``` - -## 5. 导出旧主库 - -在一台可连旧 RDS 的机器执行: - -```bash -mysqldump \ - -h hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com \ - -u admin \ - -p \ - --single-transaction \ - --routines \ - --triggers \ - --events \ - --set-gtid-purged=OFF \ - hifast > hifast-full.sql -``` - -说明: - -- 当前业务库体量约 `187 MB` -- 这条路线的成本低,且比现网主库在线改子网更稳 - -## 6. 初始化新 RDS - -先连接新 RDS: - -```bash -mysql -h -u admin -p -``` - -建议先执行: - -```sql -CALL mysql.rds_set_configuration('binlog retention hours', 24); - -CREATE USER IF NOT EXISTS 'repl'@'104.238.220.230' IDENTIFIED BY ''; -GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl'@'104.238.220.230'; -FLUSH PRIVILEGES; -``` - -如果库是空的,再导入: - -```bash -mysql -h -u admin -p hifast < hifast-full.sql -``` - -导入完成后确认: - -```bash -mysql -h -u admin -p -e "USE hifast; SHOW TABLES;" -``` - -## 7. 切换 AWS 应用到新 RDS - -### 7.1 备份当前配置 - -在 AWS app EC2: - -```bash -cp /opt/ppanel/configs/ppanel.yaml /opt/ppanel/configs/ppanel.yaml.bak.$(date +%Y%m%d%H%M%S) -``` - -### 7.2 修改数据库地址 - -编辑: - -- `/opt/ppanel/configs/ppanel.yaml` - -把: - -```yaml -MySQL: - Addr: hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306 -``` - -改成: - -```yaml -MySQL: - Addr: hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306 -``` - -### 7.3 重启业务容器 - -```bash -cd /opt/ppanel -docker compose -f docker-compose.cloud.yml up -d ppanel-server -``` - -### 7.4 应用验收 - -```bash -curl -sf http://127.0.0.1:8080/v1/common/heartbeat -docker compose -f /opt/ppanel/docker-compose.cloud.yml logs --tail=100 ppanel-server -``` - -重点确认: - -- 心跳返回 `200` -- 日志中无 MySQL 连接异常 - -## 8. 改挂 `104` 从库到新 RDS - -先在新 RDS 获取位点: - -```sql -SHOW MASTER STATUS; -``` - -然后在 `104` 执行: - -```sql -STOP REPLICA; -RESET REPLICA ALL; -CHANGE REPLICATION SOURCE TO - SOURCE_HOST='hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com', - SOURCE_PORT=3306, - SOURCE_USER='repl', - SOURCE_PASSWORD='', - SOURCE_LOG_FILE='', - SOURCE_LOG_POS=, - SOURCE_SSL=1; -START REPLICA; -SHOW REPLICA STATUS\G -``` - -验收标准: - -- `Replica_IO_Running: Yes` -- `Replica_SQL_Running: Yes` -- `Seconds_Behind_Source: 0` - -## 9. 切换后验收 - -至少做下面这些检查: - -1. AWS 应用心跳正常 -2. 管理后台可登录 -3. 新建一条测试数据后,主库可见 -4. `104` 从库能同步到该测试数据 -5. Navicat 可从白名单来源连接新 RDS -6. Redis 主从仍正常,不受本次 MySQL 切换影响 - -推荐额外核对: - -```bash -docker compose -f /opt/ppanel/docker-compose.cloud.yml ps -docker exec hifast-redis redis-cli -a '' INFO replication -``` - -## 10. 回滚方案 - -如果切换后 AWS 应用异常: - -1. 立刻把 `/opt/ppanel/configs/ppanel.yaml` 中 `MySQL.Addr` 改回旧 endpoint -2. 重启 `ppanel-server`: - -```bash -cd /opt/ppanel -docker compose -f docker-compose.cloud.yml up -d ppanel-server -``` - -3. 优先恢复主生产可用 -4. `104` 是否回切旧 RDS 复制,视恢复窗口决定 - -如果只是 `104` 复制改挂失败,但 AWS 应用已正常使用新 RDS: - -- 不必立刻回滚应用 -- 先修好 `104 -> 新 RDS` 的白名单、位点、用户或网络 - -## 11. 切换完成后的收尾 - -全部验收通过后再做: - -1. 已将新 endpoint 记录到正式运维文档 -2. 仍需根据回收窗口安排旧 RDS 下线 -3. 仍需按网络收敛计划处理 `private-1b` 路由语义 -4. 已更新外部复制 runbook 中的主库 endpoint - -## 12. 当前建议的执行顺序 - -本次切换当时按以下顺序执行: - -1. 创建 `hifast-mysql-prod-v2` -2. 验证新 RDS 网络与参数 -3. 导出旧库 -4. 导入新库 -5. 创建 / 确认 `repl` 用户 -6. 切 AWS 应用到新 RDS -7. 验证应用 -8. 改挂 `104` 到新 RDS -9. 验证从库 -10. 收尾,并保留旧 RDS 待后续下线 diff --git a/ops/hifast-s3-backup-runbook-zh.md b/ops/hifast-s3-backup-runbook-zh.md deleted file mode 100644 index 764d82c..0000000 --- a/ops/hifast-s3-backup-runbook-zh.md +++ /dev/null @@ -1,311 +0,0 @@ -# Hifast S3 备份集成 Runbook - -本文档记录 `2026-05-13` 已经实际确认和完成的 S3 备份集成状态,以及后续如何把它和当前 `AWS 主生产 + 104 外部备用` 架构结合起来。 - -## 1. 当前已完成状态 - -- 已创建 S3 备份桶:`hifast-prod-backups-200810848252-ap-east-1` -- Region: `ap-east-1` -- Public access: blocked -- Versioning: enabled -- 已确认 RDS 主库 `hifast-mysql-prod-v2` 自动备份已启用: - - retention: `7 days` - - `2026-05-13` 已有自动快照 -- 已确认 `104.238.220.230` MySQL 外部从库健康,可作为逻辑备份源: - - `Replica_IO_Running: Yes` - - `Replica_SQL_Running: Yes` - - `Seconds_Behind_Source: 0` -- 已确认 `104.238.220.230` Redis 从库健康,可作为 RDB 备份源: - - `role:slave` - - `master_link_status:up` - -## 2. 推荐组合方案 - -当前最稳的做法不是只依赖一种备份,而是保留两层: - -1. `RDS automated backup` -2. `104` 从库导出的逻辑备份上传到 S3 - -原因: - -- RDS 自动备份适合快速恢复整库 -- S3 逻辑备份适合独立下载、跨环境恢复、长期归档 -- 从 `104` 导出 MySQL / Redis 备份,对 AWS 主生产扰动最小 - -## 3. 仓库已补充内容 - -- MySQL 备份脚本: - - [`deploy/scripts/mysql_backup_to_s3.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/mysql_backup_to_s3.sh) -- Redis RDB 备份脚本: - - [`deploy/scripts/redis_rdb_backup_to_s3.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/redis_rdb_backup_to_s3.sh) -- 环境变量模板: - - [`deploy/aws/ap-east-1/configs/backup-to-s3.env.example`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/aws/ap-east-1/configs/backup-to-s3.env.example) -- 主从切换环境变量模板: - - [`deploy/aws/ap-east-1/configs/replica-ops.env.example`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/aws/ap-east-1/configs/replica-ops.env.example) -- 数据迁移交互工具: - - [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh) -- 主从运维交互工具: - - 统一入口仍使用 [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh) - -## 4. 建议部署位置 - -### 4.1 MySQL - -优先部署在 `104.238.220.230`: - -- 直接从本地 MySQL 从库导出 -- 不占用 AWS RDS 主库的备份窗口 -- 故障时备份和备用节点仍在同一台机器上 - -### 4.2 Redis - -优先部署在 `104.238.220.230`: - -- 直接从 Redis 从库导出 `RDB` -- 不影响 AWS Redis 主库对外服务 - -## 5. 104 当前还缺的东西 - -截至 `2026-05-13`,`104` 已具备: - -- `mysql` -- `mysqldump` -- `gzip` - -但仍缺: - -- `aws cli` -- 一套最小权限的 S3 上传凭证 - -## 6. 推荐权限模型 - -当前最实用的选择有两种: - -1. `104` 使用专用 IAM 用户的最小权限 `AK/SK` -2. 未来如果备份转到 AWS EC2,再改为 `EC2 Instance Role` - -按现有拓扑,推荐先走第 1 种,因为 MySQL 和 Redis 的备份源都在 `104`。 - -### 6.1 最小权限策略示例 - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "ListBackupBucket", - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::hifast-prod-backups-200810848252-ap-east-1" - }, - { - "Sid": "WriteBackupObjects", - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:AbortMultipartUpload" - ], - "Resource": "arn:aws:s3:::hifast-prod-backups-200810848252-ap-east-1/*" - } - ] -} -``` - -说明: - -- 不建议给 `s3:*` -- 不建议把 AK/SK 写进 repo -- 建议只写入 `104` 本机的 `/root/.aws/credentials` - -## 7. 104 安装与配置步骤 - -### 7.1 安装 `aws cli` - -```bash -apt-get update -apt-get install -y awscli -``` - -### 7.2 写入 AWS 凭证 - -```bash -mkdir -p /root/.aws -chmod 700 /root/.aws -cat >/root/.aws/credentials <<'EOF' -[default] -aws_access_key_id=CHANGE_ME -aws_secret_access_key=CHANGE_ME -EOF -chmod 600 /root/.aws/credentials -``` - -### 7.3 准备环境变量文件 - -```bash -cp deploy/aws/ap-east-1/configs/backup-to-s3.env.example /root/backup-to-s3.env -chmod 600 /root/backup-to-s3.env -``` - -把下面这些值改成真实值: - -- `MYSQL_PASSWORD` -- `REDIS_PASSWORD` -- `HOST_TAG` - -如果 MySQL 就跑在 `104` 本机,可保持: - -- `MYSQL_HOST=127.0.0.1` - -如果 Redis 就跑在 `104` 本机,可保持: - -- `REDIS_HOST=127.0.0.1` - -## 8. 手工执行方式 - -### 8.1 MySQL 逻辑备份 - -```bash -set -a -source /root/backup-to-s3.env -set +a - -bash deploy/scripts/mysql_backup_to_s3.sh -``` - -默认产物: - -- 本地:`/var/backups/hifast/*.sql.gz` -- S3:`s3://hifast-prod-backups-200810848252-ap-east-1/mysql//` - -### 8.2 Redis RDB 备份 - -```bash -set -a -source /root/backup-to-s3.env -set +a - -S3_PREFIX=redis bash deploy/scripts/redis_rdb_backup_to_s3.sh -``` - -默认产物: - -- 本地:`/var/backups/hifast/*.rdb.gz` -- S3:`s3://hifast-prod-backups-200810848252-ap-east-1/redis//` - -## 9. 定时任务建议 - -### 9.1 MySQL 每天凌晨执行 - -```cron -15 3 * * * . /root/backup-to-s3.env && /bin/bash /opt/ppanel/deploy/scripts/mysql_backup_to_s3.sh >>/var/log/hifast-mysql-backup.log 2>&1 -``` - -### 9.2 Redis 每天凌晨执行 - -```cron -45 3 * * * . /root/backup-to-s3.env && S3_PREFIX=redis /bin/bash /opt/ppanel/deploy/scripts/redis_rdb_backup_to_s3.sh >>/var/log/hifast-redis-backup.log 2>&1 -``` - -说明: - -- MySQL 和 Redis 建议错峰执行 -- 本地临时文件默认只保留 `3` 天 -- 长期保留建议通过 S3 Lifecycle 管理,而不是靠本机 cron 删除 - -### 9.3 MySQL 每 10 分钟执行一次 - -如果你要在 `104` 上做高频逻辑备份,仓库里已经补了 `systemd timer` 安装脚本: - -- 安装脚本: - - [`deploy/scripts/install_mysql_backup_timer.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/install_mysql_backup_timer.sh) -- systemd service: - - [`deploy/systemd/hifast-mysql-backup.service`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/systemd/hifast-mysql-backup.service) -- systemd timer: - - [`deploy/systemd/hifast-mysql-backup.timer`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/systemd/hifast-mysql-backup.timer) - -安装方式: - -```bash -cp deploy/aws/ap-northeast-1/configs/backup-to-s3.env.example /root/backup-to-s3.env -chmod 600 /root/backup-to-s3.env -vim /root/backup-to-s3.env - -bash deploy/scripts/install_mysql_backup_timer.sh /opt/ppanel /root/backup-to-s3.env -``` - -查看状态: - -```bash -systemctl list-timers --all | grep hifast-mysql-backup -systemctl status hifast-mysql-backup.timer --no-pager -l -systemctl status hifast-mysql-backup.service --no-pager -l -journalctl -u hifast-mysql-backup.service -n 50 --no-pager -``` - -补充说明: - -- 定时表达式是 `OnCalendar=*:0/10`,即每 `10` 分钟执行一次 -- 备份脚本已加 `flock` 锁,上一轮未结束时,下一轮会自动跳过,不会并发打包 -- 高频逻辑备份会持续产生 `mysqldump` 开销,建议只在 `104` 这样的从库或备用库上执行,不要直接打主库 - -## 10. 恢复思路 - -### 10.1 MySQL - -1. 从 S3 下载目标 `sql.gz` -2. 校验 `.sha256` -3. 解压 -4. 导入目标 MySQL - -示例: - -```bash -aws s3 cp s3://hifast-prod-backups-200810848252-ap-east-1/mysql/104-standby/20260513T120000Z_104-standby_hifast.sql.gz . -aws s3 cp s3://hifast-prod-backups-200810848252-ap-east-1/mysql/104-standby/20260513T120000Z_104-standby_hifast.sql.gz.sha256 . -sha256sum -c 20260513T120000Z_104-standby_hifast.sql.gz.sha256 -gunzip -c 20260513T120000Z_104-standby_hifast.sql.gz | mysql -h -u -p -``` - -### 10.2 Redis - -1. 从 S3 下载目标 `rdb.gz` -2. 校验 `.sha256` -3. 解压得到 `dump.rdb` -4. 在维护窗口内替换 Redis 数据文件后重启 - -## 11. 当前推荐落地顺序 - -1. 保持当前 `RDS automated backup` 不变 -2. 在 `104` 安装 `aws cli` -3. 创建最小权限 S3 上传凭证并仅保存到 `104` -4. 先手工执行一轮 MySQL 备份上传 -5. 验证 S3 对象、校验文件、恢复可读性 -6. 再补 Redis RDB 备份 -7. 最后加 cron 和 S3 Lifecycle - -## 12. 104 上的交互式运维脚本 - -为了减少手工敲命令,仓库里现在统一使用一套交互式总入口。 - -### 12.1 统一入口 - -```bash -bash deploy/scripts/hifast_data_sync_tool.sh /root/replica-ops.env -``` - -菜单支持: - -- 备份 MySQL 到 S3 -- 备份 Redis 到 S3 -- 导出 MySQL dump -- 导入 MySQL dump 到 AWS RDS -- 导出后直接导入 -- 导出 Redis RDB -- 导入 Redis RDB 到 Docker / 宿主机 Redis -- 查看 MySQL / Redis 当前主从状态 -- 强制重拉 MySQL 主从 -- 强制重拉 Redis 主从 -- 把 MySQL 从库提升为可写主库 -- 把 Redis 从库提升为主库 diff --git a/pkg/hash/hash.go b/pkg/hash/hash.go index 8bd87b5..b42ca7b 100644 --- a/pkg/hash/hash.go +++ b/pkg/hash/hash.go @@ -3,15 +3,26 @@ package hash import ( "crypto/md5" "fmt" + "hash/fnv" + "strconv" "github.com/spaolacci/murmur3" ) +const invitePeerHashSalt = "ppanel_invite_" + "sales_v1" + // Hash returns the hash value of data. func Hash(data []byte) uint64 { return murmur3.Sum64(data) } +func InvitePeerHash(userId int64) string { + h := fnv.New64a() + _, _ = h.Write([]byte(invitePeerHashSalt)) + _, _ = h.Write([]byte(strconv.FormatInt(userId, 10))) + return fmt.Sprintf("%010d", h.Sum64()%10000000000) +} + // Md5 returns the md5 bytes of data. func Md5(data []byte) []byte { digest := md5.New() diff --git a/pkg/payment/epay/epay.go b/pkg/payment/epay/epay.go index 8933d9f..4fb49d5 100644 --- a/pkg/payment/epay/epay.go +++ b/pkg/payment/epay/epay.go @@ -2,6 +2,7 @@ package epay import ( "encoding/json" + "fmt" "io" "net/http" "net/url" @@ -88,28 +89,42 @@ func (c *Client) VerifySign(params map[string]string) bool { return c.createSign(params) == params["sign"] } -func (c *Client) QueryOrderStatus(orderNo string) bool { +// QueryOrderStatus returns (paid, err). A non-nil err means the query itself +// failed (network / 5xx / decode error) and the result is inconclusive — callers +// MUST NOT treat that as "unpaid". A nil err with paid=false means the gateway +// answered and reported the order is not paid. +func (c *Client) QueryOrderStatus(orderNo string) (bool, error) { client := http.Client{ Timeout: 5 * time.Second, } resp, err := client.Get(c.Url + "/api.php" + "?act=order" + "&pid=" + c.Pid + "&key=" + c.Key + "&out_trade_no=" + orderNo) if err != nil { logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error())) - return false + return false, fmt.Errorf("epay query request failed: %w", err) } defer resp.Body.Close() + if resp.StatusCode >= 500 { + err := fmt.Errorf("epay query upstream status %d", resp.StatusCode) + logger.Error("[Epay] QueryOrderStatus upstream 5xx", logger.Field("orderNo", orderNo), logger.Field("status", resp.StatusCode)) + return false, err + } value, err := io.ReadAll(resp.Body) if err != nil { logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error())) - return false + return false, fmt.Errorf("epay query read body failed: %w", err) } var response queryOrderStatusResponse - err = json.Unmarshal(value, &response) - if err != nil { + if err = json.Unmarshal(value, &response); err != nil { logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error())) - return false + return false, fmt.Errorf("epay query decode failed: %w", err) } - return response.Status == 1 + // EPay API contract: code != 1 means the API itself errored (e.g. wrong key). + // Treat it as a query failure, not a definitive "unpaid", to avoid wrongly + // closing a paid order under a transient upstream config error. + if response.Code != 1 { + return false, fmt.Errorf("epay query api code=%d msg=%q", response.Code, response.Msg) + } + return response.Status == 1, nil } // StructToMap converts a struct to map[string]string diff --git a/pkg/xerr/errMsg.go b/pkg/xerr/errMsg.go index 82e7293..5c32531 100644 --- a/pkg/xerr/errMsg.go +++ b/pkg/xerr/errMsg.go @@ -37,6 +37,7 @@ func init() { TelegramNotBound: "Telegram not bound ", UserNotBindOauth: "User not bind oauth method", InviteCodeError: "Invite code error", + UserCommissionNotEnough: "佣金余额不足", RegisterIPLimit: "Too many registrations", EmailBindError: "Email already bound", UserBindInviteCodeExist: "Invite code already bound", @@ -82,6 +83,8 @@ func init() { // System error DebugModeError: "Debug mode is enabled", + SendSmsError: "短信发送失败", + GetAuthenticatorError: "Unsupported login method", AuthenticatorNotSupportedError: "The authenticator does not support this method", @@ -94,15 +97,18 @@ func init() { TelephoneExist: "Telephone already exists", DeviceExist: "device exists", PasswordIsEmpty: "password is empty", + AreaCodeIsEmpty: "国家区号不能为空", TelephoneError: "telephone number error", DeviceNotExist: "Device does not exist", UseridNotMatch: "Userid not match", + DeviceBindLimitExceeded: "设备绑定数量已达上限", // Order error OrderNotExist: "Order does not exist", PaymentMethodNotFound: "Payment method not found", OrderStatusError: "Order status error", InsufficientOfPeriod: "Insufficient number of period", + ExistAvailableTraffic: "存在可用流量", OrderAlreadyRefunded: "Order already refunded", OrderRefundNoSubscription: "Refund target subscription not found", OrderRefundCommissionMismatch: "Refund commission source not found", diff --git a/pkg/xerr/errMsg_test.go b/pkg/xerr/errMsg_test.go new file mode 100644 index 0000000..e244a52 --- /dev/null +++ b/pkg/xerr/errMsg_test.go @@ -0,0 +1,45 @@ +package xerr + +import "testing" + +// TestMapErrMsg_MissingCodesAreFilled 覆盖 HIF-138 中补齐的 5 个错误码: +// 这些码之前在 message 表中缺失,导致 MapErrMsg 回退到 "Internal Server Error"。 +func TestMapErrMsg_MissingCodesAreFilled(t *testing.T) { + cases := []struct { + name string + code uint32 + want string + }{ + {"UserCommissionNotEnough", UserCommissionNotEnough, "佣金余额不足"}, + {"SendSmsError", SendSmsError, "短信发送失败"}, + {"AreaCodeIsEmpty", AreaCodeIsEmpty, "国家区号不能为空"}, + {"DeviceBindLimitExceeded", DeviceBindLimitExceeded, "设备绑定数量已达上限"}, + {"ExistAvailableTraffic", ExistAvailableTraffic, "存在可用流量"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := MapErrMsg(tc.code) + if got == "Internal Server Error" { + t.Fatalf("MapErrMsg(%d) fell back to Internal Server Error; expected %q", tc.code, tc.want) + } + if got != tc.want { + t.Errorf("MapErrMsg(%d) = %q, want %q", tc.code, got, tc.want) + } + if !IsCodeErr(tc.code) { + t.Errorf("IsCodeErr(%d) = false, want true", tc.code) + } + }) + } +} + +// TestMapErrMsg_UnknownCodeFallsBack 验证未定义码仍然安全回退,不 panic。 +func TestMapErrMsg_UnknownCodeFallsBack(t *testing.T) { + const unknown uint32 = 99999 + if got := MapErrMsg(unknown); got != "Internal Server Error" { + t.Errorf("MapErrMsg(%d) = %q, want %q", unknown, got, "Internal Server Error") + } + if IsCodeErr(unknown) { + t.Errorf("IsCodeErr(%d) = true, want false", unknown) + } +} diff --git a/ppanel.api b/ppanel.api index b6d8726..65566cb 100644 --- a/ppanel.api +++ b/ppanel.api @@ -31,6 +31,8 @@ import ( "apis/admin/marketing.api" "apis/admin/application.api" "apis/admin/group.api" + "apis/admin/invite.api" + "apis/admin/promo.api" "apis/public/user.api" "apis/public/subscribe.api" "apis/public/redemption.api" diff --git a/pr-body-hif-16.md b/pr-body-hif-16.md new file mode 100644 index 0000000..f7dbf2c --- /dev/null +++ b/pr-body-hif-16.md @@ -0,0 +1,64 @@ +## 关联 Issue + +Closes HIF-16 + +## 改动摘要 + +拆分订单退款终态与队列 claim 临时态:退款成功后写入 `status=7`,`status=6` 仅保留为 worker claimed 状态,避免后台恢复任务把退款订单重新入队。 + +## 改动细节 + +- `internal/logic/admin/order/refundOrderLogic.go`:退款状态改为 `7`,重复退款优先检查 `type=24` 退款审计日志,再兼容旧的 `333` 佣金退款日志。 +- `queue/logic/order/activateOrderLogic.go`、`queue/logic/order/stuckOrderRecoveryLogic.go`:release/recovery 只按真实 `claimed(6)` 回退,不再依赖 `333` 日志猜测状态语义。 +- `internal/logic/admin/order/getOrderListLogic.go`:管理端状态展示改为 `6=claimed`、`7=refunded`。 +- `internal/model/log/refund.go`:新增 `HasOrderRefundLog`,用于基于退款审计日志做幂等判断。 +- `internal/logic/admin/order/refundOrderLogic_test.go`、`internal/model/log/refund_test.go`、`queue/logic/order/order_status_recovery_test.go`:补充退款写 `7`、重复退款、退款日志判断、claim release/recovery 行为测试。 + +## 测试计划 + +- [ ] `go build ./...` 通过 +- [x] `go vet ./...` 通过 +- [ ] `go test -race ./... -count=1` 通过 +- [ ] golangci-lint 通过 +- [x] 新增/修改的逻辑有对应单测覆盖 +- [x] (如涉及 DB 变更)无 DB migration +- [ ] (如涉及 API)curl / Postman 验证命令贴在下面 + +``` +go test ./internal/logic/admin/order ./internal/model/log ./queue/logic/order +ok github.com/perfect-panel/server/internal/logic/admin/order +ok github.com/perfect-panel/server/internal/model/log +ok github.com/perfect-panel/server/queue/logic/order + +go test ./... +PASS + +go test -v ./... +PASS + +go vet ./... +PASS + +golangci-lint run --new-from-rev=origin/internal +PASS +``` + +说明:本地 `lefthook run pre-commit` 无法执行,因为环境缺少 `lefthook` 命令;已手动执行 hook 配置中的等价检查。全量 `golangci-lint run` 仍会失败在 internal 上已有历史 lint 问题,本分支使用 `--new-from-rev=origin/internal` 验证未新增 lint 问题。 + +未做 curl 验证:本地没有可用服务配置与测试数据库,且该改动主要通过事务单测覆盖退款状态与队列恢复行为。 + +## 风险 / 回滚 + +- 风险:线上若已经存在 `status=6` 表示已退款的历史订单,本改动不会自动迁移,后续查询会显示为 `claimed`。建议上线前评估历史数据,可按退款审计日志或旧 `333` 佣金退款日志筛出并迁移到 `status=7`。 +- 回滚:回滚本 PR 后恢复旧的 `status=6` 退款语义和 `333` 日志守卫;不涉及 schema 变更。 + +## Reviewer 自检清单 + +- [x] PR 标题符合 commitlint 规范(`修复/新功能/重构/文档/配置(#): ...`) +- [x] 分支命名 `fix/-…` / `feat/-…` / `chore/…` +- [x] 目标分支 = `internal` +- [x] 改动 scope 与 Issue 描述一致,无 scope creep +- [x] **无无关代码改动**(架构师红线) +- [x] 无密钥/凭证泄露 +- [ ] CI 全绿 +- [ ] 测试工程师已验收(如涉及业务逻辑) diff --git a/pr-body-hif-21.md b/pr-body-hif-21.md new file mode 100644 index 0000000..f828754 --- /dev/null +++ b/pr-body-hif-21.md @@ -0,0 +1,57 @@ +## 关联 Issue + +Closes HIF-21 + +## 改动摘要 + +禁止 `PUT /v1/admin/order/status` 通过通用改状态入口把订单写成 `status=6`(claimed)或 `status=7`(refunded)。 +这两个状态分别保留给激活 worker 临时 claim 流程和专用退款流程,避免后台或旧前端绕过退款副作用只改订单状态。 + +## 改动细节 + +- `internal/logic/admin/order/updateOrderStatusLogic.go`:在入口校验中同时拒绝 `status=6` 和 `status=7`,统一返回 `OrderStatusError`。 +- `internal/logic/admin/order/updateOrderStatusLogic_test.go`:扩充单测,覆盖通用改状态接口对 `claimed` / `refunded` 两个保留状态的拒绝行为。 + +## 测试计划 + +- [x] `go build ./...` 通过 +- [x] `go vet ./...` 通过 +- [ ] `go test -race ./... -count=1` 通过 +- [ ] golangci-lint 通过 +- [x] 新增/修改的逻辑有对应单测覆盖 +- [ ] (如涉及 DB 变更)migration up/down 双向验证 +- [ ] (如涉及 API)curl / Postman 验证命令贴在下面 + + + +```bash +go test ./internal/logic/admin/order/... +ok github.com/perfect-panel/server/internal/logic/admin/order 4.224s + +go test ./... +# 业务相关包通过;tests/acceptance 因 staging https://tapi.hifast.biz 请求超时失败 +# FAIL: TestPublicSmoke/error_path_missing_auth +# GET https://tapi.hifast.biz/v1/public/user/info: context deadline exceeded + +golangci-lint run +# 当前基线存在与本次改动无关的历史问题,例如: +# pkg/updater/updater.go:301 errcheck +# internal/logic/admin/group/exportGroupResultLogic.go:110 errcheck +# pkg/proc/shutdown.go:19 unused +``` + +## 风险 / 回滚 + +- 风险较低:仅收紧 admin 通用改状态接口的允许输入,不改退款逻辑、不改队列状态流转。 +- 若需回滚,直接回退本 PR 即可;不会涉及数据迁移或额外清理。 + +## Reviewer 自检清单 + +- [x] PR 标题符合 commitlint 规范(`修复/新功能/重构/文档/配置(#): ...`) +- [x] 分支命名 `fix/-…` / `feat/-…` / `chore/…` +- [x] 目标分支 = `internal` +- [x] 改动 scope 与 Issue 描述一致,无 scope creep +- [x] **无无关代码改动**(架构师红线) +- [x] 无密钥/凭证泄露 +- [ ] CI 全绿 +- [ ] 测试工程师已验收(如涉及业务逻辑) diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml deleted file mode 100644 index 1b478d8..0000000 --- a/prometheus/prometheus.yml +++ /dev/null @@ -1,40 +0,0 @@ -global: - scrape_interval: 15s - evaluation_interval: 15s - -scrape_configs: - - job_name: prometheus - static_configs: - - targets: - - prometheus:9090 - - - job_name: grafana - metrics_path: /metrics - static_configs: - - targets: - - grafana:3000 - - - job_name: node-exporter - static_configs: - - targets: - - node-exporter:9100 - - - job_name: cadvisor - static_configs: - - targets: - - cadvisor:8080 - - - job_name: nginx-exporter - static_configs: - - targets: - - nginx-exporter:9113 - - - job_name: loki - static_configs: - - targets: - - loki:3100 - - - job_name: tempo - static_configs: - - targets: - - tempo:3200 diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 8b20137..6bc4005 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -19,6 +19,7 @@ import ( "github.com/google/uuid" "github.com/hibiken/asynq" "github.com/perfect-panel/server/internal/model/order" + "github.com/perfect-panel/server/internal/model/promo" "github.com/perfect-panel/server/internal/model/redemption" "github.com/perfect-panel/server/internal/model/subscribe" "github.com/perfect-panel/server/internal/model/user" @@ -48,6 +49,7 @@ const ( OrderStatusClose = 3 // Order closed/cancelled OrderStatusFailed = 4 // Order processing failed OrderStatusClaimed = 6 // Internal transient claim while a worker processes the order + OrderStatusRefunded = 7 // Order refunded terminal state OrderStatusFinished = 5 // Order successfully completed ) @@ -148,6 +150,20 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task) return err } + if err = l.recordPromoUsage(ctx, orderInfo); err != nil { + if releaseErr := l.releaseClaim(ctx, orderInfo.OrderNo); releaseErr != nil { + logger.WithContext(ctx).Error("[ActivateOrderLogic] releaseClaim also failed, stuck recovery will handle", + logger.Field("order_no", orderInfo.OrderNo), + logger.Field("release_error", releaseErr.Error()), + ) + } + logger.WithContext(ctx).Error("[ActivateOrderLogic] 促销使用记录写入失败,将重试", + logger.Field("order_no", orderInfo.OrderNo), + logger.Field("promo_rule_id", orderInfo.PromoRuleId), + logger.Field("error", err.Error()), + ) + return err + } l.finalizeCouponAndOrder(ctx, orderInfo) commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "activation_finished", @@ -157,6 +173,37 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task) return nil } +func (l *ActivateOrderLogic) recordPromoUsage(ctx context.Context, orderInfo *order.Order) error { + if orderInfo == nil || orderInfo.PromoRuleId <= 0 || orderInfo.Quantity <= 0 || orderInfo.SubscribeId <= 0 || orderInfo.OrderNo == "" { + return nil + } + + promoPrice := int64(0) + if orderInfo.Price > orderInfo.PromoDiscount { + promoPrice = (orderInfo.Price - orderInfo.PromoDiscount) / orderInfo.Quantity + } + if promoPrice <= 0 { + return nil + } + + return l.svc.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var count int64 + if e := tx.Model(&promo.Usage{}).Where("order_no = ?", orderInfo.OrderNo).Count(&count).Error; e != nil { + return e + } + if count > 0 { + return nil + } + return l.svc.PromoModel.InsertUsage(ctx, &promo.Usage{ + UserId: orderInfo.UserId, + PromoRuleId: orderInfo.PromoRuleId, + SubscribeId: orderInfo.SubscribeId, + OrderNo: orderInfo.OrderNo, + PromoPrice: promoPrice, + }, tx) + }) +} + // parsePayload unMarshals the task payload into a structured format func (l *ActivateOrderLogic) parsePayload(ctx context.Context, payload []byte) (*queueTypes.ForthwithActivateOrderPayload, error) { var p queueTypes.ForthwithActivateOrderPayload diff --git a/queue/logic/order/activateOrderLogic.go_bak b/queue/logic/order/activateOrderLogic.go_bak deleted file mode 100644 index f57f57f..0000000 --- a/queue/logic/order/activateOrderLogic.go_bak +++ /dev/null @@ -1,675 +0,0 @@ -package orderLogic - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "time" - - "github.com/perfect-panel/server/pkg/constant" - - "github.com/perfect-panel/server/pkg/logger" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - "github.com/google/uuid" - "github.com/hibiken/asynq" - "github.com/perfect-panel/server/internal/config" - "github.com/perfect-panel/server/internal/logic/telegram" - "github.com/perfect-panel/server/internal/model/order" - "github.com/perfect-panel/server/internal/model/user" - "github.com/perfect-panel/server/internal/svc" - "github.com/perfect-panel/server/pkg/tool" - "github.com/perfect-panel/server/pkg/uuidx" - "github.com/perfect-panel/server/queue/types" - "gorm.io/gorm" -) - -const ( - Subscribe = 1 - Renewal = 2 - ResetTraffic = 3 - Recharge = 4 -) - -type ActivateOrderLogic struct { - svc *svc.ServiceContext -} - -func NewActivateOrderLogic(svc *svc.ServiceContext) *ActivateOrderLogic { - return &ActivateOrderLogic{ - svc: svc, - } -} - -func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task) error { - payload := types.ForthwithActivateOrderPayload{} - if err := json.Unmarshal(task.Payload(), &payload); err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Unmarshal payload failed", - logger.Field("error", err.Error()), - logger.Field("payload", string(task.Payload())), - ) - return nil - } - // Find order by order no - orderInfo, err := l.svc.OrderModel.FindOneByOrderNo(ctx, payload.OrderNo) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find order failed", - logger.Field("error", err.Error()), - logger.Field("order_no", payload.OrderNo), - ) - return nil - } - // 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished - if orderInfo.Status != 2 { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Order status error", - logger.Field("order_no", orderInfo.OrderNo), - logger.Field("status", orderInfo.Status), - ) - return nil - } - switch orderInfo.Type { - case Subscribe: - err = l.NewPurchase(ctx, orderInfo) - case Renewal: - err = l.Renewal(ctx, orderInfo) - case ResetTraffic: - err = l.ResetTraffic(ctx, orderInfo) - case Recharge: - err = l.Recharge(ctx, orderInfo) - default: - logger.WithContext(ctx).Error("[ActivateOrderLogic] Order type is invalid", logger.Field("type", orderInfo.Type)) - return nil - } - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Process task failed", logger.Field("error", err.Error())) - return nil - } - // if coupon is not empty - if orderInfo.Coupon != "" { - // update coupon status - err = l.svc.CouponModel.UpdateCount(ctx, orderInfo.Coupon) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update coupon status failed", - logger.Field("error", err.Error()), - logger.Field("coupon", orderInfo.Coupon), - ) - } - } - // update order status - orderInfo.Status = 5 - err = l.svc.OrderModel.Update(ctx, orderInfo) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update order status failed", - logger.Field("error", err.Error()), - logger.Field("order_no", orderInfo.OrderNo), - ) - } - - return nil -} - -// NewPurchase New purchase -func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.Order) error { - var userInfo *user.User - var err error - if orderInfo.UserId != 0 { - // find user by user id - userInfo, err = l.svc.UserModel.FindOne(ctx, orderInfo.UserId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", - logger.Field("error", err.Error()), - logger.Field("user_id", orderInfo.UserId), - ) - return err - } - } else { - // If User ID is 0, it means that the order is a guest order, need to create a new user - // query info with redis - cacheKey := fmt.Sprintf(constant.TempOrderCacheKey, orderInfo.OrderNo) - data, err := l.svc.Redis.Get(ctx, cacheKey).Result() - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Get temp order cache failed", - logger.Field("error", err.Error()), - logger.Field("cache_key", cacheKey), - ) - return err - } - var tempOrder constant.TemporaryOrderInfo - if err = json.Unmarshal([]byte(data), &tempOrder); err != nil { - logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Unmarshal temp order failed", - logger.Field("error", err.Error()), - ) - return err - } - // create user - - userInfo = &user.User{ - Password: tool.EncodePassWord(tempOrder.Password), - AuthMethods: []user.AuthMethods{ - { - AuthType: tempOrder.AuthType, - AuthIdentifier: tempOrder.Identifier, - }, - }, - } - err = l.svc.UserModel.Transaction(ctx, func(tx *gorm.DB) error { - // Save user information - if err := tx.Save(userInfo).Error; err != nil { - return err - } - // Generate ReferCode - userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id) - // Update ReferCode - if err := tx.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil { - return err - } - orderInfo.UserId = userInfo.Id - return tx.Model(&order.Order{}).Where("order_no = ?", orderInfo.OrderNo).Update("user_id", userInfo.Id).Error - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Create user failed", - logger.Field("error", err.Error()), - ) - return err - } - - if tempOrder.InviteCode != "" { - // find referer by refer code - referer, err := l.svc.UserModel.FindOneByReferCode(ctx, tempOrder.InviteCode) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find referer failed", - logger.Field("error", err.Error()), - logger.Field("refer_code", tempOrder.InviteCode), - ) - } else { - userInfo.RefererId = referer.Id - err = l.svc.UserModel.Update(ctx, userInfo) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user referer failed", - logger.Field("error", err.Error()), - logger.Field("user_id", userInfo.Id), - ) - } - } - } - - logger.WithContext(ctx).Info("[ActivateOrderLogic] Create guest user success", logger.Field("user_id", userInfo.Id), logger.Field("Identifier", tempOrder.Identifier), logger.Field("AuthType", tempOrder.AuthType)) - } - // find subscribe by id - sub, err := l.svc.SubscribeModel.FindOne(ctx, orderInfo.SubscribeId) - if err != nil { - logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Find subscribe failed", - logger.Field("error", err.Error()), - logger.Field("subscribe_id", orderInfo.SubscribeId), - ) - return err - } - // create user subscribe - now := time.Now() - - userSub := user.Subscribe{ - Id: 0, - UserId: orderInfo.UserId, - OrderId: orderInfo.Id, - SubscribeId: orderInfo.SubscribeId, - StartTime: now, - ExpireTime: tool.AddTime(sub.UnitTime, orderInfo.Quantity, now), - Traffic: sub.Traffic, - Download: 0, - Upload: 0, - Token: uuidx.SubscribeToken(orderInfo.OrderNo), - UUID: uuid.New().String(), - Status: 1, - } - err = l.svc.UserModel.InsertSubscribe(ctx, &userSub) - - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert user subscribe failed", - logger.Field("error", err.Error()), - ) - return err - } - // handler commission - if userInfo.RefererId != 0 && - l.svc.Config.Invite.ReferralPercentage != 0 && - (!l.svc.Config.Invite.OnlyFirstPurchase || orderInfo.IsNew) { - referer, err := l.svc.UserModel.FindOne(ctx, userInfo.RefererId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find referer failed", - logger.Field("error", err.Error()), - logger.Field("referer_id", userInfo.RefererId), - ) - goto updateCache - } - // calculate commission - amount := float64(orderInfo.Price) * (float64(l.svc.Config.Invite.ReferralPercentage) / 100) - referer.Commission += int64(amount) - err = l.svc.UserModel.Update(ctx, referer) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update referer commission failed", - logger.Field("error", err.Error()), - ) - goto updateCache - } - // create commission log - commissionLog := user.CommissionLog{ - UserId: referer.Id, - OrderNo: orderInfo.OrderNo, - Amount: int64(amount), - } - err = l.svc.UserModel.InsertCommissionLog(ctx, &commissionLog) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert commission log failed", - logger.Field("error", err.Error()), - ) - } - err = l.svc.UserModel.UpdateUserCache(ctx, referer) - if err != nil { - logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Update referer cache", logger.Field("error", err.Error()), logger.Field("user_id", referer.Id)) - } - } -updateCache: - for _, id := range tool.StringToInt64Slice(sub.Server) { - cacheKey := fmt.Sprintf("%s%d", config.ServerUserListCacheKey, id) - err = l.svc.Redis.Del(ctx, cacheKey).Err() - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Del server user list cache failed", - logger.Field("error", err.Error()), - logger.Field("cache_key", cacheKey), - ) - } - } - data, err := l.svc.ServerModel.FindServerListByGroupIds(ctx, tool.StringToInt64Slice(sub.ServerGroup)) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find server list failed", logger.Field("error", err.Error())) - return err - } - for _, item := range data { - cacheKey := fmt.Sprintf("%s%d", config.ServerUserListCacheKey, item.Id) - err = l.svc.Redis.Del(ctx, cacheKey).Err() - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Del server user list cache failed", - logger.Field("error", err.Error()), - logger.Field("cache_key", cacheKey), - ) - } - } - userTelegramChatId, ok := findTelegram(userInfo) - - // sendMessage To Telegram - if ok { - text, err := tool.RenderTemplateToString(telegram.PurchaseNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "SubscribeName": sub.Name, - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "ExpireTime": userSub.ExpireTime.Format("2006-01-02 15:04:05"), - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", - logger.Field("error", err.Error()), - ) - } - l.sendUserNotifyWithTelegram(userTelegramChatId, text) - } - // send message to admin - text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "TradeNo": orderInfo.TradeNo, - "SubscribeName": sub.Name, - //"UserEmail": userInfo.Email, - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "OrderStatus": "已支付", - "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), - "PaymentMethod": orderInfo.Method, - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", - logger.Field("error", err.Error()), - ) - } - l.sendAdminNotifyWithTelegram(ctx, text) - logger.WithContext(ctx).Info("[ActivateOrderLogic] Insert user subscribe success") - return nil -} - -// Renewal Renewal -func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order) error { - // find user by user id - userInfo, err := l.svc.UserModel.FindOne(ctx, orderInfo.UserId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", - logger.Field("error", err.Error()), - logger.Field("user_id", orderInfo.UserId), - ) - return err - } - // find user subscribe by subscribe token - userSub, err := l.svc.UserModel.FindOneSubscribeByToken(ctx, orderInfo.SubscribeToken) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user subscribe failed", - logger.Field("error", err.Error()), - logger.Field("order_id", orderInfo.Id), - ) - return err - } - // find subscribe by id - sub, err := l.svc.SubscribeModel.FindOne(ctx, orderInfo.SubscribeId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find subscribe failed", - logger.Field("error", err.Error()), - logger.Field("subscribe_id", orderInfo.SubscribeId), - logger.Field("order_id", orderInfo.Id), - ) - return err - } - now := time.Now() - if userSub.ExpireTime.Before(now) { - userSub.ExpireTime = now - } - - // Check whether traffic reset on renewal is enabled - if sub.RenewalReset != nil && *sub.RenewalReset { - userSub.Download = 0 - userSub.Upload = 0 - } - if userSub.FinishedAt != nil { - userSub.FinishedAt = nil - } - - userSub.ExpireTime = tool.AddTime(sub.UnitTime, orderInfo.Quantity, userSub.ExpireTime) - userSub.Status = 1 - // update user subscribe - err = l.svc.UserModel.UpdateSubscribe(ctx, userSub) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user subscribe failed", - logger.Field("error", err.Error()), - ) - return err - } - // handler commission - if userInfo.RefererId != 0 && - l.svc.Config.Invite.ReferralPercentage != 0 && - !l.svc.Config.Invite.OnlyFirstPurchase { - referer, err := l.svc.UserModel.FindOne(ctx, userInfo.RefererId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find referer failed", - logger.Field("error", err.Error()), - logger.Field("referer_id", userInfo.RefererId), - ) - goto sendMessage - } - // calculate commission - amount := float64(orderInfo.Price) * (float64(l.svc.Config.Invite.ReferralPercentage) / 100) - referer.Commission += int64(amount) - err = l.svc.UserModel.Update(ctx, referer) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update referer commission failed", - logger.Field("error", err.Error()), - ) - goto sendMessage - } - // create commission log - commissionLog := user.CommissionLog{ - UserId: referer.Id, - OrderNo: orderInfo.OrderNo, - Amount: int64(amount), - } - err = l.svc.UserModel.InsertCommissionLog(ctx, &commissionLog) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert commission log failed", - logger.Field("error", err.Error()), - ) - } - err = l.svc.UserModel.UpdateUserCache(ctx, referer) - if err != nil { - logger.WithContext(ctx).Errorw("[ActivateOrderLogic] Update referer cache", logger.Field("error", err.Error()), logger.Field("user_id", referer.Id)) - } - } - -sendMessage: - userTelegramChatId, ok := findTelegram(userInfo) - // SendMessage To Telegram - if ok { - text, err := tool.RenderTemplateToString(telegram.RenewalNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "SubscribeName": sub.Name, - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "ExpireTime": userSub.ExpireTime.Format("2006-01-02 15:04:05"), - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", - logger.Field("error", err.Error()), - ) - } - l.sendUserNotifyWithTelegram(userTelegramChatId, text) - } - - // send message to admin - text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "TradeNo": orderInfo.TradeNo, - "SubscribeName": sub.Name, - //"UserEmail": userInfo.Email, - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "OrderStatus": "已支付", - "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), - "PaymentMethod": orderInfo.Method, - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", - logger.Field("error", err.Error()), - ) - } - l.sendAdminNotifyWithTelegram(ctx, text) - return nil -} - -// ResetTraffic Reset traffic -func (l *ActivateOrderLogic) ResetTraffic(ctx context.Context, orderInfo *order.Order) error { - // find user by user id - userInfo, err := l.svc.UserModel.FindOne(ctx, orderInfo.UserId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", - logger.Field("error", err.Error()), - logger.Field("user_id", orderInfo.UserId), - ) - return err - } - // Generate a Subscribe Token through orderNo - // find user subscribe by subscribe token - userSub, err := l.svc.UserModel.FindOneSubscribeByToken(ctx, orderInfo.SubscribeToken) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user subscribe failed", - logger.Field("error", err.Error()), - logger.Field("order_id", orderInfo.Id), - ) - return err - } - userSub.Download = 0 - userSub.Upload = 0 - userSub.Status = 1 - // update user subscribe - err = l.svc.UserModel.UpdateSubscribe(ctx, userSub) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user subscribe failed", - logger.Field("error", err.Error()), - ) - return err - } - sub, err := l.svc.SubscribeModel.FindOne(ctx, userSub.SubscribeId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find subscribe failed", - logger.Field("error", err.Error()), - logger.Field("subscribe_id", userSub.SubscribeId), - ) - return err - } - userTelegramChatId, ok := findTelegram(userInfo) - // SendMessage To Telegram - if ok { - text, err := tool.RenderTemplateToString(telegram.ResetTrafficNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "SubscribeName": sub.Name, - "ResetTime": time.Now().Format("2006-01-02 15:04:05"), - "ExpireTime": userSub.ExpireTime.Format("2006-01-02 15:04:05"), - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", - logger.Field("error", err.Error()), - ) - } - l.sendUserNotifyWithTelegram(userTelegramChatId, text) - } - - // send message to admin - text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "TradeNo": orderInfo.TradeNo, - "SubscribeName": "流量重置", - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "OrderStatus": "已支付", - "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), - "PaymentMethod": orderInfo.Method, - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", - logger.Field("error", err.Error()), - ) - } - l.sendAdminNotifyWithTelegram(ctx, text) - return nil -} - -// Recharge Recharge to user -func (l *ActivateOrderLogic) Recharge(ctx context.Context, orderInfo *order.Order) error { - // find user by user id - userInfo, err := l.svc.UserModel.FindOne(ctx, orderInfo.UserId) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Find user failed", - logger.Field("error", err.Error()), - logger.Field("user_id", orderInfo.UserId), - ) - return err - } - userInfo.Balance += orderInfo.Price - // update user - err = l.svc.DB.Transaction(func(tx *gorm.DB) error { - err = l.svc.UserModel.Update(ctx, userInfo, tx) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Update user failed", - logger.Field("error", err.Error()), - ) - return err - } - // Create Balance Log - balanceLog := user.BalanceLog{ - UserId: orderInfo.UserId, - Amount: orderInfo.Price, - Type: 1, - OrderId: orderInfo.Id, - Balance: userInfo.Balance, - } - err = l.svc.UserModel.InsertBalanceLog(ctx, &balanceLog, tx) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Insert balance log failed", - logger.Field("error", err.Error()), - ) - return err - } - - return nil - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Database transaction failed", - logger.Field("error", err.Error()), - ) - return err - } - userTelegramChatId, ok := findTelegram(userInfo) - // SendMessage To Telegram - if ok { - text, err := tool.RenderTemplateToString(telegram.RechargeNotify, map[string]string{ - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "PaymentMethod": orderInfo.Method, - "Time": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), - "Balance": fmt.Sprintf("%.2f", float64(userInfo.Balance)/100), - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render template failed", - logger.Field("error", err.Error()), - ) - } - l.sendUserNotifyWithTelegram(userTelegramChatId, text) - } - // send message to admin - text, err := tool.RenderTemplateToString(telegram.AdminOrderNotify, map[string]string{ - "OrderNo": orderInfo.OrderNo, - "TradeNo": orderInfo.TradeNo, - "OrderAmount": fmt.Sprintf("%.2f", float64(orderInfo.Price)/100), - "SubscribeName": "余额充值", - "OrderStatus": "已支付", - "OrderTime": orderInfo.CreatedAt.Format("2006-01-02 15:04:05"), - "PaymentMethod": orderInfo.Method, - }) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Render AdminOrderNotify template failed", - logger.Field("error", err.Error()), - ) - } - l.sendAdminNotifyWithTelegram(ctx, text) - return nil -} - -// sendUserNotifyWithTelegram send message to user -func (l *ActivateOrderLogic) sendUserNotifyWithTelegram(chatId int64, text string) { - msg := tgbotapi.NewMessage(chatId, text) - msg.ParseMode = "markdown" - _, err := l.svc.TelegramBot.Send(msg) - if err != nil { - logger.Error("[ActivateOrderLogic] Send telegram user message failed", - logger.Field("error", err.Error()), - ) - } -} - -// sendAdminNotifyWithTelegram send message to admin -func (l *ActivateOrderLogic) sendAdminNotifyWithTelegram(ctx context.Context, text string) { - admins, err := l.svc.UserModel.QueryAdminUsers(ctx) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Query admin users failed", - logger.Field("error", err.Error()), - ) - return - } - for _, admin := range admins { - telegramId, ok := findTelegram(admin) - if !ok { - continue - } - msg := tgbotapi.NewMessage(telegramId, text) - msg.ParseMode = "markdown" - _, err := l.svc.TelegramBot.Send(msg) - if err != nil { - logger.WithContext(ctx).Error("[ActivateOrderLogic] Send telegram admin message failed", - logger.Field("error", err.Error()), - ) - } - } -} - -// findTelegram find user telegram id -func findTelegram(u *user.User) (int64, bool) { - for _, item := range u.AuthMethods { - if item.AuthType == "telegram" { - // string to int64 - parseInt, err := strconv.ParseInt(item.AuthIdentifier, 10, 64) - if err != nil { - return 0, false - } - return parseInt, true - } - - } - return 0, false -} diff --git a/queue/logic/order/order_status_recovery_test.go b/queue/logic/order/order_status_recovery_test.go new file mode 100644 index 0000000..a3c9bb1 --- /dev/null +++ b/queue/logic/order/order_status_recovery_test.go @@ -0,0 +1,78 @@ +package orderLogic + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/perfect-panel/server/internal/svc" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestReleaseClaimOnlyReleasesClaimedStatus(t *testing.T) { + const orderNo = "ORD-CLAIMED-1" + + db, mock, cleanup := newOrderQueueTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `order`"). + WithArgs(OrderStatusPaid, sqlmock.AnyArg(), orderNo, OrderStatusClaimed). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + logic := NewActivateOrderLogic(&svc.ServiceContext{DB: db}) + if err := logic.releaseClaim(context.Background(), orderNo); err != nil { + t.Fatalf("releaseClaim error: %v", err) + } + assertOrderQueueExpectations(t, mock) +} + +func TestStuckOrderRecoveryScansOnlyClaimedStatus(t *testing.T) { + db, mock, cleanup := newOrderQueueTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `order`"). + WithArgs(OrderStatusClaimed, sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id", "order_no", "status"})) + + logic := NewStuckOrderRecoveryLogic(&svc.ServiceContext{DB: db}) + if err := logic.ProcessTask(context.Background(), nil); err != nil { + t.Fatalf("ProcessTask error: %v", err) + } + assertOrderQueueExpectations(t, mock) +} + +func newOrderQueueTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error { + if strings.Contains(actualSQL, expectedSQL) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, mock, func() { + _ = sqlDB.Close() + } +} + +func assertOrderQueueExpectations(t *testing.T, mock sqlmock.Sqlmock) { + t.Helper() + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} diff --git a/queue/logic/traffic/trafficStatisticsLogic.go b/queue/logic/traffic/trafficStatisticsLogic.go index c7d9307..be5b32e 100644 --- a/queue/logic/traffic/trafficStatisticsLogic.go +++ b/queue/logic/traffic/trafficStatisticsLogic.go @@ -3,7 +3,6 @@ package traffic import ( "context" "encoding/json" - "fmt" "strings" "time" @@ -140,8 +139,8 @@ func (l *TrafficStatisticsLogic) ProcessTask(ctx context.Context, task *asynq.Ta logger.Field("error", delErr.Error()), ) } - cacheKey := fmt.Sprintf("%s%d", node.ServerUserListCacheKey, payload.ServerId) - if delErr := l.svc.Redis.Del(ctx, cacheKey).Err(); delErr != nil { + cacheKeys := node.ServerUserListCacheKeysForServer(payload.ServerId) + if delErr := l.svc.Redis.Del(ctx, cacheKeys...).Err(); delErr != nil { logger.WithContext(ctx).Error("[TrafficStatistics] Clear server user cache failed", logger.Field("serverId", payload.ServerId), logger.Field("error", delErr.Error()), diff --git a/readme_zh.md b/readme_zh.md index 618d1d8..9615742 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -1,3 +1,21 @@ + + +> ### TawCorp hifast-server(内部分支) +> +> 本仓库是 [perfect-panel/server](https://github.com/perfect-panel/server) 的 TawCorp **canonical** 分支。**2026-06-03** 从 `git.kxsw.us/HI-VPN/hi-server` 迁移至此;旧 Gitea 仓库已废弃 —— 不要再 push 或 pull 旧地址。 +> +> - **开发流程(内部贡献者必读)**:[`doc/development-workflow-zh.md`](doc/development-workflow-zh.md) +> - **分支模型**:`internal`(日常开发主干,自动部署 staging)→ `main`(对外正式版本) +> - **合并策略**:PR + 架构师 review + GitHub UI Squash and merge;直接 push 到 `internal` / `main` 由 lefthook pre-push 拦截,流程上也禁止 +> - **issue 跟踪**:Multica 工作区 `Hifast`(前缀 `HIF-`) +> +> 外部贡献者:阅读 [`CONTRIBUTING_ZH.md`](CONTRIBUTING_ZH.md) 了解上游兼容基线。 + +--- + # PPanel 服务端
diff --git a/scripts/convert_recovery_orders/main.go b/scripts/convert_recovery_orders/main.go deleted file mode 100644 index e2d0e23..0000000 --- a/scripts/convert_recovery_orders/main.go +++ /dev/null @@ -1,227 +0,0 @@ -package main - -import ( - "encoding/json" - "flag" - "fmt" - "os" - "sort" - "strconv" - "strings" - "time" -) - -type remoteOrder struct { - OrderNo string `json:"order_no"` - OutOrderNo string `json:"out_order_no"` - OrderMoney json.Number `json:"order_money"` - OrderStatus string `json:"order_status"` - PaymentTime *int64 `json:"payment_time"` - CreateTime int64 `json:"create_time"` -} - -type recoveryFile struct { - Orders []recoveryOrder `json:"orders"` -} - -type recoveryOrder struct { - OutOrderNo string `json:"out_order_no"` - OrderNo string `json:"order_no"` - OrderStatus string `json:"order_status"` - SubscribeId int64 `json:"subscribe_id"` - ExpireAt int64 `json:"expire_at"` - Quantity int64 `json:"quantity"` - PaidAt int64 `json:"paid_at,omitempty"` - Note string `json:"note,omitempty"` - PaymentOrderNo string `json:"payment_order_no,omitempty"` - OrderMoneyCents int64 `json:"order_money_cents,omitempty"` -} - -type adminOrderResponse struct { - Code uint32 `json:"code"` - Msg string `json:"msg"` - Data struct { - Total int64 `json:"total"` - List []adminOrder `json:"list"` - } `json:"data"` -} - -type adminOrder struct { - OrderNo string `json:"order_no"` -} - -func main() { - var ( - input string - output string - adminOrders string - subscribeID int64 - onlyMissing bool - ) - flag.StringVar(&input, "in", "aaa.json", "input payment platform orders JSON") - flag.StringVar(&output, "out", "internal/recovery/recovery_orders.json", "output recovery orders JSON") - flag.StringVar(&adminOrders, "admin-orders", "", "optional admin order list response JSON; when provided, matched out_order_no values are skipped") - flag.Int64Var(&subscribeID, "subscribe-id", 1, "subscribe id to use for recovered subscriptions") - flag.BoolVar(&onlyMissing, "only-missing", true, "only output payment success orders missing from admin order list when -admin-orders is set") - flag.Parse() - - orders, err := readOrders(input) - must(err) - existingOrders := map[string]struct{}{} - if adminOrders != "" { - existingOrders, err = readAdminOrderSet(adminOrders) - must(err) - } - - daysByAmount := map[int64]int64{ - 1953: 7, - 4193: 30, - 9093: 90, - 31500: 365, - } - - out := recoveryFile{Orders: make([]recoveryOrder, 0, len(orders))} - var skipped []string - var matchedExisting int - seen := make(map[string]struct{}) - for _, item := range orders { - if strings.ToLower(strings.TrimSpace(item.OrderStatus)) != "success" { - continue - } - localOrderNo := strings.TrimSpace(item.OutOrderNo) - if localOrderNo == "" { - skipped = append(skipped, fmt.Sprintf("%s missing out_order_no", item.OrderNo)) - continue - } - if _, ok := seen[localOrderNo]; ok { - skipped = append(skipped, fmt.Sprintf("%s duplicate out_order_no", localOrderNo)) - continue - } - if _, exists := existingOrders[localOrderNo]; exists && onlyMissing { - matchedExisting++ - seen[localOrderNo] = struct{}{} - continue - } - amount := decimalYuanToCents(item.OrderMoney) - days, ok := daysByAmount[amount] - if !ok { - skipped = append(skipped, fmt.Sprintf("%s unknown amount %s", localOrderNo, item.OrderMoney.String())) - continue - } - base := item.CreateTime - if item.PaymentTime != nil && *item.PaymentTime > 0 { - base = *item.PaymentTime - } - if base <= 0 { - skipped = append(skipped, fmt.Sprintf("%s missing payment/create time", localOrderNo)) - continue - } - seen[localOrderNo] = struct{}{} - out.Orders = append(out.Orders, recoveryOrder{ - OutOrderNo: localOrderNo, - OrderNo: strings.TrimSpace(item.OrderNo), - OrderStatus: "success", - SubscribeId: subscribeID, - ExpireAt: time.Unix(base, 0).Add(time.Duration(days) * 24 * time.Hour).UnixMilli(), - Quantity: days, - PaidAt: base, - Note: "data recovery", - PaymentOrderNo: strings.TrimSpace(item.OrderNo), - OrderMoneyCents: amount, - }) - } - - sort.Slice(out.Orders, func(i, j int) bool { - return out.Orders[i].OutOrderNo < out.Orders[j].OutOrderNo - }) - - must(writeJSON(output, out)) - fmt.Printf("converted recovery orders=%d matched_existing=%d skipped=%d output=%s\n", len(out.Orders), matchedExisting, len(skipped), output) - for _, msg := range skipped { - fmt.Printf("skip: %s\n", msg) - } -} - -func readAdminOrderSet(path string) (map[string]struct{}, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var resp adminOrderResponse - if err := json.Unmarshal(data, &resp); err == nil && resp.Data.List != nil { - if resp.Code != 0 && resp.Code != 200 { - return nil, fmt.Errorf("admin order response failed: code=%d msg=%s", resp.Code, resp.Msg) - } - return adminOrdersToSet(resp.Data.List), nil - } - - var list []adminOrder - if err := json.Unmarshal(data, &list); err != nil { - return nil, err - } - return adminOrdersToSet(list), nil -} - -func adminOrdersToSet(list []adminOrder) map[string]struct{} { - set := make(map[string]struct{}, len(list)) - for _, item := range list { - orderNo := strings.TrimSpace(item.OrderNo) - if orderNo != "" { - set[orderNo] = struct{}{} - } - } - return set -} - -func readOrders(path string) ([]remoteOrder, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var orders []remoteOrder - if err := json.Unmarshal(data, &orders); err != nil { - return nil, err - } - return orders, nil -} - -func writeJSON(path string, data recoveryFile) error { - bytes, err := json.MarshalIndent(data, "", " ") - if err != nil { - return err - } - bytes = append(bytes, '\n') - return os.WriteFile(path, bytes, 0644) -} - -func decimalYuanToCents(n json.Number) int64 { - s := strings.TrimSpace(n.String()) - if s == "" { - return 0 - } - parts := strings.SplitN(s, ".", 2) - yuan, _ := strconv.ParseInt(parts[0], 10, 64) - cents := yuan * 100 - if len(parts) == 1 { - return cents - } - frac := parts[1] - if len(frac) > 2 { - frac = frac[:2] - } - for len(frac) < 2 { - frac += "0" - } - f, _ := strconv.ParseInt(frac, 10, 64) - if strings.HasPrefix(parts[0], "-") { - return cents - f - } - return cents + f -} - -func must(err error) { - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/scripts/doc.go b/scripts/doc.go new file mode 100644 index 0000000..b63d60a --- /dev/null +++ b/scripts/doc.go @@ -0,0 +1,3 @@ +// Package scripts keeps standalone maintenance tools out of normal package +// builds. Run individual tools with go run scripts/.go. +package scripts diff --git a/scripts/reconcile_mihapay_orders/main.go b/scripts/reconcile_mihapay_orders/main.go deleted file mode 100644 index aba8fb3..0000000 --- a/scripts/reconcile_mihapay_orders/main.go +++ /dev/null @@ -1,708 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/md5" - "encoding/hex" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "net/url" - "os" - "sort" - "strconv" - "strings" - "time" - - "github.com/hibiken/asynq" - "github.com/perfect-panel/server/internal/config" - "github.com/perfect-panel/server/internal/model/order" - "github.com/perfect-panel/server/internal/model/payment" - "github.com/perfect-panel/server/internal/model/subscribe" - "github.com/perfect-panel/server/internal/model/user" - "github.com/perfect-panel/server/pkg/conf" - "github.com/perfect-panel/server/pkg/orm" - "github.com/perfect-panel/server/pkg/tool" - queueTypes "github.com/perfect-panel/server/queue/types" - "github.com/redis/go-redis/v9" - "gorm.io/gorm" -) - -const ( - defaultBaseURL = "https://gfiuseui.lkfezg.cn" - - // Hard-code Mihapay V2 credentials here when this reconciliation script should - // not depend on payment.config. Leave them empty to read pid/key/url from DB. - hardcodedMerchantID = "80567" - hardcodedMerchantKey = "475951745395f05a3be2ba7ca8235ba3" - hardcodedBaseURL = defaultBaseURL -) - -type options struct { - configPath string - baseURL string - paymentID int64 - method string - start string - end string - page int - limit int - maxPages int - apply bool - amountDiff int64 - verbose bool - printNotFound bool -} - -type merchantConfig struct { - merchantID string - key string - baseURL string -} - -type orderListResponse struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - List []remoteOrder `json:"list"` - Total int `json:"total"` - } `json:"data"` -} - -type remoteOrder struct { - OrderNo string `json:"order_no"` - OutOrderNo string `json:"out_order_no"` - OrderMoney json.Number `json:"order_money"` - UserMoney json.Number `json:"user_money"` - OrderStatus string `json:"order_status"` - NotifyStatus string `json:"notify_status"` - CreateTime int64 `json:"create_time"` - PaymentTime int64 `json:"payment_time"` - NotifyTime int64 `json:"notify_time"` - ClientIP string `json:"client_ip"` - Device string `json:"device"` - OrderRemark string `json:"order_remark"` -} - -type matchedOrder struct { - Remote remoteOrder - Local order.Order - RemoteCents int64 - Audit subscriptionAudit - Action string - Reason string - PaymentAudit string -} - -type subscriptionAudit struct { - Checked bool - Status string - UserSubID int64 - UserID int64 - StartTime time.Time - ExpireTime time.Time - ExpectedExpire time.Time - SubscribeUnit string - Quantity int64 - Reason string -} - -type reconcileStats struct { - RemotePages int - RemoteOrders int - RemotePaid int - RemoteMissingNo int - DuplicateRemote int - LocalNotFound int - LocalDateFiltered int - LocalMethodSkip int - Matched int - StatusCounts map[string]int - LocalNotFoundOrders []remoteOrder -} - -func main() { - ctx := context.Background() - opt := parseFlags() - - var cfg config.Config - conf.MustLoad(opt.configPath, &cfg) - - db, err := orm.ConnectMysql(orm.Mysql{Config: cfg.MySQL}) - must(err, "connect mysql") - sqlDB, err := db.DB() - must(err, "get sql db") - defer sqlDB.Close() - - var redisClient *redis.Client - var queueClient *asynq.Client - if opt.apply { - redisClient = redis.NewClient(&redis.Options{ - Addr: cfg.Redis.Host, - Password: cfg.Redis.Pass, - DB: cfg.Redis.DB, - }) - defer redisClient.Close() - - queueClient = asynq.NewClient(asynq.RedisClientOpt{ - Addr: cfg.Redis.Host, - Password: cfg.Redis.Pass, - DB: 5, - }) - defer queueClient.Close() - } - - merchant, paymentInfo, err := resolveMerchantConfig(ctx, db, opt) - must(err, "resolve mihapay config") - if opt.paymentID > 0 { - opt.method = paymentInfo.Platform - } - baseURL := opt.baseURL - if baseURL == defaultBaseURL && merchant.baseURL != "" { - baseURL = merchant.baseURL - } - - client := &http.Client{Timeout: 15 * time.Second} - matches, stats, err := reconcile(ctx, client, db, redisClient, queueClient, opt, baseURL, merchant, paymentInfo) - must(err, "reconcile orders") - - printSummary(matches, stats, opt) -} - -func parseFlags() options { - opt := options{} - flag.StringVar(&opt.configPath, "config", "etc/ppanel.yaml", "ppanel config path") - flag.StringVar(&opt.baseURL, "base-url", defaultBaseURL, "Mihapay base URL") - flag.Int64Var(&opt.paymentID, "payment-id", 0, "payment table id to use for merchant credentials") - flag.StringVar(&opt.method, "method", "epay", "local order method/platform to match when payment-id is not set") - flag.StringVar(&opt.start, "start", "", "local order created_at start, e.g. 2026-05-05 00:00:00") - flag.StringVar(&opt.end, "end", "", "local order created_at end, e.g. 2026-05-06 00:00:00") - flag.IntVar(&opt.page, "page", 1, "remote start page") - flag.IntVar(&opt.limit, "limit", 100, "remote page size") - flag.IntVar(&opt.maxPages, "max-pages", 50, "maximum remote pages to fetch") - flag.BoolVar(&opt.apply, "apply", false, "apply fixes; default is dry-run") - flag.Int64Var(&opt.amountDiff, "amount-diff-cents", 1, "allowed amount difference in cents") - flag.BoolVar(&opt.verbose, "v", false, "print diagnostic details") - flag.BoolVar(&opt.printNotFound, "print-not-found", false, "print remote paid orders that have no matching local order") - flag.Parse() - - if opt.start == "" { - now := time.Now() - opt.start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05") - } - if opt.end == "" { - start, err := time.ParseInLocation("2006-01-02 15:04:05", opt.start, time.Local) - if err == nil { - opt.end = start.AddDate(0, 0, 1).Format("2006-01-02 15:04:05") - } - } - opt.baseURL = strings.TrimRight(opt.baseURL, "/") - if opt.page <= 0 { - opt.page = 1 - } - if opt.limit <= 0 || opt.limit > 500 { - opt.limit = 100 - } - if opt.maxPages <= 0 { - opt.maxPages = 1 - } - return opt -} - -func resolveMerchantConfig(ctx context.Context, db *gorm.DB, opt options) (merchantConfig, *payment.Payment, error) { - if hardcodedMerchantID != "" && hardcodedMerchantKey != "" { - return merchantConfig{ - merchantID: hardcodedMerchantID, - key: hardcodedMerchantKey, - baseURL: strings.TrimRight(hardcodedBaseURL, "/"), - }, nil, nil - } - - var p payment.Payment - query := db.WithContext(ctx).Model(&payment.Payment{}) - if opt.paymentID > 0 { - query = query.Where("id = ?", opt.paymentID) - } else { - query = query.Where("LOWER(REPLACE(platform, '_', '')) = LOWER(REPLACE(?, '_', ''))", opt.method) - } - if err := query.First(&p).Error; err != nil { - return merchantConfig{}, nil, err - } - - var epayCfg payment.EPayConfig - if err := epayCfg.Unmarshal([]byte(p.Config)); err != nil { - return merchantConfig{}, nil, err - } - if epayCfg.Pid == "" || epayCfg.Key == "" { - return merchantConfig{}, nil, fmt.Errorf("payment id %d has empty pid/key", p.Id) - } - return merchantConfig{merchantID: epayCfg.Pid, key: epayCfg.Key, baseURL: strings.TrimRight(epayCfg.Url, "/")}, &p, nil -} - -func reconcile( - ctx context.Context, - client *http.Client, - db *gorm.DB, - redisClient *redis.Client, - queueClient *asynq.Client, - opt options, - baseURL string, - merchant merchantConfig, - paymentInfo *payment.Payment, -) ([]matchedOrder, reconcileStats, error) { - var matches []matchedOrder - var stats reconcileStats - stats.StatusCounts = make(map[string]int) - seen := make(map[string]struct{}) - page := opt.page - - for fetchedPages := 0; fetchedPages < opt.maxPages; fetchedPages++ { - resp, err := fetchRemoteOrders(ctx, client, baseURL, merchant, page, opt.limit) - if err != nil { - return matches, stats, err - } - if resp.Code != 1 { - return matches, stats, fmt.Errorf("remote orderlist failed: code=%d msg=%s", resp.Code, resp.Msg) - } - stats.RemotePages++ - stats.RemoteOrders += len(resp.Data.List) - if opt.verbose { - fmt.Printf("remote page=%d list=%d total=%d\n", page, len(resp.Data.List), resp.Data.Total) - } - if len(resp.Data.List) == 0 { - break - } - - for _, remote := range resp.Data.List { - status := strings.TrimSpace(remote.OrderStatus) - if status == "" { - status = "" - } - stats.StatusCounts[status]++ - if !isRemotePaidStatus(remote.OrderStatus) { - continue - } - stats.RemotePaid++ - if remote.OutOrderNo == "" { - stats.RemoteMissingNo++ - continue - } - if _, ok := seen[remote.OutOrderNo]; ok { - stats.DuplicateRemote++ - continue - } - seen[remote.OutOrderNo] = struct{}{} - - item, ok := matchRemoteOrder(ctx, db, opt, &stats, remote) - if !ok { - continue - } - stats.Matched++ - matches = append(matches, item) - if opt.apply && (item.Action == "补单" || item.Action == "重投队列") { - if err := applyOrderFix(ctx, db, redisClient, queueClient, paymentInfo, item); err != nil { - return matches, stats, err - } - } - } - - page++ - if resp.Data.Total > 0 && page*opt.limit >= resp.Data.Total+opt.limit { - break - } - } - return matches, stats, nil -} - -func isRemotePaidStatus(status string) bool { - switch strings.ToLower(strings.TrimSpace(status)) { - case "paid", "success", "succeeded", "trade_success", "completed", "complete", "1": - return true - default: - return false - } -} - -func fetchRemoteOrders(ctx context.Context, client *http.Client, baseURL string, merchant merchantConfig, page, limit int) (*orderListResponse, error) { - params := map[string]string{ - "merchantId": merchant.merchantID, - "page": strconv.Itoa(page), - "limit": strconv.Itoa(limit), - "order": "create_time desc", - } - params["sign"] = sign(params, merchant.key) - - form := url.Values{} - for k, v := range params { - form.Set(k, v) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/api/v2/orderlist", strings.NewReader(form.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("remote status %d: %s", resp.StatusCode, string(body)) - } - - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.UseNumber() - var out orderListResponse - if err := decoder.Decode(&out); err != nil { - return nil, fmt.Errorf("decode remote response: %w body=%s", err, string(body)) - } - return &out, nil -} - -func matchRemoteOrder(ctx context.Context, db *gorm.DB, opt options, stats *reconcileStats, remote remoteOrder) (matchedOrder, bool) { - var local order.Order - if err := db.WithContext(ctx).Model(&order.Order{}).Where("order_no = ?", remote.OutOrderNo).First(&local).Error; err != nil { - stats.LocalNotFound++ - stats.LocalNotFoundOrders = append(stats.LocalNotFoundOrders, remote) - if opt.verbose { - fmt.Printf("skip local_not_found out_order_no=%s remote_no=%s status=%s money=%s\n", remote.OutOrderNo, remote.OrderNo, remote.OrderStatus, remote.OrderMoney) - } - return matchedOrder{}, false - } - if opt.method != "" && local.Method != opt.method { - stats.LocalMethodSkip++ - if opt.verbose { - fmt.Printf("skip method out_order_no=%s local_method=%s want=%s\n", remote.OutOrderNo, local.Method, opt.method) - } - return matchedOrder{}, false - } - if opt.start != "" && local.CreatedAt.Format("2006-01-02 15:04:05") < opt.start { - stats.LocalDateFiltered++ - return matchedOrder{}, false - } - if opt.end != "" && local.CreatedAt.Format("2006-01-02 15:04:05") >= opt.end { - stats.LocalDateFiltered++ - return matchedOrder{}, false - } - - remoteCents := decimalYuanToCents(remote.OrderMoney) - item := matchedOrder{ - Remote: remote, - Local: local, - RemoteCents: remoteCents, - PaymentAudit: "远端已支付", - } - item.Audit = auditSubscription(ctx, db, local) - - switch { - case local.Status == 5: - item.Action = "跳过" - item.Reason = "本地已完成" - case remoteCents > 0 && abs64(local.Amount-remoteCents) > opt.amountDiff: - item.Action = "跳过" - item.Reason = fmt.Sprintf("金额不匹配 本地=%d 远端=%d", local.Amount, remoteCents) - case local.Status == 2: - item.Action = "重投队列" - item.Reason = "本地已支付但未完成" - case local.Status != 1 && local.Status != 3: - item.Action = "跳过" - item.Reason = fmt.Sprintf("本地状态 %d 不适合自动补单", local.Status) - default: - item.Action = "补单" - item.Reason = "远端已支付,本地未支付/已关闭" - } - return item, true -} - -func auditSubscription(ctx context.Context, db *gorm.DB, local order.Order) subscriptionAudit { - audit := subscriptionAudit{ - Checked: true, - Quantity: local.Quantity, - } - if local.Type != 1 && local.Type != 2 { - audit.Status = "跳过" - audit.Reason = fmt.Sprintf("订单类型 %d 不核对订阅时间", local.Type) - return audit - } - - var plan subscribe.Subscribe - if err := db.WithContext(ctx).Model(&subscribe.Subscribe{}).Where("id = ?", local.SubscribeId).First(&plan).Error; err != nil { - audit.Status = "异常" - audit.Reason = "套餐不存在: " + err.Error() - return audit - } - audit.SubscribeUnit = plan.UnitTime - - var userSub user.Subscribe - err := db.WithContext(ctx).Model(&user.Subscribe{}).Where("order_id = ?", local.Id).Order("id DESC").First(&userSub).Error - if err == nil { - audit.UserSubID = userSub.Id - audit.UserID = userSub.UserId - audit.StartTime = userSub.StartTime - audit.ExpireTime = userSub.ExpireTime - audit.ExpectedExpire = userSub.ExpireTime - audit.Status = "已核对" - audit.Reason = "订阅已绑定当前订单" - return audit - } - - if local.Type == 2 && local.SubscribeToken != "" { - err = db.WithContext(ctx).Model(&user.Subscribe{}).Where("token = ?", local.SubscribeToken).First(&userSub).Error - if err == nil { - base := subscriptionRenewalBaseTime(time.Now(), userSub.ExpireTime, userSub.FinishedAt) - audit.UserSubID = userSub.Id - audit.UserID = userSub.UserId - audit.StartTime = userSub.StartTime - audit.ExpireTime = userSub.ExpireTime - audit.ExpectedExpire = tool.AddTime(plan.UnitTime, local.Quantity, base) - if local.Status == 5 { - audit.Status = "异常" - audit.Reason = "订单已完成但订阅 order_id 未指向该订单" - } else { - audit.Status = "待激活" - audit.Reason = "续费订单未完成,预计激活后续期" - } - return audit - } - } - - if local.Status == 5 { - audit.Status = "异常" - audit.Reason = "订单已完成但未找到对应订阅" - return audit - } - - audit.Status = "待激活" - audit.ExpectedExpire = tool.AddTime(plan.UnitTime, local.Quantity, time.Now()) - audit.Reason = "订单未完成,订阅时间需激活队列生成" - return audit -} - -func subscriptionRenewalBaseTime(now time.Time, expire time.Time, finishedAt *time.Time) time.Time { - base := expire - if finishedAt != nil && finishedAt.After(base) { - base = *finishedAt - } - if base.Before(now) { - base = now - } - return base -} - -func applyOrderFix( - ctx context.Context, - db *gorm.DB, - redisClient *redis.Client, - queueClient *asynq.Client, - paymentInfo *payment.Payment, - item matchedOrder, -) error { - return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - updates := map[string]interface{}{ - "status": 2, - "trade_no": item.Remote.OrderNo, - "updated_at": time.Now(), - } - if paymentInfo != nil { - updates["payment_id"] = paymentInfo.Id - updates["method"] = paymentInfo.Platform - } - result := tx.Model(&order.Order{}). - Where("order_no = ? AND status IN ?", item.Local.OrderNo, []uint8{1, 2, 3}). - Updates(updates) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return fmt.Errorf("order %s was not updated; status changed concurrently", item.Local.OrderNo) - } - clearOrderCache(ctx, redisClient, item.Local) - return enqueueActivation(ctx, queueClient, item.Local.OrderNo) - }) -} - -func enqueueActivation(ctx context.Context, queueClient *asynq.Client, orderNo string) error { - payload, err := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: orderNo}) - if err != nil { - return err - } - _, err = queueClient.EnqueueContext(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload, asynq.MaxRetry(5))) - return err -} - -func clearOrderCache(ctx context.Context, redisClient *redis.Client, local order.Order) { - keys := []string{ - fmt.Sprintf("cache:order:id:%d", local.Id), - fmt.Sprintf("cache:order:no:%s", local.OrderNo), - } - _ = redisClient.Del(ctx, keys...).Err() -} - -func sign(params map[string]string, key string) string { - keys := make([]string, 0, len(params)) - for k, v := range params { - if k != "sign" && k != "sign_type" && v != "" { - keys = append(keys, k) - } - } - sort.Strings(keys) - - parts := make([]string, 0, len(keys)) - for _, k := range keys { - parts = append(parts, k+"="+params[k]) - } - sum := md5.Sum([]byte(strings.Join(parts, "&") + key)) - return hex.EncodeToString(sum[:]) -} - -func decimalYuanToCents(n json.Number) int64 { - s := strings.TrimSpace(n.String()) - if s == "" { - return 0 - } - parts := strings.SplitN(s, ".", 2) - yuan, _ := strconv.ParseInt(parts[0], 10, 64) - cents := yuan * 100 - if len(parts) == 1 { - return cents - } - frac := parts[1] - if len(frac) > 2 { - frac = frac[:2] - } - for len(frac) < 2 { - frac += "0" - } - f, _ := strconv.ParseInt(frac, 10, 64) - if strings.HasPrefix(parts[0], "-") { - return cents - f - } - return cents + f -} - -func printSummary(matches []matchedOrder, stats reconcileStats, opt options) { - var fix, requeue, skip int - for _, item := range matches { - switch item.Action { - case "补单": - fix++ - case "重投队列": - requeue++ - default: - skip++ - } - fmt.Printf("%s order_no=%s remote_no=%s local_status=%d amount=%d remote_amount=%d reason=%s\n", - item.Action, - item.Local.OrderNo, - item.Remote.OrderNo, - item.Local.Status, - item.Local.Amount, - item.RemoteCents, - item.Reason, - ) - if item.Audit.Checked { - fmt.Printf(" payment=%s subscription=%s sub_id=%d user_id=%d expire=%s expected=%s note=%s\n", - item.PaymentAudit, - item.Audit.Status, - item.Audit.UserSubID, - item.Audit.UserID, - formatTime(item.Audit.ExpireTime), - formatTime(item.Audit.ExpectedExpire), - item.Audit.Reason, - ) - } - } - mode := "DRY-RUN" - if opt.apply { - mode = "APPLIED" - } - fmt.Printf("\nremote pages=%d orders=%d paid=%d missing_out_order_no=%d duplicates=%d\n", - stats.RemotePages, stats.RemoteOrders, stats.RemotePaid, stats.RemoteMissingNo, stats.DuplicateRemote) - if len(stats.StatusCounts) > 0 { - keys := make([]string, 0, len(stats.StatusCounts)) - for key := range stats.StatusCounts { - keys = append(keys, key) - } - sort.Strings(keys) - fmt.Print("remote status_counts=") - for i, key := range keys { - if i > 0 { - fmt.Print(", ") - } - fmt.Printf("%s:%d", key, stats.StatusCounts[key]) - } - fmt.Println() - } - fmt.Printf("local not_found=%d method_skipped=%d date_filtered=%d matched=%d\n", - stats.LocalNotFound, stats.LocalMethodSkip, stats.LocalDateFiltered, stats.Matched) - if opt.printNotFound && len(stats.LocalNotFoundOrders) > 0 { - printNotFoundOrders(stats.LocalNotFoundOrders) - } - fmt.Printf("\n%s summary: matched=%d fixable=%d requeue=%d skipped=%d\n", mode, len(matches), fix, requeue, skip) -} - -func printNotFoundOrders(items []remoteOrder) { - fmt.Println("\nlocal_not_found orders:") - fmt.Println("out_order_no\tremote_no\tstatus\torder_money\tuser_money\tcreate_time\tpayment_time\tclient_ip\tdevice\tremark") - for _, item := range items { - fmt.Printf("%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - item.OutOrderNo, - item.OrderNo, - item.OrderStatus, - item.OrderMoney.String(), - item.UserMoney.String(), - formatUnixTime(item.CreateTime), - formatUnixTime(item.PaymentTime), - oneLine(item.ClientIP), - oneLine(item.Device), - oneLine(item.OrderRemark), - ) - } -} - -func formatTime(t time.Time) string { - if t.IsZero() { - return "-" - } - return t.Format("2006-01-02 15:04:05") -} - -func formatUnixTime(v int64) string { - if v <= 0 { - return "-" - } - if v > 1_000_000_000_000 { - return time.UnixMilli(v).Format("2006-01-02 15:04:05") - } - return time.Unix(v, 0).Format("2006-01-02 15:04:05") -} - -func oneLine(s string) string { - s = strings.ReplaceAll(s, "\t", " ") - s = strings.ReplaceAll(s, "\r", " ") - s = strings.ReplaceAll(s, "\n", " ") - return strings.TrimSpace(s) -} - -func abs64(v int64) int64 { - if v < 0 { - return -v - } - return v -} - -func must(err error, step string) { - if err == nil { - return - } - fmt.Fprintf(os.Stderr, "%s: %v\n", step, err) - os.Exit(1) -} diff --git a/tempo/tempo-config.yaml b/tempo/tempo-config.yaml deleted file mode 100644 index 8a68571..0000000 --- a/tempo/tempo-config.yaml +++ /dev/null @@ -1,37 +0,0 @@ -server: - http_listen_port: 3200 - grpc_listen_port: 9095 - -distributor: - receivers: - otlp: - protocols: - grpc: - endpoint: 0.0.0.0:4317 - http: - endpoint: 0.0.0.0:4318 - -ingester: - max_block_duration: 5m - -compactor: - compaction: - block_retention: 168h - -storage: - trace: - backend: local - wal: - path: /var/tempo/wal - local: - path: /var/tempo/blocks - -metrics_generator: - registry: - external_labels: - source: tempo - cluster: ppanel - storage: - path: /var/tempo/generator/wal - remote_write: - - url: http://prometheus:9090/api/v1/write diff --git a/tests/acceptance/README.md b/tests/acceptance/README.md new file mode 100644 index 0000000..7afb09c --- /dev/null +++ b/tests/acceptance/README.md @@ -0,0 +1,42 @@ +# Acceptance Tests + +This package contains black-box Go tests for the staging API surface. + +## Local smoke run + +```bash +go test ./tests/acceptance/... -staging-url=https://tapi.hifast.biz +``` + +Without credentials, tests that require admin/user login or NodeSecret are skipped and unauthenticated error-path checks still run. + +## Workflow configuration + +`STAGING_BASE_URL` should be provided through GitHub Actions secrets or variables. When omitted, the workflow falls back to `https://tapi.hifast.biz`. + +The following secrets are optional. When any of them are missing, the workflow still runs and the corresponding acceptance cases are skipped automatically: + +- `ACCEPTANCE_ADMIN_EMAIL` +- `ACCEPTANCE_ADMIN_PASSWORD` +- `ACCEPTANCE_USER_EMAIL` +- `ACCEPTANCE_USER_PASSWORD` +- `STAGING_DB_HOST` +- `STAGING_DB_USER` +- `STAGING_DB_PASSWORD` +- `STAGING_DB_NAME` +- `STAGING_REDIS_ADDR` +- `STAGING_REDIS_PASSWORD` + +`ACCEPTANCE_NODE_SECRET` is optional. When DB credentials are present, the loader reads NodeSecret from the staging `system` table instead of storing it as a long-lived secret. + +## Useful flags + +- `-staging-url`: API base URL. +- `-run-id`: run identifier used for QA-owned transient data. +- `-report-path`: write a JSON summary artifact. +- `-seed-sql`: seed SQL path, default `fixtures/seed.sql`. +- `-node-server-id` and `-node-protocol`: node smoke target. + +## Fixture rules + +Seed data must use a `qa_acceptance_` prefix or the provided `RUN_ID`. Cleanup must only delete QA-prefixed rows and Redis keys. Do not update or delete production-like staging data. diff --git a/tests/acceptance/acceptance_test.go b/tests/acceptance/acceptance_test.go new file mode 100644 index 0000000..f8590a6 --- /dev/null +++ b/tests/acceptance/acceptance_test.go @@ -0,0 +1,111 @@ +package acceptance + +import ( + "context" + "net/http" + "net/url" + "os" + "testing" +) + +func TestMain(m *testing.M) { + code := m.Run() + if err := writeReport(testConfig); err != nil && code == 0 { + code = 1 + } + os.Exit(code) +} + +func TestFixtureSeedLoader(t *testing.T) { + c := cfg(t) + ctx := context.Background() + newFixtureLoader(c).SeedIfConfigured(t, ctx) +} + +func TestPublicSmoke(t *testing.T) { + c := cfg(t) + ctx := context.Background() + client := newClient(c) + + t.Run("happy path user info", func(t *testing.T) { + token := loginUser(t, ctx, client, c) + envelope, raw, status, err := client.WithToken(token).Get(ctx, "/v1/public/user/info", nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + assertOK(t, envelope, raw, status) + recordCase(t, "public", http.MethodGet, "/v1/public/user/info", "200 envelope") + }) + + t.Run("error path missing auth", func(t *testing.T) { + envelope, raw, status, err := client.Get(ctx, "/v1/public/user/info", nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + assertAPIError(t, envelope, raw, status) + recordCase(t, "public", http.MethodGet, "/v1/public/user/info", "non-200 envelope without auth") + }) +} + +func TestAdminSmoke(t *testing.T) { + c := cfg(t) + ctx := context.Background() + client := newClient(c) + + t.Run("happy path current admin", func(t *testing.T) { + token := loginAdmin(t, ctx, client, c) + envelope, raw, status, err := client.WithToken(token).Get(ctx, "/v1/admin/user/current", nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + assertOK(t, envelope, raw, status) + recordCase(t, "admin", http.MethodGet, "/v1/admin/user/current", "200 envelope") + }) + + t.Run("error path missing auth", func(t *testing.T) { + envelope, raw, status, err := client.Get(ctx, "/v1/admin/user/current", nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + assertAPIError(t, envelope, raw, status) + recordCase(t, "admin", http.MethodGet, "/v1/admin/user/current", "non-200 envelope without auth") + }) +} + +func TestNodeSmoke(t *testing.T) { + c := cfg(t) + ctx := context.Background() + client := newClient(c) + loader := newFixtureLoader(c) + + t.Run("happy path server config", func(t *testing.T) { + secret, err := loader.NodeSecret(ctx) + if err != nil { + t.Fatalf("load node secret: %v", err) + } + secret = requireSecret(t, "node secret", secret) + query := url.Values{} + query.Set("secret_key", secret) + query.Set("protocols", c.NodeProtocol) + envelope, raw, status, err := client.Get(ctx, "/v2/server/"+c.NodeServerID, query) + if err != nil { + t.Fatalf("request failed: %v", err) + } + assertOK(t, envelope, raw, status) + recordCase(t, "node", http.MethodGet, "/v2/server/{server_id}", "200 envelope") + }) + + t.Run("error path invalid secret", func(t *testing.T) { + query := url.Values{} + query.Set("secret_key", "invalid-"+c.RunID) + query.Set("protocols", c.NodeProtocol) + _, raw, status, err := client.Get(ctx, "/v1/server/config", query) + if err != nil { + t.Fatalf("request failed: %v", err) + } + if status != http.StatusForbidden { + t.Fatalf("http status = %d, want 403; body=%s", status, sanitizeBody(raw)) + } + recordCase(t, "node", http.MethodGet, "/v1/server/config", "403 with invalid secret") + }) +} diff --git a/tests/acceptance/auth.go b/tests/acceptance/auth.go new file mode 100644 index 0000000..50d06fe --- /dev/null +++ b/tests/acceptance/auth.go @@ -0,0 +1,45 @@ +package acceptance + +import ( + "context" + "encoding/json" + "testing" +) + +type loginResponse struct { + Token string `json:"token"` +} + +func loginAdmin(t *testing.T, ctx context.Context, client *Client, c Config) string { + t.Helper() + return login(t, ctx, client, "/v1/auth/admin/login", c.AdminEmail, c.AdminPassword) +} + +func loginUser(t *testing.T, ctx context.Context, client *Client, c Config) string { + t.Helper() + return login(t, ctx, client, "/v1/auth/login", c.UserEmail, c.UserPassword) +} + +func login(t *testing.T, ctx context.Context, client *Client, path string, email string, password string) string { + t.Helper() + requireSecret(t, "login email", email) + requireSecret(t, "login password", password) + + envelope, raw, status, err := client.PostJSON(ctx, path, map[string]string{ + "email": email, + "password": password, + }) + if err != nil { + t.Fatalf("login request failed: %v", err) + } + assertOK(t, envelope, raw, status) + + var data loginResponse + if err := json.Unmarshal(envelope.Data, &data); err != nil { + t.Fatalf("decode login response: %v", err) + } + if data.Token == "" { + t.Fatal("login response token is empty") + } + return data.Token +} diff --git a/tests/acceptance/client.go b/tests/acceptance/client.go new file mode 100644 index 0000000..fd8a6c4 --- /dev/null +++ b/tests/acceptance/client.go @@ -0,0 +1,130 @@ +package acceptance + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" +) + +type Client struct { + baseURL string + httpClient *http.Client + token string +} + +type Envelope struct { + Code uint32 `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func newClient(c Config) *Client { + return &Client{ + baseURL: strings.TrimRight(c.StagingURL, "/"), + httpClient: &http.Client{ + Timeout: c.HTTPTimeout, + }, + } +} + +func (c *Client) WithToken(token string) *Client { + next := *c + next.token = token + return &next +} + +func (c *Client) Get(ctx context.Context, path string, query url.Values) (*Envelope, []byte, int, error) { + return c.Do(ctx, http.MethodGet, path, query, nil) +} + +func (c *Client) PostJSON(ctx context.Context, path string, payload any) (*Envelope, []byte, int, error) { + return c.Do(ctx, http.MethodPost, path, nil, payload) +} + +func (c *Client) Do(ctx context.Context, method string, path string, query url.Values, payload any) (*Envelope, []byte, int, error) { + target, err := url.Parse(c.baseURL + "/" + strings.TrimLeft(path, "/")) + if err != nil { + return nil, nil, 0, fmt.Errorf("build request URL: %w", err) + } + if query != nil { + target.RawQuery = query.Encode() + } + + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + if err != nil { + return nil, nil, 0, fmt.Errorf("marshal request body: %w", err) + } + body = bytes.NewReader(encoded) + } + + req, err := http.NewRequestWithContext(ctx, method, target.String(), body) + if err != nil { + return nil, nil, 0, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "hifast-acceptance/1.0") + req.Header.Set("Login-Type", "email") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.token != "" { + req.Header.Set("Authorization", c.token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, 0, fmt.Errorf("%s %s: %w", method, target.Redacted(), err) + } + defer func() { + _ = resp.Body.Close() + }() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, nil, resp.StatusCode, fmt.Errorf("read response: %w", err) + } + + envelope := &Envelope{} + if strings.Contains(resp.Header.Get("Content-Type"), "application/json") && len(raw) > 0 { + if err := json.Unmarshal(raw, envelope); err != nil { + return nil, raw, resp.StatusCode, fmt.Errorf("decode response envelope: %w", err) + } + } + return envelope, raw, resp.StatusCode, nil +} + +func assertOK(t *testing.T, envelope *Envelope, raw []byte, status int) { + t.Helper() + if status != http.StatusOK { + t.Fatalf("http status = %d, want 200; body=%s", status, sanitizeBody(raw)) + } + if envelope == nil || envelope.Code != 200 { + t.Fatalf("api code = %d, want 200; msg=%q body=%s", envelope.Code, envelope.Msg, sanitizeBody(raw)) + } +} + +func assertAPIError(t *testing.T, envelope *Envelope, raw []byte, status int) { + t.Helper() + if status != http.StatusOK { + t.Fatalf("http status = %d, want 200 API envelope; body=%s", status, sanitizeBody(raw)) + } + if envelope == nil || envelope.Code == 200 { + t.Fatalf("api code = %d, want non-200; body=%s", envelope.Code, sanitizeBody(raw)) + } +} + +func sanitizeBody(raw []byte) string { + value := string(raw) + if len(value) > 512 { + value = value[:512] + "..." + } + return value +} diff --git a/tests/acceptance/config.go b/tests/acceptance/config.go new file mode 100644 index 0000000..2dbd267 --- /dev/null +++ b/tests/acceptance/config.go @@ -0,0 +1,112 @@ +package acceptance + +import ( + "flag" + "fmt" + "os" + "strings" + "testing" + "time" +) + +type Config struct { + StagingURL string + AdminEmail string + AdminPassword string + UserEmail string + UserPassword string + NodeSecret string + NodeServerID string + NodeProtocol string + RunID string + ReportPath string + SeedSQLPath string + DB DBConfig + Redis RedisConfig + HTTPTimeout time.Duration +} + +type DBConfig struct { + Host string + User string + Password string + Name string +} + +type RedisConfig struct { + Addr string + Password string + DB int +} + +var testConfig = Config{ + HTTPTimeout: 15 * time.Second, + NodeServerID: "31", + NodeProtocol: "trojan", + SeedSQLPath: "fixtures/seed.sql", +} + +func init() { + flag.StringVar(&testConfig.StagingURL, "staging-url", getenv("STAGING_BASE_URL", "https://tapi.hifast.biz"), "staging base URL") + flag.StringVar(&testConfig.AdminEmail, "admin-email", os.Getenv("ACCEPTANCE_ADMIN_EMAIL"), "admin login email") + flag.StringVar(&testConfig.AdminPassword, "admin-password", os.Getenv("ACCEPTANCE_ADMIN_PASSWORD"), "admin login password") + flag.StringVar(&testConfig.UserEmail, "user-email", os.Getenv("ACCEPTANCE_USER_EMAIL"), "user login email") + flag.StringVar(&testConfig.UserPassword, "user-password", os.Getenv("ACCEPTANCE_USER_PASSWORD"), "user login password") + flag.StringVar(&testConfig.NodeSecret, "node-secret", os.Getenv("ACCEPTANCE_NODE_SECRET"), "node secret; read from staging DB when omitted") + flag.StringVar(&testConfig.NodeServerID, "node-server-id", getenv("ACCEPTANCE_NODE_SERVER_ID", testConfig.NodeServerID), "server id for node smoke tests") + flag.StringVar(&testConfig.NodeProtocol, "node-protocol", getenv("ACCEPTANCE_NODE_PROTOCOL", testConfig.NodeProtocol), "server protocol for node smoke tests") + flag.StringVar(&testConfig.RunID, "run-id", getenv("ACCEPTANCE_RUN_ID", defaultRunID()), "acceptance run id") + flag.StringVar(&testConfig.ReportPath, "report-path", os.Getenv("ACCEPTANCE_REPORT_PATH"), "optional JSON report path") + flag.StringVar(&testConfig.SeedSQLPath, "seed-sql", getenv("ACCEPTANCE_SEED_SQL", testConfig.SeedSQLPath), "seed SQL file path") + flag.StringVar(&testConfig.DB.Host, "db-host", os.Getenv("STAGING_DB_HOST"), "staging DB host") + flag.StringVar(&testConfig.DB.User, "db-user", os.Getenv("STAGING_DB_USER"), "staging DB user") + flag.StringVar(&testConfig.DB.Password, "db-password", os.Getenv("STAGING_DB_PASSWORD"), "staging DB password") + flag.StringVar(&testConfig.DB.Name, "db-name", os.Getenv("STAGING_DB_NAME"), "staging DB name") + flag.StringVar(&testConfig.Redis.Addr, "redis-addr", os.Getenv("STAGING_REDIS_ADDR"), "staging Redis addr") + flag.StringVar(&testConfig.Redis.Password, "redis-password", os.Getenv("STAGING_REDIS_PASSWORD"), "staging Redis password") + flag.IntVar(&testConfig.Redis.DB, "redis-db", getenvInt("STAGING_REDIS_DB", 0), "staging Redis DB") +} + +func cfg(t *testing.T) Config { + t.Helper() + if strings.TrimSpace(testConfig.StagingURL) == "" { + t.Fatal("staging-url is required") + } + return testConfig +} + +func requireSecret(t *testing.T, name string, value string) string { + t.Helper() + if strings.TrimSpace(value) == "" { + t.Skipf("%s is required for this acceptance test", name) + } + return value +} + +func getenv(key string, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func getenvInt(key string, fallback int) int { + value := os.Getenv(key) + if value == "" { + return fallback + } + var parsed int + if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil { + return fallback + } + return parsed +} + +func defaultRunID() string { + runID := os.Getenv("GITHUB_RUN_ID") + attempt := getenv("GITHUB_RUN_ATTEMPT", "1") + if runID == "" { + return "qa_local" + } + return "qa_" + runID + "_" + attempt +} diff --git a/tests/acceptance/fixtures.go b/tests/acceptance/fixtures.go new file mode 100644 index 0000000..4b07d47 --- /dev/null +++ b/tests/acceptance/fixtures.go @@ -0,0 +1,132 @@ +package acceptance + +import ( + "context" + "database/sql" + "fmt" + "os" + "strings" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" + "github.com/redis/go-redis/v9" +) + +type FixtureLoader struct { + cfg Config +} + +func newFixtureLoader(c Config) *FixtureLoader { + return &FixtureLoader{cfg: c} +} + +func (l *FixtureLoader) SeedIfConfigured(t *testing.T, ctx context.Context) { + t.Helper() + if !l.hasDBConfig() { + t.Log("staging DB config not provided; skipping seed loader") + return + } + sqlBytes, err := os.ReadFile(l.cfg.SeedSQLPath) + if err != nil { + t.Fatalf("read seed SQL %q: %v", l.cfg.SeedSQLPath, err) + } + + db, err := sql.Open("mysql", l.dsn(true)) + if err != nil { + t.Fatalf("open staging DB: %v", err) + } + defer func() { + _ = db.Close() + }() + + if err := db.PingContext(ctx); err != nil { + t.Fatalf("ping staging DB: %v", err) + } + + statements := splitSQL(string(sqlBytes)) + for _, statement := range statements { + statement = strings.ReplaceAll(statement, "{{RUN_ID}}", l.cfg.RunID) + if _, err := db.ExecContext(ctx, statement); err != nil { + t.Fatalf("execute seed SQL: %v; statement=%s", err, statement) + } + } +} + +func (l *FixtureLoader) NodeSecret(ctx context.Context) (string, error) { + if l.cfg.NodeSecret != "" { + return l.cfg.NodeSecret, nil + } + if !l.hasDBConfig() { + return "", nil + } + + db, err := sql.Open("mysql", l.dsn(false)) + if err != nil { + return "", fmt.Errorf("open staging DB: %w", err) + } + defer func() { + _ = db.Close() + }() + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + var secret string + err = db.QueryRowContext(ctx, "SELECT value FROM system WHERE category = 'server' AND `key` = 'NodeSecret' LIMIT 1").Scan(&secret) + if err != nil { + return "", fmt.Errorf("read NodeSecret from staging DB: %w", err) + } + return secret, nil +} + +func (l *FixtureLoader) RedisClient() *redis.Client { + if l.cfg.Redis.Addr == "" { + return nil + } + return redis.NewClient(&redis.Options{ + Addr: l.cfg.Redis.Addr, + Password: l.cfg.Redis.Password, + DB: l.cfg.Redis.DB, + }) +} + +func (l *FixtureLoader) hasDBConfig() bool { + return l.cfg.DB.Host != "" && l.cfg.DB.User != "" && l.cfg.DB.Name != "" +} + +func (l *FixtureLoader) dsn(multiStatements bool) string { + params := "parseTime=true&timeout=5s&readTimeout=10s&writeTimeout=10s" + if multiStatements { + params += "&multiStatements=true" + } + return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", + l.cfg.DB.User, + l.cfg.DB.Password, + l.cfg.DB.Host, + l.cfg.DB.Name, + params, + ) +} + +func splitSQL(script string) []string { + parts := strings.Split(script, ";") + statements := make([]string, 0, len(parts)) + for _, part := range parts { + lines := strings.Split(part, "\n") + kept := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "--") { + continue + } + kept = append(kept, line) + } + statement := strings.Join(kept, "\n") + if statement == "" || strings.HasPrefix(statement, "--") { + continue + } + statements = append(statements, statement) + } + return statements +} diff --git a/tests/acceptance/fixtures/seed.sql b/tests/acceptance/fixtures/seed.sql new file mode 100644 index 0000000..40bacbe --- /dev/null +++ b/tests/acceptance/fixtures/seed.sql @@ -0,0 +1,4 @@ +-- Idempotent staging acceptance fixture seed. +-- HIF-5 lands the loader and smoke skeleton; HIF-6/HIF-7 can extend this file +-- with concrete qa_acceptance_ rows after the MVP surface is expanded. +SELECT 1; diff --git a/tests/acceptance/helpers_test.go b/tests/acceptance/helpers_test.go new file mode 100644 index 0000000..8ab17c2 --- /dev/null +++ b/tests/acceptance/helpers_test.go @@ -0,0 +1,77 @@ +package acceptance + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestSplitSQLDropsCommentsAndKeepsStatements(t *testing.T) { + script := ` +-- comment before first statement +SELECT 1; + +-- comment before second statement +SELECT '{{RUN_ID}}'; +` + + statements := splitSQL(script) + if len(statements) != 2 { + t.Fatalf("len(statements) = %d, want 2: %#v", len(statements), statements) + } + if statements[0] != "SELECT 1" { + t.Fatalf("first statement = %q", statements[0]) + } + if statements[1] != "SELECT '{{RUN_ID}}'" { + t.Fatalf("second statement = %q", statements[1]) + } +} + +func TestSanitizeBodyTruncatesLongBodies(t *testing.T) { + raw := []byte(strings.Repeat("a", 600)) + got := sanitizeBody(raw) + if len(got) <= 512 { + t.Fatalf("sanitized body length = %d, want truncation marker", len(got)) + } + if !strings.Contains(got, "") { + t.Fatalf("sanitized body missing truncation marker: %q", got) + } +} + +func TestWriteReportCreatesJSONArtifact(t *testing.T) { + dir := t.TempDir() + reportPath := filepath.Join(dir, "report.json") + + original := acceptanceReporter + acceptanceReporter = &Reporter{started: time.Now()} + t.Cleanup(func() { + acceptanceReporter = original + }) + + recordCase(t, "public", "GET", "/v1/public/user/info", "200 envelope") + if err := writeReport(Config{ + RunID: "qa_test", + StagingURL: "https://tapi.hifast.biz", + ReportPath: reportPath, + }); err != nil { + t.Fatalf("write report: %v", err) + } + + raw, err := os.ReadFile(reportPath) + if err != nil { + t.Fatalf("read report: %v", err) + } + var report Report + if err := json.Unmarshal(raw, &report); err != nil { + t.Fatalf("decode report: %v", err) + } + if report.RunID != "qa_test" { + t.Fatalf("run id = %q", report.RunID) + } + if len(report.Cases) != 1 { + t.Fatalf("cases len = %d, want 1", len(report.Cases)) + } +} diff --git a/tests/acceptance/inventory.md b/tests/acceptance/inventory.md new file mode 100644 index 0000000..7d11e3b --- /dev/null +++ b/tests/acceptance/inventory.md @@ -0,0 +1,366 @@ +# API Acceptance Inventory + +Generated from `apis/admin/*.api`, `apis/public/*.api`, and `apis/node/*.api` on 2026-06-03. This is an inventory only; it does not add acceptance test logic. + +## Summary + +- API definition files scanned: 35 (public 12, admin 22, node 1) +- Endpoints inventoried: 289 (public 78, admin 205, node 6) +- MVP priority counts: P0 159, P1 67, P2 63 + +## Priority Rules + +- P0: login/auth, user identity and profile, subscription/package, order, node config, and core admin query paths. +- P1: common operational paths such as tickets, announcements, payment/config reads, logs, coupons, invite, documents, and non-critical updates. +- P2: destructive operations or paths with strong external side effects, including delete/batch delete, file upload, real SMS/email, OAuth/Telegram callbacks, app-store/payment webhooks, and other stateful batch jobs. + +## Source Files + +### public + +- `apis/public/announcement.api` +- `apis/public/document.api` +- `apis/public/file.api` +- `apis/public/iap.api` +- `apis/public/order.api` +- `apis/public/payment.api` +- `apis/public/portal.api` +- `apis/public/recovery.api` +- `apis/public/redemption.api` +- `apis/public/subscribe.api` +- `apis/public/ticket.api` +- `apis/public/user.api` + +### admin + +- `apis/admin/ads.api` +- `apis/admin/announcement.api` +- `apis/admin/application.api` +- `apis/admin/auth.api` +- `apis/admin/console.api` +- `apis/admin/coupon.api` +- `apis/admin/device.api` +- `apis/admin/document.api` +- `apis/admin/group.api` +- `apis/admin/invite.api` +- `apis/admin/log.api` +- `apis/admin/marketing.api` +- `apis/admin/order.api` +- `apis/admin/payment.api` +- `apis/admin/promo.api` +- `apis/admin/redemption.api` +- `apis/admin/server.api` +- `apis/admin/subscribe.api` +- `apis/admin/system.api` +- `apis/admin/ticket.api` +- `apis/admin/tool.api` +- `apis/admin/user.api` + +### node + +- `apis/node/node.api` + +## public Endpoints + +| Domain | Method | Path | Request Type | Response Type | Auth | Priority | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | +| public | GET | `/v1/public/announcement/list` | `QueryAnnouncementRequest` | `QueryAnnouncementResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Query announcement | +| public | GET | `/v1/public/document/list` | `-` | `QueryDocumentListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get document list | +| public | GET | `/v1/public/document/detail` | `QueryDocumentDetailRequest` | `Document` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get document detail | +| public | POST | `/v1/public/file/upload` | `FileUploadRequest` | `FileUploadResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Upload file to RustFS;暂缓真实文件上传,需测试存储桶或 mock storage | +| public | POST | `/v1/public/file/upload/init` | `FileUploadInitRequest` | `FileUploadInitResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Init file upload;暂缓真实文件上传,需测试存储桶或 mock storage | +| public | POST | `/v1/public/file/upload/complete` | `FileUploadCompleteRequest` | `FileUploadCompleteResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Complete file upload;暂缓真实文件上传,需测试存储桶或 mock storage | +| public | POST | `/v1/public/iap/apple/transactions/attach` | `AttachAppleTransactionRequest` | `AttachAppleTransactionResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Attach Apple Transaction;订单/支付路径需沙箱网关与测试订单隔离 | +| public | POST | `/v1/public/iap/apple/transactions/attach/id` | `AttachAppleTransactionByIdRequest` | `AttachAppleTransactionResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Attach Apple Transaction By Id;订单/支付路径需沙箱网关与测试订单隔离 | +| public | POST | `/v1/public/iap/apple/transactions/restore` | `RestoreAppleTransactionsRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Restore Apple Transactions;订单/支付路径需沙箱网关与测试订单隔离 | +| public | GET | `/v1/public/iap/apple/status` | `-` | `GetAppleStatusResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Get Apple IAP Status;订单/支付路径需沙箱网关与测试订单隔离 | +| public | POST | `/v1/public/order/pre` | `PurchaseOrderRequest` | `PreOrderResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Pre create order;订单/支付路径需沙箱网关与测试订单隔离 | +| public | POST | `/v1/public/order/purchase` | `PurchaseOrderRequest` | `PurchaseOrderResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: purchase Subscription;订单/支付路径需沙箱网关与测试订单隔离 | +| public | POST | `/v1/public/order/renewal` | `RenewalOrderRequest` | `RenewalOrderResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Renewal Subscription;订单/支付路径需沙箱网关与测试订单隔离 | +| public | POST | `/v1/public/order/reset` | `ResetTrafficOrderRequest` | `ResetTrafficOrderResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Reset traffic;订单/支付路径需沙箱网关与测试订单隔离;状态变更操作,需隔离测试资源 | +| public | POST | `/v1/public/order/recharge` | `RechargeOrderRequest` | `RechargeOrderResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Recharge;订单/支付路径需沙箱网关与测试订单隔离 | +| public | POST | `/v1/public/order/close` | `CloseOrderRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Close order;订单/支付路径需沙箱网关与测试订单隔离 | +| public | GET | `/v1/public/order/detail` | `QueryOrderDetailRequest` | `OrderDetail` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get order;订单/支付路径需沙箱网关与测试订单隔离 | +| public | GET | `/v1/public/order/list` | `QueryOrderListRequest` | `QueryOrderListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get order list;订单/支付路径需沙箱网关与测试订单隔离 | +| public | GET | `/v1/public/payment/methods` | `-` | `GetAvailablePaymentMethodsResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get available payment methods;订单/支付路径需沙箱网关与测试订单隔离 | +| public | GET | `/v1/public/portal/payment-method` | `-` | `GetAvailablePaymentMethodsResponse` | 设备校验(DeviceMiddleware) | P1 | 中间件: DeviceMiddleware;doc: Get available payment methods;订单/支付路径需沙箱网关与测试订单隔离 | +| public | GET | `/v1/public/portal/subscribe` | `GetSubscriptionRequest` | `GetSubscriptionResponse` | 设备校验(DeviceMiddleware) | P0 | 中间件: DeviceMiddleware;doc: Get Subscription | +| public | POST | `/v1/public/portal/pre` | `PrePurchaseOrderRequest` | `PrePurchaseOrderResponse` | 设备校验(DeviceMiddleware) | P0 | 中间件: DeviceMiddleware;doc: Pre Purchase Order;订单/支付路径需沙箱网关与测试订单隔离 | +| public | POST | `/v1/public/portal/purchase` | `PortalPurchaseRequest` | `PortalPurchaseResponse` | 设备校验(DeviceMiddleware) | P2 | 中间件: DeviceMiddleware;doc: Purchase subscription;订单/支付路径需沙箱网关与测试订单隔离 | +| public | GET | `/v1/public/portal/order/status` | `QueryPurchaseOrderRequest` | `QueryPurchaseOrderResponse` | 设备校验(DeviceMiddleware) | P0 | 中间件: DeviceMiddleware;doc: Query Purchase Order;订单/支付路径需沙箱网关与测试订单隔离 | +| public | POST | `/v1/public/portal/order/checkout` | `CheckoutOrderRequest` | `CheckoutOrderResponse` | 设备校验(DeviceMiddleware) | P0 | 中间件: DeviceMiddleware;doc: Purchase Checkout;订单/支付路径需沙箱网关与测试订单隔离 | +| public | POST | `/v1/public/recovery/send_code` | `RecoverySendCodeRequest` | `SendCodeResponse` | 设备校验(DeviceMiddleware) | P2 | 中间件: DeviceMiddleware;doc: Send recovery verification code | +| public | POST | `/v1/public/recovery/order` | `RecoverOrderRequest` | `RecoverOrderResponse` | 设备校验(DeviceMiddleware) | P0 | 中间件: DeviceMiddleware;doc: Recover order subscription;订单/支付路径需沙箱网关与测试订单隔离 | +| public | POST | `/v1/public/redemption` | `RedeemCodeRequest` | `RedeemCodeResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Redeem code | +| public | GET | `/v1/public/subscribe/list` | `QuerySubscribeListRequest` | `QuerySubscribeListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get subscribe list | +| public | GET | `/v1/public/subscribe/node/list` | `-` | `QueryUserSubscribeNodeListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get user subscribe node info | +| public | GET | `/v1/public/subscribe/group/list` | `-` | `QuerySubscribeGroupListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get subscribe group list | +| public | GET | `/v1/public/ticket/list` | `GetUserTicketListRequest` | `GetUserTicketListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get ticket list | +| public | GET | `/v1/public/ticket/detail` | `GetUserTicketDetailRequest` | `Ticket` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get ticket detail | +| public | PUT | `/v1/public/ticket` | `UpdateUserTicketStatusRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update ticket status | +| public | POST | `/v1/public/ticket/follow` | `CreateUserTicketFollowRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create ticket follow | +| public | POST | `/v1/public/ticket` | `CreateUserTicketRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create ticket | +| public | GET | `/v1/public/user/info` | `-` | `User` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query User Info | +| public | PUT | `/v1/public/user/notify` | `UpdateUserNotifyRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Update User Notify | +| public | PUT | `/v1/public/user/password` | `UpdateUserPasswordRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update User Password | +| public | GET | `/v1/public/user/subscribe` | `-` | `QueryUserSubscribeListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query User Subscribe | +| public | POST | `/v1/public/user/unsubscribe/pre` | `PreUnsubscribeRequest` | `PreUnsubscribeResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Pre Unsubscribe | +| public | POST | `/v1/public/user/unsubscribe` | `UnsubscribeRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Unsubscribe | +| public | GET | `/v1/public/user/balance_log` | `-` | `QueryUserBalanceLogListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query User Balance Log | +| public | GET | `/v1/public/user/affiliate/count` | `-` | `QueryUserAffiliateCountResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query User Affiliate Count | +| public | GET | `/v1/public/user/affiliate/list` | `QueryUserAffiliateListRequest` | `QueryUserAffiliateListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query User Affiliate List | +| public | GET | `/v1/public/user/bind_telegram` | `-` | `BindTelegramResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Bind Telegram;外部 OAuth/Telegram 依赖需 mock provider | +| public | POST | `/v1/public/user/unbind_telegram` | `-` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Unbind Telegram;外部 OAuth/Telegram 依赖需 mock provider | +| public | GET | `/v1/public/user/commission_log` | `QueryUserCommissionLogListRequest` | `QueryUserCommissionLogListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query User Commission Log | +| public | POST | `/v1/public/user/bind_oauth` | `BindOAuthRequest` | `BindOAuthResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Bind OAuth;外部 OAuth/Telegram 依赖需 mock provider | +| public | POST | `/v1/public/user/bind_oauth/callback` | `BindOAuthCallbackRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Bind OAuth Callback;外部回调路径需签名样例和重放保护用例;外部 OAuth/Telegram 依赖需 mock provider | +| public | GET | `/v1/public/user/oauth_methods` | `-` | `GetOAuthMethodsResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Get OAuth Methods;外部 OAuth/Telegram 依赖需 mock provider | +| public | POST | `/v1/public/user/unbind_oauth` | `UnbindOAuthRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Unbind OAuth;外部 OAuth/Telegram 依赖需 mock provider | +| public | PUT | `/v1/public/user/subscribe_token` | `ResetUserSubscribeTokenRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Reset User Subscribe Token;状态变更操作,需隔离测试资源 | +| public | GET | `/v1/public/user/login_log` | `GetLoginLogRequest` | `GetLoginLogResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Login Log | +| public | GET | `/v1/public/user/subscribe_log` | `GetSubscribeLogRequest` | `GetSubscribeLogResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Subscribe Log | +| public | POST | `/v1/public/user/verify_email` | `VerifyEmailRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Verify Email;暂缓真实邮件发送,需 mock 邮件队列 | +| public | PUT | `/v1/public/user/bind_mobile` | `UpdateBindMobileRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update Bind Mobile | +| public | PUT | `/v1/public/user/bind_email` | `UpdateBindEmailRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Update Bind Email;暂缓真实邮件发送,需 mock 邮件队列 | +| public | GET | `/v1/public/user/devices` | `-` | `GetDeviceListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Device List | +| public | PUT | `/v1/public/user/unbind_device` | `UnbindDeviceRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Unbind Device | +| public | PUT | `/v1/public/user/subscribe_note` | `UpdateUserSubscribeNoteRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update User Subscribe Note | +| public | PUT | `/v1/public/user/rules` | `UpdateUserRulesRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update User Rules | +| public | POST | `/v1/public/user/commission_withdraw` | `CommissionWithdrawRequest` | `WithdrawalLog` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Commission Withdraw;提现路径影响资金数据,需专用测试账号 | +| public | POST | `/v1/public/user/withdrawal_cancel` | `CancelWithdrawalRequest` | `WithdrawalLog` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Cancel pending withdrawal;提现路径影响资金数据,需专用测试账号 | +| public | GET | `/v1/public/user/withdrawal_log` | `QueryWithdrawalLogListRequest` | `QueryWithdrawalLogListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query Withdrawal Log;提现路径影响资金数据,需专用测试账号 | +| public | GET | `/v1/public/user/device_online_statistics` | `-` | `GetDeviceOnlineStatsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Device Online Statistics | +| public | DELETE | `/v1/public/user/current_user_account` | `-` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete Current User Account;破坏性操作,需专用测试数据并校验回滚/幂等 | +| public | POST | `/v1/public/user/bind_email_with_verification` | `BindEmailWithVerificationRequest` | `BindEmailWithVerificationResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Bind Email With Verification;暂缓真实邮件发送,需 mock 邮件队列 | +| public | POST | `/v1/public/user/bind_invite_code` | `BindInviteCodeRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Bind Invite Code | +| public | POST | `/v1/public/user/delete_account` | `DeleteAccountRequest` | `DeleteAccountResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete Account;破坏性操作,需专用测试数据并校验回滚/幂等 | +| public | GET | `/v1/public/user/agent_downloads` | `GetAgentDownloadsRequest` | `GetAgentDownloadsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Agent Downloads | +| public | GET | `/v1/public/user/agent_realtime` | `GetAgentRealtimeRequest` | `GetAgentRealtimeResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Agent Realtime | +| public | GET | `/v1/public/user/invite_records` | `GetInviteRecordsRequest` | `GetInviteRecordsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Invite Records | +| public | GET | `/v1/public/user/invite_sales` | `GetInviteSalesRequest` | `GetInviteSalesResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Invite Sales | +| public | POST | `/v1/public/user/subscribe_status` | `GetSubscribeStatusRequest` | `GetSubscribeStatusResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Subscribe Status | +| public | GET | `/v1/public/user/invite_stats` | `GetUserInviteStatsRequest` | `GetUserInviteStatsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get User Invite Stats | +| public | GET | `/v1/public/user/traffic_stats` | `GetUserTrafficStatsRequest` | `GetUserTrafficStatsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get User Traffic Statistics | +| public | GET | `/v1/public/user/device_ws_connect` | `-` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Webosocket Device Connect | + +## admin Endpoints + +| Domain | Method | Path | Request Type | Response Type | Auth | Priority | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | +| admin | POST | `/v1/admin/ads` | `CreateAdsRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Create Ads | +| admin | PUT | `/v1/admin/ads` | `UpdateAdsRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update Ads | +| admin | DELETE | `/v1/admin/ads` | `DeleteAdsRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete Ads;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/ads/list` | `GetAdsListRequest` | `GetAdsListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get Ads List | +| admin | GET | `/v1/admin/ads/detail` | `GetAdsDetailRequest` | `Ads` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Ads Detail | +| admin | POST | `/v1/admin/announcement` | `CreateAnnouncementRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Create announcement | +| admin | PUT | `/v1/admin/announcement` | `UpdateAnnouncementRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update announcement | +| admin | GET | `/v1/admin/announcement/list` | `GetAnnouncementListRequest` | `GetAnnouncementListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get announcement list | +| admin | DELETE | `/v1/admin/announcement` | `DeleteAnnouncementRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete announcement;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/announcement/detail` | `GetAnnouncementRequest` | `Announcement` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get announcement | +| admin | POST | `/v1/admin/application` | `CreateSubscribeApplicationRequest` | `SubscribeApplication` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create subscribe application | +| admin | PUT | `/v1/admin/application/subscribe_application` | `UpdateSubscribeApplicationRequest` | `SubscribeApplication` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update subscribe application | +| admin | GET | `/v1/admin/application/subscribe_application_list` | `GetSubscribeApplicationListRequest` | `GetSubscribeApplicationListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get subscribe application list | +| admin | DELETE | `/v1/admin/application/subscribe_application` | `DeleteSubscribeApplicationRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete subscribe application;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/application/preview` | `PreviewSubscribeTemplateRequest` | `PreviewSubscribeTemplateResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Preview Template | +| admin | GET | `/v1/admin/auth-method/list` | `-` | `GetAuthMethodListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get auth method list | +| admin | GET | `/v1/admin/auth-method/config` | `GetAuthMethodConfigRequest` | `AuthMethodConfig` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get auth method config | +| admin | PUT | `/v1/admin/auth-method/config` | `UpdateAuthMethodConfigRequest` | `AuthMethodConfig` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update auth method config | +| admin | POST | `/v1/admin/auth-method/test_sms_send` | `TestSmsSendRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Test sms send;暂缓真实短信发送,需 mock 短信服务 | +| admin | POST | `/v1/admin/auth-method/test_email_send` | `TestEmailSendRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Test email send;暂缓真实邮件发送,需 mock 邮件队列 | +| admin | GET | `/v1/admin/auth-method/sms_platform` | `-` | `PlatformResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Get sms support platform;暂缓真实短信发送,需 mock 短信服务 | +| admin | GET | `/v1/admin/auth-method/email_platform` | `-` | `PlatformResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Get email support platform;暂缓真实邮件发送,需 mock 邮件队列 | +| admin | GET | `/v1/admin/console/server` | `-` | `ServerTotalDataResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query server total data | +| admin | GET | `/v1/admin/console/revenue` | `-` | `RevenueStatisticsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query revenue statistics | +| admin | GET | `/v1/admin/console/user` | `-` | `UserStatisticsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query user statistics | +| admin | GET | `/v1/admin/console/ticket` | `-` | `TicketWaitRelpyResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query ticket wait reply | +| admin | POST | `/v1/admin/coupon` | `CreateCouponRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Create coupon | +| admin | PUT | `/v1/admin/coupon` | `UpdateCouponRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update coupon | +| admin | DELETE | `/v1/admin/coupon` | `DeleteCouponRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete coupon;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | DELETE | `/v1/admin/coupon/batch` | `BatchDeleteCouponRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Batch delete coupon;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/coupon/list` | `GetCouponListRequest` | `GetCouponListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get coupon list | +| admin | POST | `/v1/admin/document` | `CreateDocumentRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Create document | +| admin | PUT | `/v1/admin/document` | `UpdateDocumentRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update document | +| admin | DELETE | `/v1/admin/document` | `DeleteDocumentRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete document;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | DELETE | `/v1/admin/document/batch` | `BatchDeleteDocumentRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Batch delete document;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/document/list` | `GetDocumentListRequest` | `GetDocumentListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get document list | +| admin | GET | `/v1/admin/document/detail` | `GetDocumentDetailRequest` | `Document` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get document detail | +| admin | GET | `/v1/admin/group/node/list` | `GetNodeGroupListRequest` | `GetNodeGroupListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get node group list | +| admin | POST | `/v1/admin/group/node` | `CreateNodeGroupRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create node group | +| admin | PUT | `/v1/admin/group/node` | `UpdateNodeGroupRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update node group | +| admin | DELETE | `/v1/admin/group/node` | `DeleteNodeGroupRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete node group;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/group/config` | `GetGroupConfigRequest` | `GetGroupConfigResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get group config | +| admin | PUT | `/v1/admin/group/config` | `UpdateGroupConfigRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update group config | +| admin | POST | `/v1/admin/group/recalculate` | `RecalculateGroupRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Recalculate group | +| admin | GET | `/v1/admin/group/recalculation/status` | `-` | `RecalculationState` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get recalculation status | +| admin | GET | `/v1/admin/group/history` | `GetGroupHistoryRequest` | `GetGroupHistoryResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get group history | +| admin | GET | `/v1/admin/group/export` | `ExportGroupResultRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Export group result | +| admin | GET | `/v1/admin/group/history/detail` | `GetGroupHistoryDetailRequest` | `GetGroupHistoryDetailResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get group history detail | +| admin | GET | `/v1/admin/group/preview` | `PreviewUserNodesRequest` | `PreviewUserNodesResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Preview user nodes | +| admin | POST | `/v1/admin/group/reset` | `ResetGroupsRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Reset all groups;状态变更操作,需隔离测试资源 | +| admin | GET | `/v1/admin/group/subscribe/mapping` | `GetSubscribeGroupMappingRequest` | `GetSubscribeGroupMappingResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get subscribe group mapping | +| admin | GET | `/v1/admin/invite/list` | `GetInviteManageListRequest` | `GetInviteManageListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get invite manage list | +| admin | GET | `/v1/admin/log/message/list` | `GetMessageLogListRequest` | `GetMessageLogListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get message log list | +| admin | GET | `/v1/admin/log/email/list` | `FilterLogParams` | `FilterEmailLogResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Filter email log;暂缓真实邮件发送,需 mock 邮件队列 | +| admin | GET | `/v1/admin/log/mobile/list` | `FilterLogParams` | `FilterMobileLogResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Filter mobile log | +| admin | GET | `/v1/admin/log/subscribe/list` | `FilterSubscribeLogRequest` | `FilterSubscribeLogResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Filter subscribe log | +| admin | GET | `/v1/admin/log/login/list` | `FilterLoginLogRequest` | `FilterLoginLogResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Filter login log | +| admin | GET | `/v1/admin/log/register/list` | `FilterRegisterLogRequest` | `FilterRegisterLogResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Filter register log | +| admin | GET | `/v1/admin/log/subscribe/reset/list` | `FilterResetSubscribeLogRequest` | `FilterResetSubscribeLogResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Filter reset subscribe log;状态变更操作,需隔离测试资源 | +| admin | GET | `/v1/admin/log/subscribe/traffic/list` | `FilterSubscribeTrafficRequest` | `FilterSubscribeTrafficResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Filter user subscribe traffic log | +| admin | GET | `/v1/admin/log/server/traffic/list` | `FilterServerTrafficLogRequest` | `FilterServerTrafficLogResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Filter server traffic log | +| admin | GET | `/v1/admin/log/balance/list` | `FilterBalanceLogRequest` | `FilterBalanceLogResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Filter balance log | +| admin | GET | `/v1/admin/log/commission/list` | `FilterCommissionLogRequest` | `FilterCommissionLogResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Filter commission log | +| admin | GET | `/v1/admin/log/order/refund/list` | `FilterOrderRefundLogRequest` | `FilterOrderRefundLogResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Filter order refund log;订单/支付路径需沙箱网关与测试订单隔离 | +| admin | GET | `/v1/admin/log/gift/list` | `FilterGiftLogRequest` | `FilterGiftLogResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Filter gift log | +| admin | GET | `/v1/admin/log/traffic/details` | `FilterTrafficLogDetailsRequest` | `FilterTrafficLogDetailsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Filter traffic log details | +| admin | GET | `/v1/admin/log/setting` | `-` | `LogSetting` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get log setting | +| admin | POST | `/v1/admin/log/setting` | `LogSetting` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update log setting | +| admin | GET | `/v1/admin/log/error_message/list` | `GetErrorLogMessageListRequest` | `GetErrorLogMessageListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get error log message list | +| admin | GET | `/v1/admin/log/error_message/detail` | `-` | `GetErrorLogMessageDetailResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get error log message detail | +| admin | GET | `/v1/admin/log/message/detail` | `GetLogMessageRawRequest` | `GetLogMessageRawResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get log message raw detail (temporary) | +| admin | POST | `/v1/admin/marketing/email/batch/send` | `CreateBatchSendEmailTaskRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Create a batch send email task;暂缓真实邮件发送,需 mock 邮件队列 | +| admin | GET | `/v1/admin/marketing/email/batch/list` | `GetBatchSendEmailTaskListRequest` | `GetBatchSendEmailTaskListResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Get batch send email task list;暂缓真实邮件发送,需 mock 邮件队列 | +| admin | POST | `/v1/admin/marketing/email/batch/stop` | `StopBatchSendEmailTaskRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Stop a batch send email task;暂缓真实邮件发送,需 mock 邮件队列;状态变更操作,需隔离测试资源 | +| admin | POST | `/v1/admin/marketing/email/batch/pre-send-count` | `GetPreSendEmailCountRequest` | `GetPreSendEmailCountResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Get pre-send email count;暂缓真实邮件发送,需 mock 邮件队列 | +| admin | POST | `/v1/admin/marketing/email/batch/status` | `GetBatchSendEmailTaskStatusRequest` | `GetBatchSendEmailTaskStatusResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Get batch send email task status;暂缓真实邮件发送,需 mock 邮件队列 | +| admin | POST | `/v1/admin/marketing/quota/create` | `CreateQuotaTaskRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Create a quota task | +| admin | POST | `/v1/admin/marketing/quota/pre-count` | `QueryQuotaTaskPreCountRequest` | `QueryQuotaTaskPreCountResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Query quota task pre-count | +| admin | GET | `/v1/admin/marketing/quota/list` | `QueryQuotaTaskListRequest` | `QueryQuotaTaskListResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Query quota task list | +| admin | POST | `/v1/admin/marketing/quota/status` | `QueryQuotaTaskStatusRequest` | `QueryQuotaTaskStatusResponse` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Query quota task status | +| admin | POST | `/v1/admin/order` | `CreateOrderRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create order;订单/支付路径需沙箱网关与测试订单隔离 | +| admin | GET | `/v1/admin/order/list` | `GetOrderListRequest` | `GetOrderListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get order list;订单/支付路径需沙箱网关与测试订单隔离 | +| admin | PUT | `/v1/admin/order/status` | `UpdateOrderStatusRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update order status;订单/支付路径需沙箱网关与测试订单隔离 | +| admin | POST | `/v1/admin/order/refund` | `RefundOrderRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Refund order;订单/支付路径需沙箱网关与测试订单隔离 | +| admin | POST | `/v1/admin/order/activate` | `ActivateOrderRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Manually activate order;订单/支付路径需沙箱网关与测试订单隔离 | +| admin | POST | `/v1/admin/payment` | `CreatePaymentMethodRequest` | `PaymentConfig` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Create Payment Method;订单/支付路径需沙箱网关与测试订单隔离 | +| admin | PUT | `/v1/admin/payment` | `UpdatePaymentMethodRequest` | `PaymentConfig` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update Payment Method;订单/支付路径需沙箱网关与测试订单隔离 | +| admin | DELETE | `/v1/admin/payment` | `DeletePaymentMethodRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete Payment Method;订单/支付路径需沙箱网关与测试订单隔离;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/payment/list` | `GetPaymentMethodListRequest` | `GetPaymentMethodListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get Payment Method List;订单/支付路径需沙箱网关与测试订单隔离 | +| admin | GET | `/v1/admin/payment/platform` | `-` | `PlatformResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get supported payment platform;订单/支付路径需沙箱网关与测试订单隔离 | +| admin | POST | `/v1/admin/promo/rule` | `CreatePromoRuleRequest` | `PromoRule` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Create promo rule | +| admin | GET | `/v1/admin/promo/rule/list` | `GetPromoRuleListRequest` | `GetPromoRuleListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get promo rule list | +| admin | GET | `/v1/admin/promo/rule/:id` | `GetPromoRuleDetailRequest` | `PromoRule` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get promo rule detail | +| admin | PUT | `/v1/admin/promo/rule/:id` | `UpdatePromoRuleRequest` | `PromoRule` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update promo rule | +| admin | DELETE | `/v1/admin/promo/rule/:id` | `DeletePromoRuleRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete promo rule;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | POST | `/v1/admin/promo/price` | `SetPromoPriceRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Set promo prices | +| admin | GET | `/v1/admin/promo/price/list` | `GetPromoPriceListRequest` | `GetPromoPriceListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get promo price list | +| admin | DELETE | `/v1/admin/promo/price/:id` | `DeletePromoPriceRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete promo price;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/promo/usage/list` | `GetPromoUsageListRequest` | `GetPromoUsageListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get promo usage list | +| admin | POST | `/v1/admin/redemption/code` | `CreateRedemptionCodeRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Create redemption code | +| admin | PUT | `/v1/admin/redemption/code` | `UpdateRedemptionCodeRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update redemption code | +| admin | PUT | `/v1/admin/redemption/code/status` | `ToggleRedemptionCodeStatusRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Toggle redemption code status | +| admin | DELETE | `/v1/admin/redemption/code` | `DeleteRedemptionCodeRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete redemption code;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | DELETE | `/v1/admin/redemption/code/batch` | `BatchDeleteRedemptionCodeRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Batch delete redemption code;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/redemption/code/list` | `GetRedemptionCodeListRequest` | `GetRedemptionCodeListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get redemption code list | +| admin | GET | `/v1/admin/redemption/record/list` | `GetRedemptionRecordListRequest` | `GetRedemptionRecordListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get redemption record list | +| admin | POST | `/v1/admin/server/create` | `CreateServerRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create Server | +| admin | POST | `/v1/admin/server/update` | `UpdateServerRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update Server | +| admin | POST | `/v1/admin/server/delete` | `DeleteServerRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete Server;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/server/list` | `FilterServerListRequest` | `FilterServerListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Filter Server List | +| admin | GET | `/v1/admin/server/protocols` | `GetServerProtocolsRequest` | `GetServerProtocolsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Server Protocols | +| admin | POST | `/v1/admin/server/node/create` | `CreateNodeRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create Node | +| admin | POST | `/v1/admin/server/node/update` | `UpdateNodeRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update Node | +| admin | POST | `/v1/admin/server/node/delete` | `DeleteNodeRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete Node;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/server/node/list` | `FilterNodeListRequest` | `FilterNodeListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Filter Node List | +| admin | POST | `/v1/admin/server/node/status/toggle` | `ToggleNodeStatusRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Toggle Node Status | +| admin | POST | `/v1/admin/server/server/sort` | `ResetSortRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Reset server sort;状态变更操作,需隔离测试资源 | +| admin | POST | `/v1/admin/server/node/sort` | `ResetSortRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Reset node sort;状态变更操作,需隔离测试资源 | +| admin | GET | `/v1/admin/server/node/tags` | `-` | `QueryNodeTagResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Query all node tags | +| admin | POST | `/v1/admin/subscribe/group` | `CreateSubscribeGroupRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create subscribe group | +| admin | PUT | `/v1/admin/subscribe/group` | `UpdateSubscribeGroupRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update subscribe group | +| admin | GET | `/v1/admin/subscribe/group/list` | `-` | `GetSubscribeGroupListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get subscribe group list | +| admin | DELETE | `/v1/admin/subscribe/group` | `DeleteSubscribeGroupRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete subscribe group;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | DELETE | `/v1/admin/subscribe/group/batch` | `BatchDeleteSubscribeGroupRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Batch delete subscribe group;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | POST | `/v1/admin/subscribe` | `CreateSubscribeRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create subscribe | +| admin | PUT | `/v1/admin/subscribe` | `UpdateSubscribeRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update subscribe | +| admin | GET | `/v1/admin/subscribe/list` | `GetSubscribeListRequest` | `GetSubscribeListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get subscribe list | +| admin | DELETE | `/v1/admin/subscribe` | `DeleteSubscribeRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete subscribe;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | DELETE | `/v1/admin/subscribe/batch` | `BatchDeleteSubscribeRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Batch delete subscribe;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/subscribe/details` | `GetSubscribeDetailsRequest` | `Subscribe` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get subscribe details | +| admin | POST | `/v1/admin/subscribe/sort` | `SubscribeSortRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Subscribe sort | +| admin | POST | `/v1/admin/subscribe/reset_all_token` | `-` | `ResetAllSubscribeTokenResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Reset all subscribe tokens;状态变更操作,需隔离测试资源 | +| admin | GET | `/v1/admin/system/site_config` | `-` | `SiteConfig` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get site config | +| admin | PUT | `/v1/admin/system/site_config` | `SiteConfig` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update site config | +| admin | GET | `/v1/admin/system/subscribe_config` | `-` | `SubscribeConfig` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get subscribe config | +| admin | PUT | `/v1/admin/system/subscribe_config` | `SubscribeConfig` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update subscribe config | +| admin | GET | `/v1/admin/system/register_config` | `-` | `RegisterConfig` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get register config | +| admin | PUT | `/v1/admin/system/register_config` | `RegisterConfig` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update register config | +| admin | GET | `/v1/admin/system/verify_config` | `-` | `VerifyConfig` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get verify config | +| admin | PUT | `/v1/admin/system/verify_config` | `VerifyConfig` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update verify config | +| admin | GET | `/v1/admin/system/node_config` | `-` | `NodeConfig` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get node config | +| admin | PUT | `/v1/admin/system/node_config` | `NodeConfig` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update node config | +| admin | GET | `/v1/admin/system/invite_config` | `-` | `InviteConfig` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get invite config | +| admin | PUT | `/v1/admin/system/invite_config` | `InviteConfig` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update invite config | +| admin | GET | `/v1/admin/system/tos_config` | `-` | `TosConfig` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get Team of Service Config | +| admin | PUT | `/v1/admin/system/tos_config` | `TosConfig` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update Team of Service Config | +| admin | GET | `/v1/admin/system/privacy` | `-` | `PrivacyPolicyConfig` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: get Privacy Policy Config | +| admin | PUT | `/v1/admin/system/privacy` | `PrivacyPolicyConfig` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update Privacy Policy Config | +| admin | GET | `/v1/admin/system/currency_config` | `-` | `CurrencyConfig` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get Currency Config | +| admin | PUT | `/v1/admin/system/currency_config` | `CurrencyConfig` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update Currency Config | +| admin | POST | `/v1/admin/system/setting_telegram_bot` | `-` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: setting telegram bot;外部 OAuth/Telegram 依赖需 mock provider | +| admin | GET | `/v1/admin/system/get_node_multiplier` | `-` | `GetNodeMultiplierResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Node Multiplier | +| admin | POST | `/v1/admin/system/set_node_multiplier` | `SetNodeMultiplierRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Set Node Multiplier | +| admin | GET | `/v1/admin/system/verify_code_config` | `-` | `VerifyCodeConfig` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get Verify Code Config | +| admin | PUT | `/v1/admin/system/verify_code_config` | `VerifyCodeConfig` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update Verify Code Config | +| admin | GET | `/v1/admin/system/signature_config` | `-` | `SignatureConfig` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get Signature Config | +| admin | PUT | `/v1/admin/system/signature_config` | `SignatureConfig` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update Signature Config | +| admin | GET | `/v1/admin/system/node_multiplier/preview` | `-` | `PreViewNodeMultiplierResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: PreView Node Multiplier | +| admin | GET | `/v1/admin/system/module` | `-` | `ModuleConfig` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get Module Config | +| admin | GET | `/v1/admin/ticket/list` | `GetTicketListRequest` | `GetTicketListResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get ticket list | +| admin | GET | `/v1/admin/ticket/detail` | `GetTicketRequest` | `Ticket` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get ticket detail | +| admin | PUT | `/v1/admin/ticket` | `UpdateTicketStatusRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Update ticket status | +| admin | POST | `/v1/admin/ticket/follow` | `CreateTicketFollowRequest` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Create ticket follow | +| admin | GET | `/v1/admin/tool/log` | `-` | `LogResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get System Log | +| admin | GET | `/v1/admin/tool/restart` | `-` | `-` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Restart System | +| admin | GET | `/v1/admin/tool/version` | `-` | `VersionResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Get Version | +| admin | GET | `/v1/admin/tool/ip/location` | `QueryIPLocationRequest` | `QueryIPLocationResponse` | 是(AuthMiddleware) | P1 | 中间件: AuthMiddleware;doc: Query IP Location | +| admin | GET | `/v1/admin/user/list` | `GetUserListRequest` | `GetUserListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get user list | +| admin | GET | `/v1/admin/user/detail` | `GetDetailRequest` | `User` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get user detail | +| admin | PUT | `/v1/admin/user/basic` | `UpdateUserBasiceInfoRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update user basic info | +| admin | PUT | `/v1/admin/user/notify` | `UpdateUserNotifySettingRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Update user notify setting | +| admin | DELETE | `/v1/admin/user` | `GetDetailRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete user;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/user/current` | `-` | `User` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Current user | +| admin | DELETE | `/v1/admin/user/batch` | `BatchDeleteUserRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Batch delete user;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | POST | `/v1/admin/user` | `CreateUserRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create user | +| admin | PUT | `/v1/admin/user/device` | `UserDevice` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: User device | +| admin | DELETE | `/v1/admin/user/device` | `DeleteUserDeivceRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete user device;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | PUT | `/v1/admin/user/device/kick_offline` | `KickOfflineRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: kick offline user device;状态变更操作,需隔离测试资源 | +| admin | POST | `/v1/admin/user/auth_method` | `CreateUserAuthMethodRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create user auth method | +| admin | DELETE | `/v1/admin/user/auth_method` | `DeleteUserAuthMethodRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete user auth method;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | PUT | `/v1/admin/user/auth_method` | `UpdateUserAuthMethodRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update user auth method | +| admin | GET | `/v1/admin/user/auth_method` | `GetUserAuthMethodRequest` | `GetUserAuthMethodResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get user auth method | +| admin | GET | `/v1/admin/user/subscribe` | `GetUserSubscribeListRequest` | `GetUserSubscribeListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get user subcribe | +| admin | GET | `/v1/admin/user/subscribe/detail` | `GetUserSubscribeByIdRequest` | `UserSubscribeDetail` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get user subcribe by id | +| admin | GET | `/v1/admin/user/subscribe/logs` | `GetUserSubscribeLogsRequest` | `GetUserSubscribeLogsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get user subcribe logs | +| admin | GET | `/v1/admin/user/subscribe/reset/logs` | `GetUserSubscribeResetTrafficLogsRequest` | `GetUserSubscribeResetTrafficLogsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get user subcribe reset traffic logs;状态变更操作,需隔离测试资源 | +| admin | GET | `/v1/admin/user/subscribe/traffic_logs` | `GetUserSubscribeTrafficLogsRequest` | `GetUserSubscribeTrafficLogsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get user subcribe traffic logs | +| admin | GET | `/v1/admin/user/subscribe/device` | `GetUserSubscribeDevicesRequest` | `GetUserSubscribeDevicesResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get user subcribe devices | +| admin | POST | `/v1/admin/user/subscribe` | `CreateUserSubscribeRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Create user subcribe | +| admin | PUT | `/v1/admin/user/subscribe` | `UpdateUserSubscribeRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update user subcribe | +| admin | DELETE | `/v1/admin/user/subscribe` | `DeleteUserSubscribeRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Delete user subcribe;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | GET | `/v1/admin/user/login/logs` | `GetUserLoginLogsRequest` | `GetUserLoginLogsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get user login logs | +| admin | POST | `/v1/admin/user/subscribe/reset/token` | `ResetUserSubscribeTokenRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Reset user subscribe token;状态变更操作,需隔离测试资源 | +| admin | POST | `/v1/admin/user/subscribe/toggle` | `ToggleUserSubscribeStatusRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Stop user subscribe | +| admin | POST | `/v1/admin/user/subscribe/reset/traffic` | `ResetUserSubscribeTrafficRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Reset user subscribe traffic;状态变更操作,需隔离测试资源 | +| admin | GET | `/v1/admin/user/family/list` | `GetFamilyListRequest` | `GetFamilyListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get family list | +| admin | GET | `/v1/admin/user/family/detail` | `GetFamilyDetailRequest` | `FamilyDetail` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get family detail | +| admin | PUT | `/v1/admin/user/family/max_members` | `UpdateFamilyMaxMembersRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Update family max members | +| admin | PUT | `/v1/admin/user/family/member/remove` | `RemoveFamilyMemberRequest` | `-` | 是(AuthMiddleware) | P2 | 中间件: AuthMiddleware;doc: Remove family member;破坏性操作,需专用测试数据并校验回滚/幂等 | +| admin | PUT | `/v1/admin/user/family/dissolve` | `DissolveFamilyRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Dissolve family | +| admin | GET | `/v1/admin/user/withdrawal/list` | `GetWithdrawalListRequest` | `GetWithdrawalListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get withdrawal list;提现路径影响资金数据,需专用测试账号 | +| admin | POST | `/v1/admin/user/withdrawal/approve` | `ApproveWithdrawalRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Approve withdrawal;提现路径影响资金数据,需专用测试账号 | +| admin | POST | `/v1/admin/user/withdrawal/reject` | `RejectWithdrawalRequest` | `-` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Reject withdrawal;提现路径影响资金数据,需专用测试账号 | +| admin | GET | `/v1/admin/user/invite/stats` | `GetAdminUserInviteStatsRequest` | `GetAdminUserInviteStatsResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get admin user invite stats | +| admin | GET | `/v1/admin/user/invite/list` | `GetAdminUserInviteListRequest` | `GetAdminUserInviteListResponse` | 是(AuthMiddleware) | P0 | 中间件: AuthMiddleware;doc: Get admin user invite list | + +## node Endpoints + +| Domain | Method | Path | Request Type | Response Type | Auth | Priority | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | +| node | GET | `/v1/server/user` | `GetServerUserListRequest` | `GetServerUserListResponse` | 是(ServerMiddleware/secret_key) | P0 | 中间件: ServerMiddleware;doc: Get user list;节点接口依赖 server_id/secret_key 测试夹具 | +| node | POST | `/v1/server/push` | `ServerPushUserTrafficRequest` | `-` | 是(ServerMiddleware/secret_key) | P0 | 中间件: ServerMiddleware;doc: Push user Traffic;节点接口依赖 server_id/secret_key 测试夹具 | +| node | POST | `/v1/server/status` | `ServerPushStatusRequest` | `-` | 是(ServerMiddleware/secret_key) | P0 | 中间件: ServerMiddleware;doc: Push server status;节点接口依赖 server_id/secret_key 测试夹具 | +| node | GET | `/v1/server/config` | `GetServerConfigRequest` | `GetServerConfigResponse` | 是(ServerMiddleware/secret_key) | P0 | 中间件: ServerMiddleware;doc: Get server config;节点接口依赖 server_id/secret_key 测试夹具 | +| node | POST | `/v1/server/online` | `OnlineUsersRequest` | `-` | 是(ServerMiddleware/secret_key) | P0 | 中间件: ServerMiddleware;doc: Push online users;节点接口依赖 server_id/secret_key 测试夹具 | +| node | GET | `/v2/server/:server_id` | `QueryServerConfigRequest` | `QueryServerConfigResponse` | 否 | P0 | doc: Get Server Protocol Config;节点接口依赖 server_id/secret_key 测试夹具 | + diff --git a/tests/acceptance/reporter.go b/tests/acceptance/reporter.go new file mode 100644 index 0000000..7d92788 --- /dev/null +++ b/tests/acceptance/reporter.go @@ -0,0 +1,64 @@ +package acceptance + +import ( + "encoding/json" + "os" + "sync" + "testing" + "time" +) + +type Reporter struct { + mu sync.Mutex + started time.Time + cases []ReportCase +} + +type ReportCase struct { + Name string `json:"name"` + Domain string `json:"domain"` + Method string `json:"method"` + Path string `json:"path"` + Expected string `json:"expected"` +} + +type Report struct { + RunID string `json:"run_id"` + BaseURL string `json:"base_url"` + Duration string `json:"duration"` + Cases []ReportCase `json:"cases"` +} + +var acceptanceReporter = &Reporter{started: time.Now()} + +func recordCase(t *testing.T, domain string, method string, path string, expected string) { + t.Helper() + acceptanceReporter.mu.Lock() + defer acceptanceReporter.mu.Unlock() + acceptanceReporter.cases = append(acceptanceReporter.cases, ReportCase{ + Name: t.Name(), + Domain: domain, + Method: method, + Path: path, + Expected: expected, + }) +} + +func writeReport(c Config) error { + if c.ReportPath == "" { + return nil + } + acceptanceReporter.mu.Lock() + defer acceptanceReporter.mu.Unlock() + report := Report{ + RunID: c.RunID, + BaseURL: c.StagingURL, + Duration: time.Since(acceptanceReporter.started).String(), + Cases: append([]ReportCase(nil), acceptanceReporter.cases...), + } + out, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + return os.WriteFile(c.ReportPath, out, 0o600) +} diff --git a/user.json b/user.json index 0c076cf..0148114 100644 --- a/user.json +++ b/user.json @@ -1183,15 +1183,15 @@ ] } }, - "/v1/public/user/invite_sales": { + "/v1/public/user/invite_records": { "get": { - "summary": "Get Invite Sales", - "operationId": "GetInviteSales", + "summary": "Get Invite Records", + "operationId": "GetInviteRecords", "responses": { "200": { "description": "A successful response.", "schema": { - "$ref": "#/definitions/GetInviteSalesResponse" + "$ref": "#/definitions/GetInviteRecordsResponse" } } }, @@ -1678,6 +1678,16 @@ "required": true, "type": "integer", "format": "int32" + }, + { + "name": "biz_type", + "in": "query", + "required": false, + "type": "string", + "enum": [ + "withdrawal", + "commission_refund" + ] } ], "tags": [ @@ -3166,7 +3176,7 @@ "connection_records" ] }, - "GetInviteSalesRequest": { + "GetInviteRecordsRequest": { "type": "object", "properties": { "page": { @@ -3186,7 +3196,7 @@ "format": "int64" } }, - "title": "GetInviteSalesRequest", + "title": "GetInviteRecordsRequest", "required": [ "page", "size", @@ -3194,7 +3204,7 @@ "end_time" ] }, - "GetInviteSalesResponse": { + "GetInviteRecordsResponse": { "type": "object", "properties": { "total": { @@ -3204,11 +3214,11 @@ "list": { "type": "array", "items": { - "$ref": "#/definitions/InvitedUserSale" + "$ref": "#/definitions/InviteRecord" } } }, - "title": "GetInviteSalesResponse", + "title": "GetInviteRecordsResponse", "required": [ "total", "list" @@ -3558,30 +3568,34 @@ "gift_days" ] }, - "InvitedUserSale": { + "InviteRecord": { "type": "object", "properties": { - "amount": { - "type": "number", - "format": "double" + "role": { + "type": "string" }, - "updated_at": { + "peer_hash": { + "type": "string" + }, + "gift_days": { "type": "integer", "format": "int64" }, - "user_hash": { + "order_no": { "type": "string" }, - "product_name": { - "type": "string" + "created_at": { + "type": "integer", + "format": "int64" } }, - "title": "InvitedUserSale", + "title": "InviteRecord", "required": [ - "amount", - "updated_at", - "user_hash", - "product_name" + "role", + "peer_hash", + "gift_days", + "order_no", + "created_at" ] }, "MessageLog": { @@ -5117,6 +5131,13 @@ "size": { "type": "integer", "format": "int32" + }, + "biz_type": { + "type": "string", + "enum": [ + "withdrawal", + "commission_refund" + ] } }, "title": "QueryWithdrawalLogListRequest", @@ -5900,6 +5921,9 @@ "discount": { "type": "number", "format": "double" + }, + "promo": { + "$ref": "#/definitions/SubscribePromo" } }, "title": "SubscribeDiscount", @@ -7161,11 +7185,15 @@ "updated_at": { "type": "integer", "format": "int64" + }, + "biz_type": { + "type": "string" } }, "title": "WithdrawalLog", "required": [ "id", + "biz_type", "user_id", "amount", "content", diff --git a/迁移到命令.txt b/迁移到命令.txt deleted file mode 100644 index 780fb22..0000000 --- a/迁移到命令.txt +++ /dev/null @@ -1,44 +0,0 @@ -导出数据库: -mysqldump --socket=/var/run/mysqld/mysqld.sock -uroot -p --single-transaction --routines --triggers --events --set-gtid-purged=OFF --source-data=2 --no-tablespaces hifast > /root/data/hifast-final-0515.sql - -jpcV41ppanel - -gzip -1 /root/hifast-final-0515.sql - - - - - -导出redis: -redis-cli -a '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr' BGSAVE -sleep 5 -cp /var/lib/redis/dump.rdb /root/data/redis-backup-0515.rdb - - -导入数据库: -gunzip -c /root/hifast-final-0515.sql.gz | docker exec -i ppanel-mysql mysql -uroot -p'jpcV41ppanel' hifast - - -导入redis: -docker stop ppanel-redis -docker cp /root/data/redis-backup-0515.rdb ppanel-redis:/data/dump.rdb -docker start ppanel-redis -docker exec ppanel-redis redis-cli -a 'hifast67yj' DBSIZE - - - -docker stop ppanel-redis || true -docker rm -f ppanel-redis - -rm -f /root/data/dump.rdb -rm -rf /root/data/appendonlydir -rm -f /root/data/appendonly.aof* -mkdir -p /root/data - -docker pull redis:8.6.3 -docker run -d \ - --name ppanel-redis \ - --restart always \ - -p 6379:6379 \ - redis:8.6.3 \ - redis-server --requirepass 'hifast67yj'