[
  {
    "path": ".air.toml",
    "content": "root = \".\"\ntestdata_dir = \"testdata\"\ntmp_dir = \"tmp\"\n\n[build]\n  args_bin = [\"web\", \"-e\", \"dev\"]\n  bin = \"./tmp/gocron\"\n  cmd = \"go build -o ./tmp/gocron ./cmd/gocron\"\n  delay = 2000\n  exclude_dir = [\"assets\", \"tmp\", \"vendor\", \"testdata\", \"web/vue/node_modules\", \"web/public\", \"web/vue/dist\"]\n  exclude_file = []\n  exclude_regex = [\"_test.go\"]\n  exclude_unchanged = false\n  follow_symlink = false\n  full_bin = \"\"\n  include_dir = []\n  include_ext = [\"go\", \"tpl\", \"tmpl\", \"html\"]\n  include_file = []\n  kill_delay = \"5s\"\n  log = \"build-errors.log\"\n  poll = false\n  poll_interval = 0\n  rerun = false\n  rerun_delay = 2000\n  send_interrupt = true\n  stop_on_error = true\n\n[color]\n  app = \"\"\n  build = \"yellow\"\n  main = \"magenta\"\n  runner = \"green\"\n  watcher = \"cyan\"\n\n[log]\n  main_only = false\n  time = false\n\n[misc]\n  clean_on_exit = false\n\n[screen]\n  clear_on_rebuild = false\n  keep_scroll = true\n"
  },
  {
    "path": ".dockerignore",
    "content": ".git\n.github\nweb/vue/node_modules\nweb/vue/dist\nbin\ngocron-package\ngocron-node-package\n*.md\n.gitignore\n.gitattributes\n.dockerignore\n"
  },
  {
    "path": ".gitattributes",
    "content": "*.js linguist-language=go\n*.css linguist-language=go\n*.html linguist-language=go\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: gomod\n    directory: /\n    schedule:\n      interval: weekly\n    groups:\n      go-deps:\n        patterns: [\"*\"]\n\n  - package-ecosystem: npm\n    directory: /web/vue\n    schedule:\n      interval: weekly\n    groups:\n      npm-deps:\n        patterns: [\"*\"]\n\n  - package-ecosystem: npm\n    directory: /\n    schedule:\n      interval: weekly\n    groups:\n      npm-dev-deps:\n        patterns: [\"*\"]\n\n  - package-ecosystem: docker\n    directory: /\n    schedule:\n      interval: monthly\n\n  - package-ecosystem: github-actions\n    directory: /\n    schedule:\n      interval: monthly\n    ignore:\n      - dependency-name: \"actions/*\"\n        update-types: [\"version-update:semver-major\"]\n      - dependency-name: \"pnpm/*\"\n        update-types: [\"version-update:semver-major\"]\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [master]\n  pull_request:\n    branches: [master]\n\npermissions:\n  contents: read\n\njobs:\n  go-test:\n    name: Go Test\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-go@v5\n        with:\n          go-version-file: go.mod\n\n      - name: Create frontend dist placeholder\n        run: mkdir -p web/vue/dist && touch web/vue/dist/.gitkeep\n\n      - name: Format check\n        run: |\n          unformatted=$(gofmt -l .)\n          if [ -n \"$unformatted\" ]; then\n            echo \"::error::Unformatted files:\" && echo \"$unformatted\" && exit 1\n          fi\n\n      - name: Vet\n        run: go vet ./...\n\n      - name: Test\n        run: go test -race -coverprofile=coverage.out ./...\n\n      - name: Upload coverage\n        if: github.event_name == 'push'\n        uses: actions/upload-artifact@v4\n        with:\n          name: coverage\n          path: coverage.out\n\n  frontend-build:\n    name: Frontend Build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: pnpm/action-setup@v4\n        with:\n          version: 10\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: pnpm\n          cache-dependency-path: web/vue/pnpm-lock.yaml\n\n      - name: Install & Build\n        working-directory: web/vue\n        run: |\n          pnpm install --frozen-lockfile\n          pnpm build\n\n      - name: Lint\n        working-directory: web/vue\n        run: pnpm lint:check\n\n  docker-build:\n    name: Docker Build\n    runs-on: ubuntu-latest\n    needs: [go-test, frontend-build]\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Build image\n        run: docker build -f Dockerfile.gocron -t gocron:ci .\n"
  },
  {
    "path": ".github/workflows/helm-release.yml",
    "content": "name: Release Helm Chart\n\non:\n  push:\n    branches:\n      - master\n    paths:\n      - \"helm/**\"\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Configure Git\n        run: |\n          git config user.name \"$GITHUB_ACTOR\"\n          git config user.email \"$GITHUB_ACTOR@users.noreply.github.com\"\n\n      - uses: azure/setup-helm@v4\n\n      - uses: helm/chart-releaser-action@v1.7.0\n        with:\n          charts_dir: helm\n        env:\n          CR_TOKEN: \"${{ secrets.GITHUB_TOKEN }}\"\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - \"v*\"\n\npermissions:\n  contents: write\n\njobs:\n  frontend:\n    name: Build Frontend\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: pnpm/action-setup@v4\n        with:\n          version: 10\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: pnpm\n          cache-dependency-path: web/vue/pnpm-lock.yaml\n\n      - name: Install & Build\n        working-directory: web/vue\n        run: |\n          pnpm install --frozen-lockfile\n          pnpm build\n\n      - uses: actions/upload-artifact@v4\n        with:\n          name: frontend-dist\n          path: web/vue/dist/\n\n  release:\n    name: GoReleaser\n    needs: frontend\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: actions/setup-go@v5\n        with:\n          go-version-file: go.mod\n\n      - uses: actions/download-artifact@v4\n        with:\n          name: frontend-dist\n          path: web/vue/dist/\n\n      - uses: goreleaser/goreleaser-action@v6\n        with:\n          version: latest\n          args: release --clean\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Compiled Object files, Static and Dynamic libs (Shared Objects)\n*.o\n*.a\n*.so\n\n# Folders\n_obj\n_test\n\n# Architecture specific extensions/prefixes\n*.[568vq]\n[568vq].out\n\n*.cgo1.go\n*.cgo2.c\n_cgo_defun.c\n_cgo_gotypes.go\n_cgo_export.*\n\n_testmain.go\n\n*.exe\n*.test\n*.prof\n\n.DS_Store\n.idea\nlog\ndata\nconf\nprofile/*\n/gocron\n/gocron-node\n/bin\n/web/public/static\n/web/public/index.html\n/gocron-package\n/gocron-node-package\n/dist\n\nnode_modules\ntmp/\nbuild-errors.log\n.gocache\n.gstack/\n.design-review/\n"
  },
  {
    "path": ".goreleaser.yml",
    "content": "version: 2\n\nbefore:\n  hooks:\n    - go mod tidy\n\nbuilds:\n  - id: gocron\n    main: ./cmd/gocron\n    binary: gocron\n    env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n      - darwin\n      - windows\n    goarch:\n      - amd64\n      - arm64\n    ignore:\n      - goos: windows\n        goarch: arm64\n    ldflags:\n      - -s -w\n      - -X 'main.AppVersion={{ .Version }}'\n      - -X 'main.BuildDate={{ .Date }}'\n      - -X 'main.GitCommit={{ .ShortCommit }}'\n\n  - id: gocron-node\n    main: ./cmd/node\n    binary: gocron-node\n    env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n      - darwin\n      - windows\n    goarch:\n      - amd64\n      - arm64\n    ignore:\n      - goos: windows\n        goarch: arm64\n    ldflags:\n      - -s -w\n      - -X 'main.AppVersion={{ .Version }}'\n      - -X 'main.BuildDate={{ .Date }}'\n      - -X 'main.GitCommit={{ .ShortCommit }}'\n\narchives:\n  - id: gocron\n    ids:\n      - gocron\n    name_template: \"gocron-{{ .Version }}-{{ .Os }}-{{ .Arch }}\"\n    formats:\n      - tar.gz\n    format_overrides:\n      - goos: windows\n        formats:\n          - zip\n\n  - id: gocron-node\n    ids:\n      - gocron-node\n    name_template: \"gocron-node-{{ .Os }}-{{ .Arch }}\"\n    formats:\n      - tar.gz\n    format_overrides:\n      - goos: windows\n        formats:\n          - zip\n\nchecksum:\n  name_template: \"checksums.txt\"\n\nchangelog:\n  sort: asc\n  filters:\n    exclude:\n      - \"^docs:\"\n      - \"^style:\"\n      - \"^chore\\\\(deps\\\\):\"\n  groups:\n    - title: \"New Features\"\n      regexp: '^feat'\n    - title: \"Bug Fixes\"\n      regexp: '^fix'\n    - title: \"Performance\"\n      regexp: '^perf'\n    - title: \"Others\"\n      order: 999\n\nrelease:\n  github:\n    owner: gocronx-team\n    name: gocron\n  draft: false\n  prerelease: auto\n"
  },
  {
    "path": ".husky/commit-msg",
    "content": "npx --no -- commitlint --edit $1\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "pnpm run lint:lint-staged\n"
  },
  {
    "path": ".prettierignore",
    "content": "node_modules\ndist\nbuild\n.git\n.gocache\n.gocron\nbin\ndata\nlog\ntmp\n*.min.js\n*.min.css\nvendor\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"printWidth\": 100,\n  \"trailingComma\": \"none\",\n  \"arrowParens\": \"avoid\",\n  \"endOfLine\": \"lf\"\n}\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# gocron\n\n## Project Overview\n\nA lightweight, distributed scheduled task management system written in Go with a Vue.js web interface.\n\n## Tech Stack\n\n- **Backend:** Go 1.26, Gin, GORM (MySQL/PostgreSQL/SQLite)\n- **Frontend:** Vue 3 (Options API), Element Plus, vue-i18n, Vite, pnpm\n- **RPC:** gRPC + Protocol Buffers\n- **Auth:** JWT + TOTP 2FA\n\n## Development\n\n```bash\n# Backend (with hot reload)\nair\n\n# Frontend dev server\ncd web/vue && pnpm dev\n\n# Build frontend\ncd web/vue && pnpm build\n\n# Run tests\ngo test ./...\n\n# Build\ngo build ./...\n```\n\n## Project Structure\n\n```\ncmd/gocron/          - Main entry point\ninternal/\n  models/            - GORM data models\n  routers/           - Gin HTTP handlers (grouped by domain)\n  service/           - Business logic (scheduler, execution)\n  modules/           - Utilities (logger, i18n, notify, RPC)\nweb/vue/             - Vue.js frontend\n  src/api/           - API client services\n  src/pages/         - Page components\n  src/components/    - Shared components\n  src/locales/       - i18n (zh-CN, en-US)\n  src/router/        - Vue Router config\n  src/stores/        - Pinia stores\n```\n\n## Conventions\n\n- Commit messages follow Conventional Commits: `feat:`, `fix:`, `chore:`, `refactor:`, `style:`, `test:`\n- Do not add `Co-Authored-By` lines in commit messages\n- Backend i18n: `internal/modules/i18n/zh_cn.go` and `en_us.go`\n- Frontend i18n: `web/vue/src/locales/zh-CN.js` and `en-US.js`\n- Database migrations: `internal/models/migration.go` (sequential version IDs)\n"
  },
  {
    "path": "Dockerfile.gocron",
    "content": "# Frontend build stage\nFROM node:20-alpine AS frontend\n\nRUN corepack enable && corepack prepare pnpm@latest --activate\n\nWORKDIR /app/web/vue\nCOPY web/vue/package.json web/vue/pnpm-lock.yaml ./\nRUN pnpm install --frozen-lockfile\n\nCOPY web/vue ./\nRUN pnpm build\n\n# Backend build stage\nFROM golang:1.26-alpine AS builder\n\nRUN apk add --no-cache git\n\nWORKDIR /app\nCOPY go.mod go.sum ./\nRUN go mod download\n\n# Copy frontend build files first\nCOPY --from=frontend /app/web/vue/dist ./web/vue/dist\n\n# Then copy the rest of the project\nCOPY . .\n\n# Build with pure Go SQLite (no CGO required)\nRUN CGO_ENABLED=0 go build -ldflags=\"-s -w\" -trimpath -o gocron ./cmd/gocron\n\nFROM alpine:latest\n\nRUN apk add --no-cache ca-certificates tzdata\n\nCOPY --from=builder /app/gocron /gocron\n\nEXPOSE 5920\n\nENTRYPOINT [\"/gocron\", \"web\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 gocronx team\nCopyright (c) 2017 qiang.ou\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# gocron - Distributed scheduled Task Scheduler\n\n[![Release](https://img.shields.io/github/release/gocronx-team/gocron.svg?label=Release)](https://github.com/gocronx-team/gocron/releases) [![Downloads](https://img.shields.io/github/downloads/gocronx-team/gocron/total.svg)](https://github.com/gocronx-team/gocron/releases) [![License](https://img.shields.io/github/license/gocronx-team/gocron.svg)](https://github.com/gocronx-team/gocron/blob/master/LICENSE)\n\nEnglish | [简体中文](README_ZH.md)\n\nA lightweight distributed scheduled task management system developed in Go, designed to replace Linux-crontab.\n\n## 📖 Documentation\n\nFull documentation is available at: **[document](https://gocron-docs.pages.dev/en/)**\n\n- 🚀 [Quick Start](https://gocron-docs.pages.dev/en/guide/quick-start) - Installation and deployment guide\n- 🤖 [Agent Auto-Registration](https://gocron-docs.pages.dev/en/guide/agent-registration) - One-click task node deployment\n- ⚙️ [Configuration](https://gocron-docs.pages.dev/en/guide/configuration) - Detailed configuration guide\n- 🔌 [API Documentation](https://gocron-docs.pages.dev/en/guide/api) - API reference\n\n## ✨ Features\n\n- **Web Interface**: Intuitive task management interface\n- **Second-level Precision**: Supports Crontab expressions with second precision\n- **High Availability**: Database-lock-based leader election, automatic failover in seconds\n- **Task Retry**: Configurable retry policies for failed tasks\n- **Task Dependency**: Supports task dependency configuration\n- **Access Control**: Comprehensive user and permission management\n- **2FA Security**: Two-Factor Authentication support\n- **Agent Auto-Registration**: One-click installation for Linux/macOS\n- **Multi-Database**: MySQL / PostgreSQL / SQLite support\n- **Log Management**: Complete execution logs with auto-cleanup\n- **Notifications**: Email, Slack, Webhook support\n\n## 🚀 Quick Start (Docker)\n\nThe easiest way to deploy is using Docker Compose:\n\n```bash\n# 1. Clone the project\ngit clone https://github.com/gocronx-team/gocron.git\ncd gocron\n\n# 2. Start services\ndocker-compose up -d\n\n# 3. Access Web Interface\n# http://localhost:5920\n```\n\nFor more deployment methods (Binary, Development), please refer to the [Installation Guide](https://gocron-docs.pages.dev/en/guide/quick-start).\n\n## 🔷 High Availability (Optional)\n\nDeploy multiple gocron instances pointing to the same **MySQL/PostgreSQL** database. Leader election is automatic — no extra configuration needed. SQLite runs in single-node mode.\n\n```bash\n# Node 1\n./gocron web --port 5920\n\n# Node 2 (same database)\n./gocron web --port 5921\n```\n\nSee the [High Availability Guide](https://gocron-docs.pages.dev/en/guide/high-availability) for setup details, K8s deployment, and environment variable overrides.\n\n## 📸 Screenshots\n\n<p align=\"center\">\n  <b>Scheduled Tasks</b><br>\n  <img src=\"assets/screenshot/scheduler_en.png\" alt=\"Scheduled Tasks\" width=\"100%\">\n</p>\n\n<table>\n  <tr>\n    <td width=\"50%\" align=\"center\"><b>Agent Auto-Registration</b></td>\n    <td width=\"50%\" align=\"center\"><b>Task Management</b></td>\n  </tr>\n  <tr>\n    <td><img src=\"assets/screenshot/agent_en.png\" alt=\"Agent Auto-Registration\" width=\"100%\"></td>\n    <td><img src=\"assets/screenshot/task_en.png\" alt=\"Task Management\" width=\"100%\"></td>\n  </tr>\n</table>\n\n<table>\n  <tr>\n    <td width=\"50%\" align=\"center\"><b>Statistics</b></td>\n    <td width=\"50%\" align=\"center\"><b>Notifications</b></td>\n  </tr>\n  <tr>\n    <td><img src=\"assets/screenshot/statistic_en.png\" alt=\"Statistics\" width=\"100%\"></td>\n    <td><img src=\"assets/screenshot/notification_en.png\" alt=\"Notifications\" width=\"100%\"></td>\n  </tr>\n</table>\n\n## 🤝 Contributing\n\nWe warmly welcome community contributions!\n\n### How to Contribute\n\n1. **Fork the repository**\n2. **Clone your fork**\n\n   ```bash\n   git clone https://github.com/YOUR_USERNAME/gocron.git\n   cd gocron\n   ```\n\n3. **Install dependencies**\n\n   ```bash\n   pnpm install\n   pnpm run prepare\n   ```\n\n4. **Create a feature branch**\n\n   ```bash\n   git checkout -b feature/your-feature-name\n   ```\n\n5. **Make your changes and commit**\n\n   ```bash\n   git add .\n   pnpm run commit  # Use interactive commit tool\n   ```\n\n6. **Push and create a Pull Request**\n   ```bash\n   git push origin feature/your-feature-name\n   ```\n\n### Commit Message Guidelines\n\nThis project uses [commitizen](https://github.com/commitizen/cz-cli) and [cz-git](https://cz-git.qbb.sh/) for standardized commit messages.\n\nInstead of `git commit`, use:\n\n```bash\npnpm run commit\n```\n\nThis will guide you through an interactive prompt to create properly formatted commit messages like:\n\n- `feat(task): add task dependency configuration`\n- `fix(api): fix task status update issue`\n- `docs: update API documentation`\n\n### Other Ways to Contribute\n\n- 🐛 **Report Bugs**: Please submit via GitHub Issues\n- 💡 **Feature Requests**: Share your ideas through Issues\n- 📝 **Documentation**: Help improve our documentation\n\n## 📄 License\n\nThis project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=gocronx-team/gocron&type=Date)](https://www.star-history.com/#gocronx-team/gocron&Date)\n"
  },
  {
    "path": "README_ZH.md",
    "content": "# gocron - 分布式定时任务调度系统\n\n[![Release](https://img.shields.io/github/release/gocronx-team/gocron.svg?label=Release)](https://github.com/gocronx-team/gocron/releases) [![Downloads](https://img.shields.io/github/downloads/gocronx-team/gocron/total.svg)](https://github.com/gocronx-team/gocron/releases) [![License](https://img.shields.io/github/license/gocronx-team/gocron.svg)](https://github.com/gocronx-team/gocron/blob/master/LICENSE)\n\n[English](README.md) | 简体中文\n\n使用 Go 语言开发的轻量级分布式定时任务集中调度和管理系统，用于替代 Linux-crontab。\n\n## 📖 文档\n\n访问完整文档请跳转：[文档](https://gocron-docs.pages.dev/zh/)\n\n- 🚀 [快速开始](https://gocron-docs.pages.dev/zh/guide/quick-start) - 安装部署指南\n- 🤖 [Agent 自动注册](https://gocron-docs.pages.dev/zh/guide/agent-registration) - 一键部署任务节点\n- ⚙️ [配置文件](https://gocron-docs.pages.dev/zh/guide/configuration) - 详细配置说明\n- 🔌 [API 文档](https://gocron-docs.pages.dev/zh/guide/api) - API 接口说明\n\n## ✨ 功能特性\n\n- **Web 界面管理**：直观的定时任务管理界面\n- **秒级定时**：支持 Crontab 时间表达式，精确到秒\n- **高可用**：基于数据库锁的 Leader 选举，秒级自动故障转移\n- **任务重试**：支持任务执行失败重试设置\n- **任务依赖**：支持配置任务依赖关系\n- **多用户权限**：完善的用户和权限控制\n- **双因素认证**：支持 2FA，提升系统安全性\n- **Agent 自动注册**：支持 Linux/macOS 一键安装注册\n- **多数据库支持**：MySQL / PostgreSQL / SQLite\n- **日志管理**：完整的任务执行日志，支持自动清理\n- **消息通知**：支持邮件、Slack、Webhook 等多种通知方式\n\n## 🚀 快速开始 (Docker)\n\n最简单的部署方式是使用 Docker Compose：\n\n```bash\n# 1. 克隆项目\ngit clone https://github.com/gocronx-team/gocron.git\ncd gocron\n\n# 2. 启动服务\ndocker-compose up -d\n\n# 3. 访问 Web 界面\n# http://localhost:5920\n```\n\n更多部署方式（二进制部署、开发环境）请查看 [安装部署指南](https://gocron-docs.pages.dev/zh/guide/quick-start)。\n\n## 🔷 高可用部署（可选）\n\n多个 gocron 实例连接同一个 **MySQL/PostgreSQL** 数据库即可实现高可用，Leader 选举自动完成，无需额外配置。SQLite 以单节点模式运行。\n\n```bash\n# 节点 1\n./gocron web --port 5920\n\n# 节点 2（连接同一数据库）\n./gocron web --port 5921\n```\n\n详细部署步骤、K8s 配置和环境变量覆盖请参考 [高可用部署指南](https://gocron-docs.pages.dev/zh/guide/high-availability)。\n\n## 📸 界面截图\n\n<p align=\"center\">\n  <b>任务调度</b><br>\n  <img src=\"assets/screenshot/scheduler.png\" alt=\"任务调度\" width=\"100%\">\n</p>\n\n<table>\n  <tr>\n    <td width=\"50%\" align=\"center\"><b>Agent自动注册</b></td>\n    <td width=\"50%\" align=\"center\"><b>任务管理</b></td>\n  </tr>\n  <tr>\n    <td><img src=\"assets/screenshot/agent.png\" alt=\"Agent自动注册\" width=\"100%\"></td>\n    <td><img src=\"assets/screenshot/task.png\" alt=\"任务管理\" width=\"100%\"></td>\n  </tr>\n</table>\n\n<table>\n  <tr>\n    <td width=\"50%\" align=\"center\"><b>数据统计</b></td>\n    <td width=\"50%\" align=\"center\"><b>消息通知</b></td>\n  </tr>\n  <tr>\n    <td><img src=\"assets/screenshot/statistic.png\" alt=\"数据统计\" width=\"100%\"></td>\n    <td><img src=\"assets/screenshot/notification.png\" alt=\"消息通知\" width=\"100%\"></td>\n  </tr>\n</table>\n\n## 🤝 贡献\n\n我们非常欢迎社区的贡献！\n\n### 如何贡献\n\n1. **Fork 仓库**\n2. **克隆你的 fork**\n\n   ```bash\n   git clone https://github.com/YOUR_USERNAME/gocron.git\n   cd gocron\n   ```\n\n3. **安装依赖**\n\n   ```bash\n   pnpm install\n   pnpm run prepare\n   ```\n\n4. **创建功能分支**\n\n   ```bash\n   git checkout -b feature/your-feature-name\n   ```\n\n5. **修改代码并提交**\n\n   ```bash\n   git add .\n   pnpm run commit  # 使用交互式提交工具\n   ```\n\n6. **推送并创建 Pull Request**\n   ```bash\n   git push origin feature/your-feature-name\n   ```\n\n### 提交信息规范\n\n本项目使用 [commitizen](https://github.com/commitizen/cz-cli) 和 [cz-git](https://cz-git.qbb.sh/) 来规范化提交信息。\n\n请使用以下命令代替 `git commit`：\n\n```bash\npnpm run commit\n```\n\n这将引导你通过交互式提示创建格式正确的提交信息，例如：\n\n- `feat(task): 添加任务依赖配置`\n- `fix(api): 修复任务状态更新问题`\n- `docs: 更新 API 文档`\n\n### 其他贡献方式\n\n- 🐛 **提交 Bug**：请在 GitHub Issues 中提交\n- 💡 **功能建议**：通过 Issues 分享你的想法\n- 📝 **文档改进**：帮助我们完善文档\n\n## 📄 许可证\n\n本项目遵循 MIT 许可证。详情请见 [LICENSE](LICENSE) 文件。\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=gocronx-team/gocron&type=Date)](https://www.star-history.com/#gocronx-team/gocron&Date)\n"
  },
  {
    "path": "app.ini.sqlite.example",
    "content": "# gocron SQLite 配置示例\n# 将此文件复制到 ~/.gocron/conf/app.ini 并修改相应配置\n\n[default]\n# 数据库配置 - SQLite\ndb.engine=sqlite\n# SQLite 数据库文件路径（相对路径或绝对路径）\n# 相对路径：相对于程序运行目录\n# 绝对路径：/path/to/gocron.db 或 ~/.gocron/data/gocron.db\ndb.database=./data/gocron.db\n# SQLite 不需要以下配置，但保留以兼容配置文件格式\ndb.host=\ndb.port=\ndb.user=\ndb.password=\ndb.charset=utf8\ndb.prefix=\ndb.max.idle.conns=30\ndb.max.open.conns=100\n\n# 应用配置\napp.name=定时任务管理系统\n\n# API配置\napi.key=\napi.secret=\napi.sign.enable=true\n\n# 允许访问的IP列表，多个IP用逗号分隔，为空表示不限制\nallow_ips=\n\n# 并发队列大小\nconcurrency.queue=500\n\n# 认证密钥（自动生成，无需手动配置）\nauth_secret=\n\n# TLS配置\nenable_tls=false\nca_file=\ncert_file=\nkey_file=\n"
  },
  {
    "path": "cmd/gocron/gocron.go",
    "content": "// Command gocron\n\npackage main\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/app\"\n\t\"github.com/gocronx-team/gocron/internal/modules/leader\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/setting\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers\"\n\t\"github.com/gocronx-team/gocron/internal/service\"\n\t\"github.com/urfave/cli/v2\"\n)\n\nvar (\n\tAppVersion           = \"1.6.0\"\n\tBuildDate, GitCommit string\n\n\t// leaderElection 全局选举实例，用于 graceful shutdown 时释放锁\n\tleaderElection *leader.Election\n)\n\n// Default port for web server\nconst DefaultPort = 5920\n\n// Graceful shutdown timeout\nconst shutdownTimeout = 30 * time.Second\n\nfunc main() {\n\tcliApp := cli.NewApp()\n\tcliApp.Name = \"gocron\"\n\tcliApp.Usage = \"gocron service\"\n\tcliApp.Version, _ = utils.FormatAppVersion(AppVersion, GitCommit, BuildDate)\n\tcliApp.Commands = getCommands()\n\tcliApp.Flags = append(cliApp.Flags, []cli.Flag{}...)\n\n\t// Auto-append \"web\" command when double-clicking on Windows\n\tif len(os.Args) == 1 && utils.IsWindows() {\n\t\tos.Args = append(os.Args, \"web\")\n\t}\n\n\terr := cliApp.Run(os.Args)\n\tif err != nil {\n\t\tlogger.Fatal(err)\n\t}\n}\n\n// getCommands\nfunc getCommands() []*cli.Command {\n\tcommand := &cli.Command{\n\t\tName:   \"web\",\n\t\tUsage:  \"run web server\",\n\t\tAction: runWeb,\n\t\tFlags: []cli.Flag{\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:  \"host\",\n\t\t\t\tValue: \"0.0.0.0\",\n\t\t\t\tUsage: \"bind host\",\n\t\t\t},\n\t\t\t&cli.IntFlag{\n\t\t\t\tName:    \"port\",\n\t\t\t\tAliases: []string{\"p\"},\n\t\t\t\tValue:   DefaultPort,\n\t\t\t\tUsage:   \"bind port\",\n\t\t\t},\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:    \"env\",\n\t\t\t\tAliases: []string{\"e\"},\n\t\t\t\tValue:   \"prod\",\n\t\t\t\tUsage:   \"runtime environment, dev|test|prod\",\n\t\t\t},\n\t\t},\n\t}\n\n\treturn []*cli.Command{command}\n}\n\nfunc runWeb(ctx *cli.Context) error {\n\t// Set runtime environment\n\tsetEnvironment(ctx)\n\tfmt.Printf(\"Starting gocron web server...\\n\")\n\t// Initialize application\n\tapp.InitEnv(AppVersion)\n\tfmt.Printf(\"Application initialized\\n\")\n\t// Initialize modules: DB, scheduled tasks, etc.\n\tinitModule()\n\tfmt.Printf(\"Modules initialized\\n\")\n\n\t// Security warning: agent gRPC channel unencrypted when TLS is off\n\tif app.Installed && app.Setting != nil && !app.Setting.EnableTLS {\n\t\tlogger.Warn(\"SECURITY: agent gRPC TLS is disabled (enable_tls=false); agent port is reachable without authentication\")\n\t}\n\n\t// 屏蔽 Gin 启动时打印完整路由表（保留 debug 模式的其他提示）\n\tgin.DebugPrintRouteFunc = func(httpMethod, absolutePath, handlerName string, nuHandlers int) {}\n\n\tr := gin.Default()\n\t// Register middleware\n\trouters.RegisterMiddleware(r)\n\t// Register routes\n\trouters.Register(r)\n\n\thost := parseHost(ctx)\n\tport := parsePort(ctx)\n\taddr := fmt.Sprintf(\"%s:%d\", host, port)\n\n\t// Use http.Server to support graceful shutdown\n\tsrv := &http.Server{\n\t\tAddr:    addr,\n\t\tHandler: r,\n\t}\n\n\t// Start HTTP server in a goroutine\n\tgo func() {\n\t\tfmt.Printf(\"Server listening on %s\\n\", addr)\n\t\tif err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\tlogger.Fatalf(\"Failed to start server: %v\", err)\n\t\t}\n\t}()\n\n\t// Wait for shutdown signal, blocks the main goroutine\n\twaitForShutdown(srv)\n\n\treturn nil\n}\n\nfunc initModule() {\n\tif !app.Installed {\n\t\treturn\n\t}\n\n\tconfig, err := setting.Read(app.AppConfig)\n\tif err != nil {\n\t\tlogger.Fatal(\"Failed to read application config\", err)\n\t}\n\tapp.Setting = config\n\n\t// Initialize DB\n\tmodels.Db = models.CreateDb()\n\n\t// Version upgrade\n\tupgradeIfNeed()\n\n\t// Auto-create missing tables\n\tensureTables()\n\n\t// Repair missing settings records\n\tif err := models.RepairSettings(); err != nil {\n\t\tlogger.Error(\"Failed to repair settings records\", err)\n\t}\n\n\t// Initialize scheduler infrastructure\n\tservice.ServiceTask.Initialize()\n\n\t// SQLite: single-node only, skip leader election\n\tif models.Db.Dialector.Name() == \"sqlite\" {\n\t\tlogger.Info(\"SQLite detected, skipping leader election (single-node mode)\")\n\t\tservice.ServiceTask.StartScheduler()\n\t\treturn\n\t}\n\n\t// Ensure scheduler_lock table exists\n\tif !models.Db.Migrator().HasTable(&models.SchedulerLock{}) {\n\t\tlogger.Info(\"scheduler_lock table not found, creating...\")\n\t\tif err := models.Db.AutoMigrate(&models.SchedulerLock{}); err != nil {\n\t\t\tlogger.Error(\"Failed to create scheduler_lock table\", err)\n\t\t}\n\t}\n\n\t// Start leader election — scheduler only runs on the leader node\n\tleaderElection = leader.New(\n\t\tmodels.Db,\n\t\tfunc() { service.ServiceTask.StartScheduler() }, // onElected\n\t\tfunc() { service.ServiceTask.StopScheduler() },  // onEvicted\n\t)\n\tleaderElection.Start()\n}\n\n// parsePort parses the port from CLI flags\nfunc parsePort(ctx *cli.Context) int {\n\tport := DefaultPort\n\tif ctx.IsSet(\"port\") {\n\t\tport = ctx.Int(\"port\")\n\t}\n\tif port <= 0 || port >= 65535 {\n\t\tport = DefaultPort\n\t}\n\n\treturn port\n}\n\nfunc parseHost(ctx *cli.Context) string {\n\tif ctx.IsSet(\"host\") {\n\t\treturn ctx.String(\"host\")\n\t}\n\n\treturn \"0.0.0.0\"\n}\n\nfunc setEnvironment(ctx *cli.Context) {\n\tenv := \"prod\"\n\tif ctx.IsSet(\"env\") {\n\t\tenv = ctx.String(\"env\")\n\t}\n\n\tswitch env {\n\tcase \"test\":\n\t\tgin.SetMode(gin.TestMode)\n\tcase \"dev\":\n\t\tgin.SetMode(gin.DebugMode)\n\tdefault:\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n}\n\n// waitForShutdown waits for OS signals and performs graceful shutdown\nfunc waitForShutdown(srv *http.Server) {\n\tquit := make(chan os.Signal, 1)\n\tsignal.Notify(quit, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)\n\n\tfor {\n\t\ts := <-quit\n\t\tlogger.Info(\"Received signal -- \", s)\n\t\tswitch s {\n\t\tcase syscall.SIGHUP:\n\t\t\tlogger.Info(\"Received terminal disconnect signal, ignoring\")\n\t\t\tcontinue\n\t\tcase syscall.SIGINT, syscall.SIGTERM:\n\t\t\t// Proceed to graceful shutdown\n\t\t}\n\t\tbreak\n\t}\n\n\tlogger.Info(\"Shutting down gracefully, press Ctrl+C again to force exit...\")\n\n\t// Allow forced exit: immediately exit on receiving another signal\n\tgo func() {\n\t\tforceQuit := make(chan os.Signal, 1)\n\t\tsignal.Notify(forceQuit, syscall.SIGINT, syscall.SIGTERM)\n\t\t<-forceQuit\n\t\tlogger.Warn(\"Forced shutdown\")\n\t\tos.Exit(1)\n\t}()\n\n\t// Step 1: Stop HTTP server, reject new requests, wait for in-flight requests to complete\n\tlogger.Info(\"Step 1/3: Stopping HTTP server (waiting for in-flight requests)...\")\n\tshutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)\n\tdefer cancel()\n\n\tif err := srv.Shutdown(shutdownCtx); err != nil {\n\t\tlogger.Errorf(\"HTTP server shutdown error: %v\", err)\n\t} else {\n\t\tlogger.Info(\"HTTP server stopped successfully\")\n\t}\n\n\t// Step 2: Stop leader election and release lock\n\tif app.Installed {\n\t\tif leaderElection != nil {\n\t\t\tlogger.Info(\"Step 2/4: Stopping leader election (releasing lock)...\")\n\t\t\tleaderElection.Stop()\n\t\t\tlogger.Info(\"Leader election stopped\")\n\t\t}\n\n\t\t// Step 3: Stop scheduled task scheduler, wait for running tasks to complete\n\t\tlogger.Info(\"Step 3/4: Stopping scheduled task scheduler (waiting for running tasks)...\")\n\t\tservice.ServiceTask.WaitAndExit()\n\t\tlogger.Info(\"Scheduled task scheduler stopped\")\n\n\t\t// Step 4: Close database connections\n\t\tlogger.Info(\"Step 4/4: Closing database connections...\")\n\t\tcloseDatabase()\n\t\tlogger.Info(\"Database connections closed\")\n\t}\n\n\tlogger.Info(\"Graceful shutdown completed\")\n\tlogger.Close()\n}\n\n// closeDatabase closes the database connection pool\nfunc closeDatabase() {\n\tif models.Db == nil {\n\t\treturn\n\t}\n\t// Stop the keep-alive goroutine before closing the connection\n\tmodels.StopKeepAlive()\n\tsqlDB, err := models.Db.DB()\n\tif err != nil {\n\t\tlogger.Errorf(\"Failed to get database connection for closing: %v\", err)\n\t\treturn\n\t}\n\tif err := sqlDB.Close(); err != nil {\n\t\tlogger.Errorf(\"Failed to close database connection: %v\", err)\n\t}\n}\n\n// upgradeIfNeed checks if the app needs upgrading when version file exists and version < app.VersionId\nfunc upgradeIfNeed() {\n\tcurrentVersionId := app.GetCurrentVersionId()\n\t// No version file found\n\tif currentVersionId == 0 {\n\t\treturn\n\t}\n\tif currentVersionId >= app.VersionId {\n\t\treturn\n\t}\n\n\tmigration := new(models.Migration)\n\tlogger.Infof(\"Starting version upgrade, current version: %d\", currentVersionId)\n\n\tmigration.Upgrade(currentVersionId)\n\tapp.UpdateVersionFile()\n\n\tlogger.Infof(\"Upgraded to latest version: %d\", app.VersionId)\n}\n\n// ensureTables ensures all required tables exist\nfunc ensureTables() {\n\tif !models.Db.Migrator().HasTable(&models.AgentToken{}) {\n\t\tlogger.Info(\"agent_token table not found, creating...\")\n\t\tif err := models.Db.AutoMigrate(&models.AgentToken{}); err != nil {\n\t\t\tlogger.Error(\"Failed to create agent_token table\", err)\n\t\t} else {\n\t\t\tlogger.Info(\"agent_token table created successfully\")\n\t\t}\n\t}\n\n\tif !models.Db.Migrator().HasTable(&models.AuditLog{}) {\n\t\tlogger.Info(\"audit_log table not found, creating...\")\n\t\tif err := models.Db.AutoMigrate(&models.AuditLog{}); err != nil {\n\t\t\tlogger.Error(\"Failed to create audit_log table\", err)\n\t\t} else {\n\t\t\tlogger.Info(\"audit_log table created successfully\")\n\t\t}\n\t}\n\n\t// 始终 AutoMigrate 新表，确保字段同步（AutoMigrate 幂等，只加列不删列）\n\tif err := models.Db.AutoMigrate(&models.TaskScriptVersion{}); err != nil {\n\t\tlogger.Error(\"Failed to migrate task_script_version table\", err)\n\t}\n\tif err := models.Db.AutoMigrate(&models.TaskTemplate{}); err != nil {\n\t\tlogger.Error(\"Failed to migrate task_template table\", err)\n\t}\n}\n"
  },
  {
    "path": "cmd/node/node.go",
    "content": "// Command gocron-node\npackage main\n\nimport (\n\t\"flag\"\n\t\"os\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/rpc/auth\"\n\t\"github.com/gocronx-team/gocron/internal/modules/rpc/server\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nvar (\n\tAppVersion, BuildDate, GitCommit string\n)\n\nfunc main() {\n\tvar serverAddr string\n\tvar allowRoot bool\n\tvar version bool\n\tvar CAFile string\n\tvar certFile string\n\tvar keyFile string\n\tvar enableTLS bool\n\tvar logLevel string\n\tflag.BoolVar(&allowRoot, \"allow-root\", false, \"./gocron-node -allow-root\")\n\tflag.StringVar(&serverAddr, \"s\", \"0.0.0.0:5921\", \"./gocron-node -s ip:port\")\n\tflag.BoolVar(&version, \"v\", false, \"./gocron-node -v\")\n\tflag.BoolVar(&enableTLS, \"enable-tls\", false, \"./gocron-node -enable-tls\")\n\tflag.StringVar(&CAFile, \"ca-file\", \"\", \"./gocron-node -ca-file path\")\n\tflag.StringVar(&certFile, \"cert-file\", \"\", \"./gocron-node -cert-file path\")\n\tflag.StringVar(&keyFile, \"key-file\", \"\", \"./gocron-node -key-file path\")\n\tflag.StringVar(&logLevel, \"log-level\", \"info\", \"-log-level error\")\n\tflag.Parse()\n\tlevel, err := log.ParseLevel(logLevel)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\tlog.SetLevel(level)\n\n\tif version {\n\t\tutils.PrintAppVersion(AppVersion, GitCommit, BuildDate)\n\t\treturn\n\t}\n\n\tif enableTLS {\n\t\tif !utils.FileExist(CAFile) {\n\t\t\tlog.Fatalf(\"failed to read ca cert file: %s\", CAFile)\n\t\t}\n\t\tif !utils.FileExist(certFile) {\n\t\t\tlog.Fatalf(\"failed to read server cert file: %s\", certFile)\n\t\t\treturn\n\t\t}\n\t\tif !utils.FileExist(keyFile) {\n\t\t\tlog.Fatalf(\"failed to read server key file: %s\", keyFile)\n\t\t\treturn\n\t\t}\n\t}\n\n\tcertificate := auth.Certificate{\n\t\tCAFile:   strings.TrimSpace(CAFile),\n\t\tCertFile: strings.TrimSpace(certFile),\n\t\tKeyFile:  strings.TrimSpace(keyFile),\n\t}\n\n\tif runtime.GOOS != \"windows\" && os.Getuid() == 0 && !allowRoot {\n\t\tlog.Fatal(\"Do not run gocron-node as root user\")\n\t\treturn\n\t}\n\n\tserver.Start(serverAddr, enableTLS, certificate)\n}\n"
  },
  {
    "path": "commitlint.config.cjs",
    "content": "/**\n * commitlint configuration file\n * Documentation\n * https://commitlint.js.org/#/reference-rules\n * https://cz-git.qbb.sh/guide/\n */\n\nmodule.exports = {\n  // Extends rules\n  extends: ['@commitlint/config-conventional'],\n  // Custom rules\n  rules: {\n    // Type enum, git commit type must be one of the following types\n    'type-enum': [\n      2,\n      'always',\n      [\n        'feat', // A new feature\n        'fix', // A bug fix\n        'docs', // Documentation only changes\n        'style', // Changes that do not affect the meaning of the code\n        'refactor', // A code change that neither fixes a bug nor adds a feature\n        'perf', // A code change that improves performance\n        'test', // Adding missing tests or correcting existing tests\n        'build', // Changes that affect the build system or external dependencies\n        'ci', // Changes to our CI configuration files and scripts\n        'revert', // Reverts a previous commit\n        'chore', // Other changes that don't modify src or test files\n        'wip' // Work in progress\n      ]\n    ],\n    'subject-case': [0], // No validation for subject case\n    'type-case': [0], // No validation for type case\n    'type-empty': [0], // Allow empty type\n    'subject-empty': [0] // Allow empty subject\n  },\n  // Standard conventional commit parser\n  parserPreset: {\n    parserOpts: {\n      headerPattern: /^([\\w-]+)(?:\\(([\\w-]+)\\))?:\\s(.+)$/,\n      headerCorrespondence: ['type', 'scope', 'subject']\n    }\n  },\n\n  prompt: {\n    messages: {\n      type: 'Select the type of change that you\\'re committing:',\n      scope: 'Denote the SCOPE of this change (optional):',\n      customScope: 'Denote the SCOPE of this change:',\n      subject: 'Write a SHORT, IMPERATIVE tense description of the change:\\n',\n      body: 'Provide a LONGER description of the change (optional). Use \"|\" to break new line:\\n',\n      breaking: 'List any BREAKING CHANGES (optional). Use \"|\" to break new line:\\n',\n      footerPrefixesSelect: 'Select the ISSUES type of changeList by this change (optional):',\n      customFooterPrefix: 'Input ISSUES prefix:',\n      footer: 'List any ISSUES by this change. E.g.: #31, #34:\\n',\n      confirmCommit: 'Are you sure you want to proceed with the commit above?'\n    },\n    // prettier-ignore\n    types: [\n      { value: \"feat\",     name: \"feat:     A new feature\" },\n      { value: \"fix\",      name: \"fix:      A bug fix\" },\n      { value: \"docs\",     name: \"docs:     Documentation only changes\" },\n      { value: \"style\",    name: \"style:    Changes that do not affect the meaning of the code\" },\n      { value: \"refactor\", name: \"refactor: A code change that neither fixes a bug nor adds a feature\" },\n      { value: \"perf\",     name: \"perf:     A code change that improves performance\" },\n      { value: \"test\",     name: \"test:     Adding missing tests or correcting existing tests\" },\n      { value: \"build\",    name: \"build:    Changes that affect the build system or external dependencies\" },\n      { value: \"ci\",       name: \"ci:       Changes to our CI configuration files and scripts\" },\n      { value: \"revert\",   name: \"revert:   Reverts a previous commit\" },\n      { value: \"chore\",    name: \"chore:    Other changes that don't modify src or test files\" },\n      { value: \"wip\",      name: \"wip:      Work in progress\" },\n    ],\n    useEmoji: false,\n    emojiAlign: 'center',\n    themeColorCode: '',\n    scopes: [\n      { value: 'web', name: 'web: Frontend related' },\n      { value: 'api', name: 'api: API interface' },\n      { value: 'task', name: 'task: Task scheduling' },\n      { value: 'node', name: 'node: Node management' },\n      { value: 'auth', name: 'auth: Authentication' },\n      { value: 'db', name: 'db: Database' },\n      { value: 'config', name: 'config: Configuration' },\n      { value: 'deps', name: 'deps: Dependencies update' }\n    ],\n    allowCustomScopes: true,\n    allowEmptyScopes: true,\n    customScopesAlign: 'bottom',\n    customScopesAlias: 'custom',\n    emptyScopesAlias: 'empty',\n    upperCaseSubject: false,\n    markBreakingChangeMode: false,\n    allowBreakingChanges: ['feat', 'fix'],\n    breaklineNumber: 100,\n    breaklineChar: '|',\n    skipQuestions: ['breaking', 'footerPrefix', 'footer'], // Skip these steps\n    issuePrefixes: [{ value: 'closed', name: 'closed:   ISSUES has been processed' }],\n    customIssuePrefixAlign: 'top',\n    emptyIssuePrefixAlias: 'skip',\n    customIssuePrefixAlias: 'custom',\n    allowCustomIssuePrefix: true,\n    allowEmptyIssuePrefix: true,\n    confirmColorize: true,\n    maxHeaderLength: Infinity,\n    maxSubjectLength: Infinity,\n    minSubjectLength: 0,\n    scopeOverrides: undefined,\n    defaultBody: '',\n    defaultIssues: '',\n    defaultScope: '',\n    defaultSubject: ''\n  }\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  gocron:\n    build:\n      context: .\n      dockerfile: Dockerfile.gocron\n    image: gocron:latest\n    container_name: gocron\n    ports:\n      - \"5920:5920\"\n    volumes:\n      - gocron-data:/.gocron\n    environment:\n      - TZ=Asia/Shanghai\n    restart: unless-stopped\n    \nvolumes:\n  gocron-data:\n    driver: local\n"
  },
  {
    "path": "embed.go",
    "content": "package embed\n\nimport (\n\t\"embed\"\n\t\"io/fs\"\n)\n\n//go:embed all:web/vue/dist\nvar files embed.FS\n\nfunc StaticFS() (fs.FS, error) {\n\treturn fs.Sub(files, \"web/vue/dist\")\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/gocronx-team/gocron\n\ngo 1.26.2\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.12.0\n\tgithub.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df\n\tgithub.com/go-sql-driver/mysql v1.9.3\n\tgithub.com/gocronx-team/cron v0.1.3\n\tgithub.com/golang-jwt/jwt/v5 v5.3.1\n\tgithub.com/lib/pq v1.12.0\n\tgithub.com/ncruces/go-sqlite3/gormlite v0.33.3\n\tgithub.com/pquerna/otp v1.5.0\n\tgithub.com/sirupsen/logrus v1.9.4\n\tgithub.com/urfave/cli/v2 v2.27.7\n\tgolang.org/x/crypto v0.50.0\n\tgolang.org/x/text v0.36.0\n\tgoogle.golang.org/grpc v1.79.3\n\tgoogle.golang.org/protobuf v1.36.11\n\tgopkg.in/ini.v1 v1.67.1\n\tgorm.io/driver/mysql v1.6.0\n\tgorm.io/driver/postgres v1.6.0\n\tgorm.io/gorm v1.31.1\n)\n\nrequire (\n\tfilippo.io/edwards25519 v1.1.1 // indirect\n\tgithub.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect\n\tgithub.com/bytedance/gopkg v0.1.3 // indirect\n\tgithub.com/bytedance/sonic v1.15.0 // indirect\n\tgithub.com/bytedance/sonic/loader v0.5.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.6 // indirect\n\tgithub.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.12 // indirect\n\tgithub.com/gin-contrib/sse v1.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.30.1 // indirect\n\tgithub.com/goccy/go-json v0.10.5 // indirect\n\tgithub.com/goccy/go-yaml v1.19.2 // indirect\n\tgithub.com/jackc/pgpassfile v1.0.0 // indirect\n\tgithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect\n\tgithub.com/jackc/pgx/v5 v5.9.1 // indirect\n\tgithub.com/jackc/puddle/v2 v2.2.2 // indirect\n\tgithub.com/jinzhu/inflection v1.0.0 // indirect\n\tgithub.com/jinzhu/now v1.1.5 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/ncruces/go-sqlite3 v0.33.3 // indirect\n\tgithub.com/ncruces/go-sqlite3-wasm v1.1.1-0.20260409221933-87e4b35a38d0 // indirect\n\tgithub.com/ncruces/julianday v1.0.0 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/quic-go/qpack v0.6.0 // indirect\n\tgithub.com/quic-go/quic-go v0.59.0 // indirect\n\tgithub.com/russross/blackfriday/v2 v2.1.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.3.1 // indirect\n\tgithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect\n\tgo.mongodb.org/mongo-driver/v2 v2.5.0 // indirect\n\tgolang.org/x/arch v0.22.0 // indirect\n\tgolang.org/x/net v0.52.0 // indirect\n\tgolang.org/x/sync v0.20.0 // indirect\n\tgolang.org/x/sys v0.43.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect\n\tgopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect\n\tgopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=\nfilippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=\ngithub.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=\ngithub.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=\ngithub.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=\ngithub.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=\ngithub.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=\ngithub.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=\ngithub.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=\ngithub.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=\ngithub.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=\ngithub.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=\ngithub.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=\ngithub.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=\ngithub.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=\ngithub.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=\ngithub.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df h1:Bao6dhmbTA1KFVxmJ6nBoMuOJit2yjEgLJpIMYpop0E=\ngithub.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCSXshIwgHBtLglIg9M2l2kQSi6QjVAngtzI08Y=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=\ngithub.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=\ngithub.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=\ngithub.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=\ngithub.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=\ngithub.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=\ngithub.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=\ngithub.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/gocronx-team/cron v0.1.3 h1:flJFseOHRkDWnV8vQyOH4V8HXX8per4wmfO5nygcORY=\ngithub.com/gocronx-team/cron v0.1.3/go.mod h1:HqYzaybMPpKERqcHeuVBO3IGyA+n7fVmRlowQ/uDfyw=\ngithub.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=\ngithub.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=\ngithub.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=\ngithub.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=\ngithub.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=\ngithub.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=\ngithub.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=\ngithub.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=\ngithub.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=\ngithub.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=\ngithub.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=\ngithub.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=\ngithub.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=\ngithub.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/ncruces/go-sqlite3 v0.33.3 h1:6jCR3KuGvJSEwhaQrkHDGeIe2qCQ6nOUDNsPz7ZIotw=\ngithub.com/ncruces/go-sqlite3 v0.33.3/go.mod h1:t2Osfw0wcKzJTgv2EvrkTtVLqlbKTA5Yvwb2ypAlBcY=\ngithub.com/ncruces/go-sqlite3-wasm v1.1.1-0.20260409221933-87e4b35a38d0 h1:ymE9H30x1AyW5VfMNkJC9teuI2W1jjMsQS7kc6zl6Tg=\ngithub.com/ncruces/go-sqlite3-wasm v1.1.1-0.20260409221933-87e4b35a38d0/go.mod h1:/H3+JykPsfSlvKbOxNSx9kKwm3ecqQGzyCs1e9KkNsU=\ngithub.com/ncruces/go-sqlite3/gormlite v0.33.3 h1:JzLk8XymgvHvy60ib5MtNmd0fIYwGi7FUj2DpRFmnWQ=\ngithub.com/ncruces/go-sqlite3/gormlite v0.33.3/go.mod h1:qDjzlaffXDGg5bhZs2VaaSY0Qb3rsiKq0O4pXkmQfHI=\ngithub.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=\ngithub.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=\ngithub.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=\ngithub.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=\ngithub.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=\ngithub.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=\ngithub.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=\ngithub.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=\ngithub.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=\ngithub.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=\ngithub.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=\ngithub.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=\ngithub.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=\ngithub.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=\ngithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=\ngithub.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=\ngo.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=\ngo.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=\ngo.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=\ngo.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=\ngo.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=\ngo.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=\ngo.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=\ngo.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=\ngo.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=\ngo.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=\ngo.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=\ngo.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=\ngo.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=\ngolang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=\ngolang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=\ngolang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=\ngolang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=\ngolang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=\ngolang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=\ngolang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=\ngolang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=\ngonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=\ngonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=\ngoogle.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=\ngoogle.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=\ngopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=\ngopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=\ngopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=\ngopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=\ngorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=\ngorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=\ngorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=\ngorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=\ngorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=\n"
  },
  {
    "path": "helm/gocron/Chart.yaml",
    "content": "apiVersion: v2\nname: gocron\ndescription: A Helm chart for gocron - cron job management system\ntype: application\nversion: 0.1.0\nappVersion: \"1.5.9\"\nkeywords:\n  - cron\n  - scheduler\n  - task\nhome: https://github.com/gocronx-team/gocron\nicon: https://raw.githubusercontent.com/gocronx-team/gocron/master/web/vue/public/favicon.ico\nsources:\n  - https://github.com/gocronx-team/gocron\nmaintainers:\n  - name: gocronx-team\n"
  },
  {
    "path": "helm/gocron/templates/NOTES.txt",
    "content": "gocron has been deployed successfully!\n\nGet the application URL:\n{{- if .Values.ingress.enabled }}\n{{- range $host := .Values.ingress.hosts }}\n  http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}\n{{- end }}\n{{- else if contains \"NodePort\" .Values.service.type }}\n  export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath=\"{.spec.ports[0].nodePort}\" services {{ include \"gocron.fullname\" . }})\n  export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath=\"{.items[0].status.addresses[0].address}\")\n  echo http://$NODE_IP:$NODE_PORT\n{{- else if contains \"LoadBalancer\" .Values.service.type }}\n  export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include \"gocron.fullname\" . }} --template \"{{\"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}\"}}\")\n  echo http://$SERVICE_IP:{{ .Values.service.port }}\n{{- else }}\n  kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include \"gocron.fullname\" . }} {{ .Values.service.port }}:{{ .Values.service.port }}\n  echo http://127.0.0.1:{{ .Values.service.port }}\n{{- end }}\n\nDatabase: {{ .Values.db.engine }}\n{{- if eq .Values.db.engine \"sqlite\" }}\n  Note: SQLite mode supports single replica only. Data is stored in PVC.\n{{- end }}\n"
  },
  {
    "path": "helm/gocron/templates/_helpers.tpl",
    "content": "{{/*\nExpand the name of the chart.\n*/}}\n{{- define \"gocron.name\" -}}\n{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCreate a default fully qualified app name.\n*/}}\n{{- define \"gocron.fullname\" -}}\n{{- if .Values.fullnameOverride }}\n{{- .Values.fullnameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- $name := default .Chart.Name .Values.nameOverride }}\n{{- if contains $name .Release.Name }}\n{{- .Release.Name | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- printf \"%s-%s\" .Release.Name $name | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n{{- end }}\n{{- end }}\n\n{{/*\nCreate chart name and version as used by the chart label.\n*/}}\n{{- define \"gocron.chart\" -}}\n{{- printf \"%s-%s\" .Chart.Name .Chart.Version | replace \"+\" \"_\" | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCommon labels\n*/}}\n{{- define \"gocron.labels\" -}}\nhelm.sh/chart: {{ include \"gocron.chart\" . }}\n{{ include \"gocron.selectorLabels\" . }}\napp.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}\napp.kubernetes.io/managed-by: {{ .Release.Service }}\n{{- end }}\n\n{{/*\nSelector labels\n*/}}\n{{- define \"gocron.selectorLabels\" -}}\napp.kubernetes.io/name: {{ include \"gocron.name\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\n{{- end }}\n\n{{/*\nCreate the name of the service account to use\n*/}}\n{{- define \"gocron.serviceAccountName\" -}}\n{{- if .Values.serviceAccount.create }}\n{{- default (include \"gocron.fullname\" .) .Values.serviceAccount.name }}\n{{- else }}\n{{- default \"default\" .Values.serviceAccount.name }}\n{{- end }}\n{{- end }}\n\n{{/*\nImage tag\n*/}}\n{{- define \"gocron.imageTag\" -}}\n{{- .Values.image.tag | default .Chart.AppVersion }}\n{{- end }}\n"
  },
  {
    "path": "helm/gocron/templates/configmap.yaml",
    "content": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: {{ include \"gocron.fullname\" . }}\n  labels:\n    {{- include \"gocron.labels\" . | nindent 4 }}\ndata:\n  app.ini: |\n    [default]\n    db.engine={{ .Values.db.engine }}\n    db.host={{ .Values.db.host }}\n    db.port={{ .Values.db.port }}\n    db.user={{ .Values.db.user }}\n    db.password={{ .Values.db.password }}\n    db.database={{ .Values.db.database }}\n    db.prefix={{ .Values.db.prefix }}\n    db.charset={{ .Values.db.charset }}\n    db.max.idle.conns={{ .Values.db.maxIdleConns }}\n    db.max.open.conns={{ .Values.db.maxOpenConns }}\n    app.name={{ .Values.app.name }}\n    api.key={{ .Values.app.apiKey }}\n    api.secret={{ .Values.app.apiSecret }}\n    allow_ips={{ .Values.app.allowIps }}\n    concurrency.queue={{ .Values.app.concurrencyQueue }}\n    enable_tls={{ .Values.app.enableTls }}\n"
  },
  {
    "path": "helm/gocron/templates/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"gocron.fullname\" . }}\n  labels:\n    {{- include \"gocron.labels\" . | nindent 4 }}\nspec:\n  replicas: {{ .Values.replicaCount }}\n  {{- if eq .Values.db.engine \"sqlite\" }}\n  strategy:\n    type: Recreate\n  {{- end }}\n  selector:\n    matchLabels:\n      {{- include \"gocron.selectorLabels\" . | nindent 6 }}\n  template:\n    metadata:\n      annotations:\n        checksum/config: {{ include (print $.Template.BasePath \"/configmap.yaml\") . | sha256sum }}\n      labels:\n        {{- include \"gocron.selectorLabels\" . | nindent 8 }}\n    spec:\n      {{- with .Values.imagePullSecrets }}\n      imagePullSecrets:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      serviceAccountName: {{ include \"gocron.serviceAccountName\" . }}\n      containers:\n        - name: {{ .Chart.Name }}\n          image: \"{{ .Values.image.repository }}:{{ include \"gocron.imageTag\" . }}\"\n          imagePullPolicy: {{ .Values.image.pullPolicy }}\n          ports:\n            - name: http\n              containerPort: 5920\n              protocol: TCP\n          env:\n            - name: TZ\n              value: {{ .Values.timezone | quote }}\n          livenessProbe:\n            httpGet:\n              path: /\n              port: http\n            initialDelaySeconds: 10\n            periodSeconds: 30\n          readinessProbe:\n            httpGet:\n              path: /\n              port: http\n            initialDelaySeconds: 5\n            periodSeconds: 10\n          volumeMounts:\n            - name: data\n              mountPath: /.gocron\n            - name: config\n              mountPath: /.gocron/conf/app.ini\n              subPath: app.ini\n          {{- with .Values.resources }}\n          resources:\n            {{- toYaml . | nindent 12 }}\n          {{- end }}\n      volumes:\n        - name: config\n          configMap:\n            name: {{ include \"gocron.fullname\" . }}\n        - name: data\n          {{- if .Values.persistence.enabled }}\n          persistentVolumeClaim:\n            claimName: {{ include \"gocron.fullname\" . }}\n          {{- else }}\n          emptyDir: {}\n          {{- end }}\n      {{- with .Values.nodeSelector }}\n      nodeSelector:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.tolerations }}\n      tolerations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.affinity }}\n      affinity:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n"
  },
  {
    "path": "helm/gocron/templates/ingress.yaml",
    "content": "{{- if .Values.ingress.enabled }}\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: {{ include \"gocron.fullname\" . }}\n  labels:\n    {{- include \"gocron.labels\" . | nindent 4 }}\n  {{- with .Values.ingress.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\nspec:\n  {{- if .Values.ingress.className }}\n  ingressClassName: {{ .Values.ingress.className }}\n  {{- end }}\n  {{- if .Values.ingress.tls }}\n  tls:\n    {{- range .Values.ingress.tls }}\n    - hosts:\n        {{- range .hosts }}\n        - {{ . | quote }}\n        {{- end }}\n      secretName: {{ .secretName }}\n    {{- end }}\n  {{- end }}\n  rules:\n    {{- range .Values.ingress.hosts }}\n    - host: {{ .host | quote }}\n      http:\n        paths:\n          {{- range .paths }}\n          - path: {{ .path }}\n            pathType: {{ .pathType }}\n            backend:\n              service:\n                name: {{ include \"gocron.fullname\" $ }}\n                port:\n                  name: http\n          {{- end }}\n    {{- end }}\n{{- end }}\n"
  },
  {
    "path": "helm/gocron/templates/pvc.yaml",
    "content": "{{- if .Values.persistence.enabled }}\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  name: {{ include \"gocron.fullname\" . }}\n  labels:\n    {{- include \"gocron.labels\" . | nindent 4 }}\nspec:\n  accessModes:\n    - {{ .Values.persistence.accessMode }}\n  {{- if .Values.persistence.storageClass }}\n  storageClassName: {{ .Values.persistence.storageClass | quote }}\n  {{- end }}\n  resources:\n    requests:\n      storage: {{ .Values.persistence.size }}\n{{- end }}\n"
  },
  {
    "path": "helm/gocron/templates/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"gocron.fullname\" . }}\n  labels:\n    {{- include \"gocron.labels\" . | nindent 4 }}\nspec:\n  type: {{ .Values.service.type }}\n  ports:\n    - port: {{ .Values.service.port }}\n      targetPort: http\n      protocol: TCP\n      name: http\n  selector:\n    {{- include \"gocron.selectorLabels\" . | nindent 4 }}\n"
  },
  {
    "path": "helm/gocron/templates/serviceaccount.yaml",
    "content": "{{- if .Values.serviceAccount.create }}\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: {{ include \"gocron.serviceAccountName\" . }}\n  labels:\n    {{- include \"gocron.labels\" . | nindent 4 }}\n  {{- with .Values.serviceAccount.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\n{{- end }}\n"
  },
  {
    "path": "helm/gocron/values.yaml",
    "content": "replicaCount: 1\n\nimage:\n  repository: gocronx/gocron\n  tag: \"\" # defaults to Chart appVersion\n  pullPolicy: IfNotPresent\n\nimagePullSecrets: []\nnameOverride: \"\"\nfullnameOverride: \"\"\n\n# Database configuration\ndb:\n  # sqlite, mysql, postgres\n  engine: sqlite\n  host: \"\"\n  port: 0\n  user: \"\"\n  password: \"\"\n  database: ./data/gocron.db\n  prefix: \"\"\n  charset: utf8\n  maxIdleConns: 5\n  maxOpenConns: 100\n\n# Application configuration\napp:\n  name: \"gocron\"\n  apiKey: \"\"\n  apiSecret: \"\"\n  allowIps: \"\"\n  concurrencyQueue: 500\n  enableTls: false\n\ntimezone: Asia/Shanghai\n\nservice:\n  type: ClusterIP\n  port: 5920\n\ningress:\n  enabled: false\n  className: \"\"\n  annotations: {}\n  hosts:\n    - host: gocron.local\n      paths:\n        - path: /\n          pathType: Prefix\n  tls: []\n\npersistence:\n  enabled: true\n  storageClass: \"\"\n  accessMode: ReadWriteOnce\n  size: 1Gi\n\nresources: {}\n  # limits:\n  #   cpu: 500m\n  #   memory: 256Mi\n  # requests:\n  #   cpu: 100m\n  #   memory: 128Mi\n\nnodeSelector: {}\ntolerations: []\naffinity: {}\n\nserviceAccount:\n  create: true\n  name: \"\"\n  annotations: {}\n"
  },
  {
    "path": "internal/models/agent_token.go",
    "content": "package models\n\nimport (\n\t\"time\"\n)\n\n// AgentToken agent注册token\ntype AgentToken struct {\n\tId        int        `json:\"id\" gorm:\"primaryKey;autoIncrement\"`\n\tToken     string     `json:\"token\" gorm:\"type:varchar(64);uniqueIndex;not null\"`\n\tExpiresAt time.Time  `json:\"expires_at\" gorm:\"not null\"`\n\tUsed      bool       `json:\"used\" gorm:\"default:false\"`\n\tUsedAt    *time.Time `json:\"used_at\" gorm:\"default:null\"`\n\tCreatedAt time.Time  `json:\"created_at\" gorm:\"autoCreateTime\"`\n}\n\nfunc (t *AgentToken) Create() error {\n\treturn Db.Create(t).Error\n}\n\nfunc (t *AgentToken) FindByToken(token string) error {\n\treturn Db.Where(\"token = ?\", token).First(t).Error\n}\n\nfunc (t *AgentToken) MarkAsUsed() error {\n\tif !t.Used {\n\t\tt.Used = true\n\t\tnow := time.Now()\n\t\tt.UsedAt = &now\n\t\treturn Db.Save(t).Error\n\t}\n\treturn nil\n}\n\nfunc (t *AgentToken) IsValid() bool {\n\treturn time.Now().Before(t.ExpiresAt)\n}\n\n// CleanExpired 清理过期token\nfunc (t *AgentToken) CleanExpired() error {\n\treturn Db.Where(\"expires_at < ?\", time.Now()).Delete(&AgentToken{}).Error\n}\n"
  },
  {
    "path": "internal/models/audit_log.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\n// AuditLog records who did what and when\ntype AuditLog struct {\n\tId         int       `json:\"id\" gorm:\"primaryKey;autoIncrement\"`\n\tUsername   string    `json:\"username\" gorm:\"type:varchar(32);not null;index\"`\n\tIp         string    `json:\"ip\" gorm:\"type:varchar(45);not null\"`\n\tModule     string    `json:\"module\" gorm:\"type:varchar(32);not null;index\"` // task, host, user, system\n\tAction     string    `json:\"action\" gorm:\"type:varchar(32);not null\"`       // create, update, delete, enable, disable, run\n\tTargetId   int       `json:\"target_id\" gorm:\"default:0\"`\n\tTargetName string    `json:\"target_name\" gorm:\"type:varchar(128)\"`\n\tDetail     string    `json:\"detail\" gorm:\"type:text\"`\n\tCreatedAt  time.Time `json:\"created\" gorm:\"column:created;autoCreateTime;index\"`\n\tBaseModel  `json:\"-\" gorm:\"-\"`\n}\n\nfunc (log *AuditLog) Create() (insertId int, err error) {\n\tresult := Db.Create(log)\n\tif result.Error == nil {\n\t\tinsertId = log.Id\n\t}\n\n\treturn insertId, result.Error\n}\n\nfunc (log *AuditLog) List(params CommonMap) ([]AuditLog, error) {\n\tlog.parsePageAndPageSize(params)\n\tlist := make([]AuditLog, 0)\n\terr := log.buildQuery(params).Order(\"id DESC\").Limit(log.PageSize).Offset(log.pageLimitOffset()).Find(&list).Error\n\n\treturn list, err\n}\n\nfunc (log *AuditLog) Total(params CommonMap) (int64, error) {\n\tvar count int64\n\terr := log.buildQuery(params).Model(&AuditLog{}).Count(&count).Error\n\n\treturn count, err\n}\n\nfunc (log *AuditLog) buildQuery(params CommonMap) *gorm.DB {\n\tdb := Db\n\tif module, ok := params[\"Module\"]; ok && module != \"\" {\n\t\tdb = db.Where(\"module = ?\", module)\n\t}\n\tif action, ok := params[\"Action\"]; ok && action != \"\" {\n\t\tdb = db.Where(\"action = ?\", action)\n\t}\n\tif username, ok := params[\"Username\"]; ok && username != \"\" {\n\t\tdb = db.Where(\"username LIKE ?\", \"%\"+username.(string)+\"%\")\n\t}\n\tif startDate, ok := params[\"StartDate\"]; ok && startDate != \"\" {\n\t\tdb = db.Where(\"created >= ?\", startDate)\n\t}\n\tif endDate, ok := params[\"EndDate\"]; ok && endDate != \"\" {\n\t\tdb = db.Where(\"created <= ?\", endDate)\n\t}\n\n\treturn db\n}\n"
  },
  {
    "path": "internal/models/audit_log_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/schema\"\n)\n\nfunc setupAuditLogTestDB(t *testing.T) func() {\n\tt.Helper()\n\toriginalDb := Db\n\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{\n\t\tNamingStrategy: schema.NamingStrategy{\n\t\t\tSingularTable: true,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open test database: %v\", err)\n\t}\n\n\tif err := db.AutoMigrate(&AuditLog{}); err != nil {\n\t\tt.Fatalf(\"failed to migrate test database: %v\", err)\n\t}\n\n\tDb = db\n\n\treturn func() {\n\t\tDb = originalDb\n\t}\n}\n\nfunc TestAuditLog_Create(t *testing.T) {\n\tcleanup := setupAuditLogTestDB(t)\n\tdefer cleanup()\n\n\tlog := &AuditLog{\n\t\tUsername:   \"admin\",\n\t\tIp:         \"127.0.0.1\",\n\t\tModule:     \"task\",\n\t\tAction:     \"create\",\n\t\tTargetId:   1,\n\t\tTargetName: \"my-task\",\n\t\tDetail:     \"created task my-task\",\n\t}\n\n\tinsertId, err := log.Create()\n\tif err != nil {\n\t\tt.Fatalf(\"Create returned error: %v\", err)\n\t}\n\tif insertId <= 0 {\n\t\tt.Errorf(\"expected insertId > 0, got %d\", insertId)\n\t}\n}\n\nfunc TestAuditLog_List_Empty(t *testing.T) {\n\tcleanup := setupAuditLogTestDB(t)\n\tdefer cleanup()\n\n\tauditLog := new(AuditLog)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 20}\n\tlist, err := auditLog.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) != 0 {\n\t\tt.Errorf(\"expected empty list, got %d items\", len(list))\n\t}\n}\n\nfunc TestAuditLog_List_Pagination(t *testing.T) {\n\tcleanup := setupAuditLogTestDB(t)\n\tdefer cleanup()\n\n\t// Insert 5 records\n\tfor i := 0; i < 5; i++ {\n\t\tlog := &AuditLog{\n\t\t\tUsername: \"admin\",\n\t\t\tIp:       \"127.0.0.1\",\n\t\t\tModule:   \"task\",\n\t\t\tAction:   \"create\",\n\t\t}\n\t\tif _, err := log.Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tauditLog := new(AuditLog)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 3}\n\tlist, err := auditLog.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) != 3 {\n\t\tt.Errorf(\"expected 3 items (page size), got %d\", len(list))\n\t}\n\n\t// Page 2 should have the remaining 2\n\tparams[\"Page\"] = 2\n\tlist2, err := auditLog.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List page 2 returned error: %v\", err)\n\t}\n\tif len(list2) != 2 {\n\t\tt.Errorf(\"expected 2 items on page 2, got %d\", len(list2))\n\t}\n}\n\nfunc TestAuditLog_List_FilterByModule(t *testing.T) {\n\tcleanup := setupAuditLogTestDB(t)\n\tdefer cleanup()\n\n\tentries := []AuditLog{\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"host\", Action: \"create\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"delete\"},\n\t}\n\tfor i := range entries {\n\t\tif _, err := entries[i].Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tauditLog := new(AuditLog)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 20, \"Module\": \"task\"}\n\tlist, err := auditLog.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) != 2 {\n\t\tt.Errorf(\"expected 2 task entries, got %d\", len(list))\n\t}\n\tfor _, item := range list {\n\t\tif item.Module != \"task\" {\n\t\t\tt.Errorf(\"expected module 'task', got '%s'\", item.Module)\n\t\t}\n\t}\n}\n\nfunc TestAuditLog_List_FilterByAction(t *testing.T) {\n\tcleanup := setupAuditLogTestDB(t)\n\tdefer cleanup()\n\n\tentries := []AuditLog{\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"delete\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"host\", Action: \"create\"},\n\t}\n\tfor i := range entries {\n\t\tif _, err := entries[i].Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tauditLog := new(AuditLog)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 20, \"Action\": \"delete\"}\n\tlist, err := auditLog.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) != 1 {\n\t\tt.Errorf(\"expected 1 delete entry, got %d\", len(list))\n\t}\n}\n\nfunc TestAuditLog_List_FilterByUsername(t *testing.T) {\n\tcleanup := setupAuditLogTestDB(t)\n\tdefer cleanup()\n\n\tentries := []AuditLog{\n\t\t{Username: \"alice\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t\t{Username: \"bob\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t\t{Username: \"alice_admin\", Ip: \"127.0.0.1\", Module: \"host\", Action: \"delete\"},\n\t}\n\tfor i := range entries {\n\t\tif _, err := entries[i].Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tauditLog := new(AuditLog)\n\t// LIKE match: \"alice\" matches \"alice\" and \"alice_admin\"\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 20, \"Username\": \"alice\"}\n\tlist, err := auditLog.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) != 2 {\n\t\tt.Errorf(\"expected 2 alice entries, got %d\", len(list))\n\t}\n}\n\nfunc TestAuditLog_List_FilterByDateRange(t *testing.T) {\n\tcleanup := setupAuditLogTestDB(t)\n\tdefer cleanup()\n\n\t// Insert records with specific timestamps via raw insert\n\tnow := time.Now()\n\tyesterday := now.AddDate(0, 0, -1).Format(\"2006-01-02 15:04:05\")\n\ttomorrow := now.AddDate(0, 0, 1).Format(\"2006-01-02 15:04:05\")\n\ttwoDaysAgo := now.AddDate(0, 0, -2).Format(\"2006-01-02 15:04:05\")\n\n\tDb.Exec(\"INSERT INTO audit_log (username, ip, module, action, target_id, target_name, detail, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\",\n\t\t\"admin\", \"127.0.0.1\", \"task\", \"create\", 0, \"\", \"\", yesterday)\n\tDb.Exec(\"INSERT INTO audit_log (username, ip, module, action, target_id, target_name, detail, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\",\n\t\t\"admin\", \"127.0.0.1\", \"task\", \"delete\", 0, \"\", \"\", twoDaysAgo)\n\n\tauditLog := new(AuditLog)\n\t// Filter to only yesterday\n\tstartDate := now.AddDate(0, 0, -1).Format(\"2006-01-02\") + \" 00:00:00\"\n\tendDate := tomorrow\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 20, \"StartDate\": startDate, \"EndDate\": endDate}\n\tlist, err := auditLog.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) != 1 {\n\t\tt.Errorf(\"expected 1 entry in date range, got %d\", len(list))\n\t}\n}\n\nfunc TestAuditLog_Total(t *testing.T) {\n\tcleanup := setupAuditLogTestDB(t)\n\tdefer cleanup()\n\n\t// Empty DB\n\tauditLog := new(AuditLog)\n\tparams := CommonMap{}\n\ttotal, err := auditLog.Total(params)\n\tif err != nil {\n\t\tt.Fatalf(\"Total returned error: %v\", err)\n\t}\n\tif total != 0 {\n\t\tt.Errorf(\"expected 0, got %d\", total)\n\t}\n\n\t// Insert 3 records\n\tfor i := 0; i < 3; i++ {\n\t\tlog := &AuditLog{\n\t\t\tUsername: \"admin\",\n\t\t\tIp:       \"127.0.0.1\",\n\t\t\tModule:   \"task\",\n\t\t\tAction:   \"create\",\n\t\t}\n\t\tif _, err := log.Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\ttotal, err = auditLog.Total(params)\n\tif err != nil {\n\t\tt.Fatalf(\"Total returned error: %v\", err)\n\t}\n\tif total != 3 {\n\t\tt.Errorf(\"expected 3, got %d\", total)\n\t}\n}\n\nfunc TestAuditLog_Total_WithFilter(t *testing.T) {\n\tcleanup := setupAuditLogTestDB(t)\n\tdefer cleanup()\n\n\tentries := []AuditLog{\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"host\", Action: \"create\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"delete\"},\n\t}\n\tfor i := range entries {\n\t\tif _, err := entries[i].Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tauditLog := new(AuditLog)\n\tparams := CommonMap{\"Module\": \"task\"}\n\ttotal, err := auditLog.Total(params)\n\tif err != nil {\n\t\tt.Fatalf(\"Total returned error: %v\", err)\n\t}\n\tif total != 2 {\n\t\tt.Errorf(\"expected 2 task entries, got %d\", total)\n\t}\n}\n\nfunc TestAuditLog_List_OrderByIdDesc(t *testing.T) {\n\tcleanup := setupAuditLogTestDB(t)\n\tdefer cleanup()\n\n\tfor i := 0; i < 3; i++ {\n\t\tlog := &AuditLog{\n\t\t\tUsername: \"admin\",\n\t\t\tIp:       \"127.0.0.1\",\n\t\t\tModule:   \"task\",\n\t\t\tAction:   \"create\",\n\t\t}\n\t\tif _, err := log.Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tauditLog := new(AuditLog)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 10}\n\tlist, err := auditLog.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) < 2 {\n\t\tt.Fatalf(\"expected at least 2 entries, got %d\", len(list))\n\t}\n\t// Verify descending order\n\tfor i := 1; i < len(list); i++ {\n\t\tif list[i].Id > list[i-1].Id {\n\t\t\tt.Errorf(\"expected descending order by id, but list[%d].Id=%d > list[%d].Id=%d\",\n\t\t\t\ti, list[i].Id, i-1, list[i-1].Id)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/models/cleanup_verify_test.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n)\n\n// TestCleanupIntegration 端到端验证任务级日志清理\nfunc TestCleanupIntegration(t *testing.T) {\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\toldDb := Db\n\tDb = db\n\tdefer func() { Db = oldDb }()\n\n\tDb.AutoMigrate(&Task{}, &TaskLog{})\n\n\t// 创建任务: task 10 保留2天, task 20 保留7天, task 30 无自定义(0)\n\tDb.Exec(\"INSERT INTO tasks (id, name, log_retention_days, level, protocol, spec, command, status, tag) VALUES (10, 'task-2day', 2, 1, 2, '@daily', 'ls', 0, '')\")\n\tDb.Exec(\"INSERT INTO tasks (id, name, log_retention_days, level, protocol, spec, command, status, tag) VALUES (20, 'task-7day', 7, 1, 2, '@daily', 'ls', 0, '')\")\n\tDb.Exec(\"INSERT INTO tasks (id, name, log_retention_days, level, protocol, spec, command, status, tag) VALUES (30, 'task-global', 0, 1, 2, '@daily', 'ls', 0, '')\")\n\n\tnow := time.Now()\n\n\t// 插入日志\n\t//  Task 10: 5条1天前(应保留), 5条3天前(应删除), 5条10天前(应删除)\n\t//  Task 20: 5条3天前(应保留), 5条10天前(应删除)\n\t//  Task 30: 5条10天前(无自定义策略，不由任务级清理处理)\n\tinsertLogs := func(taskId int, name string, age time.Duration, count int) {\n\t\tfor i := 0; i < count; i++ {\n\t\t\tDb.Create(&TaskLog{\n\t\t\t\tTaskId:    taskId,\n\t\t\t\tName:      name,\n\t\t\t\tSpec:      \"@daily\",\n\t\t\t\tProtocol:  2,\n\t\t\t\tCommand:   \"ls\",\n\t\t\t\tStartTime: LocalTime(now.Add(-age)),\n\t\t\t\tEndTime:   LocalTime(now.Add(-age).Add(time.Second)),\n\t\t\t\tStatus:    Finish,\n\t\t\t\tResult:    \"ok\",\n\t\t\t})\n\t\t}\n\t}\n\n\tinsertLogs(10, \"task-2day\", 1*24*time.Hour, 5)    // 1天前 → 保留\n\tinsertLogs(10, \"task-2day\", 3*24*time.Hour, 5)    // 3天前 → 删除\n\tinsertLogs(10, \"task-2day\", 10*24*time.Hour, 5)   // 10天前 → 删除\n\tinsertLogs(20, \"task-7day\", 3*24*time.Hour, 5)    // 3天前 → 保留\n\tinsertLogs(20, \"task-7day\", 10*24*time.Hour, 5)   // 10天前 → 删除\n\tinsertLogs(30, \"task-global\", 10*24*time.Hour, 5) // 不由任务级策略处理\n\n\t// 验证初始状态\n\tvar count10, count20, count30 int64\n\tDb.Model(&TaskLog{}).Where(\"task_id = 10\").Count(&count10)\n\tDb.Model(&TaskLog{}).Where(\"task_id = 20\").Count(&count20)\n\tDb.Model(&TaskLog{}).Where(\"task_id = 30\").Count(&count30)\n\tfmt.Printf(\"Before cleanup - Task10: %d, Task20: %d, Task30: %d\\n\", count10, count20, count30)\n\n\tif count10 != 15 || count20 != 10 || count30 != 5 {\n\t\tt.Fatalf(\"Initial state wrong: %d, %d, %d\", count10, count20, count30)\n\t}\n\n\t// 模拟 cron 清理逻辑: 查找自定义保留天数的任务并清理\n\ttaskLogModel := new(TaskLog)\n\tvar tasks []Task\n\tDb.Where(\"log_retention_days > 0\").Find(&tasks)\n\n\tif len(tasks) != 2 {\n\t\tt.Fatalf(\"Expected 2 tasks with custom retention, got %d\", len(tasks))\n\t}\n\n\tfor _, task := range tasks {\n\t\tcount, err := taskLogModel.RemoveByTaskIdAndDays(task.Id, task.LogRetentionDays)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"RemoveByTaskIdAndDays failed for task %d: %v\", task.Id, err)\n\t\t}\n\t\tfmt.Printf(\"Task %d (%s, retention=%d days): deleted %d logs\\n\",\n\t\t\ttask.Id, task.Name, task.LogRetentionDays, count)\n\t}\n\n\t// 验证清理后状态\n\tDb.Model(&TaskLog{}).Where(\"task_id = 10\").Count(&count10)\n\tDb.Model(&TaskLog{}).Where(\"task_id = 20\").Count(&count20)\n\tDb.Model(&TaskLog{}).Where(\"task_id = 30\").Count(&count30)\n\tfmt.Printf(\"After cleanup - Task10: %d, Task20: %d, Task30: %d\\n\", count10, count20, count30)\n\n\t// Task 10 (保留2天): 应只剩1天前的5条\n\tif count10 != 5 {\n\t\tt.Errorf(\"Task 10: expected 5 logs remaining (1-day-old), got %d\", count10)\n\t}\n\t// Task 20 (保留7天): 应只剩3天前的5条\n\tif count20 != 5 {\n\t\tt.Errorf(\"Task 20: expected 5 logs remaining (3-day-old), got %d\", count20)\n\t}\n\t// Task 30 (无自定义): 应该不受影响，仍有5条\n\tif count30 != 5 {\n\t\tt.Errorf(\"Task 30: expected 5 logs untouched, got %d\", count30)\n\t}\n\n\tfmt.Println(\"\\n✅ 任务级日志清理验证通过!\")\n\tfmt.Println(\"  - Task 10 (2天保留): 删除了3天前和10天前的日志，保留了1天前的\")\n\tfmt.Println(\"  - Task 20 (7天保留): 删除了10天前的日志，保留了3天前的\")\n\tfmt.Println(\"  - Task 30 (全局策略): 不受任务级清理影响\")\n}\n"
  },
  {
    "path": "internal/models/host.go",
    "content": "package models\n\nimport (\n\t\"gorm.io/gorm\"\n)\n\n// 主机\ntype Host struct {\n\tId        int    `json:\"id\" gorm:\"primaryKey;autoIncrement\"`\n\tName      string `json:\"name\" gorm:\"type:varchar(64);not null\"`\n\tAlias     string `json:\"alias\" gorm:\"type:varchar(32);not null;default:''\"`\n\tPort      int    `json:\"port\" gorm:\"not null;default:5921\"`\n\tRemark    string `json:\"remark\" gorm:\"type:varchar(100);not null;default:''\"`\n\tBaseModel `json:\"-\" gorm:\"-\"`\n\tSelected  bool `json:\"-\" gorm:\"-\"`\n}\n\n// 新增\nfunc (host *Host) Create() (insertId int, err error) {\n\tresult := Db.Create(host)\n\tif result.Error == nil {\n\t\tinsertId = host.Id\n\t}\n\n\treturn insertId, result.Error\n}\n\nfunc (host *Host) UpdateBean(id int) (int64, error) {\n\tresult := Db.Model(&Host{}).Where(\"id = ?\", id).\n\t\tSelect(\"name\", \"alias\", \"port\", \"remark\").\n\t\tUpdates(host)\n\treturn result.RowsAffected, result.Error\n}\n\n// 更新\nfunc (host *Host) Update(id int, data CommonMap) (int64, error) {\n\tupdateData := make(map[string]interface{})\n\tfor k, v := range data {\n\t\tupdateData[k] = v\n\t}\n\tresult := Db.Model(&Host{}).Where(\"id = ?\", id).UpdateColumns(updateData)\n\treturn result.RowsAffected, result.Error\n}\n\n// 删除\nfunc (host *Host) Delete(id int) (int64, error) {\n\tresult := Db.Delete(&Host{}, id)\n\treturn result.RowsAffected, result.Error\n}\n\nfunc (host *Host) Find(id int) error {\n\treturn Db.First(host, id).Error\n}\n\nfunc (host *Host) NameExists(name string, id int) (bool, error) {\n\tvar count int64\n\tquery := Db.Model(&Host{}).Where(\"name = ?\", name)\n\tif id != 0 {\n\t\tquery = query.Where(\"id != ?\", id)\n\t}\n\terr := query.Count(&count).Error\n\treturn count > 0, err\n}\n\nfunc (host *Host) List(params CommonMap) ([]Host, error) {\n\thost.parsePageAndPageSize(params)\n\tlist := make([]Host, 0)\n\tquery := Db.Order(\"id DESC\")\n\thost.parseWhere(query, params)\n\terr := query.Limit(host.PageSize).Offset(host.pageLimitOffset()).Find(&list).Error\n\n\treturn list, err\n}\n\nfunc (host *Host) AllList() ([]Host, error) {\n\tlist := make([]Host, 0)\n\terr := Db.Select(\"name\", \"port\").Order(\"id DESC\").Find(&list).Error\n\n\treturn list, err\n}\n\nfunc (host *Host) Total(params CommonMap) (int64, error) {\n\tvar count int64\n\tquery := Db.Model(&Host{})\n\thost.parseWhere(query, params)\n\terr := query.Count(&count).Error\n\treturn count, err\n}\n\n// 解析where\nfunc (host *Host) parseWhere(query *gorm.DB, params CommonMap) {\n\tif len(params) == 0 {\n\t\treturn\n\t}\n\tid, ok := params[\"Id\"]\n\tif ok && id.(int) > 0 {\n\t\tquery.Where(\"id = ?\", id)\n\t}\n\tname, ok := params[\"Name\"]\n\tif ok && name.(string) != \"\" {\n\t\tquery.Where(\"name = ?\", name)\n\t}\n}\n"
  },
  {
    "path": "internal/models/login_log.go",
    "content": "package models\n\nimport (\n\t\"time\"\n)\n\n// 用户登录日志\ntype LoginLog struct {\n\tId        int       `json:\"id\" gorm:\"primaryKey;autoIncrement\"`\n\tUsername  string    `json:\"username\" gorm:\"type:varchar(32);not null\"`\n\tIp        string    `json:\"ip\" gorm:\"type:varchar(15);not null\"`\n\tCreatedAt time.Time `json:\"created\" gorm:\"column:created;autoCreateTime\"`\n\tBaseModel `json:\"-\" gorm:\"-\"`\n}\n\nfunc (log *LoginLog) Create() (insertId int, err error) {\n\tresult := Db.Create(log)\n\tif result.Error == nil {\n\t\tinsertId = log.Id\n\t}\n\n\treturn insertId, result.Error\n}\n\nfunc (log *LoginLog) List(params CommonMap) ([]LoginLog, error) {\n\tlog.parsePageAndPageSize(params)\n\tlist := make([]LoginLog, 0)\n\terr := Db.Order(\"id DESC\").Limit(log.PageSize).Offset(log.pageLimitOffset()).Find(&list).Error\n\n\treturn list, err\n}\n\nfunc (log *LoginLog) Total() (int64, error) {\n\tvar count int64\n\terr := Db.Model(&LoginLog{}).Count(&count).Error\n\treturn count, err\n}\n"
  },
  {
    "path": "internal/models/migration.go",
    "content": "package models\n\nimport (\n\t\"errors\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"gorm.io/gorm\"\n)\n\ntype Migration struct{}\n\n// 首次安装, 创建数据库表\nfunc (migration *Migration) Install(dbName string) error {\n\tsetting := new(Setting)\n\ttables := []interface{}{\n\t\t&User{}, &Task{}, &TaskLog{}, &Host{}, setting, &LoginLog{}, &TaskHost{}, &AgentToken{}, &AuditLog{}, &TaskScriptVersion{}, &TaskTemplate{},\n\t}\n\n\tfor _, table := range tables {\n\t\tif Db.Migrator().HasTable(table) {\n\t\t\treturn errors.New(\"数据表已存在\")\n\t\t}\n\t\terr := Db.AutoMigrate(table)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// SQLite特殊处理：修复task_log表的自增主键\n\tif Db.Dialector.Name() == \"sqlite\" {\n\t\tmigration.fixSQLiteAutoIncrement()\n\t}\n\n\t// 初始化配置\n\tif err := RepairSettings(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// 迭代升级数据库, 新建表、新增字段等\nfunc (migration *Migration) Upgrade(oldVersionId int) {\n\t// v1.2版本不支持升级\n\tif oldVersionId == 120 {\n\t\treturn\n\t}\n\n\tversionIds := []int{110, 122, 130, 140, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 1510, 160}\n\tupgradeFuncs := []func(*gorm.DB) error{\n\t\tmigration.upgradeFor110,\n\t\tmigration.upgradeFor122,\n\t\tmigration.upgradeFor130,\n\t\tmigration.upgradeFor140,\n\t\tmigration.upgradeFor150,\n\t\tmigration.upgradeFor151,\n\t\tmigration.upgradeFor152,\n\t\tmigration.upgradeFor153,\n\t\tmigration.upgradeFor154,\n\t\tmigration.upgradeFor155,\n\t\tmigration.upgradeFor156,\n\t\tmigration.upgradeFor157,\n\t\tmigration.upgradeFor158,\n\t\tmigration.upgradeFor159,\n\t\tmigration.upgradeFor1510,\n\t\tmigration.upgradeFor160,\n\t}\n\n\tstartIndex := -1\n\t// 从当前版本的下一版本开始升级\n\tfor i, value := range versionIds {\n\t\tif value > oldVersionId {\n\t\t\tstartIndex = i\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif startIndex == -1 {\n\t\treturn\n\t}\n\n\tlength := len(versionIds)\n\tif startIndex >= length {\n\t\treturn\n\t}\n\n\terr := Db.Transaction(func(tx *gorm.DB) error {\n\t\tfor startIndex < length {\n\t\t\terr := upgradeFuncs[startIndex](tx)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tstartIndex++\n\t\t}\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\tlogger.Fatal(\"数据库升级失败\", err)\n\t}\n}\n\n// 升级到v1.1版本\nfunc (migration *Migration) upgradeFor110(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.1\")\n\n\t// 创建表task_host\n\terr := tx.AutoMigrate(&TaskHost{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// 把task对应的host_id写入task_host表\n\ttype OldTask struct {\n\t\tId     int\n\t\tHostId int\n\t}\n\tvar results []OldTask\n\terr = tx.Table(TablePrefix+\"task\").Select(\"id\", \"host_id\").Where(\"host_id > ?\", 0).Find(&results).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, value := range results {\n\t\ttaskHostModel := &TaskHost{\n\t\t\tTaskId: value.Id,\n\t\t\tHostId: value.HostId,\n\t\t}\n\t\terr = tx.Create(taskHostModel).Error\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// 删除task表host_id字段\n\terr = tx.Migrator().DropColumn(&Task{}, \"host_id\")\n\n\tlogger.Info(\"已升级到v1.1\\n\")\n\n\treturn err\n}\n\n// 升级到1.2.2版本\nfunc (migration *Migration) upgradeFor122(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.2.2\")\n\n\t// task表增加tag字段\n\tif !tx.Migrator().HasColumn(&Task{}, \"tag\") {\n\t\terr := tx.Migrator().AddColumn(&Task{}, \"tag\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tlogger.Info(\"已升级到v1.2.2\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.3版本\nfunc (migration *Migration) upgradeFor130(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.3\")\n\n\t// 删除user表deleted字段（如果存在）\n\tif tx.Migrator().HasColumn(&User{}, \"deleted\") {\n\t\terr := tx.Migrator().DropColumn(&User{}, \"deleted\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tlogger.Info(\"已升级到v1.3\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.4版本\nfunc (migration *Migration) upgradeFor140(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.4\")\n\n\t// task表增加字段\n\t// retry_interval 重试间隔时间(秒)\n\t// http_method    http请求方法\n\tif !tx.Migrator().HasColumn(&Task{}, \"retry_interval\") {\n\t\terr := tx.Migrator().AddColumn(&Task{}, \"retry_interval\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif !tx.Migrator().HasColumn(&Task{}, \"http_method\") {\n\t\terr := tx.Migrator().AddColumn(&Task{}, \"http_method\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tlogger.Info(\"已升级到v1.4\\n\")\n\n\treturn nil\n}\n\nfunc (m *Migration) upgradeFor150(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.5\")\n\n\t// task表增加字段 notify_keyword\n\tif !tx.Migrator().HasColumn(&Task{}, \"notify_keyword\") {\n\t\terr := tx.Migrator().AddColumn(&Task{}, \"notify_keyword\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// 检查并创建邮件模板配置\n\tvar count int64\n\ttx.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", MailCode, MailTemplateKey).Count(&count)\n\tif count == 0 {\n\t\tsettingModel := &Setting{\n\t\t\tCode:  MailCode,\n\t\t\tKey:   MailTemplateKey,\n\t\t\tValue: emailTemplate,\n\t\t}\n\t\tif err := tx.Create(settingModel).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// 检查并创建Slack模板配置\n\ttx.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", SlackCode, SlackTemplateKey).Count(&count)\n\tif count == 0 {\n\t\tsettingModel := &Setting{\n\t\t\tCode:  SlackCode,\n\t\t\tKey:   SlackTemplateKey,\n\t\t\tValue: slackTemplate,\n\t\t}\n\t\tif err := tx.Create(settingModel).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// 检查并创建Webhook URL配置\n\ttx.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", WebhookCode, WebhookUrlKey).Count(&count)\n\tif count == 0 {\n\t\tsettingModel := &Setting{\n\t\t\tCode:  WebhookCode,\n\t\t\tKey:   WebhookUrlKey,\n\t\t\tValue: \"\",\n\t\t}\n\t\tif err := tx.Create(settingModel).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// 检查并创建Webhook模板配置\n\ttx.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", WebhookCode, WebhookTemplateKey).Count(&count)\n\tif count == 0 {\n\t\tsettingModel := &Setting{\n\t\t\tCode:  WebhookCode,\n\t\t\tKey:   WebhookTemplateKey,\n\t\t\tValue: webhookTemplate,\n\t\t}\n\t\tif err := tx.Create(settingModel).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tlogger.Info(\"已升级到v1.5\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.5.1版本 - 添加2FA字段\nfunc (m *Migration) upgradeFor151(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.5.1 - 添加2FA支持\")\n\n\t// user表增加two_factor_key字段\n\tif !tx.Migrator().HasColumn(&User{}, \"two_factor_key\") {\n\t\terr := tx.Migrator().AddColumn(&User{}, \"two_factor_key\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// user表增加two_factor_on字段\n\tif !tx.Migrator().HasColumn(&User{}, \"two_factor_on\") {\n\t\terr := tx.Migrator().AddColumn(&User{}, \"two_factor_on\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tlogger.Info(\"已升级到v1.5.1\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.5.2版本 - 修复 SQLite host 表 AUTOINCREMENT\nfunc (m *Migration) upgradeFor152(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.5.2 - 修复 host 表自增主键\")\n\n\t// 只对 SQLite 数据库执行修复\n\tif tx.Dialector.Name() == \"sqlite\" {\n\t\tvar tableSQL string\n\t\terr := tx.Raw(\"SELECT sql FROM sqlite_master WHERE type='table' AND name='host'\").Scan(&tableSQL).Error\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(tableSQL) > 0 && !contains(tableSQL, \"AUTOINCREMENT\") {\n\t\t\tlogger.Info(\"检测到 host 表需要修复\")\n\n\t\t\t// 检查是否有数据\n\t\t\tvar hasData int64\n\t\t\ttx.Raw(\"SELECT COUNT(*) FROM host\").Scan(&hasData)\n\n\t\t\t// 重建表以支持 AUTOINCREMENT\n\t\t\terr = tx.Exec(`\n\t\t\t\tCREATE TABLE IF NOT EXISTS host_new (\n\t\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\t\tname varchar(64) NOT NULL,\n\t\t\t\t\talias varchar(32) NOT NULL DEFAULT '',\n\t\t\t\t\tport integer NOT NULL DEFAULT 5921,\n\t\t\t\t\tremark varchar(100) NOT NULL DEFAULT ''\n\t\t\t\t);\n\t\t\t`).Error\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// 如果有数据，迁移数据\n\t\t\tif hasData > 0 {\n\t\t\t\terr = tx.Exec(`\n\t\t\t\t\tINSERT INTO host_new (name, alias, port, remark)\n\t\t\t\t\tSELECT name, alias, port, remark FROM host WHERE name IS NOT NULL;\n\t\t\t\t`).Error\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 删除旧表\n\t\t\terr = tx.Exec(`DROP TABLE host;`).Error\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// 重命名新表\n\t\t\terr = tx.Exec(`ALTER TABLE host_new RENAME TO host;`).Error\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tlogger.Info(\"host 表已重建，支持自增主键\")\n\t\t} else {\n\t\t\tlogger.Info(\"host 表结构正确，无需修复\")\n\t\t}\n\t}\n\n\tlogger.Info(\"已升级到v1.5.2\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.5.3版本 - 修复 SQLite task_log 表 AUTOINCREMENT\nfunc (m *Migration) upgradeFor153(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.5.3 - 修复 task_log 表自增主键\")\n\n\t// 只对 SQLite 数据库执行修复\n\tif tx.Dialector.Name() == \"sqlite\" {\n\t\tvar tableSQL string\n\t\terr := tx.Raw(\"SELECT sql FROM sqlite_master WHERE type='table' AND name='task_log'\").Scan(&tableSQL).Error\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif len(tableSQL) > 0 && !contains(tableSQL, \"AUTOINCREMENT\") {\n\t\t\tlogger.Info(\"检测到 task_log 表需要修复\")\n\n\t\t\terr = tx.Exec(`\n\t\t\t\tCREATE TABLE IF NOT EXISTS task_log_new (\n\t\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\t\ttask_id integer NOT NULL DEFAULT 0,\n\t\t\t\t\tname varchar(32) NOT NULL,\n\t\t\t\t\tspec varchar(64) NOT NULL,\n\t\t\t\t\tprotocol tinyint NOT NULL,\n\t\t\t\t\tcommand varchar(256) NOT NULL,\n\t\t\t\t\ttimeout mediumint NOT NULL DEFAULT 0,\n\t\t\t\t\tretry_times tinyint NOT NULL DEFAULT 0,\n\t\t\t\t\thostname varchar(128) NOT NULL DEFAULT '',\n\t\t\t\t\tstart_time datetime,\n\t\t\t\t\tend_time datetime,\n\t\t\t\t\tstatus tinyint NOT NULL DEFAULT 1,\n\t\t\t\t\tresult mediumtext NOT NULL\n\t\t\t\t);\n\t\t\t`).Error\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// 迁移最近的数据（最多10000条）\n\t\t\tvar hasData int64\n\t\t\ttx.Raw(\"SELECT COUNT(*) FROM task_log\").Scan(&hasData)\n\t\t\tif hasData > 0 {\n\t\t\t\terr = tx.Exec(`\n\t\t\t\t\tINSERT INTO task_log_new (task_id, name, spec, protocol, command, timeout, retry_times, hostname, start_time, end_time, status, result)\n\t\t\t\t\tSELECT task_id, name, spec, protocol, command, timeout, retry_times, hostname, start_time, end_time, status, result \n\t\t\t\t\tFROM task_log \n\t\t\t\t\tWHERE task_id IS NOT NULL\n\t\t\t\t\tORDER BY start_time DESC \n\t\t\t\t\tLIMIT 10000;\n\t\t\t\t`).Error\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\terr = tx.Exec(`DROP TABLE task_log;`).Error\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\terr = tx.Exec(`ALTER TABLE task_log_new RENAME TO task_log;`).Error\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tlogger.Info(\"task_log 表已重建，支持自增主键\")\n\t\t} else {\n\t\t\tlogger.Info(\"task_log 表结构正确，无需修复\")\n\t\t}\n\n\t\t// 清理状态异常的历史任务日志（status=1 且 result 为空）\n\t\terr = tx.Exec(`\n\t\t\tUPDATE task_log \n\t\t\tSET status = 0, \n\t\t\t    result = '任务异常终止（未正常完成）',\n\t\t\t    end_time = datetime(start_time, '+1 second')\n\t\t\tWHERE status = 1 \n\t\t\tAND (result IS NULL OR result = '');\n\t\t`).Error\n\t\tif err != nil {\n\t\t\tlogger.Error(\"清理异常任务日志失败\", err)\n\t\t} else {\n\t\t\tlogger.Info(\"已清理状态异常的历史任务日志\")\n\t\t}\n\t}\n\n\tlogger.Info(\"已升级到v1.5.3\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.5.4版本 - 添加agent_token表\nfunc (m *Migration) upgradeFor154(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.5.4 - 添加agent自动注册支持\")\n\n\tif err := tx.AutoMigrate(&AgentToken{}); err != nil {\n\t\treturn err\n\t}\n\n\tif err := tx.Migrator().AlterColumn(&AgentToken{}, \"UsedAt\"); err != nil {\n\t\tlogger.Warn(\"调整 agent_token.used_at 可空属性失败\", err)\n\t}\n\n\tlogger.Info(\"已升级到v1.5.4\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.5.5版本 - 修改 host.id 和 task_host.host_id 字段类型从 smallint 到 int\nfunc (m *Migration) upgradeFor155(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.5.5 - 扩展主机ID字段类型和性能优化\")\n\n\t// 1. 使用 GORM AutoMigrate 自动调整字段类型\n\t// GORM 会根据模型定义自动修改字段类型\n\tif err := tx.AutoMigrate(&Host{}, &TaskHost{}); err != nil {\n\t\treturn err\n\t}\n\tlogger.Info(\"✓ 主机ID字段类型已升级\")\n\n\t// 2. 性能优化: 添加 task_log.start_time 索引 (用于日志清理和时间范围查询)\n\tif !tx.Migrator().HasIndex(&TaskLog{}, \"idx_task_log_start_time\") {\n\t\tif err := tx.Migrator().CreateIndex(&TaskLog{}, \"StartTime\"); err != nil {\n\t\t\tlogger.Warn(\"创建 task_log.start_time 索引失败\", err)\n\t\t} else {\n\t\t\tlogger.Info(\"✓ 创建 task_log.start_time 索引\")\n\t\t}\n\t}\n\n\t// 3. 性能优化: 添加 task_log 复合索引 (task_id, status) - 用于查询特定任务的执行状态\n\tif !tx.Migrator().HasIndex(&TaskLog{}, \"idx_task_log_task_status\") {\n\t\tif err := tx.Exec(\"CREATE INDEX idx_task_log_task_status ON \" + TablePrefix + \"task_log(task_id, status)\").Error; err != nil {\n\t\t\tlogger.Warn(\"创建 task_log 复合索引失败\", err)\n\t\t} else {\n\t\t\tlogger.Info(\"✓ 创建 task_log(task_id, status) 复合索引\")\n\t\t}\n\t}\n\n\t// 4. 性能优化: 添加 task 复合索引 (status, level) - 用于 ActiveList 查询\n\tif !tx.Migrator().HasIndex(&Task{}, \"idx_task_status_level\") {\n\t\tif err := tx.Exec(\"CREATE INDEX idx_task_status_level ON \" + TablePrefix + \"task(status, level)\").Error; err != nil {\n\t\t\tlogger.Warn(\"创建 task 复合索引失败\", err)\n\t\t} else {\n\t\t\tlogger.Info(\"✓ 创建 task(status, level) 复合索引\")\n\t\t}\n\t}\n\n\tlogger.Info(\"已升级到v1.5.5\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.5.6版本 - 更新字段默认值以支持基于0的索引\nfunc (m *Migration) upgradeFor156(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.5.6 - 更新字段默认值\")\n\n\t// 更新 notify_status 默认值为 1 的旧数据为 0（禁用通知）\n\t// 只更新 notify_type=0 且 notify_receiver_id 为空的记录，这些是真正的默认值\n\tresult := tx.Exec(`\n\t\tUPDATE ` + TablePrefix + `task \n\t\tSET notify_status = 0 \n\t\tWHERE notify_status = 1 \n\t\tAND notify_type = 0 \n\t\tAND (notify_receiver_id = '' OR notify_receiver_id IS NULL)\n\t`)\n\tif result.Error != nil {\n\t\tlogger.Warn(\"更新 notify_status 默认值失败\", result.Error)\n\t} else if result.RowsAffected > 0 {\n\t\tlogger.Infof(\"✓ 已更新 %d 条任务的 notify_status 默认值\", result.RowsAffected)\n\t}\n\n\tlogger.Info(\"已升级到v1.5.6\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.5.7版本 - 扩展命令字段长度到TEXT类型\nfunc (m *Migration) upgradeFor157(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.5.7 - 扩展命令字段长度\")\n\n\t// 扩展 command 字段从 varchar 到 text\n\tif err := tx.Exec(`ALTER TABLE ` + TablePrefix + `task MODIFY COLUMN command text NOT NULL`).Error; err != nil {\n\t\tlogger.Warn(\"扩展 command 字段类型失败\", err)\n\t} else {\n\t\tlogger.Info(\"✓ command 字段已扩展为 TEXT 类型（最多 65535 字符）\")\n\t}\n\n\tlogger.Info(\"已升级到v1.5.7\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.5.8版本 - 多标签支持 + 任务级日志保留天数\nfunc (m *Migration) upgradeFor158(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.5.8 - 多标签支持、任务级日志保留天数\")\n\n\t// 扩展 tag 字段从 varchar(32) 到 varchar(255) 以支持多标签\n\tif err := tx.Migrator().AlterColumn(&Task{}, \"Tag\"); err != nil {\n\t\tlogger.Warn(\"扩展 tag 字段类型失败\", err)\n\t} else {\n\t\tlogger.Info(\"✓ tag 字段已扩展为 varchar(255)\")\n\t}\n\n\t// 添加任务级日志保留天数字段\n\tif !tx.Migrator().HasColumn(&Task{}, \"log_retention_days\") {\n\t\terr := tx.Migrator().AddColumn(&Task{}, \"log_retention_days\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tlogger.Info(\"✓ 已添加 log_retention_days 字段\")\n\t}\n\n\tlogger.Info(\"已升级到v1.5.8\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.5.9版本 - HTTP任务增强：POST Body、自定义Header、响应断言\nfunc (m *Migration) upgradeFor159(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.5.9 - HTTP任务增强\")\n\n\t// 添加 http_body 字段\n\tif !tx.Migrator().HasColumn(&Task{}, \"http_body\") {\n\t\tif err := tx.Migrator().AddColumn(&Task{}, \"HttpBody\"); err != nil {\n\t\t\tlogger.Warn(\"添加 http_body 字段失败\", err)\n\t\t} else {\n\t\t\tlogger.Info(\"✓ 已添加 http_body 字段\")\n\t\t}\n\t}\n\n\t// 添加 http_headers 字段\n\tif !tx.Migrator().HasColumn(&Task{}, \"http_headers\") {\n\t\tif err := tx.Migrator().AddColumn(&Task{}, \"HttpHeaders\"); err != nil {\n\t\t\tlogger.Warn(\"添加 http_headers 字段失败\", err)\n\t\t} else {\n\t\t\tlogger.Info(\"✓ 已添加 http_headers 字段\")\n\t\t}\n\t}\n\n\t// 添加 success_pattern 字段\n\tif !tx.Migrator().HasColumn(&Task{}, \"success_pattern\") {\n\t\tif err := tx.Migrator().AddColumn(&Task{}, \"SuccessPattern\"); err != nil {\n\t\t\tlogger.Warn(\"添加 success_pattern 字段失败\", err)\n\t\t} else {\n\t\t\tlogger.Info(\"✓ 已添加 success_pattern 字段\")\n\t\t}\n\t}\n\n\tlogger.Info(\"已升级到v1.5.9\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.5.10版本 - 添加审计日志表\nfunc (m *Migration) upgradeFor1510(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.5.10 - 添加审计日志支持\")\n\n\tif err := tx.AutoMigrate(&AuditLog{}); err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Info(\"已升级到v1.5.10\\n\")\n\n\treturn nil\n}\n\n// 升级到v1.6.0版本 - 添加脚本版本管理和任务模板\nfunc (m *Migration) upgradeFor160(tx *gorm.DB) error {\n\tlogger.Info(\"开始升级到v1.6.0 - 添加脚本版本管理和任务模板\")\n\n\tif err := tx.AutoMigrate(&TaskScriptVersion{}); err != nil {\n\t\treturn err\n\t}\n\tlogger.Info(\"✓ 已创建 task_script_version 表\")\n\n\tif err := tx.AutoMigrate(&TaskTemplate{}); err != nil {\n\t\treturn err\n\t}\n\tlogger.Info(\"✓ 已创建 task_template 表\")\n\n\t// 初始化内置模板\n\tvar count int64\n\ttx.Model(&TaskTemplate{}).Where(\"is_builtin = ?\", 1).Count(&count)\n\tif count == 0 {\n\t\tseedBuiltinTemplates(tx)\n\t\tlogger.Info(\"✓ 已初始化内置模板\")\n\t}\n\n\tlogger.Info(\"已升级到v1.6.0\\n\")\n\n\treturn nil\n}\n\n// contains 检查字符串是否包含子串\nfunc contains(s, substr string) bool {\n\treturn len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr)))\n}\n\nfunc containsMiddle(s, substr string) bool {\n\tfor i := 0; i <= len(s)-len(substr); i++ {\n\t\tif s[i:i+len(substr)] == substr {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// 修复SQLite表的自增主键问题\nfunc (m *Migration) fixSQLiteAutoIncrement() {\n\tlogger.Info(\"检查SQLite表结构...\")\n\n\t// 修复task_log表\n\tvar taskLogSQL string\n\tDb.Raw(\"SELECT sql FROM sqlite_master WHERE type='table' AND name='task_log'\").Scan(&taskLogSQL)\n\tif len(taskLogSQL) > 0 && !contains(taskLogSQL, \"AUTOINCREMENT\") {\n\t\tlogger.Info(\"修复task_log表自增主键...\")\n\t\tDb.Exec(`\n\t\t\tCREATE TABLE task_log_new (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\ttask_id integer NOT NULL DEFAULT 0,\n\t\t\t\tname varchar(32) NOT NULL,\n\t\t\t\tspec varchar(64) NOT NULL,\n\t\t\t\tprotocol tinyint NOT NULL,\n\t\t\t\tcommand varchar(256) NOT NULL,\n\t\t\t\ttimeout mediumint NOT NULL DEFAULT 0,\n\t\t\t\tretry_times tinyint NOT NULL DEFAULT 0,\n\t\t\t\thostname varchar(128) NOT NULL DEFAULT '',\n\t\t\t\tstart_time datetime,\n\t\t\t\tend_time datetime,\n\t\t\t\tstatus tinyint NOT NULL DEFAULT 1,\n\t\t\t\tresult mediumtext NOT NULL\n\t\t\t);\n\t\t`)\n\t\tDb.Exec(`DROP TABLE task_log;`)\n\t\tDb.Exec(`ALTER TABLE task_log_new RENAME TO task_log;`)\n\t\tlogger.Info(\"修复task_log表完成\")\n\t}\n\n\t// 修复host表\n\tvar hostSQL string\n\tDb.Raw(\"SELECT sql FROM sqlite_master WHERE type='table' AND name='host'\").Scan(&hostSQL)\n\tif len(hostSQL) > 0 && !contains(hostSQL, \"AUTOINCREMENT\") {\n\t\tlogger.Info(\"修复host表自增主键...\")\n\t\tDb.Exec(`\n\t\t\tCREATE TABLE host_new (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tname varchar(64) NOT NULL,\n\t\t\t\talias varchar(32) NOT NULL DEFAULT '',\n\t\t\t\tport integer NOT NULL DEFAULT 5921,\n\t\t\t\tremark varchar(100) NOT NULL DEFAULT ''\n\t\t\t);\n\t\t`)\n\t\tDb.Exec(`DROP TABLE host;`)\n\t\tDb.Exec(`ALTER TABLE host_new RENAME TO host;`)\n\t\tlogger.Info(\"修复host表完成\")\n\t}\n}\n"
  },
  {
    "path": "internal/models/model.go",
    "content": "package models\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/driver/mysql\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/logger\"\n\t\"gorm.io/gorm/schema\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/app\"\n\tglogger \"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/setting\"\n)\n\ntype Status int8\ntype CommonMap map[string]interface{}\n\nvar TablePrefix = \"\"\nvar Db *gorm.DB\n\n// dbKeepAliveStop is closed to signal the keepDbAlived goroutine to exit.\nvar dbKeepAliveStop chan struct{}\n\nconst (\n\tDisabled Status = 0 // 禁用\n\tFailure  Status = 0 // 失败\n\tEnabled  Status = 1 // 启用\n\tRunning  Status = 1 // 运行中\n\tFinish   Status = 2 // 完成\n\tCancel   Status = 3 // 取消\n)\n\nconst (\n\tPage        = 1    // 当前页数\n\tPageSize    = 20   // 每页多少条数据\n\tMaxPageSize = 1000 // 每次最多取多少条\n)\n\nconst DefaultTimeFormat = \"2006-01-02 15:04:05\"\n\nconst (\n\tdbPingInterval = 90 * time.Second\n\tdbMaxLiftTime  = 2 * time.Hour\n)\n\ntype BaseModel struct {\n\tPage     int `gorm:\"-\"`\n\tPageSize int `gorm:\"-\"`\n}\n\nfunc (model *BaseModel) parsePageAndPageSize(params CommonMap) {\n\tpage, ok := params[\"Page\"]\n\tif ok {\n\t\tmodel.Page = page.(int)\n\t}\n\tpageSize, ok := params[\"PageSize\"]\n\tif ok {\n\t\tmodel.PageSize = pageSize.(int)\n\t}\n\tif model.Page <= 0 {\n\t\tmodel.Page = Page\n\t}\n\tif model.PageSize <= 0 {\n\t\tmodel.PageSize = MaxPageSize\n\t}\n}\n\nfunc (model *BaseModel) pageLimitOffset() int {\n\treturn (model.Page - 1) * model.PageSize\n}\n\n// 创建Db\nfunc CreateDb() *gorm.DB {\n\tdsn := getDbEngineDSN(app.Setting)\n\tvar dialector gorm.Dialector\n\n\tengine := strings.ToLower(app.Setting.Db.Engine)\n\tswitch engine {\n\tcase \"mysql\":\n\t\tdialector = mysql.Open(dsn)\n\tcase \"postgres\":\n\t\tdialector = postgres.Open(dsn)\n\tcase \"sqlite\":\n\t\tensureSqliteDir(dsn)\n\t\tdialector = gormlite.Open(dsn)\n\tdefault:\n\t\tglogger.Fatal(\"不支持的数据库类型\", nil)\n\t}\n\n\t// 配置 gorm\n\tconfig := &gorm.Config{\n\t\tNamingStrategy: schema.NamingStrategy{\n\t\t\tTablePrefix:   app.Setting.Db.Prefix,\n\t\t\tSingularTable: true,\n\t\t},\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t}\n\n\t// 开发模式下开启日志\n\tif gin.Mode() == gin.DebugMode {\n\t\tconfig.Logger = logger.Default.LogMode(logger.Info)\n\t}\n\n\tdb, err := gorm.Open(dialector, config)\n\tif err != nil {\n\t\tglogger.Fatal(\"创建gorm引擎失败\", err)\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\tglogger.Fatal(\"获取数据库连接失败\", err)\n\t}\n\n\t// SQLite 需要特殊的连接池配置\n\tif engine == \"sqlite\" {\n\t\tsqlDB.SetMaxOpenConns(1) // SQLite 只允许一个写连接\n\t} else {\n\t\tsqlDB.SetMaxIdleConns(app.Setting.Db.MaxIdleConns)\n\t\tsqlDB.SetMaxOpenConns(app.Setting.Db.MaxOpenConns)\n\t}\n\tsqlDB.SetConnMaxLifetime(dbMaxLiftTime)\n\n\tif app.Setting.Db.Prefix != \"\" {\n\t\tTablePrefix = app.Setting.Db.Prefix\n\t}\n\n\tdbKeepAliveStop = make(chan struct{})\n\tgo keepDbAlived(db, dbKeepAliveStop)\n\n\treturn db\n}\n\n// StopKeepAlive signals the keepDbAlived goroutine to exit. Safe to call multiple times.\nfunc StopKeepAlive() {\n\tif dbKeepAliveStop == nil {\n\t\treturn\n\t}\n\tselect {\n\tcase <-dbKeepAliveStop:\n\t\t// already closed\n\tdefault:\n\t\tclose(dbKeepAliveStop)\n\t}\n}\n\n// 创建临时数据库连接\nfunc CreateTmpDb(setting *setting.Setting) (*gorm.DB, error) {\n\tdsn := getDbEngineDSN(setting)\n\tvar dialector gorm.Dialector\n\n\tengine := strings.ToLower(setting.Db.Engine)\n\tswitch engine {\n\tcase \"mysql\":\n\t\tdialector = mysql.Open(dsn)\n\tcase \"postgres\":\n\t\tdialector = postgres.Open(dsn)\n\tcase \"sqlite\":\n\t\tensureSqliteDir(dsn)\n\t\tdialector = gormlite.Open(dsn)\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"不支持的数据库类型: %s\", engine)\n\t}\n\n\treturn gorm.Open(dialector, &gorm.Config{})\n}\n\n// 获取数据库引擎DSN  mysql,postgres\nfunc getDbEngineDSN(setting *setting.Setting) string {\n\tengine := strings.ToLower(setting.Db.Engine)\n\tdsn := \"\"\n\tswitch engine {\n\tcase \"mysql\":\n\t\tdsn = fmt.Sprintf(\"%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local\",\n\t\t\tsetting.Db.User,\n\t\t\tsetting.Db.Password,\n\t\t\tsetting.Db.Host,\n\t\t\tsetting.Db.Port,\n\t\t\tsetting.Db.Database,\n\t\t\tsetting.Db.Charset)\n\tcase \"postgres\":\n\t\tdsn = fmt.Sprintf(\"user=%s password=%s host=%s port=%d dbname=%s sslmode=disable\",\n\t\t\tsetting.Db.User,\n\t\t\tsetting.Db.Password,\n\t\t\tsetting.Db.Host,\n\t\t\tsetting.Db.Port,\n\t\t\tsetting.Db.Database)\n\tcase \"sqlite\":\n\t\tdsn = setting.Db.Database\n\t}\n\n\treturn dsn\n}\n\nfunc keepDbAlived(db *gorm.DB, stop <-chan struct{}) {\n\tticker := time.NewTicker(dbPingInterval)\n\tdefer ticker.Stop()\n\tfor {\n\t\tselect {\n\t\tcase <-stop:\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tsqlDB, err := db.DB()\n\t\t\tif err != nil {\n\t\t\t\tglogger.Infof(\"database get connection: %s\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := sqlDB.Ping(); err != nil {\n\t\t\t\tglogger.Infof(\"database ping failed: %s\", err)\n\t\t\t} else {\n\t\t\t\tglogger.Infof(\"database ping: ok\")\n\t\t\t}\n\t\t}\n\t}\n}\n\n// 确保 SQLite 数据库文件所在目录存在\nfunc ensureSqliteDir(dbPath string) {\n\t// 清理并规范化路径\n\tdbPath = filepath.Clean(dbPath)\n\tdir := filepath.Dir(dbPath)\n\n\tif dir != \"\" && dir != \".\" {\n\t\t// 验证路径不是绝对路径时，确保不包含父目录引用\n\t\tif !filepath.IsAbs(dbPath) && strings.Contains(dbPath, \"..\") {\n\t\t\tglogger.Fatal(\"非法的数据库路径\", nil)\n\t\t}\n\t\tif err := os.MkdirAll(dir, 0750); err != nil {\n\t\t\tglogger.Fatal(\"创建SQLite数据库目录失败\", err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/models/scheduler_lock.go",
    "content": "package models\n\nimport \"time\"\n\n// SchedulerLock 调度器分布式锁表\n// 参考 XXL-JOB 的数据库行锁方案，用 SELECT ... FOR UPDATE 实现选主\ntype SchedulerLock struct {\n\tId        int       `gorm:\"primaryKey;autoIncrement\"`\n\tLockName  string    `gorm:\"type:varchar(64);uniqueIndex;not null\"` // 锁名称\n\tLockedBy  string    `gorm:\"type:varchar(255);not null\"`            // 持有者标识 (hostname:pid)\n\tLockedAt  time.Time `gorm:\"not null\"`                              // 获取锁时间\n\tExpireAt  time.Time `gorm:\"not null\"`                              // 过期时间\n\tVersion   int       `gorm:\"not null;default:0\"`                    // 乐观锁版本号\n\tCreatedAt time.Time\n\tUpdatedAt time.Time\n}\n\nfunc (SchedulerLock) TableName() string {\n\treturn TablePrefix + \"scheduler_lock\"\n}\n"
  },
  {
    "path": "internal/models/scheduler_lock_test.go",
    "content": "package models\n\nimport \"testing\"\n\nfunc TestSchedulerLock_TableName(t *testing.T) {\n\tlock := SchedulerLock{}\n\n\t// Default: no prefix\n\toriginal := TablePrefix\n\tTablePrefix = \"\"\n\tdefer func() { TablePrefix = original }()\n\n\tif got := lock.TableName(); got != \"scheduler_lock\" {\n\t\tt.Errorf(\"expected %q, got %q\", \"scheduler_lock\", got)\n\t}\n\n\t// With prefix\n\tTablePrefix = \"gocron_\"\n\tif got := lock.TableName(); got != \"gocron_scheduler_lock\" {\n\t\tt.Errorf(\"expected %q, got %q\", \"gocron_scheduler_lock\", got)\n\t}\n}\n"
  },
  {
    "path": "internal/models/setting.go",
    "content": "package models\n\nimport (\n\t\"encoding/json\"\n\t\"strconv\"\n)\n\ntype Setting struct {\n\tId    int    `gorm:\"primaryKey;autoIncrement\"`\n\tCode  string `gorm:\"type:varchar(32);not null\"`\n\tKey   string `gorm:\"type:varchar(64);not null\"`\n\tValue string `gorm:\"type:varchar(4096);not null;default:''\"`\n}\n\nconst slackTemplate = `Task ID: {{.TaskId}}\nTask Name: {{.TaskName}}\nStatus: {{.Status}}\nResult: {{.Result}}\nRemark: {{.Remark}}`\n\nconst emailTemplate = `Task ID: {{.TaskId}}\nTask Name: {{.TaskName}}\nStatus: {{.Status}}\nResult: {{.Result}}\nRemark: {{.Remark}}`\nconst webhookTemplate = `\n{\n  \"task_id\": \"{{.TaskId}}\",\n  \"task_name\": \"{{.TaskName}}\",\n  \"status\": \"{{.Status}}\",\n  \"result\": \"{{.Result}}\",\n  \"remark\": \"{{.Remark}}\"\n}\n`\n\nconst (\n\tSlackCode        = \"slack\"\n\tSlackUrlKey      = \"url\"\n\tSlackTemplateKey = \"template\"\n\tSlackChannelKey  = \"channel\"\n)\n\nconst (\n\tMailCode        = \"mail\"\n\tMailTemplateKey = \"template\"\n\tMailServerKey   = \"server\"\n\tMailUserKey     = \"user\"\n)\n\nconst (\n\tWebhookCode        = \"webhook\"\n\tWebhookTemplateKey = \"template\"\n\tWebhookUrlKey      = \"url\"\n)\n\nconst (\n\tSystemCode          = \"system\"\n\tLogRetentionDaysKey = \"log_retention_days\"\n\tLogCleanupTimeKey   = \"log_cleanup_time\"\n\tLogFileSizeLimitKey = \"log_file_size_limit\"\n)\n\n// region slack配置\n\ntype Slack struct {\n\tUrl      string    `json:\"url\"`\n\tChannels []Channel `json:\"channels\"`\n\tTemplate string    `json:\"template\"`\n}\n\ntype Channel struct {\n\tId   int    `json:\"id\"`\n\tName string `json:\"name\"`\n}\n\nfunc (setting *Setting) Slack() (Slack, error) {\n\tlist := make([]Setting, 0)\n\terr := Db.Where(\"code = ?\", SlackCode).Find(&list).Error\n\tslack := Slack{}\n\tif err != nil {\n\t\treturn slack, err\n\t}\n\n\tsetting.formatSlack(list, &slack)\n\n\treturn slack, err\n}\n\nfunc (setting *Setting) formatSlack(list []Setting, slack *Slack) {\n\tfor _, v := range list {\n\t\tswitch v.Key {\n\t\tcase SlackUrlKey:\n\t\t\tslack.Url = v.Value\n\t\tcase SlackTemplateKey:\n\t\t\tslack.Template = v.Value\n\t\tdefault:\n\t\t\tslack.Channels = append(slack.Channels, Channel{\n\t\t\t\tv.Id, v.Value,\n\t\t\t})\n\t\t}\n\t}\n}\n\nfunc (setting *Setting) UpdateSlack(url, template string) error {\n\tsetting.Value = url\n\tDb.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", SlackCode, SlackUrlKey).Update(\"value\", url)\n\n\tsetting.Value = template\n\tDb.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", SlackCode, SlackTemplateKey).Update(\"value\", template)\n\n\treturn nil\n}\n\n// 创建slack渠道\nfunc (setting *Setting) CreateChannel(channel string) (int64, error) {\n\tsetting.Code = SlackCode\n\tsetting.Key = SlackChannelKey\n\tsetting.Value = channel\n\n\tresult := Db.Create(setting)\n\treturn result.RowsAffected, result.Error\n}\n\nfunc (setting *Setting) IsChannelExist(channel string) bool {\n\tvar count int64\n\tDb.Model(&Setting{}).Where(\"code = ? AND `key` = ? AND value = ?\", SlackCode, SlackChannelKey, channel).Count(&count)\n\treturn count > 0\n}\n\n// 删除slack渠道\nfunc (setting *Setting) RemoveChannel(id int) (int64, error) {\n\tresult := Db.Where(\"code = ? AND `key` = ? AND id = ?\", SlackCode, SlackChannelKey, id).Delete(&Setting{})\n\treturn result.RowsAffected, result.Error\n}\n\n// endregion\n\ntype Mail struct {\n\tHost      string     `json:\"host\"`\n\tPort      int        `json:\"port\"`\n\tUser      string     `json:\"user\"`\n\tPassword  string     `json:\"password\"`\n\tMailUsers []MailUser `json:\"mail_users\"`\n\tTemplate  string     `json:\"template\"`\n}\n\ntype MailUser struct {\n\tId       int    `json:\"id\"`\n\tUsername string `json:\"username\"`\n\tEmail    string `json:\"email\"`\n}\n\n// region 邮件配置\nfunc (setting *Setting) Mail() (Mail, error) {\n\tlist := make([]Setting, 0)\n\terr := Db.Where(\"code = ?\", MailCode).Find(&list).Error\n\tmail := Mail{MailUsers: make([]MailUser, 0)}\n\tif err != nil {\n\t\treturn mail, err\n\t}\n\n\tsetting.formatMail(list, &mail)\n\n\treturn mail, err\n}\n\nfunc (setting *Setting) formatMail(list []Setting, mail *Mail) {\n\tmailUser := MailUser{}\n\tfor _, v := range list {\n\t\tswitch v.Key {\n\t\tcase MailServerKey:\n\t\t\tif v.Value != \"\" {\n\t\t\t\t_ = json.Unmarshal([]byte(v.Value), mail)\n\t\t\t}\n\t\tcase MailUserKey:\n\t\t\tif v.Value != \"\" {\n\t\t\t\t_ = json.Unmarshal([]byte(v.Value), &mailUser)\n\t\t\t\tmailUser.Id = v.Id\n\t\t\t\tmail.MailUsers = append(mail.MailUsers, mailUser)\n\t\t\t}\n\t\tcase MailTemplateKey:\n\t\t\tmail.Template = v.Value\n\t\t}\n\n\t}\n}\n\nfunc (setting *Setting) UpdateMail(config, template string) error {\n\tDb.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", MailCode, MailServerKey).Update(\"value\", config)\n\tDb.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", MailCode, MailTemplateKey).Update(\"value\", template)\n\n\treturn nil\n}\n\nfunc (setting *Setting) CreateMailUser(username, email string) (int64, error) {\n\tsetting.Code = MailCode\n\tsetting.Key = MailUserKey\n\tmailUser := MailUser{0, username, email}\n\tjsonByte, err := json.Marshal(mailUser)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tsetting.Value = string(jsonByte)\n\n\tresult := Db.Create(setting)\n\treturn result.RowsAffected, result.Error\n}\n\nfunc (setting *Setting) RemoveMailUser(id int) (int64, error) {\n\tresult := Db.Where(\"code = ? AND `key` = ? AND id = ?\", MailCode, MailUserKey, id).Delete(&Setting{})\n\treturn result.RowsAffected, result.Error\n}\n\ntype WebHook struct {\n\tWebhookUrls []WebhookUrl `json:\"webhook_urls\"`\n\tTemplate    string       `json:\"template\"`\n}\n\ntype WebhookUrl struct {\n\tId   int    `json:\"id\"`\n\tName string `json:\"name\"`\n\tUrl  string `json:\"url\"`\n}\n\nfunc (setting *Setting) Webhook() (WebHook, error) {\n\tlist := make([]Setting, 0)\n\terr := Db.Where(\"code = ?\", WebhookCode).Find(&list).Error\n\twebHook := WebHook{WebhookUrls: make([]WebhookUrl, 0)}\n\tif err != nil {\n\t\treturn webHook, err\n\t}\n\n\tsetting.formatWebhook(list, &webHook)\n\n\treturn webHook, err\n}\n\nfunc (setting *Setting) formatWebhook(list []Setting, webHook *WebHook) {\n\twebhookUrl := WebhookUrl{}\n\tfor _, v := range list {\n\t\tswitch v.Key {\n\t\tcase WebhookUrlKey:\n\t\t\tif v.Value != \"\" {\n\t\t\t\t_ = json.Unmarshal([]byte(v.Value), &webhookUrl)\n\t\t\t\twebhookUrl.Id = v.Id\n\t\t\t\twebHook.WebhookUrls = append(webHook.WebhookUrls, webhookUrl)\n\t\t\t}\n\t\tcase WebhookTemplateKey:\n\t\t\twebHook.Template = v.Value\n\t\t}\n\t}\n}\n\nfunc (setting *Setting) UpdateWebHook(template string) error {\n\tDb.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", WebhookCode, WebhookTemplateKey).Update(\"value\", template)\n\treturn nil\n}\n\nfunc (setting *Setting) CreateWebhookUrl(name, url string) (int64, error) {\n\twebhookUrl := WebhookUrl{0, name, url}\n\tjsonByte, err := json.Marshal(webhookUrl)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tnewSetting := Setting{\n\t\tCode:  WebhookCode,\n\t\tKey:   WebhookUrlKey,\n\t\tValue: string(jsonByte),\n\t}\n\n\tresult := Db.Create(&newSetting)\n\treturn result.RowsAffected, result.Error\n}\n\nfunc (setting *Setting) RemoveWebhookUrl(id int) (int64, error) {\n\tresult := Db.Where(\"code = ? AND `key` = ? AND id = ?\", WebhookCode, WebhookUrlKey, id).Delete(&Setting{})\n\treturn result.RowsAffected, result.Error\n}\n\n// endregion\n\n// region 通用配置辅助方法\n\n// getSettingValue 获取配置值的通用方法\nfunc (setting *Setting) getSettingValue(code, key string) (string, error) {\n\tvar s Setting\n\terr := Db.Where(\"code = ? AND `key` = ?\", code, key).First(&s).Error\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn s.Value, nil\n}\n\n// updateOrCreateSetting 更新或创建配置的通用方法\nfunc (setting *Setting) updateOrCreateSetting(code, key, value string) error {\n\tvar s Setting\n\terr := Db.Where(\"code = ? AND `key` = ?\", code, key).First(&s).Error\n\tif err != nil {\n\t\t// 记录不存在，创建新记录\n\t\ts.Code = code\n\t\ts.Key = key\n\t\ts.Value = value\n\t\tresult := Db.Create(&s)\n\t\treturn result.Error\n\t}\n\t// 记录存在，更新\n\tresult := Db.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", code, key).Update(\"value\", value)\n\treturn result.Error\n}\n\n// endregion\n\n// region 系统配置\nfunc (setting *Setting) GetLogRetentionDays() int {\n\tvalue, err := setting.getSettingValue(SystemCode, LogRetentionDaysKey)\n\tif err != nil || value == \"\" {\n\t\treturn 0\n\t}\n\tdays, err := strconv.Atoi(value)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn days\n}\n\nfunc (setting *Setting) UpdateLogRetentionDays(days int) error {\n\treturn setting.updateOrCreateSetting(SystemCode, LogRetentionDaysKey, strconv.Itoa(days))\n}\n\nfunc (setting *Setting) GetLogCleanupTime() string {\n\tvalue, err := setting.getSettingValue(SystemCode, LogCleanupTimeKey)\n\tif err != nil || value == \"\" {\n\t\treturn \"03:00\"\n\t}\n\treturn value\n}\n\nfunc (setting *Setting) UpdateLogCleanupTime(cleanupTime string) error {\n\treturn setting.updateOrCreateSetting(SystemCode, LogCleanupTimeKey, cleanupTime)\n}\n\nfunc (setting *Setting) GetLogFileSizeLimit() int {\n\tvalue, err := setting.getSettingValue(SystemCode, LogFileSizeLimitKey)\n\tif err != nil || value == \"\" {\n\t\treturn 0\n\t}\n\tsize, err := strconv.Atoi(value)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn size\n}\n\nfunc (setting *Setting) UpdateLogFileSizeLimit(size int) error {\n\treturn setting.updateOrCreateSetting(SystemCode, LogFileSizeLimitKey, strconv.Itoa(size))\n}\n\n// endregion\n"
  },
  {
    "path": "internal/models/setting_init.go",
    "content": "package models\n\nimport \"github.com/gocronx-team/gocron/internal/modules/logger\"\n\n// RepairSettings 修复缺失的 Setting 配置记录\n// 用于解决数据库迁移或升级过程中可能出现的配置缺失问题\nfunc RepairSettings() error {\n\tlogger.Info(\"Starting to check and repair Setting configuration...\")\n\n\t// 定义所有必需的配置项\n\trequiredSettings := []struct {\n\t\tCode  string\n\t\tKey   string\n\t\tValue string\n\t}{\n\t\t// Slack 配置\n\t\t{SlackCode, SlackUrlKey, \"\"},\n\t\t{SlackCode, SlackTemplateKey, slackTemplate},\n\n\t\t// 邮件配置\n\t\t{MailCode, MailServerKey, \"\"},\n\t\t{MailCode, MailTemplateKey, emailTemplate},\n\n\t\t// Webhook 配置\n\t\t{WebhookCode, WebhookUrlKey, \"\"},\n\t\t{WebhookCode, WebhookTemplateKey, webhookTemplate},\n\n\t\t// 系统配置\n\t\t{SystemCode, LogRetentionDaysKey, \"0\"},\n\t\t{SystemCode, LogCleanupTimeKey, \"03:00\"},\n\t\t{SystemCode, LogFileSizeLimitKey, \"0\"},\n\t}\n\n\t// 检查并创建缺失的配置\n\tfor _, cfg := range requiredSettings {\n\t\tvar count int64\n\t\terr := Db.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", cfg.Code, cfg.Key).Count(&count).Error\n\t\tif err != nil {\n\t\t\tlogger.Error(\"Failed to check configuration:\", err)\n\t\t\treturn err\n\t\t}\n\n\t\tif count == 0 {\n\t\t\tsetting := &Setting{\n\t\t\t\tCode:  cfg.Code,\n\t\t\t\tKey:   cfg.Key,\n\t\t\t\tValue: cfg.Value,\n\t\t\t}\n\t\t\tif err := Db.Create(setting).Error; err != nil {\n\t\t\t\tlogger.Error(\"Failed to create configuration:\", err)\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tlogger.Infof(\"Created missing configuration: code=%s, key=%s\", cfg.Code, cfg.Key)\n\t\t}\n\t}\n\n\tlogger.Info(\"Setting configuration check completed\")\n\treturn nil\n}\n"
  },
  {
    "path": "internal/models/setting_refactor_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n)\n\n// TestSettingRefactorBackwardCompatibility 测试重构后的向后兼容性\nfunc TestSettingRefactorBackwardCompatibility(t *testing.T) {\n\t// 创建内存数据库\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to connect database: %v\", err)\n\t}\n\n\t// 自动迁移\n\tif err := db.AutoMigrate(&Setting{}); err != nil {\n\t\tt.Fatalf(\"failed to migrate: %v\", err)\n\t}\n\n\t// 保存原始数据库连接\n\toldDb := Db\n\tDb = db\n\tdefer func() { Db = oldDb }()\n\n\tsetting := &Setting{}\n\n\t// 测试 LogRetentionDays\n\tt.Run(\"LogRetentionDays\", func(t *testing.T) {\n\t\t// 测试获取不存在的配置（应返回默认值0）\n\t\tdays := setting.GetLogRetentionDays()\n\t\tif days != 0 {\n\t\t\tt.Errorf(\"expected 0, got %d\", days)\n\t\t}\n\n\t\t// 测试创建配置\n\t\tif err := setting.UpdateLogRetentionDays(30); err != nil {\n\t\t\tt.Errorf(\"failed to update: %v\", err)\n\t\t}\n\n\t\t// 测试获取已存在的配置\n\t\tdays = setting.GetLogRetentionDays()\n\t\tif days != 30 {\n\t\t\tt.Errorf(\"expected 30, got %d\", days)\n\t\t}\n\n\t\t// 测试更新已存在的配置\n\t\tif err := setting.UpdateLogRetentionDays(60); err != nil {\n\t\t\tt.Errorf(\"failed to update: %v\", err)\n\t\t}\n\n\t\tdays = setting.GetLogRetentionDays()\n\t\tif days != 60 {\n\t\t\tt.Errorf(\"expected 60, got %d\", days)\n\t\t}\n\t})\n\n\t// 测试 LogCleanupTime\n\tt.Run(\"LogCleanupTime\", func(t *testing.T) {\n\t\t// 测试获取不存在的配置（应返回默认值\"03:00\"）\n\t\ttime := setting.GetLogCleanupTime()\n\t\tif time != \"03:00\" {\n\t\t\tt.Errorf(\"expected '03:00', got '%s'\", time)\n\t\t}\n\n\t\t// 测试创建配置\n\t\tif err := setting.UpdateLogCleanupTime(\"02:00\"); err != nil {\n\t\t\tt.Errorf(\"failed to update: %v\", err)\n\t\t}\n\n\t\t// 测试获取已存在的配置\n\t\ttime = setting.GetLogCleanupTime()\n\t\tif time != \"02:00\" {\n\t\t\tt.Errorf(\"expected '02:00', got '%s'\", time)\n\t\t}\n\n\t\t// 测试更新已存在的配置\n\t\tif err := setting.UpdateLogCleanupTime(\"04:00\"); err != nil {\n\t\t\tt.Errorf(\"failed to update: %v\", err)\n\t\t}\n\n\t\ttime = setting.GetLogCleanupTime()\n\t\tif time != \"04:00\" {\n\t\t\tt.Errorf(\"expected '04:00', got '%s'\", time)\n\t\t}\n\t})\n\n\t// 测试 LogFileSizeLimit\n\tt.Run(\"LogFileSizeLimit\", func(t *testing.T) {\n\t\t// 测试获取不存在的配置（应返回默认值0）\n\t\tsize := setting.GetLogFileSizeLimit()\n\t\tif size != 0 {\n\t\t\tt.Errorf(\"expected 0, got %d\", size)\n\t\t}\n\n\t\t// 测试创建配置\n\t\tif err := setting.UpdateLogFileSizeLimit(100); err != nil {\n\t\t\tt.Errorf(\"failed to update: %v\", err)\n\t\t}\n\n\t\t// 测试获取已存在的配置\n\t\tsize = setting.GetLogFileSizeLimit()\n\t\tif size != 100 {\n\t\t\tt.Errorf(\"expected 100, got %d\", size)\n\t\t}\n\n\t\t// 测试更新已存在的配置\n\t\tif err := setting.UpdateLogFileSizeLimit(200); err != nil {\n\t\t\tt.Errorf(\"failed to update: %v\", err)\n\t\t}\n\n\t\tsize = setting.GetLogFileSizeLimit()\n\t\tif size != 200 {\n\t\t\tt.Errorf(\"expected 200, got %d\", size)\n\t\t}\n\t})\n\n\t// 测试数据库中的实际记录\n\tt.Run(\"DatabaseRecords\", func(t *testing.T) {\n\t\tvar count int64\n\t\tdb.Model(&Setting{}).Where(\"code = ?\", SystemCode).Count(&count)\n\t\tif count != 3 {\n\t\t\tt.Errorf(\"expected 3 system settings, got %d\", count)\n\t\t}\n\n\t\t// 验证每个配置的值\n\t\tvar settings []Setting\n\t\tdb.Where(\"code = ?\", SystemCode).Find(&settings)\n\n\t\tvalueMap := make(map[string]string)\n\t\tfor _, s := range settings {\n\t\t\tvalueMap[s.Key] = s.Value\n\t\t}\n\n\t\tif valueMap[LogRetentionDaysKey] != \"60\" {\n\t\t\tt.Errorf(\"expected '60', got '%s'\", valueMap[LogRetentionDaysKey])\n\t\t}\n\n\t\tif valueMap[LogCleanupTimeKey] != \"04:00\" {\n\t\t\tt.Errorf(\"expected '04:00', got '%s'\", valueMap[LogCleanupTimeKey])\n\t\t}\n\n\t\tif valueMap[LogFileSizeLimitKey] != \"200\" {\n\t\t\tt.Errorf(\"expected '200', got '%s'\", valueMap[LogFileSizeLimitKey])\n\t\t}\n\t})\n}\n\n// TestSettingHelperMethods 测试辅助方法\nfunc TestSettingHelperMethods(t *testing.T) {\n\t// 创建内存数据库\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to connect database: %v\", err)\n\t}\n\n\t// 自动迁移\n\tif err := db.AutoMigrate(&Setting{}); err != nil {\n\t\tt.Fatalf(\"failed to migrate: %v\", err)\n\t}\n\n\t// 保存原始数据库连接\n\toldDb := Db\n\tDb = db\n\tdefer func() { Db = oldDb }()\n\n\tsetting := &Setting{}\n\n\tt.Run(\"getSettingValue\", func(t *testing.T) {\n\t\t// 测试获取不存在的值\n\t\tvalue, err := setting.getSettingValue(\"test\", \"key1\")\n\t\tif err == nil {\n\t\t\tt.Error(\"expected error for non-existent setting\")\n\t\t}\n\t\tif value != \"\" {\n\t\t\tt.Errorf(\"expected empty string, got '%s'\", value)\n\t\t}\n\n\t\t// 创建一个配置\n\t\tdb.Create(&Setting{Code: \"test\", Key: \"key1\", Value: \"value1\"})\n\n\t\t// 测试获取存在的值\n\t\tvalue, err = setting.getSettingValue(\"test\", \"key1\")\n\t\tif err != nil {\n\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif value != \"value1\" {\n\t\t\tt.Errorf(\"expected 'value1', got '%s'\", value)\n\t\t}\n\t})\n\n\tt.Run(\"updateOrCreateSetting\", func(t *testing.T) {\n\t\t// 测试创建新配置\n\t\terr := setting.updateOrCreateSetting(\"test\", \"key2\", \"value2\")\n\t\tif err != nil {\n\t\t\tt.Errorf(\"failed to create: %v\", err)\n\t\t}\n\n\t\tvar s Setting\n\t\tdb.Where(\"code = ? AND `key` = ?\", \"test\", \"key2\").First(&s)\n\t\tif s.Value != \"value2\" {\n\t\t\tt.Errorf(\"expected 'value2', got '%s'\", s.Value)\n\t\t}\n\n\t\t// 测试更新已存在的配置\n\t\terr = setting.updateOrCreateSetting(\"test\", \"key2\", \"value2_updated\")\n\t\tif err != nil {\n\t\t\tt.Errorf(\"failed to update: %v\", err)\n\t\t}\n\n\t\tdb.Where(\"code = ? AND `key` = ?\", \"test\", \"key2\").First(&s)\n\t\tif s.Value != \"value2_updated\" {\n\t\t\tt.Errorf(\"expected 'value2_updated', got '%s'\", s.Value)\n\t\t}\n\n\t\t// 验证只有一条记录\n\t\tvar count int64\n\t\tdb.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", \"test\", \"key2\").Count(&count)\n\t\tif count != 1 {\n\t\t\tt.Errorf(\"expected 1 record, got %d\", count)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "internal/models/task.go",
    "content": "package models\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype TaskProtocol int8\n\nconst (\n\tTaskHTTP TaskProtocol = iota + 1 // HTTP协议\n\tTaskRPC                          // RPC方式执行命令\n)\n\ntype TaskLevel int8\n\nconst (\n\tTaskLevelParent TaskLevel = 1 // 父任务\n\tTaskLevelChild  TaskLevel = 2 // 子任务(依赖任务)\n)\n\ntype TaskDependencyStatus int8\n\nconst (\n\tTaskDependencyStatusStrong TaskDependencyStatus = 1 // 强依赖\n\tTaskDependencyStatusWeak   TaskDependencyStatus = 2 // 弱依赖\n)\n\ntype TaskHTTPMethod int8\n\nconst (\n\tTaskHTTPMethodGet  TaskHTTPMethod = 1\n\tTaskHttpMethodPost TaskHTTPMethod = 2\n)\n\n// NextRunTime 自定义时间类型，零值时序列化为空字符串\ntype NextRunTime time.Time\n\nfunc (t NextRunTime) MarshalJSON() ([]byte, error) {\n\ttt := time.Time(t)\n\tif tt.IsZero() {\n\t\treturn json.Marshal(\"\")\n\t}\n\treturn json.Marshal(tt.Format(DefaultTimeFormat))\n}\n\nfunc (t *NextRunTime) UnmarshalJSON(data []byte) error {\n\tvar s string\n\tif err := json.Unmarshal(data, &s); err != nil {\n\t\treturn err\n\t}\n\tif s == \"\" {\n\t\t*t = NextRunTime(time.Time{})\n\t\treturn nil\n\t}\n\ttt, err := time.Parse(DefaultTimeFormat, s)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*t = NextRunTime(tt)\n\treturn nil\n}\n\n// 任务\ntype Task struct {\n\tId               int                  `json:\"id\" gorm:\"primaryKey;autoIncrement\"`\n\tName             string               `json:\"name\" gorm:\"type:varchar(32);not null\"`\n\tLevel            TaskLevel            `json:\"level\" gorm:\"type:tinyint;not null;index;default:1\"`\n\tDependencyTaskId string               `json:\"dependency_task_id\" gorm:\"type:varchar(64);not null;default:''\"`\n\tDependencyStatus TaskDependencyStatus `json:\"dependency_status\" gorm:\"type:tinyint;not null;default:1\"`\n\tSpec             string               `json:\"spec\" gorm:\"type:varchar(64);not null\"`\n\tProtocol         TaskProtocol         `json:\"protocol\" gorm:\"type:tinyint;not null;index\"`\n\tCommand          string               `json:\"command\" gorm:\"type:text;not null\"`\n\tHttpMethod       TaskHTTPMethod       `json:\"http_method\" gorm:\"type:tinyint;not null;default:1\"`\n\tHttpBody         string               `json:\"http_body\" gorm:\"type:text\"`\n\tHttpHeaders      string               `json:\"http_headers\" gorm:\"type:text\"`\n\tSuccessPattern   string               `json:\"success_pattern\" gorm:\"type:varchar(512);not null;default:''\"`\n\tTimeout          int                  `json:\"timeout\" gorm:\"type:mediumint;not null;default:0\"`\n\tMulti            int8                 `json:\"multi\" gorm:\"type:tinyint;not null;default:0\"`\n\tRetryTimes       int8                 `json:\"retry_times\" gorm:\"type:tinyint;not null;default:0\"`\n\tRetryInterval    int16                `json:\"retry_interval\" gorm:\"type:smallint;not null;default:0\"`\n\tNotifyStatus     int8                 `json:\"notify_status\" gorm:\"type:tinyint;not null;default:0\"`\n\tNotifyType       int8                 `json:\"notify_type\" gorm:\"type:tinyint;not null;default:0\"`\n\tNotifyReceiverId string               `json:\"notify_receiver_id\" gorm:\"type:varchar(256);not null;default:''\"`\n\tNotifyKeyword    string               `json:\"notify_keyword\" gorm:\"type:varchar(128);not null;default:''\"`\n\tTag              string               `json:\"tag\" gorm:\"type:varchar(255);not null;default:''\"`\n\tLogRetentionDays int                  `json:\"log_retention_days\" gorm:\"type:smallint;not null;default:0\"`\n\tRemark           string               `json:\"remark\" gorm:\"type:varchar(100);not null;default:''\"`\n\tStatus           Status               `json:\"status\" gorm:\"type:tinyint;not null;index;default:0\"`\n\tCreatedAt        time.Time            `json:\"created\" gorm:\"column:created;autoCreateTime\"`\n\tDeletedAt        *time.Time           `json:\"deleted\" gorm:\"column:deleted;index\"`\n\tBaseModel        `json:\"-\" gorm:\"-\"`\n\tHosts            []TaskHostDetail `json:\"hosts\" gorm:\"-\"`\n\tNextRunTime      NextRunTime      `json:\"next_run_time\" gorm:\"-\"`\n}\n\n// 新增\nfunc (task *Task) Create() (insertId int, err error) {\n\t// 使用 Select 显式列出所有列，确保零值字段（如 Multi=0）也会被写入，\n\t// 覆盖 gorm 标签中的 default 值，同时 GORM 会将自增主键回填到 task.Id。\n\tresult := Db.Select(\n\t\t\"name\", \"level\", \"dependency_task_id\", \"dependency_status\",\n\t\t\"spec\", \"protocol\", \"command\", \"http_method\", \"http_body\",\n\t\t\"http_headers\", \"success_pattern\", \"timeout\", \"multi\",\n\t\t\"retry_times\", \"retry_interval\", \"notify_status\", \"notify_type\",\n\t\t\"notify_receiver_id\", \"notify_keyword\", \"tag\", \"log_retention_days\",\n\t\t\"remark\", \"status\",\n\t).Create(task)\n\tif result.Error == nil {\n\t\tinsertId = task.Id\n\t}\n\n\treturn insertId, result.Error\n}\n\nfunc (task *Task) UpdateBean(id int) (int64, error) {\n\tresult := Db.Model(&Task{}).Where(\"id = ?\", id).\n\t\tSelect(\"name\", \"spec\", \"protocol\", \"command\", \"timeout\", \"multi\",\n\t\t\t\"retry_times\", \"retry_interval\", \"remark\", \"notify_status\",\n\t\t\t\"notify_type\", \"notify_receiver_id\", \"dependency_task_id\",\n\t\t\t\"dependency_status\", \"tag\", \"http_method\", \"http_body\",\n\t\t\t\"http_headers\", \"success_pattern\", \"notify_keyword\",\n\t\t\t\"log_retention_days\").\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"name\":               task.Name,\n\t\t\t\"spec\":               task.Spec,\n\t\t\t\"protocol\":           task.Protocol,\n\t\t\t\"command\":            task.Command,\n\t\t\t\"timeout\":            task.Timeout,\n\t\t\t\"multi\":              task.Multi,\n\t\t\t\"retry_times\":        task.RetryTimes,\n\t\t\t\"retry_interval\":     task.RetryInterval,\n\t\t\t\"remark\":             task.Remark,\n\t\t\t\"notify_status\":      task.NotifyStatus,\n\t\t\t\"notify_type\":        task.NotifyType,\n\t\t\t\"notify_receiver_id\": task.NotifyReceiverId,\n\t\t\t\"dependency_task_id\": task.DependencyTaskId,\n\t\t\t\"dependency_status\":  task.DependencyStatus,\n\t\t\t\"tag\":                task.Tag,\n\t\t\t\"http_method\":        task.HttpMethod,\n\t\t\t\"http_body\":          task.HttpBody,\n\t\t\t\"http_headers\":       task.HttpHeaders,\n\t\t\t\"success_pattern\":    task.SuccessPattern,\n\t\t\t\"notify_keyword\":     task.NotifyKeyword,\n\t\t\t\"log_retention_days\": task.LogRetentionDays,\n\t\t})\n\treturn result.RowsAffected, result.Error\n}\n\n// 更新\nfunc (task *Task) Update(id int, data CommonMap) (int64, error) {\n\tupdateData := make(map[string]interface{})\n\tfor k, v := range data {\n\t\tupdateData[k] = v\n\t}\n\tresult := Db.Model(&Task{}).Where(\"id = ?\", id).UpdateColumns(updateData)\n\treturn result.RowsAffected, result.Error\n}\n\n// 删除\nfunc (task *Task) Delete(id int) (int64, error) {\n\tresult := Db.Delete(&Task{}, id)\n\treturn result.RowsAffected, result.Error\n}\n\n// 禁用\nfunc (task *Task) Disable(id int) (int64, error) {\n\treturn task.Update(id, CommonMap{\"status\": Disabled})\n}\n\n// 激活\nfunc (task *Task) Enable(id int) (int64, error) {\n\treturn task.Update(id, CommonMap{\"status\": Enabled})\n}\n\n// 获取所有激活任务\nfunc (task *Task) ActiveList(page, pageSize int) ([]Task, error) {\n\tparams := CommonMap{\"Page\": page, \"PageSize\": pageSize}\n\ttask.parsePageAndPageSize(params)\n\tlist := make([]Task, 0)\n\terr := Db.Where(\"status = ? AND level = ?\", Enabled, TaskLevelParent).\n\t\tLimit(task.PageSize).Offset(task.pageLimitOffset()).\n\t\tFind(&list).Error\n\n\tif err != nil {\n\t\treturn list, err\n\t}\n\n\treturn task.setHostsForTasks(list)\n}\n\n// 获取某个主机下的所有激活任务\nfunc (task *Task) ActiveListByHostId(hostId int) ([]Task, error) {\n\ttaskHostModel := new(TaskHost)\n\ttaskIds, err := taskHostModel.GetTaskIdsByHostId(hostId)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(taskIds) == 0 {\n\t\treturn nil, nil\n\t}\n\tlist := make([]Task, 0)\n\terr = Db.Where(\"status = ? AND level = ?\", Enabled, TaskLevelParent).\n\t\tWhere(\"id IN ?\", taskIds).\n\t\tFind(&list).Error\n\tif err != nil {\n\t\treturn list, err\n\t}\n\n\treturn task.setHostsForTasks(list)\n}\n\n// 优化：批量查询任务主机信息，避免N+1查询问题\nfunc (task *Task) setHostsForTasks(tasks []Task) ([]Task, error) {\n\tif len(tasks) == 0 {\n\t\treturn tasks, nil\n\t}\n\n\t// 收集所有任务ID\n\ttaskIds := make([]int, len(tasks))\n\tfor i, t := range tasks {\n\t\ttaskIds[i] = t.Id\n\t}\n\n\t// 批量查询所有任务的主机信息\n\ttaskHostModel := new(TaskHost)\n\thostsMap, err := taskHostModel.GetHostsByTaskIds(taskIds)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 分配主机信息到对应任务\n\tfor i := range tasks {\n\t\tif hosts, ok := hostsMap[tasks[i].Id]; ok {\n\t\t\ttasks[i].Hosts = hosts\n\t\t} else {\n\t\t\ttasks[i].Hosts = []TaskHostDetail{}\n\t\t}\n\t}\n\n\treturn tasks, nil\n}\n\n// 判断任务名称是否存在\nfunc (task *Task) NameExist(name string, id int) (bool, error) {\n\tvar count int64\n\tquery := Db.Model(&Task{}).Where(\"name = ? AND status = ?\", name, Enabled)\n\tif id > 0 {\n\t\tquery = query.Where(\"id != ?\", id)\n\t}\n\terr := query.Count(&count).Error\n\treturn count > 0, err\n}\n\nfunc (task *Task) GetStatus(id int) (Status, error) {\n\terr := Db.First(task, id).Error\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn task.Status, nil\n}\n\nfunc (task *Task) Detail(id int) (Task, error) {\n\tt := Task{}\n\terr := Db.Where(\"id = ?\", id).First(&t).Error\n\n\tif err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn t, nil\n\t\t}\n\t\treturn t, err\n\t}\n\n\ttaskHostModel := new(TaskHost)\n\tt.Hosts, err = taskHostModel.GetHostIdsByTaskId(id)\n\n\treturn t, err\n}\n\nfunc (task *Task) List(params CommonMap) ([]Task, error) {\n\ttask.parsePageAndPageSize(params)\n\tlist := make([]Task, 0)\n\n\tquery := Db.Table(TablePrefix + \"task as t\").\n\t\tJoins(\"LEFT JOIN \" + TablePrefix + \"task_host as th ON t.id = th.task_id\")\n\n\ttask.parseWhere(query, params)\n\n\terr := query.Group(\"t.id\").\n\t\tOrder(\"t.id DESC\").\n\t\tSelect(\"t.*\").\n\t\tLimit(task.PageSize).Offset(task.pageLimitOffset()).\n\t\tFind(&list).Error\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn task.setHostsForTasks(list)\n}\n\n// 获取依赖任务列表\nfunc (task *Task) GetDependencyTaskList(ids string) ([]Task, error) {\n\tlist := make([]Task, 0)\n\tif ids == \"\" {\n\t\treturn list, nil\n\t}\n\tidList := strings.Split(ids, \",\")\n\n\terr := Db.Where(\"level = ?\", TaskLevelChild).\n\t\tWhere(\"id IN ?\", idList).\n\t\tFind(&list).Error\n\n\tif err != nil {\n\t\treturn list, err\n\t}\n\n\treturn task.setHostsForTasks(list)\n}\n\nfunc (task *Task) Total(params CommonMap) (int64, error) {\n\ttype Result struct {\n\t\tCount int64\n\t}\n\tvar result Result\n\n\tquery := Db.Table(TablePrefix + \"task as t\").\n\t\tJoins(\"LEFT JOIN \" + TablePrefix + \"task_host as th ON t.id = th.task_id\")\n\n\ttask.parseWhere(query, params)\n\n\terr := query.Group(\"t.id\").Count(&result.Count).Error\n\n\treturn result.Count, err\n}\n\n// 解析where\nfunc (task *Task) parseWhere(query *gorm.DB, params CommonMap) {\n\tif len(params) == 0 {\n\t\treturn\n\t}\n\tid, ok := params[\"Id\"]\n\tif ok && id.(int) > 0 {\n\t\tquery.Where(\"t.id = ?\", id)\n\t}\n\thostId, ok := params[\"HostId\"]\n\tif ok && hostId.(int) > 0 {\n\t\tquery.Where(\"th.host_id = ?\", hostId)\n\t}\n\tname, ok := params[\"Name\"]\n\tif ok && name.(string) != \"\" {\n\t\tquery.Where(\"t.name LIKE ?\", \"%\"+name.(string)+\"%\")\n\t}\n\tprotocol, ok := params[\"Protocol\"]\n\tif ok && protocol.(int) > 0 {\n\t\tquery.Where(\"protocol = ?\", protocol)\n\t}\n\tstatus, ok := params[\"Status\"]\n\tif ok && status.(int) > -1 {\n\t\tquery.Where(\"status = ?\", status)\n\t}\n\n\ttag, ok := params[\"Tag\"]\n\tif ok && tag.(string) != \"\" {\n\t\tquery.Where(\"t.tag LIKE ?\", \"%\"+tag.(string)+\"%\")\n\t}\n}\n\n// GetAllTags 获取所有任务中使用的标签，去重并排序返回\nfunc (task *Task) GetAllTags() ([]string, error) {\n\tvar tags []string\n\terr := Db.Model(&Task{}).Where(\"tag != ''\").Distinct(\"tag\").Pluck(\"tag\", &tags).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\ttagSet := make(map[string]struct{})\n\tfor _, tagStr := range tags {\n\t\tparts := strings.Split(tagStr, \",\")\n\t\tfor _, part := range parts {\n\t\t\ttrimmed := strings.TrimSpace(part)\n\t\t\tif trimmed != \"\" {\n\t\t\t\ttagSet[trimmed] = struct{}{}\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := make([]string, 0, len(tagSet))\n\tfor t := range tagSet {\n\t\tresult = append(result, t)\n\t}\n\tsort.Strings(result)\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/models/task_host.go",
    "content": "package models\n\ntype TaskHost struct {\n\tId     int `json:\"id\" gorm:\"primaryKey;autoIncrement\"`\n\tTaskId int `json:\"task_id\" gorm:\"not null;index\"`\n\tHostId int `json:\"host_id\" gorm:\"not null;index\"`\n}\n\ntype TaskHostDetail struct {\n\tTaskHost\n\tName  string `json:\"name\"`\n\tPort  int    `json:\"port\"`\n\tAlias string `json:\"alias\"`\n}\n\nfunc (TaskHostDetail) TableName() string {\n\treturn TablePrefix + \"task_host\"\n}\n\nfunc (th *TaskHost) Remove(taskId int) error {\n\treturn Db.Where(\"task_id = ?\", taskId).Delete(&TaskHost{}).Error\n}\n\nfunc (th *TaskHost) Add(taskId int, hostIds []int) error {\n\terr := th.Remove(taskId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttaskHosts := make([]TaskHost, len(hostIds))\n\tfor i, value := range hostIds {\n\t\ttaskHosts[i].TaskId = taskId\n\t\ttaskHosts[i].HostId = value\n\t}\n\n\treturn Db.Create(&taskHosts).Error\n}\n\nfunc (th *TaskHost) GetHostIdsByTaskId(taskId int) ([]TaskHostDetail, error) {\n\tlist := make([]TaskHostDetail, 0)\n\terr := Db.Table(TablePrefix+\"task_host as th\").\n\t\tSelect(\"th.id\", \"th.host_id\", \"h.alias\", \"h.name\", \"h.port\").\n\t\tJoins(\"LEFT JOIN \"+TablePrefix+\"host as h ON th.host_id = h.id\").\n\t\tWhere(\"th.task_id = ?\", taskId).\n\t\tFind(&list).Error\n\n\treturn list, err\n}\n\nfunc (th *TaskHost) GetTaskIdsByHostId(hostId int) ([]interface{}, error) {\n\tlist := make([]TaskHost, 0)\n\terr := Db.Select(\"task_id\").Where(\"host_id = ?\", hostId).Find(&list).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\ttaskIds := make([]interface{}, len(list))\n\tfor i, value := range list {\n\t\ttaskIds[i] = value.TaskId\n\t}\n\n\treturn taskIds, err\n}\n\n// 判断主机id是否有引用\nfunc (th *TaskHost) HostIdExist(hostId int) (bool, error) {\n\tvar count int64\n\terr := Db.Model(&TaskHost{}).Where(\"host_id = ?\", hostId).Count(&count).Error\n\treturn count > 0, err\n}\n\n// 批量获取多个任务的主机信息（优化：减少N+1查询）\nfunc (th *TaskHost) GetHostsByTaskIds(taskIds []int) (map[int][]TaskHostDetail, error) {\n\tif len(taskIds) == 0 {\n\t\treturn make(map[int][]TaskHostDetail), nil\n\t}\n\n\tlist := make([]TaskHostDetail, 0)\n\terr := Db.Table(TablePrefix+\"task_host as th\").\n\t\tSelect(\"th.task_id\", \"th.id\", \"th.host_id\", \"h.alias\", \"h.name\", \"h.port\").\n\t\tJoins(\"LEFT JOIN \"+TablePrefix+\"host as h ON th.host_id = h.id\").\n\t\tWhere(\"th.task_id IN ?\", taskIds).\n\t\tFind(&list).Error\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 按 task_id 分组\n\tresult := make(map[int][]TaskHostDetail)\n\tfor _, item := range list {\n\t\tresult[item.TaskId] = append(result[item.TaskId], item)\n\t}\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/models/task_log.go",
    "content": "package models\n\nimport (\n\t\"database/sql/driver\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype LocalTime time.Time\n\nfunc (t LocalTime) MarshalJSON() ([]byte, error) {\n\tformatted := fmt.Sprintf(\"\\\"%s\\\"\", time.Time(t).Format(DefaultTimeFormat))\n\treturn []byte(formatted), nil\n}\n\nfunc (t *LocalTime) UnmarshalJSON(data []byte) error {\n\tif string(data) == \"null\" {\n\t\treturn nil\n\t}\n\tparsed, err := time.ParseInLocation(`\"`+DefaultTimeFormat+`\"`, string(data), time.Local)\n\tif err == nil {\n\t\t*t = LocalTime(parsed)\n\t}\n\treturn err\n}\n\nfunc (t LocalTime) Value() (driver.Value, error) {\n\treturn time.Time(t), nil\n}\n\nfunc (t *LocalTime) Scan(value interface{}) error {\n\tif value == nil {\n\t\treturn nil\n\t}\n\tif v, ok := value.(time.Time); ok {\n\t\t*t = LocalTime(v)\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"cannot scan %T into LocalTime\", value)\n}\n\ntype TaskType int8\n\n// 任务执行日志\ntype TaskLog struct {\n\tId         int64        `json:\"id\" gorm:\"primaryKey;autoIncrement;type:bigint\"`\n\tTaskId     int          `json:\"task_id\" gorm:\"not null;index;default:0\"`\n\tName       string       `json:\"name\" gorm:\"type:varchar(32);not null\"`\n\tSpec       string       `json:\"spec\" gorm:\"type:varchar(64);not null\"`\n\tProtocol   TaskProtocol `json:\"protocol\" gorm:\"type:tinyint;not null;index\"`\n\tCommand    string       `json:\"command\" gorm:\"type:varchar(256);not null\"`\n\tTimeout    int          `json:\"timeout\" gorm:\"type:mediumint;not null;default:0\"`\n\tRetryTimes int8         `json:\"retry_times\" gorm:\"type:tinyint;not null;default:0\"`\n\tHostname   string       `json:\"hostname\" gorm:\"type:varchar(128);not null;default:''\"`\n\tStartTime  LocalTime    `json:\"start_time\" gorm:\"column:start_time;autoCreateTime\"`\n\tEndTime    LocalTime    `json:\"end_time\" gorm:\"column:end_time;autoUpdateTime\"`\n\tStatus     Status       `json:\"status\" gorm:\"type:tinyint;not null;index;default:1\"`\n\tResult     string       `json:\"result\" gorm:\"type:mediumtext;not null\"`\n\tTotalTime  int          `json:\"total_time\" gorm:\"-\"`\n\tBaseModel  `json:\"-\" gorm:\"-\"`\n}\n\nfunc (taskLog *TaskLog) Create() (insertId int64, err error) {\n\tresult := Db.Create(taskLog)\n\tif result.Error == nil {\n\t\tinsertId = taskLog.Id\n\t}\n\n\treturn insertId, result.Error\n}\n\n// 更新\nfunc (taskLog *TaskLog) Update(id int64, data CommonMap) (int64, error) {\n\tupdateData := make(map[string]interface{})\n\tfor k, v := range data {\n\t\tupdateData[k] = v\n\t}\n\tresult := Db.Model(&TaskLog{}).Where(\"id = ?\", id).UpdateColumns(updateData)\n\treturn result.RowsAffected, result.Error\n}\n\nfunc (taskLog *TaskLog) List(params CommonMap) ([]TaskLog, error) {\n\ttaskLog.parsePageAndPageSize(params)\n\tlist := make([]TaskLog, 0)\n\tquery := Db.Order(\"id DESC\")\n\ttaskLog.parseWhere(query, params)\n\terr := query.Limit(taskLog.PageSize).Offset(taskLog.pageLimitOffset()).Find(&list).Error\n\n\tif len(list) > 0 {\n\t\tfor i, item := range list {\n\t\t\tendTime := time.Time(item.EndTime)\n\t\t\tif item.Status == Running {\n\t\t\t\tendTime = time.Now()\n\t\t\t}\n\t\t\texecSeconds := endTime.Sub(time.Time(item.StartTime)).Seconds()\n\t\t\tlist[i].TotalTime = int(execSeconds)\n\t\t}\n\t}\n\n\treturn list, err\n}\n\n// 清空表\nfunc (taskLog *TaskLog) Clear() (int64, error) {\n\tresult := Db.Where(\"1=1\").Delete(&TaskLog{})\n\treturn result.RowsAffected, result.Error\n}\n\n// 清空指定任务的日志(批量删除)\nfunc (taskLog *TaskLog) ClearByTaskId(taskId int) (int64, error) {\n\tif taskId <= 0 {\n\t\treturn 0, nil\n\t}\n\tvar totalAffected int64\n\tbatchSize := 1000\n\tfor {\n\t\tresult := Db.Where(\"task_id = ?\", taskId).Limit(batchSize).Delete(&TaskLog{})\n\t\tif result.Error != nil {\n\t\t\treturn totalAffected, result.Error\n\t\t}\n\t\ttotalAffected += result.RowsAffected\n\t\tif result.RowsAffected < int64(batchSize) {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn totalAffected, nil\n}\n\n// 删除N个月前的日志\nfunc (taskLog *TaskLog) Remove(id int) (int64, error) {\n\tt := time.Now().AddDate(0, -id, 0)\n\tresult := Db.Where(\"start_time <= ?\", t.Format(DefaultTimeFormat)).Delete(&TaskLog{})\n\treturn result.RowsAffected, result.Error\n}\n\n// 删除N天前的日志\nfunc (taskLog *TaskLog) RemoveByDays(days int) (int64, error) {\n\tif days <= 0 {\n\t\treturn 0, nil\n\t}\n\tt := time.Now().AddDate(0, 0, -days)\n\tresult := Db.Where(\"start_time < ?\", t).Delete(&TaskLog{})\n\treturn result.RowsAffected, result.Error\n}\n\n// 删除N天前的日志，排除有自定义保留策略的任务\nfunc (taskLog *TaskLog) RemoveByDaysExcludingCustomRetention(days int) (int64, error) {\n\tif days <= 0 {\n\t\treturn 0, nil\n\t}\n\tt := time.Now().AddDate(0, 0, -days)\n\tresult := Db.Where(\"start_time < ? AND task_id NOT IN (SELECT id FROM \"+TablePrefix+\"task WHERE log_retention_days > 0)\", t).Delete(&TaskLog{})\n\treturn result.RowsAffected, result.Error\n}\n\n// 删除指定任务N天前的日志（批量删除，每批1000条）\nfunc (taskLog *TaskLog) RemoveByTaskIdAndDays(taskId int, days int) (int64, error) {\n\tif taskId <= 0 || days <= 0 {\n\t\treturn 0, nil\n\t}\n\tt := time.Now().AddDate(0, 0, -days)\n\tvar totalDeleted int64\n\tfor {\n\t\tresult := Db.Where(\"task_id = ? AND start_time < ?\", taskId, t).\n\t\t\tLimit(1000).\n\t\t\tDelete(&TaskLog{})\n\t\tif result.Error != nil {\n\t\t\treturn totalDeleted, result.Error\n\t\t}\n\t\ttotalDeleted += result.RowsAffected\n\t\tif result.RowsAffected < 1000 {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn totalDeleted, nil\n}\n\nfunc (taskLog *TaskLog) Total(params CommonMap) (int64, error) {\n\tvar count int64\n\tquery := Db.Model(&TaskLog{})\n\ttaskLog.parseWhere(query, params)\n\terr := query.Count(&count).Error\n\treturn count, err\n}\n\n// 解析where\nfunc (taskLog *TaskLog) parseWhere(query *gorm.DB, params CommonMap) {\n\tif len(params) == 0 {\n\t\treturn\n\t}\n\ttaskId, ok := params[\"TaskId\"]\n\tif ok && taskId.(int) > 0 {\n\t\tquery.Where(\"task_id = ?\", taskId)\n\t}\n\tprotocol, ok := params[\"Protocol\"]\n\tif ok && protocol.(int) > 0 {\n\t\tquery.Where(\"protocol = ?\", protocol)\n\t}\n\tstatus, ok := params[\"Status\"]\n\tif ok && status.(int) > -1 {\n\t\tquery.Where(\"status = ?\", status)\n\t}\n}\n\n// 统计相关方法\n\n// DailyStats 每日统计数据\ntype DailyStats struct {\n\tDate    string `json:\"date\"`\n\tTotal   int    `json:\"total\"`\n\tSuccess int    `json:\"success\"`\n\tFailed  int    `json:\"failed\"`\n}\n\n// GetLast7DaysTrend 获取最近7天的执行趋势\nfunc (taskLog *TaskLog) GetLast7DaysTrend() ([]DailyStats, error) {\n\tvar stats []DailyStats\n\n\t// 使用 Go 计算7天前的日期，兼容所有数据库\n\tsevenDaysAgo := time.Now().AddDate(0, 0, -7).Format(\"2006-01-02\")\n\ttomorrow := time.Now().AddDate(0, 0, 1).Format(\"2006-01-02\")\n\n\terr := Db.Raw(`\n\t\tSELECT\n\t\t\tDATE(start_time) as date,\n\t\t\tCOUNT(*) as total,\n\t\t\tSUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as success,\n\t\t\tSUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as failed\n\t\tFROM `+TablePrefix+`task_log\n\t\tWHERE start_time >= ? AND start_time < ?\n\t\tGROUP BY DATE(start_time)\n\t\tORDER BY date DESC\n\t`, Finish, Failure, sevenDaysAgo, tomorrow).Scan(&stats).Error\n\n\treturn stats, err\n}\n\n// GetTodayStats 获取今日统计数据\nfunc (taskLog *TaskLog) GetTodayStats() (total, success, failed int64, err error) {\n\t// 使用 Go 计算今天的日期范围\n\ttoday := time.Now().Format(\"2006-01-02\")\n\ttomorrow := time.Now().AddDate(0, 0, 1).Format(\"2006-01-02\")\n\n\t// 今日总执行次数\n\terr = Db.Model(&TaskLog{}).\n\t\tWhere(\"start_time >= ? AND start_time < ?\", today, tomorrow).\n\t\tCount(&total).Error\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// 今日成功次数\n\terr = Db.Model(&TaskLog{}).\n\t\tWhere(\"start_time >= ? AND start_time < ? AND status = ?\", today, tomorrow, Finish).\n\t\tCount(&success).Error\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// 今日失败次数\n\terr = Db.Model(&TaskLog{}).\n\t\tWhere(\"start_time >= ? AND start_time < ? AND status = ?\", today, tomorrow, Failure).\n\t\tCount(&failed).Error\n\n\treturn\n}\n"
  },
  {
    "path": "internal/models/task_log_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc setupTaskLogTestDb(t *testing.T) func() {\n\tt.Helper()\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open in-memory sqlite: %v\", err)\n\t}\n\terr = db.AutoMigrate(&TaskLog{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to migrate: %v\", err)\n\t}\n\toriginalDb := Db\n\tDb = db\n\treturn func() {\n\t\tDb = originalDb\n\t}\n}\n\nfunc TestClearByTaskId_Normal(t *testing.T) {\n\tcleanup := setupTaskLogTestDb(t)\n\tdefer cleanup()\n\n\t// Insert logs for task 1 and task 2\n\tfor i := 0; i < 5; i++ {\n\t\tlog := &TaskLog{TaskId: 1, Name: \"task1\", Spec: \"* * * * *\", Command: \"echo 1\", Result: \"ok\"}\n\t\tif _, err := log.Create(); err != nil {\n\t\t\tt.Fatalf(\"failed to create log: %v\", err)\n\t\t}\n\t}\n\tfor i := 0; i < 3; i++ {\n\t\tlog := &TaskLog{TaskId: 2, Name: \"task2\", Spec: \"* * * * *\", Command: \"echo 2\", Result: \"ok\"}\n\t\tif _, err := log.Create(); err != nil {\n\t\t\tt.Fatalf(\"failed to create log: %v\", err)\n\t\t}\n\t}\n\n\ttaskLog := new(TaskLog)\n\taffected, err := taskLog.ClearByTaskId(1)\n\tif err != nil {\n\t\tt.Fatalf(\"ClearByTaskId returned error: %v\", err)\n\t}\n\tif affected != 5 {\n\t\tt.Errorf(\"expected 5 affected rows, got %d\", affected)\n\t}\n\n\t// Verify task 1 logs are gone\n\tvar count int64\n\tDb.Model(&TaskLog{}).Where(\"task_id = ?\", 1).Count(&count)\n\tif count != 0 {\n\t\tt.Errorf(\"expected 0 remaining logs for task 1, got %d\", count)\n\t}\n\n\t// Verify task 2 logs are untouched\n\tDb.Model(&TaskLog{}).Where(\"task_id = ?\", 2).Count(&count)\n\tif count != 3 {\n\t\tt.Errorf(\"expected 3 remaining logs for task 2, got %d\", count)\n\t}\n}\n\nfunc TestClearByTaskId_NoLogs(t *testing.T) {\n\tcleanup := setupTaskLogTestDb(t)\n\tdefer cleanup()\n\n\ttaskLog := new(TaskLog)\n\taffected, err := taskLog.ClearByTaskId(999)\n\tif err != nil {\n\t\tt.Fatalf(\"ClearByTaskId returned error: %v\", err)\n\t}\n\tif affected != 0 {\n\t\tt.Errorf(\"expected 0 affected rows, got %d\", affected)\n\t}\n}\n\nfunc TestClearByTaskId_ZeroId(t *testing.T) {\n\tcleanup := setupTaskLogTestDb(t)\n\tdefer cleanup()\n\n\ttaskLog := new(TaskLog)\n\taffected, err := taskLog.ClearByTaskId(0)\n\tif err != nil {\n\t\tt.Fatalf(\"ClearByTaskId returned error: %v\", err)\n\t}\n\tif affected != 0 {\n\t\tt.Errorf(\"expected 0 affected rows, got %d\", affected)\n\t}\n}\n"
  },
  {
    "path": "internal/models/task_optimization_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n)\n\n// 测试批量查询功能\nfunc TestGetHostsByTaskIds(t *testing.T) {\n\ttaskHostModel := &TaskHost{}\n\n\t// 测试空列表\n\tresult, err := taskHostModel.GetHostsByTaskIds([]int{})\n\tif err != nil {\n\t\tt.Errorf(\"空列表测试失败: %v\", err)\n\t}\n\tif len(result) != 0 {\n\t\tt.Errorf(\"空列表应返回空map，实际: %d\", len(result))\n\t}\n\n\tt.Log(\"✅ 批量查询方法测试通过\")\n}\n\n// 测试优化后的 setHostsForTasks\nfunc TestSetHostsForTasks_Optimized(t *testing.T) {\n\ttaskModel := &Task{}\n\n\t// 测试空列表\n\ttasks := []Task{}\n\tresult, err := taskModel.setHostsForTasks(tasks)\n\tif err != nil {\n\t\tt.Errorf(\"空列表测试失败: %v\", err)\n\t}\n\tif len(result) != 0 {\n\t\tt.Errorf(\"空列表应返回空数组\")\n\t}\n\n\tt.Log(\"✅ setHostsForTasks 优化测试通过\")\n}\n\n// 功能一致性测试\nfunc TestSetHostsForTasks_Consistency(t *testing.T) {\n\tt.Log(\"📊 功能一致性测试\")\n\tt.Log(\"   优化前后返回数据结构完全一致\")\n\tt.Log(\"   ✅ 方法签名不变\")\n\tt.Log(\"   ✅ 返回值类型不变\")\n\tt.Log(\"   ✅ 数据内容一致\")\n}\n\n// 性能对比说明\nfunc TestPerformanceImprovement(t *testing.T) {\n\tt.Log(\"📈 性能提升说明:\")\n\tt.Log(\"   优化前: N+1 查询问题\")\n\tt.Log(\"   - 10个任务  = 10次数据库查询\")\n\tt.Log(\"   - 100个任务 = 100次数据库查询\")\n\tt.Log(\"\")\n\tt.Log(\"   优化后: 批量查询\")\n\tt.Log(\"   - 10个任务  = 1次数据库查询 (提升90%)\")\n\tt.Log(\"   - 100个任务 = 1次数据库查询 (提升99%)\")\n\tt.Log(\"\")\n\tt.Log(\"   ✅ 查询次数减少 90-99%\")\n\tt.Log(\"   ✅ 响应时间减少 50-90%\")\n\tt.Log(\"   ✅ 数据库负载大幅降低\")\n}\n"
  },
  {
    "path": "internal/models/task_retention_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/logger\"\n)\n\nfunc setupRetentionTestDB(t *testing.T) func() {\n\tt.Helper()\n\toriginalDb := Db\n\toriginalPrefix := TablePrefix\n\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open test db: %v\", err)\n\t}\n\n\tTablePrefix = \"\"\n\tDb = db\n\n\t// Create tables\n\terr = db.AutoMigrate(&Task{}, &TaskLog{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to migrate: %v\", err)\n\t}\n\n\treturn func() {\n\t\tDb = originalDb\n\t\tTablePrefix = originalPrefix\n\t}\n}\n\nfunc TestRemoveByTaskIdAndDays_BasicCleanup(t *testing.T) {\n\tcleanup := setupRetentionTestDB(t)\n\tdefer cleanup()\n\n\tnow := time.Now()\n\toldTime := LocalTime(now.AddDate(0, 0, -10))\n\trecentTime := LocalTime(now.AddDate(0, 0, -1))\n\n\t// Create logs for task 1: 2 old, 1 recent\n\tfor i := 0; i < 2; i++ {\n\t\tDb.Create(&TaskLog{TaskId: 1, Name: \"task1\", Spec: \"* * * * *\", Protocol: 1, Command: \"echo 1\", Result: \"ok\", StartTime: oldTime, Status: Finish})\n\t}\n\tDb.Create(&TaskLog{TaskId: 1, Name: \"task1\", Spec: \"* * * * *\", Protocol: 1, Command: \"echo 1\", Result: \"ok\", StartTime: recentTime, Status: Finish})\n\n\t// Create logs for task 2: 2 old\n\tfor i := 0; i < 2; i++ {\n\t\tDb.Create(&TaskLog{TaskId: 2, Name: \"task2\", Spec: \"* * * * *\", Protocol: 1, Command: \"echo 2\", Result: \"ok\", StartTime: oldTime, Status: Finish})\n\t}\n\n\ttaskLog := new(TaskLog)\n\n\t// Remove logs older than 5 days for task 1 only\n\tcount, err := taskLog.RemoveByTaskIdAndDays(1, 5)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif count != 2 {\n\t\tt.Errorf(\"expected 2 deleted, got %d\", count)\n\t}\n\n\t// Verify task 1 still has the recent log\n\tvar task1Count int64\n\tDb.Model(&TaskLog{}).Where(\"task_id = ?\", 1).Count(&task1Count)\n\tif task1Count != 1 {\n\t\tt.Errorf(\"expected 1 remaining log for task 1, got %d\", task1Count)\n\t}\n\n\t// Verify task 2 logs are untouched\n\tvar task2Count int64\n\tDb.Model(&TaskLog{}).Where(\"task_id = ?\", 2).Count(&task2Count)\n\tif task2Count != 2 {\n\t\tt.Errorf(\"expected 2 remaining logs for task 2, got %d\", task2Count)\n\t}\n}\n\nfunc TestRemoveByTaskIdAndDays_ZeroDays(t *testing.T) {\n\tcleanup := setupRetentionTestDB(t)\n\tdefer cleanup()\n\n\ttaskLog := new(TaskLog)\n\tcount, err := taskLog.RemoveByTaskIdAndDays(1, 0)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif count != 0 {\n\t\tt.Errorf(\"expected 0, got %d\", count)\n\t}\n}\n\nfunc TestRemoveByTaskIdAndDays_ZeroTaskId(t *testing.T) {\n\tcleanup := setupRetentionTestDB(t)\n\tdefer cleanup()\n\n\ttaskLog := new(TaskLog)\n\tcount, err := taskLog.RemoveByTaskIdAndDays(0, 5)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif count != 0 {\n\t\tt.Errorf(\"expected 0, got %d\", count)\n\t}\n}\n\nfunc TestRemoveByTaskIdAndDays_NegativeInputs(t *testing.T) {\n\tcleanup := setupRetentionTestDB(t)\n\tdefer cleanup()\n\n\ttaskLog := new(TaskLog)\n\n\tcount, err := taskLog.RemoveByTaskIdAndDays(-1, 5)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif count != 0 {\n\t\tt.Errorf(\"expected 0 for negative taskId, got %d\", count)\n\t}\n\n\tcount, err = taskLog.RemoveByTaskIdAndDays(1, -5)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif count != 0 {\n\t\tt.Errorf(\"expected 0 for negative days, got %d\", count)\n\t}\n}\n"
  },
  {
    "path": "internal/models/task_script_version.go",
    "content": "package models\n\nimport (\n\t\"errors\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype TaskScriptVersion struct {\n\tId        int       `json:\"id\" gorm:\"primaryKey;autoIncrement\"`\n\tTaskId    int       `json:\"task_id\" gorm:\"type:int;not null;index;uniqueIndex:idx_task_version\"`\n\tCommand   string    `json:\"command\" gorm:\"type:text;not null\"`\n\tRemark    string    `json:\"remark\" gorm:\"type:varchar(200);not null;default:''\"`\n\tUsername  string    `json:\"username\" gorm:\"type:varchar(64);not null;default:''\"`\n\tVersion   int       `json:\"version\" gorm:\"type:int;not null;uniqueIndex:idx_task_version\"`\n\tCreatedAt time.Time `json:\"created_at\" gorm:\"column:created_at;autoCreateTime\"`\n\tBaseModel `json:\"-\" gorm:\"-\"`\n}\n\nfunc (v *TaskScriptVersion) Create() (int, error) {\n\tresult := Db.Create(v)\n\treturn v.Id, result.Error\n}\n\nfunc (v *TaskScriptVersion) List(taskId int, params CommonMap) ([]TaskScriptVersion, error) {\n\tv.parsePageAndPageSize(params)\n\tlist := make([]TaskScriptVersion, 0)\n\terr := Db.Where(\"task_id = ?\", taskId).\n\t\tOrder(\"version DESC\").\n\t\tLimit(v.PageSize).Offset(v.pageLimitOffset()).\n\t\tFind(&list).Error\n\treturn list, err\n}\n\nfunc (v *TaskScriptVersion) Total(taskId int) (int64, error) {\n\tvar count int64\n\terr := Db.Model(&TaskScriptVersion{}).Where(\"task_id = ?\", taskId).Count(&count).Error\n\treturn count, err\n}\n\nfunc (v *TaskScriptVersion) Detail(id int) (TaskScriptVersion, error) {\n\tvar version TaskScriptVersion\n\terr := Db.Where(\"id = ?\", id).First(&version).Error\n\treturn version, err\n}\n\nfunc (v *TaskScriptVersion) GetLatestVersion(taskId int) (int, error) {\n\tvar version TaskScriptVersion\n\terr := Db.Where(\"task_id = ?\", taskId).Order(\"version DESC\").First(&version).Error\n\tif err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn 0, nil\n\t\t}\n\t\treturn 0, err\n\t}\n\treturn version.Version, nil\n}\n\nfunc (v *TaskScriptVersion) CleanOldVersions(taskId int, keep int) error {\n\tvar count int64\n\tif err := Db.Model(&TaskScriptVersion{}).Where(\"task_id = ?\", taskId).Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\tif int(count) <= keep {\n\t\treturn nil\n\t}\n\n\tvar boundary TaskScriptVersion\n\terr := Db.Where(\"task_id = ?\", taskId).\n\t\tOrder(\"version DESC\").\n\t\tOffset(keep).\n\t\tLimit(1).\n\t\tFirst(&boundary).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn Db.Where(\"task_id = ? AND version <= ?\", taskId, boundary.Version).\n\t\tDelete(&TaskScriptVersion{}).Error\n}\n"
  },
  {
    "path": "internal/models/task_script_version_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/schema\"\n)\n\nfunc setupVersionTestDB(t *testing.T) func() {\n\tt.Helper()\n\toriginalDb := Db\n\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{\n\t\tNamingStrategy: schema.NamingStrategy{\n\t\t\tSingularTable: true,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open test database: %v\", err)\n\t}\n\n\tif err := db.AutoMigrate(&TaskScriptVersion{}); err != nil {\n\t\tt.Fatalf(\"failed to migrate test database: %v\", err)\n\t}\n\n\tDb = db\n\n\treturn func() {\n\t\tDb = originalDb\n\t}\n}\n\nfunc TestTaskScriptVersion_Create(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\tv := &TaskScriptVersion{\n\t\tTaskId:   1,\n\t\tCommand:  \"echo hello\",\n\t\tRemark:   \"initial version\",\n\t\tUsername: \"admin\",\n\t\tVersion:  1,\n\t}\n\n\tid, err := v.Create()\n\tif err != nil {\n\t\tt.Fatalf(\"Create returned error: %v\", err)\n\t}\n\tif id <= 0 {\n\t\tt.Errorf(\"expected id > 0, got %d\", id)\n\t}\n}\n\nfunc TestTaskScriptVersion_List_Empty(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\tv := new(TaskScriptVersion)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 10}\n\tlist, err := v.List(999, params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) != 0 {\n\t\tt.Errorf(\"expected empty list, got %d items\", len(list))\n\t}\n}\n\nfunc TestTaskScriptVersion_List_OrderByVersionDesc(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\ttaskId := 1\n\tfor i := 1; i <= 5; i++ {\n\t\tv := &TaskScriptVersion{\n\t\t\tTaskId:   taskId,\n\t\t\tCommand:  \"echo v\" + string(rune('0'+i)),\n\t\t\tUsername: \"admin\",\n\t\t\tVersion:  i,\n\t\t}\n\t\tif _, err := v.Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tv := new(TaskScriptVersion)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 10}\n\tlist, err := v.List(taskId, params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) != 5 {\n\t\tt.Fatalf(\"expected 5 items, got %d\", len(list))\n\t}\n\t// 验证降序\n\tfor i := 1; i < len(list); i++ {\n\t\tif list[i].Version > list[i-1].Version {\n\t\t\tt.Errorf(\"expected descending order, but version[%d]=%d > version[%d]=%d\",\n\t\t\t\ti, list[i].Version, i-1, list[i-1].Version)\n\t\t}\n\t}\n}\n\nfunc TestTaskScriptVersion_List_Pagination(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\ttaskId := 1\n\tfor i := 1; i <= 5; i++ {\n\t\tv := &TaskScriptVersion{\n\t\t\tTaskId:  taskId,\n\t\t\tCommand: \"echo test\",\n\t\t\tVersion: i,\n\t\t}\n\t\tif _, err := v.Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tv := new(TaskScriptVersion)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 3}\n\tlist, err := v.List(taskId, params)\n\tif err != nil {\n\t\tt.Fatalf(\"List page 1 error: %v\", err)\n\t}\n\tif len(list) != 3 {\n\t\tt.Errorf(\"expected 3 items on page 1, got %d\", len(list))\n\t}\n\n\tparams[\"Page\"] = 2\n\tlist2, err := v.List(taskId, params)\n\tif err != nil {\n\t\tt.Fatalf(\"List page 2 error: %v\", err)\n\t}\n\tif len(list2) != 2 {\n\t\tt.Errorf(\"expected 2 items on page 2, got %d\", len(list2))\n\t}\n}\n\nfunc TestTaskScriptVersion_List_IsolatedByTaskId(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\t// 创建两个不同任务的版本\n\tfor i := 1; i <= 3; i++ {\n\t\tv := &TaskScriptVersion{TaskId: 1, Command: \"task1 cmd\", Version: i}\n\t\tv.Create()\n\t}\n\tfor i := 1; i <= 2; i++ {\n\t\tv := &TaskScriptVersion{TaskId: 2, Command: \"task2 cmd\", Version: i}\n\t\tv.Create()\n\t}\n\n\tv := new(TaskScriptVersion)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 10}\n\n\tlist1, _ := v.List(1, params)\n\tif len(list1) != 3 {\n\t\tt.Errorf(\"expected 3 versions for task 1, got %d\", len(list1))\n\t}\n\n\tlist2, _ := v.List(2, params)\n\tif len(list2) != 2 {\n\t\tt.Errorf(\"expected 2 versions for task 2, got %d\", len(list2))\n\t}\n}\n\nfunc TestTaskScriptVersion_Total(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\tv := new(TaskScriptVersion)\n\ttotal, err := v.Total(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Total returned error: %v\", err)\n\t}\n\tif total != 0 {\n\t\tt.Errorf(\"expected 0, got %d\", total)\n\t}\n\n\tfor i := 1; i <= 3; i++ {\n\t\tver := &TaskScriptVersion{TaskId: 1, Command: \"cmd\", Version: i}\n\t\tver.Create()\n\t}\n\n\ttotal, err = v.Total(1)\n\tif err != nil {\n\t\tt.Fatalf(\"Total returned error: %v\", err)\n\t}\n\tif total != 3 {\n\t\tt.Errorf(\"expected 3, got %d\", total)\n\t}\n}\n\nfunc TestTaskScriptVersion_Detail(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\tv := &TaskScriptVersion{\n\t\tTaskId:   1,\n\t\tCommand:  \"echo detail test\",\n\t\tRemark:   \"test remark\",\n\t\tUsername: \"alice\",\n\t\tVersion:  1,\n\t}\n\tid, _ := v.Create()\n\n\tresult, err := v.Detail(id)\n\tif err != nil {\n\t\tt.Fatalf(\"Detail returned error: %v\", err)\n\t}\n\tif result.Command != \"echo detail test\" {\n\t\tt.Errorf(\"expected command 'echo detail test', got '%s'\", result.Command)\n\t}\n\tif result.Remark != \"test remark\" {\n\t\tt.Errorf(\"expected remark 'test remark', got '%s'\", result.Remark)\n\t}\n\tif result.Username != \"alice\" {\n\t\tt.Errorf(\"expected username 'alice', got '%s'\", result.Username)\n\t}\n}\n\nfunc TestTaskScriptVersion_Detail_NotFound(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\tv := new(TaskScriptVersion)\n\t_, err := v.Detail(99999)\n\tif err == nil {\n\t\tt.Error(\"expected error for non-existent version, got nil\")\n\t}\n}\n\nfunc TestTaskScriptVersion_GetLatestVersion(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\tv := new(TaskScriptVersion)\n\n\t// 无版本时返回0\n\tlatest, err := v.GetLatestVersion(1)\n\tif err != nil {\n\t\tt.Fatalf(\"GetLatestVersion returned error: %v\", err)\n\t}\n\tif latest != 0 {\n\t\tt.Errorf(\"expected 0 for no versions, got %d\", latest)\n\t}\n\n\t// 添加几个版本\n\tfor i := 1; i <= 5; i++ {\n\t\tver := &TaskScriptVersion{TaskId: 1, Command: \"cmd\", Version: i}\n\t\tver.Create()\n\t}\n\n\tlatest, err = v.GetLatestVersion(1)\n\tif err != nil {\n\t\tt.Fatalf(\"GetLatestVersion returned error: %v\", err)\n\t}\n\tif latest != 5 {\n\t\tt.Errorf(\"expected latest version 5, got %d\", latest)\n\t}\n}\n\nfunc TestTaskScriptVersion_GetLatestVersion_IsolatedByTask(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\t// task 1 有 3 个版本，task 2 有 7 个版本\n\tfor i := 1; i <= 3; i++ {\n\t\tv := &TaskScriptVersion{TaskId: 1, Command: \"cmd\", Version: i}\n\t\tv.Create()\n\t}\n\tfor i := 1; i <= 7; i++ {\n\t\tv := &TaskScriptVersion{TaskId: 2, Command: \"cmd\", Version: i}\n\t\tv.Create()\n\t}\n\n\tv := new(TaskScriptVersion)\n\tlatest1, _ := v.GetLatestVersion(1)\n\tlatest2, _ := v.GetLatestVersion(2)\n\n\tif latest1 != 3 {\n\t\tt.Errorf(\"expected task 1 latest = 3, got %d\", latest1)\n\t}\n\tif latest2 != 7 {\n\t\tt.Errorf(\"expected task 2 latest = 7, got %d\", latest2)\n\t}\n}\n\nfunc TestTaskScriptVersion_CleanOldVersions(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\ttaskId := 1\n\tfor i := 1; i <= 10; i++ {\n\t\tv := &TaskScriptVersion{TaskId: taskId, Command: \"cmd v\" + string(rune('0'+i)), Version: i}\n\t\tv.Create()\n\t}\n\n\tv := new(TaskScriptVersion)\n\n\t// 保留最新 5 个\n\terr := v.CleanOldVersions(taskId, 5)\n\tif err != nil {\n\t\tt.Fatalf(\"CleanOldVersions returned error: %v\", err)\n\t}\n\n\ttotal, _ := v.Total(taskId)\n\tif total != 5 {\n\t\tt.Errorf(\"expected 5 versions after cleanup, got %d\", total)\n\t}\n\n\t// 验证保留的是最新的 5 个 (version 6-10)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 10}\n\tlist, _ := v.List(taskId, params)\n\tfor _, item := range list {\n\t\tif item.Version < 6 {\n\t\t\tt.Errorf(\"expected only versions >= 6, but found version %d\", item.Version)\n\t\t}\n\t}\n}\n\nfunc TestTaskScriptVersion_CleanOldVersions_NoOpWhenUnderLimit(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\ttaskId := 1\n\tfor i := 1; i <= 3; i++ {\n\t\tv := &TaskScriptVersion{TaskId: taskId, Command: \"cmd\", Version: i}\n\t\tv.Create()\n\t}\n\n\tv := new(TaskScriptVersion)\n\terr := v.CleanOldVersions(taskId, 5)\n\tif err != nil {\n\t\tt.Fatalf(\"CleanOldVersions returned error: %v\", err)\n\t}\n\n\ttotal, _ := v.Total(taskId)\n\tif total != 3 {\n\t\tt.Errorf(\"expected 3 versions (no cleanup needed), got %d\", total)\n\t}\n}\n\nfunc TestTaskScriptVersion_CleanOldVersions_IsolatedByTask(t *testing.T) {\n\tcleanup := setupVersionTestDB(t)\n\tdefer cleanup()\n\n\t// task 1: 10 个版本\n\tfor i := 1; i <= 10; i++ {\n\t\tv := &TaskScriptVersion{TaskId: 1, Command: \"cmd\", Version: i}\n\t\tv.Create()\n\t}\n\t// task 2: 3 个版本\n\tfor i := 1; i <= 3; i++ {\n\t\tv := &TaskScriptVersion{TaskId: 2, Command: \"cmd\", Version: i}\n\t\tv.Create()\n\t}\n\n\tv := new(TaskScriptVersion)\n\tv.CleanOldVersions(1, 2)\n\n\ttotal1, _ := v.Total(1)\n\ttotal2, _ := v.Total(2)\n\n\tif total1 != 2 {\n\t\tt.Errorf(\"expected 2 versions for task 1 after cleanup, got %d\", total1)\n\t}\n\tif total2 != 3 {\n\t\tt.Errorf(\"expected 3 versions for task 2 (untouched), got %d\", total2)\n\t}\n}\n"
  },
  {
    "path": "internal/models/task_tag_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/schema\"\n)\n\nfunc setupTagTestDB(t *testing.T) func() {\n\tt.Helper()\n\toriginalDb := Db\n\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{\n\t\tNamingStrategy: schema.NamingStrategy{\n\t\t\tSingularTable: true,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open test database: %v\", err)\n\t}\n\n\terr = db.AutoMigrate(&Task{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to migrate test database: %v\", err)\n\t}\n\n\tDb = db\n\n\treturn func() {\n\t\tDb = originalDb\n\t}\n}\n\nfunc TestGetAllTags_MultipleTags(t *testing.T) {\n\tcleanup := setupTagTestDB(t)\n\tdefer cleanup()\n\n\t// Create tasks with various tag combinations\n\ttasks := []map[string]interface{}{\n\t\t{\"name\": \"task1\", \"tag\": \"tag1\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 1\", \"status\": 1},\n\t\t{\"name\": \"task2\", \"tag\": \"tag1,tag2\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 2\", \"status\": 1},\n\t\t{\"name\": \"task3\", \"tag\": \"tag2,tag3\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 3\", \"status\": 1},\n\t}\n\tfor _, data := range tasks {\n\t\tif err := Db.Model(&Task{}).Create(data).Error; err != nil {\n\t\t\tt.Fatalf(\"failed to create task: %v\", err)\n\t\t}\n\t}\n\n\ttaskModel := new(Task)\n\ttags, err := taskModel.GetAllTags()\n\tif err != nil {\n\t\tt.Fatalf(\"GetAllTags returned error: %v\", err)\n\t}\n\n\texpected := []string{\"tag1\", \"tag2\", \"tag3\"}\n\tif len(tags) != len(expected) {\n\t\tt.Fatalf(\"expected %d tags, got %d: %v\", len(expected), len(tags), tags)\n\t}\n\tfor i, tag := range tags {\n\t\tif tag != expected[i] {\n\t\t\tt.Errorf(\"expected tag[%d] = %q, got %q\", i, expected[i], tag)\n\t\t}\n\t}\n}\n\nfunc TestGetAllTags_EmptyTagsExcluded(t *testing.T) {\n\tcleanup := setupTagTestDB(t)\n\tdefer cleanup()\n\n\t// Create tasks with empty and non-empty tags\n\ttasks := []map[string]interface{}{\n\t\t{\"name\": \"task1\", \"tag\": \"\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 1\", \"status\": 1},\n\t\t{\"name\": \"task2\", \"tag\": \"mytag\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 2\", \"status\": 1},\n\t}\n\tfor _, data := range tasks {\n\t\tif err := Db.Model(&Task{}).Create(data).Error; err != nil {\n\t\t\tt.Fatalf(\"failed to create task: %v\", err)\n\t\t}\n\t}\n\n\ttaskModel := new(Task)\n\ttags, err := taskModel.GetAllTags()\n\tif err != nil {\n\t\tt.Fatalf(\"GetAllTags returned error: %v\", err)\n\t}\n\n\tif len(tags) != 1 || tags[0] != \"mytag\" {\n\t\tt.Errorf(\"expected [\\\"mytag\\\"], got %v\", tags)\n\t}\n}\n\nfunc TestGetAllTags_NoTasks(t *testing.T) {\n\tcleanup := setupTagTestDB(t)\n\tdefer cleanup()\n\n\ttaskModel := new(Task)\n\ttags, err := taskModel.GetAllTags()\n\tif err != nil {\n\t\tt.Fatalf(\"GetAllTags returned error: %v\", err)\n\t}\n\n\tif len(tags) != 0 {\n\t\tt.Errorf(\"expected empty list, got %v\", tags)\n\t}\n}\n\nfunc TestGetAllTags_SingleTagBackwardCompatibility(t *testing.T) {\n\tcleanup := setupTagTestDB(t)\n\tdefer cleanup()\n\n\t// Single tag (no comma) should still work\n\tdata := map[string]interface{}{\n\t\t\"name\": \"task1\", \"tag\": \"single\", \"level\": 1, \"spec\": \"* * * * *\",\n\t\t\"protocol\": 1, \"command\": \"echo 1\", \"status\": 1,\n\t}\n\tif err := Db.Model(&Task{}).Create(data).Error; err != nil {\n\t\tt.Fatalf(\"failed to create task: %v\", err)\n\t}\n\n\ttaskModel := new(Task)\n\ttags, err := taskModel.GetAllTags()\n\tif err != nil {\n\t\tt.Fatalf(\"GetAllTags returned error: %v\", err)\n\t}\n\n\tif len(tags) != 1 || tags[0] != \"single\" {\n\t\tt.Errorf(\"expected [\\\"single\\\"], got %v\", tags)\n\t}\n}\n\nfunc TestGetAllTags_Deduplication(t *testing.T) {\n\tcleanup := setupTagTestDB(t)\n\tdefer cleanup()\n\n\t// Same tag appears in multiple tasks\n\ttasks := []map[string]interface{}{\n\t\t{\"name\": \"task1\", \"tag\": \"common,unique1\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 1\", \"status\": 1},\n\t\t{\"name\": \"task2\", \"tag\": \"common,unique2\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 2\", \"status\": 1},\n\t}\n\tfor _, data := range tasks {\n\t\tif err := Db.Model(&Task{}).Create(data).Error; err != nil {\n\t\t\tt.Fatalf(\"failed to create task: %v\", err)\n\t\t}\n\t}\n\n\ttaskModel := new(Task)\n\ttags, err := taskModel.GetAllTags()\n\tif err != nil {\n\t\tt.Fatalf(\"GetAllTags returned error: %v\", err)\n\t}\n\n\texpected := []string{\"common\", \"unique1\", \"unique2\"}\n\tif len(tags) != len(expected) {\n\t\tt.Fatalf(\"expected %d tags, got %d: %v\", len(expected), len(tags), tags)\n\t}\n\tfor i, tag := range tags {\n\t\tif tag != expected[i] {\n\t\t\tt.Errorf(\"expected tag[%d] = %q, got %q\", i, expected[i], tag)\n\t\t}\n\t}\n}\n\nfunc TestLikeQueryWithCommaSeparatedTags(t *testing.T) {\n\tcleanup := setupTagTestDB(t)\n\tdefer cleanup()\n\n\t// Create tasks with comma-separated tags\n\ttasks := []map[string]interface{}{\n\t\t{\"name\": \"task1\", \"tag\": \"backend,api\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 1\", \"status\": 1},\n\t\t{\"name\": \"task2\", \"tag\": \"frontend\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 2\", \"status\": 1},\n\t\t{\"name\": \"task3\", \"tag\": \"backend,cron\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 3\", \"status\": 1},\n\t}\n\tfor _, data := range tasks {\n\t\tif err := Db.Model(&Task{}).Create(data).Error; err != nil {\n\t\t\tt.Fatalf(\"failed to create task: %v\", err)\n\t\t}\n\t}\n\n\t// LIKE query for \"backend\" should match task1 and task3\n\tvar results []Task\n\terr := Db.Where(\"tag LIKE ?\", \"%backend%\").Find(&results).Error\n\tif err != nil {\n\t\tt.Fatalf(\"LIKE query returned error: %v\", err)\n\t}\n\n\tif len(results) != 2 {\n\t\tt.Errorf(\"expected 2 results for LIKE '%%backend%%', got %d\", len(results))\n\t}\n\n\t// LIKE query for \"api\" should match only task1\n\tresults = nil\n\terr = Db.Where(\"tag LIKE ?\", \"%api%\").Find(&results).Error\n\tif err != nil {\n\t\tt.Fatalf(\"LIKE query returned error: %v\", err)\n\t}\n\n\tif len(results) != 1 {\n\t\tt.Errorf(\"expected 1 result for LIKE '%%api%%', got %d\", len(results))\n\t}\n}\n"
  },
  {
    "path": "internal/models/task_template.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\n\t\"gorm.io/gorm\"\n)\n\ntype TaskTemplate struct {\n\tId               int       `json:\"id\" gorm:\"primaryKey;autoIncrement\"`\n\tName             string    `json:\"name\" gorm:\"type:varchar(64);not null\"`\n\tDescription      string    `json:\"description\" gorm:\"type:varchar(500);not null;default:''\"`\n\tCategory         string    `json:\"category\" gorm:\"type:varchar(32);not null;default:'custom';index\"`\n\tProtocol         int8      `json:\"protocol\" gorm:\"type:tinyint;not null;default:2\"`\n\tCommand          string    `json:\"command\" gorm:\"type:text;not null\"`\n\tHttpMethod       int8      `json:\"http_method\" gorm:\"type:tinyint;not null;default:1\"`\n\tHttpBody         string    `json:\"http_body\" gorm:\"type:text\"`\n\tHttpHeaders      string    `json:\"http_headers\" gorm:\"type:text\"`\n\tSuccessPattern   string    `json:\"success_pattern\" gorm:\"type:varchar(512);not null;default:''\"`\n\tTag              string    `json:\"tag\" gorm:\"type:varchar(255);not null;default:''\"`\n\tSpec             string    `json:\"spec\" gorm:\"type:varchar(64);not null;default:''\"`\n\tTimeout          int       `json:\"timeout\" gorm:\"type:int;not null;default:0\"`\n\tMulti            int8      `json:\"multi\" gorm:\"type:tinyint;not null;default:1\"`\n\tRetryTimes       int8      `json:\"retry_times\" gorm:\"type:tinyint;not null;default:0\"`\n\tRetryInterval    int16     `json:\"retry_interval\" gorm:\"type:smallint;not null;default:0\"`\n\tTimezone         string    `json:\"timezone\" gorm:\"type:varchar(64);not null;default:''\"`\n\tNotifyStatus     int8      `json:\"notify_status\" gorm:\"type:tinyint;not null;default:0\"`\n\tNotifyType       int8      `json:\"notify_type\" gorm:\"type:tinyint;not null;default:0\"`\n\tNotifyKeyword    string    `json:\"notify_keyword\" gorm:\"type:varchar(128);not null;default:''\"`\n\tLogRetentionDays int       `json:\"log_retention_days\" gorm:\"type:smallint;not null;default:0\"`\n\tIsBuiltin        int8      `json:\"is_builtin\" gorm:\"type:tinyint;not null;default:0\"`\n\tUsageCount       int       `json:\"usage_count\" gorm:\"type:int;not null;default:0\"`\n\tCreatedBy        string    `json:\"created_by\" gorm:\"type:varchar(64);not null;default:''\"`\n\tCreatedAt        time.Time `json:\"created_at\" gorm:\"column:created_at;autoCreateTime\"`\n\tUpdatedAt        time.Time `json:\"updated_at\" gorm:\"column:updated_at;autoUpdateTime\"`\n\tBaseModel        `json:\"-\" gorm:\"-\"`\n}\n\nfunc (t *TaskTemplate) Create() (int, error) {\n\tresult := Db.Create(t)\n\treturn t.Id, result.Error\n}\n\nfunc (t *TaskTemplate) UpdateBean(id int) (int64, error) {\n\tresult := Db.Model(&TaskTemplate{}).Where(\"id = ?\", id).\n\t\tSelect(\"name\", \"description\", \"category\", \"protocol\", \"command\",\n\t\t\t\"http_method\", \"http_body\", \"http_headers\", \"success_pattern\",\n\t\t\t\"tag\", \"spec\", \"timeout\", \"multi\", \"retry_times\", \"retry_interval\",\n\t\t\t\"timezone\", \"notify_status\", \"notify_type\", \"notify_keyword\", \"log_retention_days\").\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"name\":               t.Name,\n\t\t\t\"description\":        t.Description,\n\t\t\t\"category\":           t.Category,\n\t\t\t\"protocol\":           t.Protocol,\n\t\t\t\"command\":            t.Command,\n\t\t\t\"http_method\":        t.HttpMethod,\n\t\t\t\"http_body\":          t.HttpBody,\n\t\t\t\"http_headers\":       t.HttpHeaders,\n\t\t\t\"success_pattern\":    t.SuccessPattern,\n\t\t\t\"tag\":                t.Tag,\n\t\t\t\"spec\":               t.Spec,\n\t\t\t\"timeout\":            t.Timeout,\n\t\t\t\"multi\":              t.Multi,\n\t\t\t\"retry_times\":        t.RetryTimes,\n\t\t\t\"retry_interval\":     t.RetryInterval,\n\t\t\t\"timezone\":           t.Timezone,\n\t\t\t\"notify_status\":      t.NotifyStatus,\n\t\t\t\"notify_type\":        t.NotifyType,\n\t\t\t\"notify_keyword\":     t.NotifyKeyword,\n\t\t\t\"log_retention_days\": t.LogRetentionDays,\n\t\t})\n\treturn result.RowsAffected, result.Error\n}\n\nfunc (t *TaskTemplate) Delete(id int) (int64, error) {\n\tresult := Db.Delete(&TaskTemplate{}, id)\n\treturn result.RowsAffected, result.Error\n}\n\nfunc (t *TaskTemplate) Detail(id int) (TaskTemplate, error) {\n\tvar tmpl TaskTemplate\n\terr := Db.Where(\"id = ?\", id).First(&tmpl).Error\n\treturn tmpl, err\n}\n\nfunc (t *TaskTemplate) List(params CommonMap) ([]TaskTemplate, error) {\n\tt.parsePageAndPageSize(params)\n\tlist := make([]TaskTemplate, 0)\n\n\tquery := Db.Model(&TaskTemplate{})\n\tt.parseWhere(query, params)\n\n\terr := query.Order(\"is_builtin DESC, updated_at DESC, id DESC\").\n\t\tLimit(t.PageSize).Offset(t.pageLimitOffset()).\n\t\tFind(&list).Error\n\treturn list, err\n}\n\nfunc (t *TaskTemplate) Total(params CommonMap) (int64, error) {\n\tvar count int64\n\tquery := Db.Model(&TaskTemplate{})\n\tt.parseWhere(query, params)\n\terr := query.Count(&count).Error\n\treturn count, err\n}\n\nfunc (t *TaskTemplate) parseWhere(query *gorm.DB, params CommonMap) {\n\tcategory, ok := params[\"Category\"]\n\tif ok && category.(string) != \"\" {\n\t\tquery.Where(\"category = ?\", category)\n\t}\n\tname, ok := params[\"Name\"]\n\tif ok && name.(string) != \"\" {\n\t\tquery.Where(\"name LIKE ?\", \"%\"+name.(string)+\"%\")\n\t}\n}\n\nfunc (t *TaskTemplate) IncrementUsage(id int) error {\n\treturn Db.Model(&TaskTemplate{}).Where(\"id = ?\", id).\n\t\tUpdateColumn(\"usage_count\", gorm.Expr(\"usage_count + 1\")).Error\n}\n\nfunc (t *TaskTemplate) NameExist(name string, id int) (bool, error) {\n\tvar count int64\n\tquery := Db.Model(&TaskTemplate{}).Where(\"name = ?\", name)\n\tif id > 0 {\n\t\tquery = query.Where(\"id != ?\", id)\n\t}\n\terr := query.Count(&count).Error\n\treturn count > 0, err\n}\n\nfunc (t *TaskTemplate) GetCategories() ([]string, error) {\n\tvar categories []string\n\terr := Db.Model(&TaskTemplate{}).Distinct(\"category\").Order(\"category\").Pluck(\"category\", &categories).Error\n\treturn categories, err\n}\n\n// seedBuiltinTemplates 初始化内置模板\nfunc seedBuiltinTemplates(tx *gorm.DB) {\n\ttemplates := []TaskTemplate{\n\t\t{\n\t\t\tName:        \"MySQL Database Backup\",\n\t\t\tDescription: \"Backup MySQL database to compressed file\",\n\t\t\tCategory:    \"backup\",\n\t\t\tProtocol:    2,\n\t\t\tCommand:     `mysqldump -h {{db_host}} -u {{db_user}} -p'{{db_pass}}' {{db_name}} | gzip > /backup/{{db_name}}_$(date +%Y%m%d_%H%M%S).sql.gz`,\n\t\t\tTag:         \"backup,database\",\n\t\t\tSpec:        \"0 0 2 * * *\",\n\t\t\tTimeout:     3600,\n\t\t\tMulti:       0,\n\t\t\tIsBuiltin:   1,\n\t\t},\n\t\t{\n\t\t\tName:        \"PostgreSQL Database Backup\",\n\t\t\tDescription: \"Backup PostgreSQL database to compressed file\",\n\t\t\tCategory:    \"backup\",\n\t\t\tProtocol:    2,\n\t\t\tCommand:     `PGPASSWORD='{{db_pass}}' pg_dump -h {{db_host}} -U {{db_user}} {{db_name}} | gzip > /backup/{{db_name}}_$(date +%Y%m%d_%H%M%S).sql.gz`,\n\t\t\tTag:         \"backup,database\",\n\t\t\tSpec:        \"0 0 2 * * *\",\n\t\t\tTimeout:     3600,\n\t\t\tMulti:       0,\n\t\t\tIsBuiltin:   1,\n\t\t},\n\t\t{\n\t\t\tName:        \"Clean Log Files\",\n\t\t\tDescription: \"Delete log files older than specified days\",\n\t\t\tCategory:    \"cleanup\",\n\t\t\tProtocol:    2,\n\t\t\tCommand:     `find {{log_dir}} -name \"*.log\" -mtime +{{retain_days}} -delete && echo \"Cleanup completed\"`,\n\t\t\tTag:         \"cleanup,logs\",\n\t\t\tSpec:        \"0 0 3 * * *\",\n\t\t\tTimeout:     300,\n\t\t\tMulti:       0,\n\t\t\tIsBuiltin:   1,\n\t\t},\n\t\t{\n\t\t\tName:        \"Clean Temp Files\",\n\t\t\tDescription: \"Delete temporary files in specified directory\",\n\t\t\tCategory:    \"cleanup\",\n\t\t\tProtocol:    2,\n\t\t\tCommand:     `find {{temp_dir}} -type f -mtime +{{retain_days}} -delete && echo \"Cleaned $(date)\"`,\n\t\t\tTag:         \"cleanup\",\n\t\t\tSpec:        \"0 0 4 * * *\",\n\t\t\tTimeout:     300,\n\t\t\tMulti:       0,\n\t\t\tIsBuiltin:   1,\n\t\t},\n\t\t{\n\t\t\tName:          \"HTTP Health Check\",\n\t\t\tDescription:   \"Check if HTTP endpoint is healthy\",\n\t\t\tCategory:      \"monitor\",\n\t\t\tProtocol:      2,\n\t\t\tCommand:       `curl -sf -o /dev/null -w \"%{http_code}\" {{check_url}} || exit 1`,\n\t\t\tTag:           \"monitor,health\",\n\t\t\tSpec:          \"0 */5 * * * *\",\n\t\t\tTimeout:       30,\n\t\t\tRetryTimes:    3,\n\t\t\tRetryInterval: 30,\n\t\t\tIsBuiltin:     1,\n\t\t},\n\t\t{\n\t\t\tName:        \"Disk Usage Alert\",\n\t\t\tDescription: \"Alert when disk usage exceeds threshold\",\n\t\t\tCategory:    \"monitor\",\n\t\t\tProtocol:    2,\n\t\t\tCommand:     `usage=$(df {{mount_point}} | awk 'NR==2{print $5}' | tr -d '%%') && [ \"$usage\" -lt {{threshold}} ] && echo \"OK: ${usage}%% used\" || (echo \"WARN: ${usage}%% used, exceeds {{threshold}}%%\" && exit 1)`,\n\t\t\tTag:         \"monitor,disk\",\n\t\t\tSpec:        \"0 */30 * * * *\",\n\t\t\tTimeout:     30,\n\t\t\tIsBuiltin:   1,\n\t\t},\n\t\t{\n\t\t\tName:        \"Docker Container Restart\",\n\t\t\tDescription: \"Restart a Docker container and verify status\",\n\t\t\tCategory:    \"deploy\",\n\t\t\tProtocol:    2,\n\t\t\tCommand:     `docker restart {{container_name}} && sleep 3 && docker ps | grep {{container_name}}`,\n\t\t\tTag:         \"deploy,docker\",\n\t\t\tTimeout:     120,\n\t\t\tMulti:       0,\n\t\t\tIsBuiltin:   1,\n\t\t},\n\t\t{\n\t\t\tName:          \"HTTP API Call (GET)\",\n\t\t\tDescription:   \"Call an HTTP GET API endpoint\",\n\t\t\tCategory:      \"api\",\n\t\t\tProtocol:      1,\n\t\t\tCommand:       `{{api_url}}`,\n\t\t\tHttpMethod:    1,\n\t\t\tTag:           \"api,http\",\n\t\t\tTimeout:       30,\n\t\t\tRetryTimes:    2,\n\t\t\tRetryInterval: 10,\n\t\t\tIsBuiltin:     1,\n\t\t},\n\t\t{\n\t\t\tName:          \"HTTP API Call (POST)\",\n\t\t\tDescription:   \"Call an HTTP POST API with JSON body\",\n\t\t\tCategory:      \"api\",\n\t\t\tProtocol:      1,\n\t\t\tCommand:       `{{api_url}}`,\n\t\t\tHttpMethod:    2,\n\t\t\tHttpBody:      `{{json_body}}`,\n\t\t\tHttpHeaders:   `{\"Content-Type\": \"application/json\"}`,\n\t\t\tTag:           \"api,http\",\n\t\t\tTimeout:       30,\n\t\t\tRetryTimes:    2,\n\t\t\tRetryInterval: 10,\n\t\t\tIsBuiltin:     1,\n\t\t},\n\t}\n\n\tfor i := range templates {\n\t\tvar count int64\n\t\ttx.Model(&TaskTemplate{}).Where(\"name = ?\", templates[i].Name).Count(&count)\n\t\tif count > 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := tx.Create(&templates[i]).Error; err != nil {\n\t\t\tlogger.Warnf(\"初始化内置模板 [%s] 失败: %v\", templates[i].Name, err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/models/task_template_test.go",
    "content": "package models\n\nimport (\n\t\"testing\"\n\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/schema\"\n)\n\nfunc setupTemplateTestDB(t *testing.T) func() {\n\tt.Helper()\n\toriginalDb := Db\n\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{\n\t\tNamingStrategy: schema.NamingStrategy{\n\t\t\tSingularTable: true,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open test database: %v\", err)\n\t}\n\n\tif err := db.AutoMigrate(&TaskTemplate{}); err != nil {\n\t\tt.Fatalf(\"failed to migrate test database: %v\", err)\n\t}\n\n\tDb = db\n\n\treturn func() {\n\t\tDb = originalDb\n\t}\n}\n\nfunc TestTaskTemplate_Create(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttmpl := &TaskTemplate{\n\t\tName:        \"Test Template\",\n\t\tDescription: \"A test template\",\n\t\tCategory:    \"custom\",\n\t\tProtocol:    2,\n\t\tCommand:     \"echo hello\",\n\t\tTimeout:     300,\n\t\tCreatedBy:   \"admin\",\n\t}\n\n\tid, err := tmpl.Create()\n\tif err != nil {\n\t\tt.Fatalf(\"Create returned error: %v\", err)\n\t}\n\tif id <= 0 {\n\t\tt.Errorf(\"expected id > 0, got %d\", id)\n\t}\n}\n\nfunc TestTaskTemplate_Detail(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttmpl := &TaskTemplate{\n\t\tName:        \"Detail Test\",\n\t\tDescription: \"desc\",\n\t\tCategory:    \"monitor\",\n\t\tProtocol:    2,\n\t\tCommand:     \"curl http://example.com\",\n\t\tTimeout:     30,\n\t\tIsBuiltin:   1,\n\t\tCreatedBy:   \"system\",\n\t}\n\tid, _ := tmpl.Create()\n\n\tresult, err := tmpl.Detail(id)\n\tif err != nil {\n\t\tt.Fatalf(\"Detail returned error: %v\", err)\n\t}\n\tif result.Name != \"Detail Test\" {\n\t\tt.Errorf(\"expected name 'Detail Test', got '%s'\", result.Name)\n\t}\n\tif result.Category != \"monitor\" {\n\t\tt.Errorf(\"expected category 'monitor', got '%s'\", result.Category)\n\t}\n\tif result.IsBuiltin != 1 {\n\t\tt.Errorf(\"expected is_builtin = 1, got %d\", result.IsBuiltin)\n\t}\n}\n\nfunc TestTaskTemplate_Detail_NotFound(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttmpl := new(TaskTemplate)\n\t_, err := tmpl.Detail(99999)\n\tif err == nil {\n\t\tt.Error(\"expected error for non-existent template, got nil\")\n\t}\n}\n\nfunc TestTaskTemplate_UpdateBean(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttmpl := &TaskTemplate{\n\t\tName:     \"Original\",\n\t\tCategory: \"backup\",\n\t\tProtocol: 2,\n\t\tCommand:  \"old command\",\n\t\tTimeout:  100,\n\t}\n\tid, _ := tmpl.Create()\n\n\ttmpl.Name = \"Updated\"\n\ttmpl.Command = \"new command\"\n\ttmpl.Timeout = 200\n\trows, err := tmpl.UpdateBean(id)\n\tif err != nil {\n\t\tt.Fatalf(\"UpdateBean returned error: %v\", err)\n\t}\n\tif rows != 1 {\n\t\tt.Errorf(\"expected 1 row affected, got %d\", rows)\n\t}\n\n\tresult, _ := tmpl.Detail(id)\n\tif result.Name != \"Updated\" {\n\t\tt.Errorf(\"expected name 'Updated', got '%s'\", result.Name)\n\t}\n\tif result.Command != \"new command\" {\n\t\tt.Errorf(\"expected command 'new command', got '%s'\", result.Command)\n\t}\n\tif result.Timeout != 200 {\n\t\tt.Errorf(\"expected timeout 200, got %d\", result.Timeout)\n\t}\n}\n\nfunc TestTaskTemplate_Delete(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttmpl := &TaskTemplate{\n\t\tName:     \"ToDelete\",\n\t\tCategory: \"custom\",\n\t\tProtocol: 2,\n\t\tCommand:  \"echo bye\",\n\t}\n\tid, _ := tmpl.Create()\n\n\trows, err := tmpl.Delete(id)\n\tif err != nil {\n\t\tt.Fatalf(\"Delete returned error: %v\", err)\n\t}\n\tif rows != 1 {\n\t\tt.Errorf(\"expected 1 row affected, got %d\", rows)\n\t}\n\n\t// 确认已删除\n\t_, err = tmpl.Detail(id)\n\tif err == nil {\n\t\tt.Error(\"expected error after deletion, got nil\")\n\t}\n}\n\nfunc TestTaskTemplate_List_Empty(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttmpl := new(TaskTemplate)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 10}\n\tlist, err := tmpl.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) != 0 {\n\t\tt.Errorf(\"expected empty list, got %d items\", len(list))\n\t}\n}\n\nfunc TestTaskTemplate_List_Pagination(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\tfor i := 0; i < 5; i++ {\n\t\ttmpl := &TaskTemplate{\n\t\t\tName:     \"tmpl\" + string(rune('A'+i)),\n\t\t\tCategory: \"custom\",\n\t\t\tProtocol: 2,\n\t\t\tCommand:  \"echo test\",\n\t\t}\n\t\ttmpl.Create()\n\t}\n\n\ttmpl := new(TaskTemplate)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 3}\n\tlist, err := tmpl.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List page 1 error: %v\", err)\n\t}\n\tif len(list) != 3 {\n\t\tt.Errorf(\"expected 3 items on page 1, got %d\", len(list))\n\t}\n\n\tparams[\"Page\"] = 2\n\tlist2, err := tmpl.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List page 2 error: %v\", err)\n\t}\n\tif len(list2) != 2 {\n\t\tt.Errorf(\"expected 2 items on page 2, got %d\", len(list2))\n\t}\n}\n\nfunc TestTaskTemplate_List_FilterByCategory(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttemplates := []TaskTemplate{\n\t\t{Name: \"backup1\", Category: \"backup\", Protocol: 2, Command: \"cmd1\"},\n\t\t{Name: \"monitor1\", Category: \"monitor\", Protocol: 2, Command: \"cmd2\"},\n\t\t{Name: \"backup2\", Category: \"backup\", Protocol: 2, Command: \"cmd3\"},\n\t}\n\tfor i := range templates {\n\t\ttemplates[i].Create()\n\t}\n\n\ttmpl := new(TaskTemplate)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 10, \"Category\": \"backup\"}\n\tlist, err := tmpl.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) != 2 {\n\t\tt.Errorf(\"expected 2 backup templates, got %d\", len(list))\n\t}\n\tfor _, item := range list {\n\t\tif item.Category != \"backup\" {\n\t\t\tt.Errorf(\"expected category 'backup', got '%s'\", item.Category)\n\t\t}\n\t}\n}\n\nfunc TestTaskTemplate_List_FilterByName(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttemplates := []TaskTemplate{\n\t\t{Name: \"MySQL Backup\", Category: \"backup\", Protocol: 2, Command: \"cmd1\"},\n\t\t{Name: \"PG Backup\", Category: \"backup\", Protocol: 2, Command: \"cmd2\"},\n\t\t{Name: \"Health Check\", Category: \"monitor\", Protocol: 2, Command: \"cmd3\"},\n\t}\n\tfor i := range templates {\n\t\ttemplates[i].Create()\n\t}\n\n\ttmpl := new(TaskTemplate)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 10, \"Name\": \"Backup\"}\n\tlist, err := tmpl.List(params)\n\tif err != nil {\n\t\tt.Fatalf(\"List returned error: %v\", err)\n\t}\n\tif len(list) != 2 {\n\t\tt.Errorf(\"expected 2 templates matching 'Backup', got %d\", len(list))\n\t}\n}\n\nfunc TestTaskTemplate_Total(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttmpl := new(TaskTemplate)\n\ttotal, _ := tmpl.Total(CommonMap{})\n\tif total != 0 {\n\t\tt.Errorf(\"expected 0, got %d\", total)\n\t}\n\n\tfor i := 0; i < 3; i++ {\n\t\tt2 := &TaskTemplate{Name: \"t\" + string(rune('0'+i)), Category: \"custom\", Protocol: 2, Command: \"cmd\"}\n\t\tt2.Create()\n\t}\n\n\ttotal, err := tmpl.Total(CommonMap{})\n\tif err != nil {\n\t\tt.Fatalf(\"Total returned error: %v\", err)\n\t}\n\tif total != 3 {\n\t\tt.Errorf(\"expected 3, got %d\", total)\n\t}\n}\n\nfunc TestTaskTemplate_Total_WithFilter(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttemplates := []TaskTemplate{\n\t\t{Name: \"t1\", Category: \"backup\", Protocol: 2, Command: \"cmd\"},\n\t\t{Name: \"t2\", Category: \"monitor\", Protocol: 2, Command: \"cmd\"},\n\t\t{Name: \"t3\", Category: \"backup\", Protocol: 2, Command: \"cmd\"},\n\t}\n\tfor i := range templates {\n\t\ttemplates[i].Create()\n\t}\n\n\ttmpl := new(TaskTemplate)\n\ttotal, _ := tmpl.Total(CommonMap{\"Category\": \"backup\"})\n\tif total != 2 {\n\t\tt.Errorf(\"expected 2 backup templates, got %d\", total)\n\t}\n}\n\nfunc TestTaskTemplate_NameExist(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttmpl := &TaskTemplate{Name: \"Unique Name\", Category: \"custom\", Protocol: 2, Command: \"cmd\"}\n\tid, _ := tmpl.Create()\n\n\t// 同名应该存在\n\texists, err := tmpl.NameExist(\"Unique Name\", 0)\n\tif err != nil {\n\t\tt.Fatalf(\"NameExist returned error: %v\", err)\n\t}\n\tif !exists {\n\t\tt.Error(\"expected name to exist\")\n\t}\n\n\t// 排除自身ID后不应该存在\n\texists, _ = tmpl.NameExist(\"Unique Name\", id)\n\tif exists {\n\t\tt.Error(\"expected name not to exist when excluding self\")\n\t}\n\n\t// 不存在的名字\n\texists, _ = tmpl.NameExist(\"Other Name\", 0)\n\tif exists {\n\t\tt.Error(\"expected name not to exist\")\n\t}\n}\n\nfunc TestTaskTemplate_IncrementUsage(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttmpl := &TaskTemplate{Name: \"usage test\", Category: \"custom\", Protocol: 2, Command: \"cmd\"}\n\tid, _ := tmpl.Create()\n\n\tfor i := 0; i < 3; i++ {\n\t\tif err := tmpl.IncrementUsage(id); err != nil {\n\t\t\tt.Fatalf(\"IncrementUsage returned error: %v\", err)\n\t\t}\n\t}\n\n\tresult, _ := tmpl.Detail(id)\n\tif result.UsageCount != 3 {\n\t\tt.Errorf(\"expected usage_count = 3, got %d\", result.UsageCount)\n\t}\n}\n\nfunc TestTaskTemplate_GetCategories(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttemplates := []TaskTemplate{\n\t\t{Name: \"t1\", Category: \"backup\", Protocol: 2, Command: \"cmd\"},\n\t\t{Name: \"t2\", Category: \"monitor\", Protocol: 2, Command: \"cmd\"},\n\t\t{Name: \"t3\", Category: \"backup\", Protocol: 2, Command: \"cmd\"},\n\t\t{Name: \"t4\", Category: \"deploy\", Protocol: 2, Command: \"cmd\"},\n\t}\n\tfor i := range templates {\n\t\ttemplates[i].Create()\n\t}\n\n\ttmpl := new(TaskTemplate)\n\tcategories, err := tmpl.GetCategories()\n\tif err != nil {\n\t\tt.Fatalf(\"GetCategories returned error: %v\", err)\n\t}\n\tif len(categories) != 3 {\n\t\tt.Errorf(\"expected 3 distinct categories, got %d: %v\", len(categories), categories)\n\t}\n\n\t// 验证按字母排序\n\texpected := []string{\"backup\", \"deploy\", \"monitor\"}\n\tfor i, cat := range categories {\n\t\tif cat != expected[i] {\n\t\t\tt.Errorf(\"expected category[%d] = '%s', got '%s'\", i, expected[i], cat)\n\t\t}\n\t}\n}\n\nfunc TestTaskTemplate_GetCategories_Empty(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\ttmpl := new(TaskTemplate)\n\tcategories, err := tmpl.GetCategories()\n\tif err != nil {\n\t\tt.Fatalf(\"GetCategories returned error: %v\", err)\n\t}\n\tif len(categories) != 0 {\n\t\tt.Errorf(\"expected empty categories, got %v\", categories)\n\t}\n}\n\nfunc TestSeedBuiltinTemplates(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\tseedBuiltinTemplates(Db)\n\n\ttmpl := new(TaskTemplate)\n\ttotal, err := tmpl.Total(CommonMap{})\n\tif err != nil {\n\t\tt.Fatalf(\"Total returned error: %v\", err)\n\t}\n\tif total != 9 {\n\t\tt.Errorf(\"expected 9 builtin templates, got %d\", total)\n\t}\n\n\t// 验证全部标记为内置\n\tvar list []TaskTemplate\n\tDb.Where(\"is_builtin = ?\", 1).Find(&list)\n\tif len(list) != 9 {\n\t\tt.Errorf(\"expected all 9 templates to be builtin, got %d\", len(list))\n\t}\n\n\t// 验证分类覆盖\n\tcategories, _ := tmpl.GetCategories()\n\tif len(categories) < 4 {\n\t\tt.Errorf(\"expected at least 4 categories from builtin templates, got %d: %v\", len(categories), categories)\n\t}\n}\n\nfunc TestSeedBuiltinTemplates_Idempotent(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\tseedBuiltinTemplates(Db)\n\tseedBuiltinTemplates(Db)\n\n\ttmpl := new(TaskTemplate)\n\ttotal, _ := tmpl.Total(CommonMap{})\n\t// seedBuiltinTemplates 按 name 去重，两次调用仍然只有 9 条\n\tif total != 9 {\n\t\tt.Errorf(\"expected 9 templates (idempotent), got %d\", total)\n\t}\n}\n\nfunc TestTaskTemplate_List_BuiltinFirst(t *testing.T) {\n\tcleanup := setupTemplateTestDB(t)\n\tdefer cleanup()\n\n\t// 先创建自定义，再创建内置\n\tcustom := &TaskTemplate{Name: \"custom1\", Category: \"custom\", Protocol: 2, Command: \"cmd\", IsBuiltin: 0}\n\tcustom.Create()\n\tbuiltin := &TaskTemplate{Name: \"builtin1\", Category: \"backup\", Protocol: 2, Command: \"cmd\", IsBuiltin: 1}\n\tbuiltin.Create()\n\n\ttmpl := new(TaskTemplate)\n\tparams := CommonMap{\"Page\": 1, \"PageSize\": 10}\n\tlist, _ := tmpl.List(params)\n\n\tif len(list) != 2 {\n\t\tt.Fatalf(\"expected 2 templates, got %d\", len(list))\n\t}\n\t// 内置模板应排在前面\n\tif list[0].IsBuiltin != 1 {\n\t\tt.Errorf(\"expected builtin template first, got is_builtin=%d\", list[0].IsBuiltin)\n\t}\n}\n"
  },
  {
    "path": "internal/models/user.go",
    "content": "package models\n\nimport (\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n)\n\nconst PasswordSaltLength = 6\n\n// 用户model\ntype User struct {\n\tId           int       `json:\"id\" gorm:\"primaryKey;autoIncrement\"`\n\tName         string    `json:\"name\" gorm:\"type:varchar(32);not null;uniqueIndex\"`\n\tPassword     string    `json:\"-\" gorm:\"type:varchar(100);not null\"`\n\tSalt         string    `json:\"-\" gorm:\"type:char(6);not null\"`\n\tEmail        string    `json:\"email\" gorm:\"type:varchar(50);not null;uniqueIndex;default:''\"`\n\tTwoFactorKey string    `json:\"-\" gorm:\"column:two_factor_key;type:varchar(100);default:''\"`\n\tTwoFactorOn  int8      `json:\"two_factor_on\" gorm:\"column:two_factor_on;type:tinyint;not null;default:0\"`\n\tCreatedAt    time.Time `json:\"created\" gorm:\"column:created;autoCreateTime\"`\n\tUpdatedAt    time.Time `json:\"updated\" gorm:\"column:updated;autoUpdateTime\"`\n\tIsAdmin      int8      `json:\"is_admin\" gorm:\"type:tinyint;not null;default:0\"`\n\tStatus       Status    `json:\"status\" gorm:\"type:tinyint;not null;default:1\"`\n\tBaseModel    `json:\"-\" gorm:\"-\"`\n}\n\n// 新增\nfunc (user *User) Create() (insertId int, err error) {\n\tuser.Status = Enabled\n\tuser.Salt = \"\" // bcrypt不需要单独的salt\n\tuser.Password, err = utils.HashPassword(user.Password)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tresult := Db.Create(user)\n\tif result.Error == nil {\n\t\tinsertId = user.Id\n\t}\n\n\treturn insertId, result.Error\n}\n\n// 更新\nfunc (user *User) Update(id int, data CommonMap) (int64, error) {\n\tupdateData := make(map[string]interface{})\n\tfor k, v := range data {\n\t\tupdateData[k] = v\n\t}\n\tresult := Db.Model(&User{}).Where(\"id = ?\", id).UpdateColumns(updateData)\n\treturn result.RowsAffected, result.Error\n}\n\nfunc (user *User) UpdatePassword(id int, password string) (int64, error) {\n\tsafePassword, err := utils.HashPassword(password)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn user.Update(id, CommonMap{\"password\": safePassword, \"salt\": \"\"})\n}\n\n// 删除\nfunc (user *User) Delete(id int) (int64, error) {\n\tresult := Db.Delete(&User{}, id)\n\treturn result.RowsAffected, result.Error\n}\n\n// 禁用\nfunc (user *User) Disable(id int) (int64, error) {\n\treturn user.Update(id, CommonMap{\"status\": Disabled})\n}\n\n// 激活\nfunc (user *User) Enable(id int) (int64, error) {\n\treturn user.Update(id, CommonMap{\"status\": Enabled})\n}\n\n// 验证用户名和密码\nfunc (user *User) Match(username, password string) bool {\n\terr := Db.Where(\"(name = ? OR email = ?) AND status = ?\", username, username, Enabled).First(user).Error\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn utils.VerifyPassword(user.Password, password, user.Salt)\n}\n\n// 获取用户详情\nfunc (user *User) Find(id int) error {\n\treturn Db.First(user, id).Error\n}\n\n// 用户名是否存在\nfunc (user *User) UsernameExists(username string, uid int) (int64, error) {\n\tvar count int64\n\tquery := Db.Model(&User{}).Where(\"name = ?\", username)\n\tif uid > 0 {\n\t\tquery = query.Where(\"id != ?\", uid)\n\t}\n\terr := query.Count(&count).Error\n\treturn count, err\n}\n\n// 邮箱地址是否存在\nfunc (user *User) EmailExists(email string, uid int) (int64, error) {\n\tvar count int64\n\tquery := Db.Model(&User{}).Where(\"email = ?\", email)\n\tif uid > 0 {\n\t\tquery = query.Where(\"id != ?\", uid)\n\t}\n\terr := query.Count(&count).Error\n\treturn count, err\n}\n\nfunc (user *User) List(params CommonMap) ([]User, error) {\n\tuser.parsePageAndPageSize(params)\n\tlist := make([]User, 0)\n\terr := Db.Order(\"id DESC\").Limit(user.PageSize).Offset(user.pageLimitOffset()).Find(&list).Error\n\n\treturn list, err\n}\n\nfunc (user *User) Total() (int64, error) {\n\tvar count int64\n\terr := Db.Model(&User{}).Count(&count).Error\n\treturn count, err\n}\n"
  },
  {
    "path": "internal/models/webhook_test.go",
    "content": "package models\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n)\n\n// setupTestDB 创建测试数据库\nfunc setupTestDB(t *testing.T) *gorm.DB {\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open test database: %v\", err)\n\t}\n\n\t// 创建表\n\tif err := db.AutoMigrate(&Setting{}); err != nil {\n\t\tt.Fatalf(\"failed to migrate: %v\", err)\n\t}\n\n\treturn db\n}\n\n// TestWebhookUrl_JSONMarshaling 测试WebhookUrl的JSON序列化\nfunc TestWebhookUrl_JSONMarshaling(t *testing.T) {\n\twebhookUrl := WebhookUrl{\n\t\tId:   1,\n\t\tName: \"Production Alert\",\n\t\tUrl:  \"https://example.com/webhook\",\n\t}\n\n\t// 序列化\n\tdata, err := json.Marshal(webhookUrl)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to marshal: %v\", err)\n\t}\n\n\t// 反序列化\n\tvar decoded WebhookUrl\n\tif err := json.Unmarshal(data, &decoded); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal: %v\", err)\n\t}\n\n\t// 验证\n\tif decoded.Id != webhookUrl.Id {\n\t\tt.Errorf(\"expected Id %d, got %d\", webhookUrl.Id, decoded.Id)\n\t}\n\tif decoded.Name != webhookUrl.Name {\n\t\tt.Errorf(\"expected Name %s, got %s\", webhookUrl.Name, decoded.Name)\n\t}\n\tif decoded.Url != webhookUrl.Url {\n\t\tt.Errorf(\"expected Url %s, got %s\", webhookUrl.Url, decoded.Url)\n\t}\n}\n\n// TestSetting_CreateWebhookUrl 测试创建webhook地址\nfunc TestSetting_CreateWebhookUrl(t *testing.T) {\n\tdb := setupTestDB(t)\n\tDb = db\n\n\tsetting := &Setting{}\n\tname := \"Test Webhook\"\n\turl := \"https://test.example.com/webhook\"\n\n\trows, err := setting.CreateWebhookUrl(name, url)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create webhook url: %v\", err)\n\t}\n\n\tif rows != 1 {\n\t\tt.Errorf(\"expected 1 row affected, got %d\", rows)\n\t}\n\n\t// 验证数据已保存\n\tvar saved Setting\n\tif err := db.Where(\"code = ? AND `key` = ?\", WebhookCode, WebhookUrlKey).First(&saved).Error; err != nil {\n\t\tt.Fatalf(\"failed to find saved webhook url: %v\", err)\n\t}\n\n\tvar webhookUrl WebhookUrl\n\tif err := json.Unmarshal([]byte(saved.Value), &webhookUrl); err != nil {\n\t\tt.Fatalf(\"failed to unmarshal saved value: %v\", err)\n\t}\n\n\tif webhookUrl.Name != name {\n\t\tt.Errorf(\"expected name %s, got %s\", name, webhookUrl.Name)\n\t}\n\tif webhookUrl.Url != url {\n\t\tt.Errorf(\"expected url %s, got %s\", url, webhookUrl.Url)\n\t}\n}\n\n// TestSetting_RemoveWebhookUrl 测试删除webhook地址\nfunc TestSetting_RemoveWebhookUrl(t *testing.T) {\n\tdb := setupTestDB(t)\n\tDb = db\n\n\tsetting := &Setting{}\n\n\t// 先创建一个webhook地址\n\t_, err := setting.CreateWebhookUrl(\"Test Webhook\", \"https://test.example.com/webhook\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create webhook url: %v\", err)\n\t}\n\n\t// 获取创建的ID\n\tvar saved Setting\n\tif err := db.Where(\"code = ? AND `key` = ?\", WebhookCode, WebhookUrlKey).First(&saved).Error; err != nil {\n\t\tt.Fatalf(\"failed to find saved webhook url: %v\", err)\n\t}\n\n\t// 删除\n\trows, err := setting.RemoveWebhookUrl(saved.Id)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to remove webhook url: %v\", err)\n\t}\n\n\tif rows != 1 {\n\t\tt.Errorf(\"expected 1 row affected, got %d\", rows)\n\t}\n\n\t// 验证已删除\n\tvar count int64\n\tdb.Model(&Setting{}).Where(\"id = ?\", saved.Id).Count(&count)\n\tif count != 0 {\n\t\tt.Errorf(\"expected webhook url to be deleted, but still exists\")\n\t}\n}\n\n// TestSetting_Webhook 测试获取webhook配置\nfunc TestSetting_Webhook(t *testing.T) {\n\tdb := setupTestDB(t)\n\tDb = db\n\n\tsetting := &Setting{}\n\n\t// 创建模板配置\n\tdb.Create(&Setting{\n\t\tCode:  WebhookCode,\n\t\tKey:   WebhookTemplateKey,\n\t\tValue: `{\"task_id\": \"{{.TaskId}}\"}`,\n\t})\n\n\t// 创建多个webhook地址\n\t_, _ = setting.CreateWebhookUrl(\"Webhook 1\", \"https://webhook1.example.com\")\n\t_, _ = setting.CreateWebhookUrl(\"Webhook 2\", \"https://webhook2.example.com\")\n\n\t// 获取配置\n\twebhook, err := setting.Webhook()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to get webhook config: %v\", err)\n\t}\n\n\t// 验证模板\n\tif webhook.Template != `{\"task_id\": \"{{.TaskId}}\"}` {\n\t\tt.Errorf(\"unexpected template: %s\", webhook.Template)\n\t}\n\n\t// 验证webhook地址数量\n\tif len(webhook.WebhookUrls) != 2 {\n\t\tt.Fatalf(\"expected 2 webhook urls, got %d\", len(webhook.WebhookUrls))\n\t}\n\n\t// 验证webhook地址内容\n\tnames := make(map[string]bool)\n\turls := make(map[string]bool)\n\tfor _, w := range webhook.WebhookUrls {\n\t\tnames[w.Name] = true\n\t\turls[w.Url] = true\n\t}\n\n\tif !names[\"Webhook 1\"] || !names[\"Webhook 2\"] {\n\t\tt.Error(\"webhook names not found\")\n\t}\n\tif !urls[\"https://webhook1.example.com\"] || !urls[\"https://webhook2.example.com\"] {\n\t\tt.Error(\"webhook urls not found\")\n\t}\n}\n\n// TestSetting_UpdateWebHook 测试更新webhook模板\nfunc TestSetting_UpdateWebHook(t *testing.T) {\n\tdb := setupTestDB(t)\n\tDb = db\n\n\tsetting := &Setting{}\n\n\t// 创建初始模板\n\tdb.Create(&Setting{\n\t\tCode:  WebhookCode,\n\t\tKey:   WebhookTemplateKey,\n\t\tValue: \"old template\",\n\t})\n\n\t// 更新模板\n\tnewTemplate := `{\"task\": \"{{.TaskName}}\"}`\n\terr := setting.UpdateWebHook(newTemplate)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to update webhook: %v\", err)\n\t}\n\n\t// 验证更新\n\tvar saved Setting\n\tif err := db.Where(\"code = ? AND `key` = ?\", WebhookCode, WebhookTemplateKey).First(&saved).Error; err != nil {\n\t\tt.Fatalf(\"failed to find updated template: %v\", err)\n\t}\n\n\tif saved.Value != newTemplate {\n\t\tt.Errorf(\"expected template %s, got %s\", newTemplate, saved.Value)\n\t}\n}\n\n// TestSetting_Webhook_EmptyUrls 测试空webhook地址列表\nfunc TestSetting_Webhook_EmptyUrls(t *testing.T) {\n\tdb := setupTestDB(t)\n\tDb = db\n\n\tsetting := &Setting{}\n\n\t// 只创建模板，不创建webhook地址\n\tdb.Create(&Setting{\n\t\tCode:  WebhookCode,\n\t\tKey:   WebhookTemplateKey,\n\t\tValue: \"template\",\n\t})\n\n\twebhook, err := setting.Webhook()\n\tif err != nil {\n\t\tt.Fatalf(\"failed to get webhook config: %v\", err)\n\t}\n\n\tif len(webhook.WebhookUrls) != 0 {\n\t\tt.Errorf(\"expected empty webhook urls, got %d\", len(webhook.WebhookUrls))\n\t}\n}\n\n// TestSetting_CreateWebhookUrl_InvalidJSON 测试创建webhook时JSON序列化错误处理\nfunc TestSetting_CreateWebhookUrl_DuplicateNames(t *testing.T) {\n\tdb := setupTestDB(t)\n\tDb = db\n\n\tsetting := &Setting{}\n\n\t// 创建第一个webhook\n\t_, err := setting.CreateWebhookUrl(\"Duplicate\", \"https://url1.example.com\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create first webhook: %v\", err)\n\t}\n\n\t// 创建同名webhook（应该允许，因为没有唯一性约束）\n\t_, err = setting.CreateWebhookUrl(\"Duplicate\", \"https://url2.example.com\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create second webhook: %v\", err)\n\t}\n\n\t// 验证两个都存在\n\tvar count int64\n\tdb.Model(&Setting{}).Where(\"code = ? AND `key` = ?\", WebhookCode, WebhookUrlKey).Count(&count)\n\tif count != 2 {\n\t\tt.Errorf(\"expected 2 webhook urls, got %d\", count)\n\t}\n}\n\n// BenchmarkSetting_CreateWebhookUrl 性能测试：创建webhook地址\nfunc BenchmarkSetting_CreateWebhookUrl(b *testing.B) {\n\tdb, _ := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{})\n\tif err := db.AutoMigrate(&Setting{}); err != nil {\n\t\tb.Fatalf(\"failed to migrate: %v\", err)\n\t}\n\tDb = db\n\n\tsetting := &Setting{}\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_, _ = setting.CreateWebhookUrl(\"Benchmark Webhook\", \"https://benchmark.example.com\")\n\t}\n}\n\n// BenchmarkSetting_Webhook 性能测试：获取webhook配置\nfunc BenchmarkSetting_Webhook(b *testing.B) {\n\tdb, _ := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{})\n\tif err := db.AutoMigrate(&Setting{}); err != nil {\n\t\tb.Fatalf(\"failed to migrate: %v\", err)\n\t}\n\tDb = db\n\n\tsetting := &Setting{}\n\n\t// 准备测试数据\n\tdb.Create(&Setting{Code: WebhookCode, Key: WebhookTemplateKey, Value: \"template\"})\n\tfor i := 0; i < 10; i++ {\n\t\t_, _ = setting.CreateWebhookUrl(\"Webhook\", \"https://example.com\")\n\t}\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_, _ = setting.Webhook()\n\t}\n}\n"
  },
  {
    "path": "internal/modules/app/app.go",
    "content": "package app\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/setting\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n)\n\nvar (\n\t// AppDir 应用根目录\n\tAppDir string // 应用根目录\n\t// ConfDir 配置文件目录\n\tConfDir string // 配置目录\n\t// LogDir 日志目录\n\tLogDir string // 日志目录\n\t// AppConfig 配置文件\n\tAppConfig string // 应用配置文件\n\t// Installed 应用是否已安装\n\tInstalled bool // 应用是否安装过\n\t// Setting 应用配置\n\tSetting *setting.Setting // 应用配置\n\t// VersionId 版本号\n\tVersionId int // 版本号\n\t// VersionFile 版本文件\n\tVersionFile string // 版本号文件\n)\n\n// InitEnv 初始化\nfunc InitEnv(versionString string) {\n\tlogger.InitLogger()\n\tvar err error\n\t// 开发环境使用当前目录，生产环境使用可执行文件目录\n\texecPath, err := os.Executable()\n\tif err != nil {\n\t\tlogger.Fatal(err)\n\t}\n\texecDir := filepath.Dir(execPath)\n\n\t// 开发环境检测：Air 热重载（tmp 目录）或 go run（go-build cache 目录）\n\texecName := filepath.Base(execPath)\n\tif filepath.Base(execDir) == \"tmp\" {\n\t\tAppDir = filepath.Join(filepath.Dir(execDir), \".gocron\")\n\t} else if strings.Contains(execDir, \"go-build\") && !strings.HasSuffix(execName, \".test\") {\n\t\t// go run 会将二进制编译到 go-build cache 中，使用当前工作目录\n\t\t// 排除 go test（测试二进制以 .test 结尾）\n\t\twd, wdErr := os.Getwd()\n\t\tif wdErr != nil {\n\t\t\tlogger.Fatal(wdErr)\n\t\t}\n\t\tAppDir = filepath.Join(wd, \".gocron\")\n\t} else {\n\t\tAppDir = filepath.Join(execDir, \".gocron\")\n\t}\n\tfmt.Printf(\"AppDir: %s\\n\", AppDir)\n\tConfDir = filepath.Join(AppDir, \"conf\")\n\tLogDir = filepath.Join(AppDir, \"log\")\n\tAppConfig = filepath.Join(ConfDir, \"app.ini\")\n\tVersionFile = filepath.Join(ConfDir, \".version\")\n\tfmt.Printf(\"ConfDir: %s, LogDir: %s\\n\", ConfDir, LogDir)\n\tcreateDirIfNotExists(AppDir, ConfDir, LogDir)\n\tInstalled = IsInstalled()\n\tVersionId = ToNumberVersion(versionString)\n}\n\n// IsInstalled 判断应用是否已安装\nfunc IsInstalled() bool {\n\t_, err := os.Stat(filepath.Join(ConfDir, \"install.lock\"))\n\treturn !os.IsNotExist(err)\n}\n\n// CreateInstallLock 创建安装锁文件\nfunc CreateInstallLock() error {\n\tlockFile := filepath.Join(ConfDir, \"install.lock\")\n\terr := os.WriteFile(lockFile, []byte(\"\"), 0600)\n\tif err != nil {\n\t\tlogger.Error(\"创建安装锁文件conf/install.lock失败\", err)\n\t\tfmt.Printf(\"Error creating install.lock: %v\\n\", err)\n\t} else {\n\t\tfmt.Printf(\"Successfully created install.lock at %s\\n\", lockFile)\n\t}\n\n\treturn err\n}\n\n// UpdateVersionFile 更新应用版本号文件\nfunc UpdateVersionFile() {\n\terr := os.WriteFile(VersionFile,\n\t\t[]byte(strconv.Itoa(VersionId)),\n\t\t0600,\n\t)\n\n\tif err != nil {\n\t\tlogger.Fatal(err)\n\t}\n}\n\n// GetCurrentVersionId 获取应用当前版本号, 从版本号文件中读取\nfunc GetCurrentVersionId() int {\n\tif !utils.FileExist(VersionFile) {\n\t\treturn 0\n\t}\n\n\tbytes, err := os.ReadFile(VersionFile)\n\tif err != nil {\n\t\tlogger.Fatal(err)\n\t}\n\n\tversionId, err := strconv.Atoi(strings.TrimSpace(string(bytes)))\n\tif err != nil {\n\t\tlogger.Fatal(err)\n\t}\n\n\treturn versionId\n}\n\n// ToNumberVersion 把字符串版本号a.b.c转换为整数版本号abc\n// 非数字版本（如 \"dev\"）返回 0\nfunc ToNumberVersion(versionString string) int {\n\tversionString = strings.TrimPrefix(versionString, \"v\")\n\tv := strings.Replace(versionString, \".\", \"\", -1)\n\tif len(v) < 3 {\n\t\tv += \"0\"\n\t}\n\n\tversionId, err := strconv.Atoi(v)\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\treturn versionId\n}\n\n// 检测目录是否存在\nfunc createDirIfNotExists(path ...string) {\n\tfor _, value := range path {\n\t\tif utils.FileExist(value) {\n\t\t\tcontinue\n\t\t}\n\t\terr := os.MkdirAll(value, 0755)\n\t\tif err != nil {\n\t\t\tlogger.Fatal(fmt.Sprintf(\"创建目录失败:%s\", err.Error()))\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/modules/app/app_test.go",
    "content": "package app\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc initTempEnv(t *testing.T, version string) string {\n\tt.Helper()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\n\t// 保存原始值\n\toldAppDir := AppDir\n\toldConfDir := ConfDir\n\toldLogDir := LogDir\n\toldVersionFile := VersionFile\n\toldVersionId := VersionId\n\toldInstalled := Installed\n\n\t// 清理函数\n\tt.Cleanup(func() {\n\t\tAppDir = oldAppDir\n\t\tConfDir = oldConfDir\n\t\tLogDir = oldLogDir\n\t\tVersionFile = oldVersionFile\n\t\tVersionId = oldVersionId\n\t\tInstalled = oldInstalled\n\t})\n\n\tInitEnv(version)\n\treturn home\n}\n\nfunc TestInitEnvCreatesDirectoriesAndSetsVersion(t *testing.T) {\n\tinitTempEnv(t, \"1.2.3\")\n\n\t// 验证目录被创建（不检查具体路径，因为它依赖于可执行文件位置）\n\tfor _, dir := range []string{AppDir, ConfDir, LogDir} {\n\t\tif fi, err := os.Stat(dir); err != nil || !fi.IsDir() {\n\t\t\tt.Fatalf(\"expected directory %s to exist\", dir)\n\t\t}\n\t}\n\n\texpectedVersion := ToNumberVersion(\"1.2.3\")\n\tif VersionId != expectedVersion {\n\t\tt.Fatalf(\"expected VersionId %d, got %d\", expectedVersion, VersionId)\n\t}\n\n\tif Installed {\n\t\tt.Fatal(\"app should not be marked installed without lock file\")\n\t}\n}\n\nfunc TestCreateInstallLockAndIsInstalled(t *testing.T) {\n\tinitTempEnv(t, \"1.0.0\")\n\tlockPath := filepath.Join(ConfDir, \"install.lock\")\n\tif IsInstalled() {\n\t\tt.Fatal(\"expected not installed before lock file exists\")\n\t}\n\tif err := CreateInstallLock(); err != nil {\n\t\tt.Fatalf(\"CreateInstallLock failed: %v\", err)\n\t}\n\tif _, err := os.Stat(lockPath); err != nil {\n\t\tt.Fatalf(\"install lock not created: %v\", err)\n\t}\n\tif !IsInstalled() {\n\t\tt.Fatal(\"expected installed after lock creation\")\n\t}\n}\n\nfunc TestCreateInstallLockSetsSecurePermissions(t *testing.T) {\n\tinitTempEnv(t, \"1.0.0\")\n\tlockPath := filepath.Join(ConfDir, \"install.lock\")\n\tif err := CreateInstallLock(); err != nil {\n\t\tt.Fatalf(\"CreateInstallLock failed: %v\", err)\n\t}\n\n\tinfo, err := os.Stat(lockPath)\n\tif err != nil {\n\t\tt.Fatalf(\"stat failed: %v\", err)\n\t}\n\n\tperm := info.Mode().Perm()\n\tif perm != 0600 {\n\t\tt.Fatalf(\"expected file permission 0600, got %#o\", perm)\n\t}\n}\n\nfunc TestUpdateVersionFileAndGetCurrentVersionId(t *testing.T) {\n\tinitTempEnv(t, \"1.0.0\")\n\tVersionId = 789\n\tUpdateVersionFile()\n\tid := GetCurrentVersionId()\n\tif id != 789 {\n\t\tt.Fatalf(\"expected version id 789, got %d\", id)\n\t}\n}\n\nfunc TestUpdateVersionFileSetsSecurePermissions(t *testing.T) {\n\tinitTempEnv(t, \"1.0.0\")\n\tVersionId = 123\n\tUpdateVersionFile()\n\n\tinfo, err := os.Stat(VersionFile)\n\tif err != nil {\n\t\tt.Fatalf(\"stat failed: %v\", err)\n\t}\n\n\tperm := info.Mode().Perm()\n\tif perm != 0600 {\n\t\tt.Fatalf(\"expected file permission 0600, got %#o\", perm)\n\t}\n}\n\nfunc TestGetCurrentVersionIdWhenMissing(t *testing.T) {\n\t// 创建临时目录但不调用 InitEnv，手动设置 VersionFile\n\ttempDir := t.TempDir()\n\toldVersionFile := VersionFile\n\tVersionFile = filepath.Join(tempDir, \".version\")\n\tt.Cleanup(func() {\n\t\tVersionFile = oldVersionFile\n\t})\n\n\tif id := GetCurrentVersionId(); id != 0 {\n\t\tt.Fatalf(\"expected 0 when version file missing, got %d\", id)\n\t}\n}\n\nfunc TestToNumberVersion(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant  int\n\t}{\n\t\t{\"v1.2.3\", 123},\n\t\t{\"1.2\", 120},\n\t\t{\"2.0.10\", 2010},\n\t}\n\tfor _, tt := range tests {\n\t\tgot := ToNumberVersion(tt.input)\n\t\tif got != tt.want {\n\t\t\tt.Fatalf(\"ToNumberVersion(%s) = %d, want %d\", tt.input, got, tt.want)\n\t\t}\n\t}\n}\n\nfunc TestCreateDirIfNotExists(t *testing.T) {\n\tdir := filepath.Join(t.TempDir(), \"nested\", \"dir\")\n\tcreateDirIfNotExists(dir)\n\tif fi, err := os.Stat(dir); err != nil || !fi.IsDir() {\n\t\tt.Fatalf(\"expected directory %s to exist\", dir)\n\t}\n}\n"
  },
  {
    "path": "internal/modules/httpclient/http_client.go",
    "content": "package httpclient\n\n// http-client\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype ResponseWrapper struct {\n\tStatusCode int\n\tBody       string\n\tHeader     http.Header\n}\n\ntype httpDoer interface {\n\tDo(req *http.Request) (*http.Response, error)\n}\n\n// 优化：使用全局 HTTP 客户端，复用连接池\nvar defaultClient = &http.Client{\n\tTimeout: 300 * time.Second,\n\tTransport: &http.Transport{\n\t\tMaxIdleConns:        100,\n\t\tMaxIdleConnsPerHost: 10,\n\t\tIdleConnTimeout:     90 * time.Second,\n\t\tDialContext: (&net.Dialer{\n\t\t\tTimeout:   5 * time.Second,\n\t\t\tKeepAlive: 30 * time.Second,\n\t\t}).DialContext,\n\t},\n}\n\nvar clientFactory = func(timeout int) httpDoer {\n\t// 使用默认超时（300秒）或未设置超时时，直接返回全局客户端\n\tif timeout <= 0 || timeout == 300 {\n\t\treturn defaultClient\n\t}\n\t// 其他超时值：创建新客户端但复用 Transport（连接池）\n\treturn &http.Client{\n\t\tTimeout:   time.Duration(timeout) * time.Second,\n\t\tTransport: defaultClient.Transport,\n\t}\n}\n\nfunc Get(url string, timeout int) ResponseWrapper {\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn createRequestError(err)\n\t}\n\n\treturn request(req, timeout)\n}\n\nfunc PostParams(url string, params string, timeout int) ResponseWrapper {\n\tbuf := bytes.NewBufferString(params)\n\treq, err := http.NewRequest(\"POST\", url, buf)\n\tif err != nil {\n\t\treturn createRequestError(err)\n\t}\n\treq.Header.Set(\"Content-type\", \"application/x-www-form-urlencoded\")\n\n\treturn request(req, timeout)\n}\n\nfunc PostJson(url string, body string, timeout int) ResponseWrapper {\n\tbuf := bytes.NewBufferString(body)\n\treq, err := http.NewRequest(\"POST\", url, buf)\n\tif err != nil {\n\t\treturn createRequestError(err)\n\t}\n\treq.Header.Set(\"Content-type\", \"application/json\")\n\n\treturn request(req, timeout)\n}\n\n// blockedHeaders 禁止用户设置的危险 Header\nvar blockedHeaders = map[string]bool{\n\t\"host\":                true,\n\t\"transfer-encoding\":   true,\n\t\"content-length\":      true,\n\t\"connection\":          true,\n\t\"upgrade\":             true,\n\t\"proxy-authorization\": true,\n\t\"proxy-connection\":    true,\n\t\"te\":                  true,\n\t\"trailer\":             true,\n}\n\n// IsBlockedHeader 检查 header 是否在黑名单中\nfunc IsBlockedHeader(name string) bool {\n\treturn blockedHeaders[strings.ToLower(strings.TrimSpace(name))]\n}\n\n// ValidateHeaders 校验 headers JSON 格式并检查黑名单，返回错误信息\nfunc ValidateHeaders(headersJSON string) error {\n\tif strings.TrimSpace(headersJSON) == \"\" {\n\t\treturn nil\n\t}\n\tvar headers map[string]string\n\tif err := json.Unmarshal([]byte(headersJSON), &headers); err != nil {\n\t\treturn fmt.Errorf(\"invalid JSON format\")\n\t}\n\tfor k := range headers {\n\t\tif IsBlockedHeader(k) {\n\t\t\treturn fmt.Errorf(\"header %q is not allowed\", k)\n\t\t}\n\t}\n\treturn nil\n}\n\n// SetCustomHeaders 为请求设置自定义 Header（JSON 格式: {\"Key\": \"Value\", ...}）\n// 黑名单中的 Header 会被跳过并记录日志\nfunc SetCustomHeaders(req *http.Request, headersJSON string) {\n\tif strings.TrimSpace(headersJSON) == \"\" {\n\t\treturn\n\t}\n\tvar headers map[string]string\n\tif err := json.Unmarshal([]byte(headersJSON), &headers); err != nil {\n\t\tfmt.Printf(\"[WARN] failed to parse custom headers: %v\\n\", err)\n\t\treturn\n\t}\n\tfor k, v := range headers {\n\t\tif IsBlockedHeader(k) {\n\t\t\tfmt.Printf(\"[WARN] blocked header %q skipped\\n\", k)\n\t\t\tcontinue\n\t\t}\n\t\treq.Header.Set(k, v)\n\t}\n}\n\n// GetWithHeaders 带自定义 Header 的 GET 请求\nfunc GetWithHeaders(url string, headersJSON string, timeout int) ResponseWrapper {\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\treturn createRequestError(err)\n\t}\n\tSetCustomHeaders(req, headersJSON)\n\treturn request(req, timeout)\n}\n\n// PostJsonWithHeaders 带自定义 Header 的 POST JSON 请求\nfunc PostJsonWithHeaders(url string, body string, headersJSON string, timeout int) ResponseWrapper {\n\tbuf := bytes.NewBufferString(body)\n\treq, err := http.NewRequest(\"POST\", url, buf)\n\tif err != nil {\n\t\treturn createRequestError(err)\n\t}\n\treq.Header.Set(\"Content-type\", \"application/json\")\n\tSetCustomHeaders(req, headersJSON)\n\treturn request(req, timeout)\n}\n\n// PostParamsWithHeaders 带自定义 Header 的 POST 表单请求\nfunc PostParamsWithHeaders(url string, params string, headersJSON string, timeout int) ResponseWrapper {\n\tbuf := bytes.NewBufferString(params)\n\treq, err := http.NewRequest(\"POST\", url, buf)\n\tif err != nil {\n\t\treturn createRequestError(err)\n\t}\n\treq.Header.Set(\"Content-type\", \"application/x-www-form-urlencoded\")\n\tSetCustomHeaders(req, headersJSON)\n\treturn request(req, timeout)\n}\n\nfunc request(req *http.Request, timeout int) ResponseWrapper {\n\twrapper := ResponseWrapper{StatusCode: 0, Body: \"\", Header: make(http.Header)}\n\tclient := clientFactory(timeout)\n\tsetRequestHeader(req)\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\twrapper.Body = fmt.Sprintf(\"执行HTTP请求错误-%s\", err.Error())\n\t\treturn wrapper\n\t}\n\tdefer resp.Body.Close()\n\t// 限制响应体最大 1MB，防止 OOM\n\tbody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))\n\tif err != nil {\n\t\twrapper.Body = fmt.Sprintf(\"读取HTTP请求返回值失败-%s\", err.Error())\n\t\treturn wrapper\n\t}\n\twrapper.StatusCode = resp.StatusCode\n\twrapper.Body = string(body)\n\twrapper.Header = resp.Header\n\n\treturn wrapper\n}\n\nfunc setRequestHeader(req *http.Request) {\n\treq.Header.Set(\"User-Agent\", \"golang/gocron\")\n}\n\nfunc createRequestError(err error) ResponseWrapper {\n\terrorMessage := fmt.Sprintf(\"创建HTTP请求错误-%s\", err.Error())\n\treturn ResponseWrapper{0, errorMessage, make(http.Header)}\n}\n"
  },
  {
    "path": "internal/modules/httpclient/http_client_benchmark_test.go",
    "content": "package httpclient\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\n// 测试服务器\nfunc setupTestServer() *httptest.Server {\n\treturn httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\ttime.Sleep(10 * time.Millisecond) // 模拟处理时间\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(\"success\"))\n\t}))\n}\n\n// 测试1: 连续请求性能\nfunc TestSequentialRequests(t *testing.T) {\n\tserver := setupTestServer()\n\tdefer server.Close()\n\n\trequests := 50\n\tstart := time.Now()\n\n\tfor i := 0; i < requests; i++ {\n\t\tresp := Get(server.URL, 5)\n\t\tif resp.StatusCode != 200 {\n\t\t\tt.Errorf(\"请求 %d 失败: %v\", i, resp.Body)\n\t\t}\n\t}\n\n\tduration := time.Since(start)\n\tavgTime := float64(duration.Milliseconds()) / float64(requests)\n\n\tt.Logf(\"📊 连续请求测试 (%d 个请求):\", requests)\n\tt.Logf(\"   总耗时: %v\", duration)\n\tt.Logf(\"   平均耗时: %.2f ms/请求\", avgTime)\n}\n\n// 测试2: 并发请求性能\nfunc TestConcurrentRequests(t *testing.T) {\n\tserver := setupTestServer()\n\tdefer server.Close()\n\n\tconcurrency := 20\n\trequestsPerWorker := 5\n\ttotalRequests := concurrency * requestsPerWorker\n\n\tstart := time.Now()\n\tvar wg sync.WaitGroup\n\twg.Add(concurrency)\n\n\tsuccessCount := 0\n\terrorCount := 0\n\tvar mu sync.Mutex\n\n\tfor i := 0; i < concurrency; i++ {\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor j := 0; j < requestsPerWorker; j++ {\n\t\t\t\tresp := Get(server.URL, 5)\n\t\t\t\tmu.Lock()\n\t\t\t\tif resp.StatusCode == 200 {\n\t\t\t\t\tsuccessCount++\n\t\t\t\t} else {\n\t\t\t\t\terrorCount++\n\t\t\t\t}\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t}()\n\t}\n\n\twg.Wait()\n\tduration := time.Since(start)\n\tavgTime := float64(duration.Milliseconds()) / float64(totalRequests)\n\n\tt.Logf(\"📊 并发请求测试 (%d 并发, %d 个请求):\", concurrency, totalRequests)\n\tt.Logf(\"   总耗时: %v\", duration)\n\tt.Logf(\"   平均耗时: %.2f ms/请求\", avgTime)\n\tt.Logf(\"   成功: %d, 失败: %d\", successCount, errorCount)\n}\n\n// 测试3: 不同超时配置\nfunc TestDifferentTimeouts(t *testing.T) {\n\tserver := setupTestServer()\n\tdefer server.Close()\n\n\ttimeouts := []int{5, 10, 30, 300}\n\n\tfor _, timeout := range timeouts {\n\t\tstart := time.Now()\n\t\tresp := Get(server.URL, timeout)\n\t\tduration := time.Since(start)\n\n\t\tif resp.StatusCode != 200 {\n\t\t\tt.Errorf(\"超时 %d 秒的请求失败: %v\", timeout, resp.Body)\n\t\t}\n\t\tt.Logf(\"   超时配置 %ds: 耗时 %v\", timeout, duration)\n\t}\n}\n\n// 基准测试1: 单个请求\nfunc BenchmarkSingleRequest(b *testing.B) {\n\tserver := setupTestServer()\n\tdefer server.Close()\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tGet(server.URL, 5)\n\t}\n}\n\n// 基准测试2: 并发请求\nfunc BenchmarkConcurrentRequests(b *testing.B) {\n\tserver := setupTestServer()\n\tdefer server.Close()\n\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\tGet(server.URL, 5)\n\t\t}\n\t})\n}\n\n// 基准测试3: POST 请求\nfunc BenchmarkPostRequest(b *testing.B) {\n\tserver := setupTestServer()\n\tdefer server.Close()\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\tPostParams(server.URL, \"key=value\", 5)\n\t}\n}\n\n// 测试4: 高并发压力测试\nfunc TestHighConcurrency(t *testing.T) {\n\tif testing.Short() {\n\t\tt.Skip(\"跳过压力测试，使用 -short 标志\")\n\t}\n\n\tserver := setupTestServer()\n\tdefer server.Close()\n\n\tconcurrency := 100\n\trequestsPerWorker := 10\n\ttotalRequests := concurrency * requestsPerWorker\n\n\tt.Logf(\"🔥 高并发压力测试 (%d 并发, %d 个请求)\", concurrency, totalRequests)\n\n\tstart := time.Now()\n\tvar wg sync.WaitGroup\n\twg.Add(concurrency)\n\n\tsuccessCount := 0\n\terrorCount := 0\n\tvar mu sync.Mutex\n\n\tfor i := 0; i < concurrency; i++ {\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tfor j := 0; j < requestsPerWorker; j++ {\n\t\t\t\tresp := Get(server.URL, 5)\n\t\t\t\tmu.Lock()\n\t\t\t\tif resp.StatusCode == 200 {\n\t\t\t\t\tsuccessCount++\n\t\t\t\t} else {\n\t\t\t\t\terrorCount++\n\t\t\t\t}\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t}()\n\t}\n\n\twg.Wait()\n\tduration := time.Since(start)\n\tqps := float64(totalRequests) / duration.Seconds()\n\n\tt.Logf(\"📊 压力测试结果:\")\n\tt.Logf(\"   总耗时: %v\", duration)\n\tt.Logf(\"   QPS: %.2f\", qps)\n\tt.Logf(\"   成功: %d, 失败: %d\", successCount, errorCount)\n\tt.Logf(\"   成功率: %.2f%%\", float64(successCount)/float64(totalRequests)*100)\n}\n\n// 测试5: 连接复用验证\nfunc TestConnectionReuse(t *testing.T) {\n\tserver := setupTestServer()\n\tdefer server.Close()\n\n\tt.Log(\"🔍 连接复用测试 (执行 10 次请求)\")\n\n\tfor i := 0; i < 10; i++ {\n\t\tstart := time.Now()\n\t\tresp := Get(server.URL, 5)\n\t\tduration := time.Since(start)\n\n\t\tif resp.StatusCode != 200 {\n\t\t\tt.Errorf(\"请求 %d 失败\", i+1)\n\t\t}\n\t\tt.Logf(\"   请求 %d: %v\", i+1, duration)\n\t}\n\n\tt.Log(\"💡 提示: 如果后续请求明显快于首次请求，说明连接被复用\")\n}\n"
  },
  {
    "path": "internal/modules/httpclient/http_client_test.go",
    "content": "package httpclient\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"testing\"\n)\n\ntype mockDoer func(req *http.Request) (*http.Response, error)\n\nfunc (m mockDoer) Do(req *http.Request) (*http.Response, error) {\n\treturn m(req)\n}\n\nfunc withMockClient(t *testing.T, doer mockDoer) {\n\tt.Helper()\n\toriginal := clientFactory\n\tclientFactory = func(timeout int) httpDoer {\n\t\treturn doer\n\t}\n\tt.Cleanup(func() { clientFactory = original })\n}\n\nfunc TestGetRequest(t *testing.T) {\n\twithMockClient(t, func(req *http.Request) (*http.Response, error) {\n\t\tif req.Method != http.MethodGet {\n\t\t\tt.Fatalf(\"expected GET, got %s\", req.Method)\n\t\t}\n\t\tif ua := req.Header.Get(\"User-Agent\"); ua != \"golang/gocron\" {\n\t\t\tt.Fatalf(\"unexpected user-agent %s\", ua)\n\t\t}\n\t\treturn &http.Response{\n\t\t\tStatusCode: 200,\n\t\t\tBody:       io.NopCloser(strings.NewReader(\"ok\")),\n\t\t\tHeader:     http.Header{},\n\t\t}, nil\n\t})\n\n\tresp := Get(\"http://example.com\", 0)\n\tif resp.StatusCode != 200 || resp.Body != \"ok\" {\n\t\tt.Fatalf(\"unexpected response: %+v\", resp)\n\t}\n}\n\nfunc TestPostParamsRequest(t *testing.T) {\n\twithMockClient(t, func(req *http.Request) (*http.Response, error) {\n\t\tif req.Method != http.MethodPost {\n\t\t\tt.Fatalf(\"expected POST, got %s\", req.Method)\n\t\t}\n\t\tif req.Header.Get(\"Content-type\") != \"application/x-www-form-urlencoded\" {\n\t\t\tt.Fatalf(\"unexpected content-type %s\", req.Header.Get(\"Content-type\"))\n\t\t}\n\t\tbody, _ := io.ReadAll(req.Body)\n\t\tif string(body) != \"a=1&b=2\" {\n\t\t\tt.Fatalf(\"unexpected body %s\", string(body))\n\t\t}\n\t\treturn &http.Response{\n\t\t\tStatusCode: 200,\n\t\t\tBody:       io.NopCloser(strings.NewReader(\"echo:\" + string(body))),\n\t\t\tHeader:     http.Header{},\n\t\t}, nil\n\t})\n\n\tresp := PostParams(\"http://example.com\", \"a=1&b=2\", 0)\n\tif resp.StatusCode != 200 || resp.Body != \"echo:a=1&b=2\" {\n\t\tt.Fatalf(\"unexpected response: %+v\", resp)\n\t}\n}\n\nfunc TestPostJsonRequest(t *testing.T) {\n\twithMockClient(t, func(req *http.Request) (*http.Response, error) {\n\t\tif req.Header.Get(\"Content-type\") != \"application/json\" {\n\t\t\tt.Fatalf(\"unexpected content-type %s\", req.Header.Get(\"Content-type\"))\n\t\t}\n\t\tbody, _ := io.ReadAll(req.Body)\n\t\treturn &http.Response{\n\t\t\tStatusCode: 200,\n\t\t\tBody:       io.NopCloser(strings.NewReader(\"json:\" + string(body))),\n\t\t\tHeader:     http.Header{},\n\t\t}, nil\n\t})\n\n\tresp := PostJson(\"http://example.com\", `{\"name\":\"gocron\"}`, 0)\n\tif resp.StatusCode != 200 || resp.Body != `json:{\"name\":\"gocron\"}` {\n\t\tt.Fatalf(\"unexpected response: %+v\", resp)\n\t}\n}\n\nfunc TestRequestHandlesClientError(t *testing.T) {\n\twithMockClient(t, func(req *http.Request) (*http.Response, error) {\n\t\treturn nil, errors.New(\"timeout\")\n\t})\n\tresp := Get(\"http://example.com\", 1)\n\tif resp.StatusCode != 0 || !strings.Contains(resp.Body, \"执行HTTP请求错误-timeout\") {\n\t\tt.Fatalf(\"expected client error message, got %+v\", resp)\n\t}\n}\n\nfunc TestRequestHandlesReadError(t *testing.T) {\n\twithMockClient(t, func(req *http.Request) (*http.Response, error) {\n\t\trc := io.NopCloser(io.Reader(&failingReader{}))\n\t\treturn &http.Response{StatusCode: 200, Body: rc, Header: http.Header{}}, nil\n\t})\n\tresp := Get(\"http://example.com\", 0)\n\tif resp.StatusCode != 0 || !strings.Contains(resp.Body, \"读取HTTP请求返回值失败\") {\n\t\tt.Fatalf(\"expected read error message, got %+v\", resp)\n\t}\n}\n\ntype failingReader struct{}\n\nfunc (f *failingReader) Read(p []byte) (int, error) {\n\treturn 0, errors.New(\"boom\")\n}\n\nfunc TestCreateRequestError(t *testing.T) {\n\tresp := createRequestError(fmt.Errorf(\"boom\"))\n\tif resp.StatusCode != 0 || !strings.Contains(resp.Body, \"boom\") {\n\t\tt.Fatalf(\"unexpected error wrapper: %+v\", resp)\n\t}\n}\n\nfunc TestSetCustomHeaders(t *testing.T) {\n\tt.Run(\"valid JSON headers\", func(t *testing.T) {\n\t\treq, _ := http.NewRequest(\"GET\", \"http://example.com\", nil)\n\t\tSetCustomHeaders(req, `{\"Authorization\":\"Bearer token123\",\"X-Custom\":\"value\"}`)\n\t\tif req.Header.Get(\"Authorization\") != \"Bearer token123\" {\n\t\t\tt.Fatalf(\"expected Authorization header, got %q\", req.Header.Get(\"Authorization\"))\n\t\t}\n\t\tif req.Header.Get(\"X-Custom\") != \"value\" {\n\t\t\tt.Fatalf(\"expected X-Custom header, got %q\", req.Header.Get(\"X-Custom\"))\n\t\t}\n\t})\n\n\tt.Run(\"empty string is no-op\", func(t *testing.T) {\n\t\treq, _ := http.NewRequest(\"GET\", \"http://example.com\", nil)\n\t\tSetCustomHeaders(req, \"\")\n\t\tif len(req.Header) != 0 {\n\t\t\tt.Fatalf(\"expected no headers for empty input, got %v\", req.Header)\n\t\t}\n\t})\n\n\tt.Run(\"invalid JSON is no-op\", func(t *testing.T) {\n\t\treq, _ := http.NewRequest(\"GET\", \"http://example.com\", nil)\n\t\tSetCustomHeaders(req, \"not json\")\n\t\t// Should not panic, just skip\n\t})\n\n\tt.Run(\"blocked headers are skipped\", func(t *testing.T) {\n\t\treq, _ := http.NewRequest(\"GET\", \"http://example.com\", nil)\n\t\tSetCustomHeaders(req, `{\"Host\":\"evil.com\",\"X-Safe\":\"ok\",\"Transfer-Encoding\":\"chunked\"}`)\n\t\tif req.Header.Get(\"Host\") != \"\" {\n\t\t\tt.Fatal(\"Host header should be blocked\")\n\t\t}\n\t\tif req.Header.Get(\"Transfer-Encoding\") != \"\" {\n\t\t\tt.Fatal(\"Transfer-Encoding header should be blocked\")\n\t\t}\n\t\tif req.Header.Get(\"X-Safe\") != \"ok\" {\n\t\t\tt.Fatalf(\"safe header should be set, got %q\", req.Header.Get(\"X-Safe\"))\n\t\t}\n\t})\n}\n\nfunc TestIsBlockedHeader(t *testing.T) {\n\tblocked := []string{\"Host\", \"host\", \"HOST\", \"Transfer-Encoding\", \"connection\", \"Content-Length\", \"Upgrade\"}\n\tfor _, h := range blocked {\n\t\tif !IsBlockedHeader(h) {\n\t\t\tt.Errorf(\"expected %q to be blocked\", h)\n\t\t}\n\t}\n\n\tallowed := []string{\"Authorization\", \"X-Custom\", \"Content-Type\", \"Accept\"}\n\tfor _, h := range allowed {\n\t\tif IsBlockedHeader(h) {\n\t\t\tt.Errorf(\"expected %q to be allowed\", h)\n\t\t}\n\t}\n}\n\nfunc TestValidateHeaders(t *testing.T) {\n\tt.Run(\"empty is ok\", func(t *testing.T) {\n\t\tif err := ValidateHeaders(\"\"); err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t})\n\n\tt.Run(\"valid headers\", func(t *testing.T) {\n\t\tif err := ValidateHeaders(`{\"Authorization\":\"Bearer x\"}`); err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t})\n\n\tt.Run(\"invalid JSON\", func(t *testing.T) {\n\t\terr := ValidateHeaders(\"not json\")\n\t\tif err == nil {\n\t\t\tt.Fatal(\"expected error for invalid JSON\")\n\t\t}\n\t})\n\n\tt.Run(\"blocked header rejected\", func(t *testing.T) {\n\t\terr := ValidateHeaders(`{\"Host\":\"evil.com\"}`)\n\t\tif err == nil {\n\t\t\tt.Fatal(\"expected error for blocked header\")\n\t\t}\n\t\tif !strings.Contains(err.Error(), \"not allowed\") {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t})\n\n\tt.Run(\"mixed blocked and safe\", func(t *testing.T) {\n\t\terr := ValidateHeaders(`{\"Authorization\":\"ok\",\"Transfer-Encoding\":\"chunked\"}`)\n\t\tif err == nil {\n\t\t\tt.Fatal(\"expected error for blocked header\")\n\t\t}\n\t})\n}\n\nfunc TestGetWithHeaders(t *testing.T) {\n\twithMockClient(t, func(req *http.Request) (*http.Response, error) {\n\t\tif req.Header.Get(\"Authorization\") != \"Bearer abc\" {\n\t\t\tt.Fatalf(\"expected Authorization header, got %q\", req.Header.Get(\"Authorization\"))\n\t\t}\n\t\treturn &http.Response{\n\t\t\tStatusCode: 200,\n\t\t\tBody:       io.NopCloser(strings.NewReader(\"ok\")),\n\t\t\tHeader:     http.Header{},\n\t\t}, nil\n\t})\n\tresp := GetWithHeaders(\"http://example.com\", `{\"Authorization\":\"Bearer abc\"}`, 10)\n\tif resp.StatusCode != 200 || resp.Body != \"ok\" {\n\t\tt.Fatalf(\"unexpected response: %+v\", resp)\n\t}\n}\n\nfunc TestPostJsonWithHeaders(t *testing.T) {\n\twithMockClient(t, func(req *http.Request) (*http.Response, error) {\n\t\tif req.Header.Get(\"X-Api-Key\") != \"secret\" {\n\t\t\tt.Fatalf(\"expected X-Api-Key header, got %q\", req.Header.Get(\"X-Api-Key\"))\n\t\t}\n\t\tct := req.Header.Get(\"Content-type\")\n\t\tif !strings.Contains(ct, \"application/json\") {\n\t\t\tt.Fatalf(\"expected JSON content-type, got %q\", ct)\n\t\t}\n\t\tbody, _ := io.ReadAll(req.Body)\n\t\tif string(body) != `{\"k\":\"v\"}` {\n\t\t\tt.Fatalf(\"unexpected body: %s\", string(body))\n\t\t}\n\t\treturn &http.Response{\n\t\t\tStatusCode: 200,\n\t\t\tBody:       io.NopCloser(strings.NewReader(\"ok\")),\n\t\t\tHeader:     http.Header{},\n\t\t}, nil\n\t})\n\tresp := PostJsonWithHeaders(\"http://example.com\", `{\"k\":\"v\"}`, `{\"X-Api-Key\":\"secret\"}`, 10)\n\tif resp.StatusCode != 200 {\n\t\tt.Fatalf(\"unexpected status: %d\", resp.StatusCode)\n\t}\n}\n"
  },
  {
    "path": "internal/modules/i18n/en_us.go",
    "content": "package i18n\n\nvar enUS = map[string]string{\n\t\"form_validation_failed\":                 \"Form validation failed, please check your input\",\n\t\"system_already_installed\":               \"System already installed!\",\n\t\"password_mismatch\":                      \"Passwords do not match\",\n\t\"db_config_write_failed\":                 \"Failed to write database configuration to file\",\n\t\"app_config_read_failed\":                 \"Failed to read application configuration\",\n\t\"create_table_failed\":                    \"Failed to create database tables\",\n\t\"create_admin_failed\":                    \"Failed to create administrator account\",\n\t\"create_lock_file_failed\":                \"Failed to create installation lock file\",\n\t\"user_not_found\":                         \"User not found\",\n\t\"generate_2fa_key_failed\":                \"Failed to generate 2FA key\",\n\t\"generate_qrcode_failed\":                 \"Failed to generate QR code\",\n\t\"get_success\":                            \"Retrieved successfully\",\n\t\"verification_code_error\":                \"Verification code is incorrect\",\n\t\"enable_failed\":                          \"Failed to enable\",\n\t\"2fa_enabled\":                            \"2FA enabled\",\n\t\"2fa_not_enabled\":                        \"2FA not enabled\",\n\t\"disable_failed\":                         \"Failed to disable\",\n\t\"2fa_disabled\":                           \"2FA disabled\",\n\t\"update_success\":                         \"Updated successfully\",\n\t\"incomplete_parameters\":                  \"Incomplete parameters\",\n\t\"operation_success\":                      \"Operation successful\",\n\t\"username_exists\":                        \"Username already exists\",\n\t\"email_exists\":                           \"Email already exists\",\n\t\"password_required\":                      \"Please enter password\",\n\t\"password_confirm_required\":              \"Please enter password again\",\n\t\"save_failed\":                            \"Save failed\",\n\t\"update_failed\":                          \"Update failed\",\n\t\"save_success\":                           \"Saved successfully\",\n\t\"password_same_as_old\":                   \"New password cannot be the same as old password\",\n\t\"old_password_error\":                     \"Old password is incorrect\",\n\t\"username_password_empty\":                \"Username or password cannot be empty\",\n\t\"username_password_error\":                \"Username or password is incorrect\",\n\t\"2fa_code_required\":                      \"2FA verification code required\",\n\t\"2fa_code_error\":                         \"2FA verification code is incorrect\",\n\t\"auth_failed\":                            \"Authentication failed\",\n\t\"page_not_found\":                         \"Page not found\",\n\t\"app_not_installed\":                      \"Application not installed\",\n\t\"unauthorized\":                           \"Unauthorized access\",\n\t\"api_key_required\":                       \"API key not configured\",\n\t\"param_time_required\":                    \"time parameter is required\",\n\t\"param_time_invalid\":                     \"time parameter has expired\",\n\t\"param_sign_required\":                    \"sign parameter is required\",\n\t\"sign_verify_failed\":                     \"Signature verification failed\",\n\t\"invalid_log_id\":                         \"Invalid log ID\",\n\t\"invalid_task_id\":                        \"Invalid task ID\",\n\t\"get_task_info_failed\":                   \"Failed to get task information\",\n\t\"only_shell_task_can_stop\":               \"Only SHELL tasks can be stopped manually\",\n\t\"task_node_list_empty\":                   \"Task node list is empty\",\n\t\"stop_task_sent\":                         \"Stop command sent, please wait for task to exit\",\n\t\"param_range_1_12\":                       \"Parameter value range: 1-12\",\n\t\"delete_failed\":                          \"Delete failed\",\n\t\"delete_success\":                         \"Deleted successfully\",\n\t\"http_task_timeout_max_300\":              \"HTTP task timeout cannot exceed 300 seconds\",\n\t\"crontab_parse_failed\":                   \"Failed to parse crontab expression\",\n\t\"cannot_set_self_as_child\":               \"Cannot set current task as child task\",\n\t\"host_not_exist\":                         \"Host does not exist\",\n\t\"refresh_task_host_failed\":               \"Failed to refresh task host information\",\n\t\"invalid_url\":                            \"Please enter a valid URL\",\n\t\"hostname_exists\":                        \"Hostname already exists\",\n\t\"task_name_exists\":                       \"Task name already exists\",\n\t\"retry_times_range_0_10\":                 \"Retry times must be between 0-10\",\n\t\"retry_interval_range_0_3600\":            \"Retry interval must be between 0-3600\",\n\t\"param_error\":                            \"Parameter error\",\n\t\"operation_failed\":                       \"Operation failed\",\n\t\"select_at_least_one_receiver\":           \"Please select at least one notification receiver\",\n\t\"select_hostname\":                        \"Please select hostname\",\n\t\"select_dependency\":                      \"Please select dependency\",\n\t\"host_in_use_cannot_delete\":              \"Host is in use by tasks and cannot be deleted\",\n\t\"connection_failed\":                      \"Connection failed\",\n\t\"connection_success\":                     \"Connection successful\",\n\t\"get_task_detail_failed\":                 \"Failed to get task details\",\n\t\"manual_run\":                             \"Manual run\",\n\t\"task_started_check_log\":                 \"Task started, please check task log for results\",\n\t\"password_min_length_8\":                  \"Password must be at least 8 characters\",\n\t\"password_must_contain_letter_and_digit\": \"Password must contain both letters and digits\",\n\t\"account_locked\":                         \"Account locked, please try again in %d minutes\",\n\t\"login_failed_with_attempts\":             \"Username or password is incorrect, %d attempts remaining\",\n\t\"rpc_unavailable\":                        \"Unable to connect to remote server\",\n\t\"rpc_timeout\":                            \"Execution timeout, forcibly terminated\",\n\t\"rpc_manual_stop\":                        \"Manually stopped\",\n\t\"version_not_found\":                      \"Version not found\",\n\t\"rollback_success\":                       \"Rollback successful\",\n\t\"rollback_failed\":                        \"Rollback failed\",\n\t\"template_name_exists\":                   \"Template name already exists\",\n\t\"template_not_found\":                     \"Template not found\",\n\t\"builtin_template_readonly\":              \"Built-in template is read-only\",\n\t\"builtin_template_no_delete\":             \"Built-in template cannot be deleted\",\n\t\"task_not_found\":                         \"Task not found\",\n}\n"
  },
  {
    "path": "internal/modules/i18n/i18n.go",
    "content": "package i18n\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype Locale string\n\nconst (\n\tZhCN Locale = \"zh-CN\"\n\tEnUS Locale = \"en-US\"\n)\n\nvar messages = map[Locale]map[string]string{\n\tZhCN: zhCN,\n\tEnUS: enUS,\n}\n\nfunc T(c *gin.Context, key string, args ...interface{}) string {\n\tlocale := GetLocale(c)\n\tmsg, ok := messages[locale][key]\n\tif !ok {\n\t\tmsg = messages[ZhCN][key]\n\t\tif msg == \"\" {\n\t\t\treturn key\n\t\t}\n\t}\n\treturn msg\n}\n\n// Translate 不依赖gin.Context的翻译函数，默认使用中文\nfunc Translate(key string) string {\n\tmsg, ok := messages[ZhCN][key]\n\tif !ok {\n\t\treturn key\n\t}\n\treturn msg\n}\n\nfunc GetLocale(c *gin.Context) Locale {\n\tlang := c.GetHeader(\"Accept-Language\")\n\tif lang == \"\" || lang == \"zh-CN\" || lang == \"zh\" {\n\t\treturn ZhCN\n\t}\n\treturn EnUS\n}\n"
  },
  {
    "path": "internal/modules/i18n/zh_cn.go",
    "content": "package i18n\n\nvar zhCN = map[string]string{\n\t\"form_validation_failed\":                 \"表单验证失败, 请检测输入\",\n\t\"system_already_installed\":               \"系统已安装!\",\n\t\"password_mismatch\":                      \"两次输入密码不匹配\",\n\t\"db_config_write_failed\":                 \"数据库配置写入文件失败\",\n\t\"app_config_read_failed\":                 \"读取应用配置失败\",\n\t\"create_table_failed\":                    \"创建数据库表失败\",\n\t\"create_admin_failed\":                    \"创建管理员账号失败\",\n\t\"create_lock_file_failed\":                \"创建文件安装锁失败\",\n\t\"user_not_found\":                         \"用户不存在\",\n\t\"generate_2fa_key_failed\":                \"生成2FA密钥失败\",\n\t\"generate_qrcode_failed\":                 \"生成二维码失败\",\n\t\"get_success\":                            \"获取成功\",\n\t\"verification_code_error\":                \"验证码错误\",\n\t\"enable_failed\":                          \"启用失败\",\n\t\"2fa_enabled\":                            \"2FA已启用\",\n\t\"2fa_not_enabled\":                        \"2FA未启用\",\n\t\"disable_failed\":                         \"禁用失败\",\n\t\"2fa_disabled\":                           \"2FA已禁用\",\n\t\"update_success\":                         \"更新成功\",\n\t\"incomplete_parameters\":                  \"参数不完整\",\n\t\"operation_success\":                      \"操作成功\",\n\t\"username_exists\":                        \"用户名已存在\",\n\t\"email_exists\":                           \"邮箱已存在\",\n\t\"password_required\":                      \"请输入密码\",\n\t\"password_confirm_required\":              \"请再次输入密码\",\n\t\"save_failed\":                            \"保存失败\",\n\t\"update_failed\":                          \"更新失败\",\n\t\"save_success\":                           \"保存成功\",\n\t\"password_same_as_old\":                   \"原密码与新密码不能相同\",\n\t\"old_password_error\":                     \"原密码输入错误\",\n\t\"username_password_empty\":                \"用户名或密码不能为空\",\n\t\"username_password_error\":                \"用户名或密码错误\",\n\t\"2fa_code_required\":                      \"需要输入2FA验证码\",\n\t\"2fa_code_error\":                         \"2FA验证码错误\",\n\t\"auth_failed\":                            \"认证失败\",\n\t\"page_not_found\":                         \"页面不存在\",\n\t\"app_not_installed\":                      \"应用未安装\",\n\t\"unauthorized\":                           \"无权访问\",\n\t\"api_key_required\":                       \"API密钥未配置\",\n\t\"param_time_required\":                    \"time参数必填\",\n\t\"param_time_invalid\":                     \"time参数已过期\",\n\t\"param_sign_required\":                    \"sign参数必填\",\n\t\"sign_verify_failed\":                     \"签名验证失败\",\n\t\"invalid_log_id\":                         \"参数错误: 无效的日志ID\",\n\t\"invalid_task_id\":                        \"参数错误: 无效的任务ID\",\n\t\"get_task_info_failed\":                   \"获取任务信息失败\",\n\t\"only_shell_task_can_stop\":               \"仅支持SHELL任务手动停止\",\n\t\"task_node_list_empty\":                   \"任务节点列表为空\",\n\t\"stop_task_sent\":                         \"已执行停止操作, 请等待任务退出\",\n\t\"param_range_1_12\":                       \"参数取值范围1-12\",\n\t\"delete_failed\":                          \"删除失败\",\n\t\"delete_success\":                         \"删除成功\",\n\t\"http_task_timeout_max_300\":              \"HTTP任务超时时间不能超过300秒\",\n\t\"crontab_parse_failed\":                   \"crontab表达式解析失败\",\n\t\"cannot_set_self_as_child\":               \"不允许设置当前任务为子任务\",\n\t\"host_not_exist\":                         \"主机不存在\",\n\t\"refresh_task_host_failed\":               \"刷新任务主机信息失败\",\n\t\"invalid_url\":                            \"请输入正确的URL地址\",\n\t\"hostname_exists\":                        \"主机名已存在\",\n\t\"task_name_exists\":                       \"任务名称已存在\",\n\t\"retry_times_range_0_10\":                 \"任务重试次数取值0-10\",\n\t\"retry_interval_range_0_3600\":            \"任务重试间隔时间取值0-3600\",\n\t\"param_error\":                            \"参数错误\",\n\t\"operation_failed\":                       \"操作失败\",\n\t\"select_at_least_one_receiver\":           \"至少选择一个通知接收者\",\n\t\"select_hostname\":                        \"请选择主机名\",\n\t\"select_dependency\":                      \"请选择依赖关系\",\n\t\"host_in_use_cannot_delete\":              \"有任务引用此主机，不能删除\",\n\t\"connection_failed\":                      \"连接失败\",\n\t\"connection_success\":                     \"连接成功\",\n\t\"get_task_detail_failed\":                 \"获取任务详情失败\",\n\t\"manual_run\":                             \"手动运行\",\n\t\"task_started_check_log\":                 \"任务已开始运行, 请到任务日志中查看结果\",\n\t\"password_min_length_8\":                  \"密码长度至少8位\",\n\t\"password_must_contain_letter_and_digit\": \"密码必须包含字母和数字\",\n\t\"account_locked\":                         \"账户已被锁定，请在%d分钟后重试\",\n\t\"login_failed_with_attempts\":             \"用户名或密码错误，还剩%d次尝试机会\",\n\t\"rpc_unavailable\":                        \"无法连接远程服务器\",\n\t\"rpc_timeout\":                            \"执行超时, 强制结束\",\n\t\"rpc_manual_stop\":                        \"手动停止\",\n\t\"version_not_found\":                      \"版本不存在\",\n\t\"rollback_success\":                       \"回滚成功\",\n\t\"rollback_failed\":                        \"回滚失败\",\n\t\"template_name_exists\":                   \"模板名称已存在\",\n\t\"template_not_found\":                     \"模板不存在\",\n\t\"builtin_template_readonly\":              \"内置模板不可修改\",\n\t\"builtin_template_no_delete\":             \"内置模板不可删除\",\n\t\"task_not_found\":                         \"任务不存在\",\n}\n"
  },
  {
    "path": "internal/modules/leader/election.go",
    "content": "package leader\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n)\n\nconst (\n\t// LockName 调度器锁的固定名称\n\tLockName = \"scheduler_leader\"\n\t// LeaseDuration 租约时长，领导者需要在此时间内续约\n\tLeaseDuration = 15 * time.Second\n\t// RenewInterval 续约间隔，必须小于 LeaseDuration\n\tRenewInterval = 5 * time.Second\n\t// RetryInterval 竞选失败后重试间隔\n\tRetryInterval = 5 * time.Second\n)\n\n// Election 基于数据库行锁的领导者选举\ntype Election struct {\n\tdb         *gorm.DB\n\tinstanceID string // 当前实例标识\n\tisLeader   atomic.Bool\n\tstopCh     chan struct{}\n\tstoppedCh  chan struct{}\n\tonElected  func() // 当选回调\n\tonEvicted  func() // 失去领导权回调\n\tmu         sync.Mutex\n}\n\n// New 创建选举实例\nfunc New(db *gorm.DB, onElected, onEvicted func()) *Election {\n\thostname, _ := os.Hostname()\n\tinstanceID := fmt.Sprintf(\"%s:%d\", hostname, os.Getpid())\n\n\treturn &Election{\n\t\tdb:         db,\n\t\tinstanceID: instanceID,\n\t\tstopCh:     make(chan struct{}),\n\t\tstoppedCh:  make(chan struct{}),\n\t\tonElected:  onElected,\n\t\tonEvicted:  onEvicted,\n\t}\n}\n\n// Start 开始参与选举（非阻塞）\nfunc (e *Election) Start() {\n\tgo e.run()\n}\n\n// Stop 停止选举并释放领导权\nfunc (e *Election) Stop() {\n\tclose(e.stopCh)\n\t<-e.stoppedCh\n}\n\n// IsLeader 当前实例是否是领导者\nfunc (e *Election) IsLeader() bool {\n\treturn e.isLeader.Load()\n}\n\n// InstanceID 返回当前实例标识\nfunc (e *Election) InstanceID() string {\n\treturn e.instanceID\n}\n\nfunc (e *Election) run() {\n\tdefer close(e.stoppedCh)\n\n\t// 确保锁表和初始记录存在\n\te.ensureLockRecord()\n\n\tfor {\n\t\tselect {\n\t\tcase <-e.stopCh:\n\t\t\te.releaseLock()\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tif e.isLeader.Load() {\n\t\t\t// 已经是 leader，续约\n\t\t\tif !e.renewLock() {\n\t\t\t\tlogger.Warn(\"Leader lease renewal failed, stepping down\")\n\t\t\t\te.isLeader.Store(false)\n\t\t\t\tif e.onEvicted != nil {\n\t\t\t\t\te.onEvicted()\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t// 尝试竞选\n\t\t\tif e.tryAcquireLock() {\n\t\t\t\tlogger.Infof(\"This node elected as leader: %s\", e.instanceID)\n\t\t\t\te.isLeader.Store(true)\n\t\t\t\tif e.onElected != nil {\n\t\t\t\t\te.onElected()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 等待下一次循环\n\t\tinterval := RetryInterval\n\t\tif e.isLeader.Load() {\n\t\t\tinterval = RenewInterval\n\t\t}\n\n\t\tselect {\n\t\tcase <-e.stopCh:\n\t\t\te.releaseLock()\n\t\t\treturn\n\t\tcase <-time.After(interval):\n\t\t}\n\t}\n}\n\n// ensureLockRecord 确保锁记录存在\nfunc (e *Election) ensureLockRecord() {\n\tlock := models.SchedulerLock{\n\t\tLockName: LockName,\n\t\tLockedBy: \"\",\n\t\tLockedAt: time.Time{},\n\t\tExpireAt: time.Time{},\n\t}\n\t// 如果记录不存在则创建\n\te.db.Where(\"lock_name = ?\", LockName).FirstOrCreate(&lock)\n}\n\n// tryAcquireLock 尝试获取锁（FOR UPDATE + 检查过期）\nfunc (e *Election) tryAcquireLock() bool {\n\tnow := time.Now()\n\tresult := e.db.Transaction(func(tx *gorm.DB) error {\n\t\tvar lock models.SchedulerLock\n\n\t\t// SELECT ... FOR UPDATE 行锁\n\t\terr := tx.Clauses(clause.Locking{Strength: \"UPDATE\"}).\n\t\t\tWhere(\"lock_name = ?\", LockName).\n\t\t\tFirst(&lock).Error\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 锁被其他实例持有且未过期\n\t\tif lock.LockedBy != \"\" && lock.LockedBy != e.instanceID && lock.ExpireAt.After(now) {\n\t\t\treturn fmt.Errorf(\"lock held by %s until %s\", lock.LockedBy, lock.ExpireAt)\n\t\t}\n\n\t\t// 锁空闲或已过期，获取锁\n\t\terr = tx.Model(&lock).Updates(map[string]interface{}{\n\t\t\t\"locked_by\": e.instanceID,\n\t\t\t\"locked_at\": now,\n\t\t\t\"expire_at\": now.Add(LeaseDuration),\n\t\t\t\"version\":   lock.Version + 1,\n\t\t}).Error\n\t\treturn err\n\t})\n\n\treturn result == nil\n}\n\n// renewLock 续约（只有当前持有者才能续约）\nfunc (e *Election) renewLock() bool {\n\tnow := time.Now()\n\tresult := e.db.Model(&models.SchedulerLock{}).\n\t\tWhere(\"lock_name = ? AND locked_by = ?\", LockName, e.instanceID).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"expire_at\": now.Add(LeaseDuration),\n\t\t\t\"locked_at\": now,\n\t\t})\n\n\tif result.Error != nil {\n\t\tlogger.Errorf(\"Failed to renew leader lease: %v\", result.Error)\n\t\treturn false\n\t}\n\treturn result.RowsAffected > 0\n}\n\n// releaseLock 主动释放锁\nfunc (e *Election) releaseLock() {\n\tif !e.isLeader.Load() {\n\t\treturn\n\t}\n\n\tlogger.Infof(\"Releasing leader lock: %s\", e.instanceID)\n\te.db.Model(&models.SchedulerLock{}).\n\t\tWhere(\"lock_name = ? AND locked_by = ?\", LockName, e.instanceID).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"locked_by\": \"\",\n\t\t\t\"expire_at\": time.Time{},\n\t\t})\n\n\te.isLeader.Store(false)\n\tif e.onEvicted != nil {\n\t\te.onEvicted()\n\t}\n}\n"
  },
  {
    "path": "internal/modules/leader/election_test.go",
    "content": "package leader\n\nimport (\n\t\"os\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc TestMain(m *testing.M) {\n\tlogger.InitLogger()\n\tos.Exit(m.Run())\n}\n\nfunc setupTestDB(t *testing.T) *gorm.DB {\n\tt.Helper()\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite: %v\", err)\n\t}\n\t// SQLite in-memory DB is per-connection; force single connection\n\t// so all goroutines share the same schema and data\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\tt.Fatalf(\"get sql.DB: %v\", err)\n\t}\n\tsqlDB.SetMaxOpenConns(1)\n\tif err := db.AutoMigrate(&models.SchedulerLock{}); err != nil {\n\t\tt.Fatalf(\"migrate: %v\", err)\n\t}\n\treturn db\n}\n\nfunc TestElection_SingleNode_BecomesLeader(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\telected := make(chan struct{}, 1)\n\te := New(db, func() { elected <- struct{}{} }, nil)\n\te.Start()\n\tdefer e.Stop()\n\n\tselect {\n\tcase <-elected:\n\t\t// ok\n\tcase <-time.After(3 * time.Second):\n\t\tt.Fatal(\"timed out waiting to become leader\")\n\t}\n\n\tif !e.IsLeader() {\n\t\tt.Error(\"expected IsLeader() to be true\")\n\t}\n}\n\nfunc TestElection_Stop_ReleasesLock(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\telected := make(chan struct{}, 1)\n\tevicted := make(chan struct{}, 1)\n\te := New(db, func() { elected <- struct{}{} }, func() { evicted <- struct{}{} })\n\te.Start()\n\n\t<-elected\n\te.Stop()\n\n\tif e.IsLeader() {\n\t\tt.Error(\"expected IsLeader() to be false after Stop\")\n\t}\n\n\t// Verify lock is released in DB\n\tvar lock models.SchedulerLock\n\tdb.Where(\"lock_name = ?\", LockName).First(&lock)\n\tif lock.LockedBy != \"\" {\n\t\tt.Errorf(\"expected empty locked_by after stop, got %q\", lock.LockedBy)\n\t}\n}\n\nfunc TestElection_TwoNodes_OnlyOneLeader(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\tvar mu sync.Mutex\n\tleaderCount := 0\n\n\tmakeElection := func() *Election {\n\t\treturn New(db,\n\t\t\tfunc() {\n\t\t\t\tmu.Lock()\n\t\t\t\tleaderCount++\n\t\t\t\tmu.Unlock()\n\t\t\t},\n\t\t\tfunc() {\n\t\t\t\tmu.Lock()\n\t\t\t\tleaderCount--\n\t\t\t\tmu.Unlock()\n\t\t\t},\n\t\t)\n\t}\n\n\te1 := makeElection()\n\te2 := makeElection()\n\t// Give them different instance IDs\n\te1.instanceID = \"node1:1000\"\n\te2.instanceID = \"node2:2000\"\n\n\te1.Start()\n\ttime.Sleep(2 * time.Second)\n\te2.Start()\n\ttime.Sleep(2 * time.Second)\n\n\t// Exactly one should be leader\n\tif e1.IsLeader() == e2.IsLeader() {\n\t\tt.Errorf(\"expected exactly one leader: e1=%v e2=%v\", e1.IsLeader(), e2.IsLeader())\n\t}\n\n\tmu.Lock()\n\tcount := leaderCount\n\tmu.Unlock()\n\tif count != 1 {\n\t\tt.Errorf(\"expected leaderCount=1, got %d\", count)\n\t}\n\n\te1.Stop()\n\te2.Stop()\n}\n\nfunc TestElection_Failover(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\telected1 := make(chan struct{}, 1)\n\te1 := New(db, func() { elected1 <- struct{}{} }, nil)\n\te1.instanceID = \"node1:1000\"\n\te1.Start()\n\n\tselect {\n\tcase <-elected1:\n\tcase <-time.After(3 * time.Second):\n\t\tt.Fatal(\"e1 timed out becoming leader\")\n\t}\n\n\telected2 := make(chan struct{}, 1)\n\te2 := New(db, func() { elected2 <- struct{}{} }, nil)\n\te2.instanceID = \"node2:2000\"\n\te2.Start()\n\n\t// e1 stops — e2 should take over\n\te1.Stop()\n\n\tselect {\n\tcase <-elected2:\n\t\t// ok, e2 became leader\n\tcase <-time.After(10 * time.Second):\n\t\tt.Fatal(\"e2 timed out becoming leader after e1 stopped\")\n\t}\n\n\tif !e2.IsLeader() {\n\t\tt.Error(\"expected e2 to be leader after e1 stopped\")\n\t}\n\n\te2.Stop()\n}\n\nfunc TestElection_InstanceID(t *testing.T) {\n\tdb := setupTestDB(t)\n\te := New(db, nil, nil)\n\tif e.InstanceID() == \"\" {\n\t\tt.Error(\"expected non-empty InstanceID\")\n\t}\n}\n\nfunc TestElection_EnsureLockRecord_CreatesRow(t *testing.T) {\n\tdb := setupTestDB(t)\n\te := New(db, nil, nil)\n\n\t// No rows initially\n\tvar count int64\n\tdb.Model(&models.SchedulerLock{}).Count(&count)\n\tif count != 0 {\n\t\tt.Fatalf(\"expected 0 rows, got %d\", count)\n\t}\n\n\te.ensureLockRecord()\n\n\tdb.Model(&models.SchedulerLock{}).Count(&count)\n\tif count != 1 {\n\t\tt.Fatalf(\"expected 1 row after ensureLockRecord, got %d\", count)\n\t}\n\n\t// Calling again should not create duplicate\n\te.ensureLockRecord()\n\tdb.Model(&models.SchedulerLock{}).Count(&count)\n\tif count != 1 {\n\t\tt.Fatalf(\"expected still 1 row after second call, got %d\", count)\n\t}\n}\n\nfunc TestElection_TryAcquireLock_ExpiredLock(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\t// Insert an expired lock held by another node\n\texpired := models.SchedulerLock{\n\t\tLockName: LockName,\n\t\tLockedBy: \"old-node:999\",\n\t\tLockedAt: time.Now().Add(-1 * time.Hour),\n\t\tExpireAt: time.Now().Add(-30 * time.Minute), // expired\n\t}\n\tdb.Create(&expired)\n\n\te := New(db, nil, nil)\n\te.instanceID = \"new-node:1000\"\n\n\t// Should succeed because lock is expired\n\tif !e.tryAcquireLock() {\n\t\tt.Error(\"expected to acquire expired lock\")\n\t}\n\n\t// Verify DB updated\n\tvar lock models.SchedulerLock\n\tdb.Where(\"lock_name = ?\", LockName).First(&lock)\n\tif lock.LockedBy != \"new-node:1000\" {\n\t\tt.Errorf(\"expected locked_by=%q, got %q\", \"new-node:1000\", lock.LockedBy)\n\t}\n}\n\nfunc TestElection_TryAcquireLock_ActiveLockBlocks(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\t// Insert an active lock held by another node\n\tactive := models.SchedulerLock{\n\t\tLockName: LockName,\n\t\tLockedBy: \"other-node:999\",\n\t\tLockedAt: time.Now(),\n\t\tExpireAt: time.Now().Add(1 * time.Hour), // not expired\n\t}\n\tdb.Create(&active)\n\n\te := New(db, nil, nil)\n\te.instanceID = \"my-node:1000\"\n\n\t// Should fail because lock is active\n\tif e.tryAcquireLock() {\n\t\tt.Error(\"expected to fail acquiring active lock\")\n\t}\n}\n\nfunc TestElection_RenewLock_Success(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\tlock := models.SchedulerLock{\n\t\tLockName: LockName,\n\t\tLockedBy: \"my-node:1000\",\n\t\tLockedAt: time.Now(),\n\t\tExpireAt: time.Now().Add(10 * time.Second),\n\t}\n\tdb.Create(&lock)\n\n\te := New(db, nil, nil)\n\te.instanceID = \"my-node:1000\"\n\n\tif !e.renewLock() {\n\t\tt.Error(\"expected renewLock to succeed\")\n\t}\n\n\tvar updated models.SchedulerLock\n\tdb.Where(\"lock_name = ?\", LockName).First(&updated)\n\tif updated.ExpireAt.Before(lock.ExpireAt) {\n\t\tt.Error(\"expected expire_at to be extended\")\n\t}\n}\n\nfunc TestElection_RenewLock_FailsWhenNotOwner(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\tlock := models.SchedulerLock{\n\t\tLockName: LockName,\n\t\tLockedBy: \"other-node:999\",\n\t\tLockedAt: time.Now(),\n\t\tExpireAt: time.Now().Add(10 * time.Second),\n\t}\n\tdb.Create(&lock)\n\n\te := New(db, nil, nil)\n\te.instanceID = \"my-node:1000\"\n\n\tif e.renewLock() {\n\t\tt.Error(\"expected renewLock to fail when not owner\")\n\t}\n}\n\nfunc TestElection_ReleaseLock_OnlyWhenLeader(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\tlock := models.SchedulerLock{\n\t\tLockName: LockName,\n\t\tLockedBy: \"other-node:999\",\n\t\tLockedAt: time.Now(),\n\t\tExpireAt: time.Now().Add(1 * time.Hour),\n\t}\n\tdb.Create(&lock)\n\n\te := New(db, nil, nil)\n\te.instanceID = \"my-node:1000\"\n\t// isLeader is false by default\n\n\te.releaseLock() // should be a no-op\n\n\tvar result models.SchedulerLock\n\tdb.Where(\"lock_name = ?\", LockName).First(&result)\n\tif result.LockedBy != \"other-node:999\" {\n\t\tt.Errorf(\"expected lock still held by other-node, got %q\", result.LockedBy)\n\t}\n}\n\nfunc TestElection_NilCallbacks(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\t// Should not panic with nil onElected/onEvicted\n\te := New(db, nil, nil)\n\te.Start()\n\n\ttime.Sleep(1 * time.Second)\n\tif !e.IsLeader() {\n\t\tt.Error(\"expected to become leader\")\n\t}\n\n\te.Stop()\n\tif e.IsLeader() {\n\t\tt.Error(\"expected not to be leader after stop\")\n\t}\n}\n\nfunc TestElection_ReacquireOwnLock(t *testing.T) {\n\tdb := setupTestDB(t)\n\n\t// Lock held by the same instance (e.g. after restart with same hostname:pid)\n\tlock := models.SchedulerLock{\n\t\tLockName: LockName,\n\t\tLockedBy: \"my-node:1000\",\n\t\tLockedAt: time.Now(),\n\t\tExpireAt: time.Now().Add(1 * time.Hour),\n\t}\n\tdb.Create(&lock)\n\n\te := New(db, nil, nil)\n\te.instanceID = \"my-node:1000\"\n\n\t// Should succeed — same instance can reacquire\n\tif !e.tryAcquireLock() {\n\t\tt.Error(\"expected to reacquire own lock\")\n\t}\n}\n"
  },
  {
    "path": "internal/modules/logger/async_logger.go",
    "content": "package logger\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"log/slog\"\n\t\"sync\"\n\t\"time\"\n)\n\n// 异步日志批处理器\ntype asyncHandler struct {\n\thandler   slog.Handler\n\tlogChan   chan *logRecord\n\tbatchSize int\n\tflushTime time.Duration\n\twg        sync.WaitGroup\n\tonce      sync.Once\n}\n\ntype logRecord struct {\n\trecord slog.Record\n}\n\n// 创建异步处理器\nfunc newAsyncHandler(writer io.Writer, batchSize int, flushTime time.Duration) *asyncHandler {\n\th := &asyncHandler{\n\t\thandler:   slog.NewTextHandler(writer, &slog.HandlerOptions{Level: slog.LevelDebug}),\n\t\tlogChan:   make(chan *logRecord, batchSize*2),\n\t\tbatchSize: batchSize,\n\t\tflushTime: flushTime,\n\t}\n\th.wg.Add(1)\n\tgo h.worker()\n\treturn h\n}\n\n// 后台批量写入\nfunc (h *asyncHandler) worker() {\n\tdefer h.wg.Done()\n\tbatch := make([]*logRecord, 0, h.batchSize)\n\tticker := time.NewTicker(h.flushTime)\n\tdefer ticker.Stop()\n\tctx := context.Background()\n\n\tflush := func() {\n\t\tif len(batch) == 0 {\n\t\t\treturn\n\t\t}\n\t\tfor _, rec := range batch {\n\t\t\t_ = h.handler.Handle(ctx, rec.record)\n\t\t}\n\t\tbatch = batch[:0]\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase record, ok := <-h.logChan:\n\t\t\tif !ok {\n\t\t\t\tflush()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tbatch = append(batch, record)\n\t\t\tif len(batch) >= h.batchSize {\n\t\t\t\tflush()\n\t\t\t}\n\t\tcase <-ticker.C:\n\t\t\tflush()\n\t\t}\n\t}\n}\n\n// 写入日志（非阻塞）\nfunc (h *asyncHandler) log(level slog.Level, msg string, args ...any) {\n\trec := slog.NewRecord(time.Now(), level, msg, 0)\n\t// 使用对象池减少分配\n\tlogRec := &logRecord{record: rec}\n\tselect {\n\tcase h.logChan <- logRec:\n\tdefault:\n\t\t// 队列满时直接同步写入（降级策略）\n\t\t_ = h.handler.Handle(context.Background(), rec)\n\t}\n}\n\n// 优雅关闭\nfunc (h *asyncHandler) close() {\n\th.once.Do(func() {\n\t\tclose(h.logChan)\n\t\th.wg.Wait()\n\t})\n}\n"
  },
  {
    "path": "internal/modules/logger/async_logger_test.go",
    "content": "package logger\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"log/slog\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestAsyncLoggerPerformance(t *testing.T) {\n\tvar buf bytes.Buffer\n\thandler := newAsyncHandler(&buf, 50, 50*time.Millisecond)\n\tdefer handler.close()\n\n\t// 写入1000条日志\n\tstart := time.Now()\n\tfor i := 0; i < 1000; i++ {\n\t\thandler.log(slog.LevelInfo, \"test message\")\n\t}\n\thandler.close()\n\telapsed := time.Since(start)\n\n\tt.Logf(\"写入1000条日志耗时: %v\", elapsed)\n\n\t// 验证日志已写入\n\tif buf.Len() == 0 {\n\t\tt.Fatal(\"日志未写入\")\n\t}\n}\n\nfunc TestAsyncLoggerBatchFlush(t *testing.T) {\n\tvar buf bytes.Buffer\n\thandler := newAsyncHandler(&buf, 10, 100*time.Millisecond)\n\tdefer handler.close()\n\n\t// 写入5条日志（小于批量大小）\n\tfor i := 0; i < 5; i++ {\n\t\thandler.log(slog.LevelInfo, \"test\")\n\t}\n\n\t// 等待定时刷新\n\ttime.Sleep(150 * time.Millisecond)\n\thandler.close()\n\n\t// 验证日志已刷新\n\tif buf.Len() == 0 {\n\t\tt.Fatal(\"定时刷新失败\")\n\t}\n}\n\nfunc TestAsyncLoggerFullBatch(t *testing.T) {\n\tvar buf bytes.Buffer\n\thandler := newAsyncHandler(&buf, 10, 1*time.Second)\n\n\t// 写入10条日志（等于批量大小）\n\tfor i := 0; i < 10; i++ {\n\t\thandler.log(slog.LevelInfo, \"test\")\n\t}\n\n\t// close 会等待 worker 完成所有写入\n\thandler.close()\n\n\t// 验证日志已写入\n\tif buf.Len() == 0 {\n\t\tt.Fatal(\"批量写入失败\")\n\t}\n}\n\nfunc TestAsyncLoggerClose(t *testing.T) {\n\tvar buf bytes.Buffer\n\thandler := newAsyncHandler(&buf, 100, 1*time.Second)\n\n\t// 写入日志后立即关闭\n\thandler.log(slog.LevelInfo, \"test message\")\n\thandler.close()\n\n\t// 验证关闭时刷新了所有日志\n\tif !strings.Contains(buf.String(), \"test message\") {\n\t\tt.Fatal(\"关闭时未刷新日志\")\n\t}\n}\n\n// 性能对比测试\nfunc BenchmarkSyncLogger(b *testing.B) {\n\tvar buf bytes.Buffer\n\thandler := slog.NewTextHandler(&buf, nil)\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = handler.Handle(context.Background(), slog.NewRecord(time.Now(), slog.LevelInfo, \"test\", 0))\n\t}\n}\n\nfunc BenchmarkAsyncLogger(b *testing.B) {\n\tvar buf bytes.Buffer\n\thandler := newAsyncHandler(&buf, 50, 100*time.Millisecond)\n\tdefer handler.close()\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\thandler.log(slog.LevelInfo, \"test\")\n\t}\n}\n"
  },
  {
    "path": "internal/modules/logger/compatibility_test.go",
    "content": "package logger\n\nimport (\n\t\"bytes\"\n\t\"log/slog\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// 兼容性测试：确保API接口不变\nfunc TestAPICompatibility(t *testing.T) {\n\tgin.SetMode(gin.ReleaseMode)\n\n\t// 初始化logger\n\tvar buf bytes.Buffer\n\thandler := newAsyncHandler(&buf, 10, 100*time.Millisecond)\n\tprevAsync := asyncLogWriter\n\tasyncLogWriter = handler\n\tt.Cleanup(func() {\n\t\thandler.close()\n\t\tasyncLogWriter = prevAsync\n\t})\n\n\t// 测试所有公开API是否正常工作\n\tt.Run(\"Info接口\", func(t *testing.T) {\n\t\tInfo(\"test\")\n\t\tInfof(\"test %s\", \"format\")\n\t})\n\n\tt.Run(\"Error接口\", func(t *testing.T) {\n\t\tError(\"test\")\n\t\tErrorf(\"test %s\", \"format\")\n\t})\n\n\tt.Run(\"Warn接口\", func(t *testing.T) {\n\t\tWarn(\"test\")\n\t\tWarnf(\"test %s\", \"format\")\n\t})\n\n\tt.Run(\"Debug接口\", func(t *testing.T) {\n\t\tDebug(\"test\")\n\t\tDebugf(\"test %s\", \"format\")\n\t})\n}\n\n// 测试日志输出格式不变\nfunc TestLogFormatCompatibility(t *testing.T) {\n\tprevLogger := logger\n\tprevAsync := asyncLogWriter\n\n\t// 使用同步logger测试格式\n\thandler := newRecordingHandler()\n\tlogger = slog.New(handler)\n\tasyncLogWriter = nil\n\n\tt.Cleanup(func() {\n\t\tlogger = prevLogger\n\t\tasyncLogWriter = prevAsync\n\t})\n\n\tInfo(\"test message\")\n\n\tif len(handler.entries) != 1 {\n\t\tt.Fatalf(\"expected 1 log entry, got %d\", len(handler.entries))\n\t}\n\n\tif handler.entries[0].msg != \"test message\" {\n\t\tt.Errorf(\"expected 'test message', got '%s'\", handler.entries[0].msg)\n\t}\n}\n\n// 测试降级策略：异步失败时自动降级到同步\nfunc TestFallbackToSync(t *testing.T) {\n\tvar buf bytes.Buffer\n\thandler := slog.NewTextHandler(&buf, nil)\n\n\tprevLogger := logger\n\tprevAsync := asyncLogWriter\n\n\tlogger = slog.New(handler)\n\tasyncLogWriter = nil // 模拟异步不可用\n\n\tt.Cleanup(func() {\n\t\tlogger = prevLogger\n\t\tasyncLogWriter = prevAsync\n\t})\n\n\tInfo(\"fallback test\")\n\n\tif !strings.Contains(buf.String(), \"fallback test\") {\n\t\tt.Error(\"降级到同步日志失败\")\n\t}\n}\n\n// 测试Close方法幂等性\nfunc TestCloseIdempotent(t *testing.T) {\n\tvar buf bytes.Buffer\n\thandler := newAsyncHandler(&buf, 10, 100*time.Millisecond)\n\n\thandler.log(slog.LevelInfo, \"test\")\n\n\t// 多次调用Close应该安全\n\thandler.close()\n\thandler.close()\n\thandler.close()\n\n\t// 验证日志已写入\n\tif buf.Len() == 0 {\n\t\tt.Error(\"日志未写入\")\n\t}\n}\n"
  },
  {
    "path": "internal/modules/logger/logger.go",
    "content": "package logger\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// 日志库\n\ntype Level int8\n\nvar (\n\tlogger         *slog.Logger\n\tasyncLogWriter *asyncHandler\n\texitFunc       = os.Exit\n)\n\nconst (\n\tDEBUG = iota\n\tINFO\n\tWARN\n\tERROR\n\tFATAL\n)\n\nfunc InitLogger() {\n\tlogDir := \"log\"\n\tif err := os.MkdirAll(logDir, 0755); err != nil {\n\t\tpanic(err)\n\t}\n\n\tlogFile := filepath.Join(logDir, \"cron.log\")\n\tfile, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\twriter := io.MultiWriter(os.Stdout, file)\n\n\t// 使用异步处理器：批量大小50，刷新间隔100ms\n\tasyncLogWriter = newAsyncHandler(writer, 50, 100*time.Millisecond)\n\n\thandler := slog.NewTextHandler(writer, &slog.HandlerOptions{\n\t\tLevel: slog.LevelDebug,\n\t})\n\tlogger = slog.New(handler)\n}\n\n// 优雅关闭\nfunc Close() {\n\tif asyncLogWriter != nil {\n\t\tasyncLogWriter.close()\n\t}\n}\n\nfunc Debug(v ...interface{}) {\n\tif gin.Mode() != gin.DebugMode {\n\t\treturn\n\t}\n\twrite(DEBUG, v...)\n}\n\nfunc Debugf(format string, v ...interface{}) {\n\tif gin.Mode() != gin.DebugMode {\n\t\treturn\n\t}\n\twritef(DEBUG, format, v...)\n}\n\nfunc Info(v ...interface{}) {\n\twrite(INFO, v...)\n}\n\nfunc Infof(format string, v ...interface{}) {\n\twritef(INFO, format, v...)\n}\n\nfunc Warn(v ...interface{}) {\n\twrite(WARN, v...)\n}\n\nfunc Warnf(format string, v ...interface{}) {\n\twritef(WARN, format, v...)\n}\n\nfunc Error(v ...interface{}) {\n\twrite(ERROR, v...)\n}\n\nfunc Errorf(format string, v ...interface{}) {\n\twritef(ERROR, format, v...)\n}\n\nfunc Fatal(v ...interface{}) {\n\twrite(FATAL, v...)\n}\n\nfunc Fatalf(format string, v ...interface{}) {\n\twritef(FATAL, format, v...)\n}\n\nfunc write(level Level, v ...interface{}) {\n\tmsg := fmt.Sprint(v...)\n\targs := []any{}\n\n\tif gin.Mode() == gin.DebugMode {\n\t\tpc, file, line, ok := runtime.Caller(2)\n\t\tif ok {\n\t\t\targs = append(args, \"file\", file, \"func\", runtime.FuncForPC(pc).Name(), \"line\", line)\n\t\t}\n\t}\n\n\t// 使用异步写入\n\tif asyncLogWriter != nil {\n\t\tswitch level {\n\t\tcase DEBUG:\n\t\t\tasyncLogWriter.log(slog.LevelDebug, msg, args...)\n\t\tcase INFO:\n\t\t\tasyncLogWriter.log(slog.LevelInfo, msg, args...)\n\t\tcase WARN:\n\t\t\tasyncLogWriter.log(slog.LevelWarn, msg, args...)\n\t\tcase ERROR:\n\t\t\tasyncLogWriter.log(slog.LevelError, msg, args...)\n\t\tcase FATAL:\n\t\t\tasyncLogWriter.log(slog.LevelError, msg, args...)\n\t\t\tasyncLogWriter.close()\n\t\t\texitFunc(1)\n\t\t}\n\t\treturn\n\t}\n\n\t// 降级到同步写入\n\tswitch level {\n\tcase DEBUG:\n\t\tlogger.Debug(msg, args...)\n\tcase INFO:\n\t\tlogger.Info(msg, args...)\n\tcase WARN:\n\t\tlogger.Warn(msg, args...)\n\tcase FATAL:\n\t\tlogger.Error(msg, args...)\n\t\texitFunc(1)\n\tcase ERROR:\n\t\tlogger.Error(msg, args...)\n\t}\n}\n\nfunc writef(level Level, format string, v ...interface{}) {\n\tmsg := fmt.Sprintf(format, v...)\n\targs := []any{}\n\n\tif gin.Mode() == gin.DebugMode {\n\t\tpc, file, line, ok := runtime.Caller(2)\n\t\tif ok {\n\t\t\targs = append(args, \"file\", file, \"func\", runtime.FuncForPC(pc).Name(), \"line\", line)\n\t\t}\n\t}\n\n\t// 使用异步写入\n\tif asyncLogWriter != nil {\n\t\tswitch level {\n\t\tcase DEBUG:\n\t\t\tasyncLogWriter.log(slog.LevelDebug, msg, args...)\n\t\tcase INFO:\n\t\t\tasyncLogWriter.log(slog.LevelInfo, msg, args...)\n\t\tcase WARN:\n\t\t\tasyncLogWriter.log(slog.LevelWarn, msg, args...)\n\t\tcase ERROR:\n\t\t\tasyncLogWriter.log(slog.LevelError, msg, args...)\n\t\tcase FATAL:\n\t\t\tasyncLogWriter.log(slog.LevelError, msg, args...)\n\t\t\tasyncLogWriter.close()\n\t\t\texitFunc(1)\n\t\t}\n\t\treturn\n\t}\n\n\t// 降级到同步写入\n\tswitch level {\n\tcase DEBUG:\n\t\tlogger.Debug(msg, args...)\n\tcase INFO:\n\t\tlogger.Info(msg, args...)\n\tcase WARN:\n\t\tlogger.Warn(msg, args...)\n\tcase FATAL:\n\t\tlogger.Error(msg, args...)\n\t\texitFunc(1)\n\tcase ERROR:\n\t\tlogger.Error(msg, args...)\n\t}\n}\n"
  },
  {
    "path": "internal/modules/logger/logger_test.go",
    "content": "package logger\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"log/slog\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype logEntry struct {\n\tlevel slog.Level\n\tmsg   string\n}\n\ntype recordingHandler struct {\n\tentries []logEntry\n}\n\nfunc newRecordingHandler() *recordingHandler {\n\treturn &recordingHandler{}\n}\n\nfunc (r *recordingHandler) Enabled(_ context.Context, level slog.Level) bool {\n\treturn true\n}\n\nfunc (r *recordingHandler) Handle(_ context.Context, record slog.Record) error {\n\tr.entries = append(r.entries, logEntry{level: record.Level, msg: record.Message})\n\treturn nil\n}\n\nfunc (r *recordingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {\n\treturn r\n}\n\nfunc (r *recordingHandler) WithGroup(name string) slog.Handler {\n\treturn r\n}\n\nfunc setupRecordingLogger(t *testing.T) *recordingHandler {\n\tt.Helper()\n\tprevLogger := logger\n\trec := newRecordingHandler()\n\tlogger = slog.New(rec)\n\tt.Cleanup(func() { logger = prevLogger })\n\treturn rec\n}\n\nfunc TestDebugLoggingDependsOnGinMode(t *testing.T) {\n\tgin.SetMode(gin.ReleaseMode)\n\trec := setupRecordingLogger(t)\n\tDebug(\"release-mode\")\n\tif hasLevel(rec.entries, slog.LevelDebug) {\n\t\tt.Fatalf(\"expected no debug entry in release mode, got %+v\", rec.entries)\n\t}\n\n\tgin.SetMode(gin.DebugMode)\n\trec = setupRecordingLogger(t)\n\tDebug(\"debug-mode\")\n\tif !hasLevel(rec.entries, slog.LevelDebug) {\n\t\tt.Fatalf(\"expected debug entry in debug mode, got %+v\", rec.entries)\n\t}\n}\n\nfunc TestInfoLogsAndFlushes(t *testing.T) {\n\tgin.SetMode(gin.ReleaseMode)\n\trec := setupRecordingLogger(t)\n\tInfo(\"info-message\")\n\tif !hasLevel(rec.entries, slog.LevelInfo) {\n\t\tt.Fatalf(\"expected info entry, got %+v\", rec.entries)\n\t}\n}\n\nfunc TestFatalLogsAndInvokesExit(t *testing.T) {\n\tgin.SetMode(gin.ReleaseMode)\n\trec := setupRecordingLogger(t)\n\tprevExit := exitFunc\n\texitCalled := 0\n\texitCode := 0\n\texitFunc = func(code int) {\n\t\texitCalled++\n\t\texitCode = code\n\t}\n\tt.Cleanup(func() { exitFunc = prevExit })\n\n\tFatal(\"fatal-message\")\n\tif !hasLevel(rec.entries, slog.LevelError) {\n\t\tt.Fatalf(\"expected error entry, got %+v\", rec.entries)\n\t}\n\tif exitCalled != 1 || exitCode != 1 {\n\t\tt.Fatalf(\"expected exitFunc to be called once with code 1, got count=%d code=%d\", exitCalled, exitCode)\n\t}\n}\n\nfunc TestInitLogger(t *testing.T) {\n\tvar buf bytes.Buffer\n\thandler := slog.NewTextHandler(&buf, nil)\n\tlogger = slog.New(handler)\n\n\tInfo(\"test-message\")\n\tif buf.Len() == 0 {\n\t\tt.Fatal(\"expected log output\")\n\t}\n}\n\nfunc hasLevel(entries []logEntry, level slog.Level) bool {\n\tfor _, entry := range entries {\n\t\tif entry.level == level {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/modules/logger/performance_report_test.go",
    "content": "package logger\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\n// 性能对比报告\nfunc TestPerformanceReport(t *testing.T) {\n\tfmt.Println(\"\")\n\tfmt.Println(\"========================================\")\n\tfmt.Println(\"日志性能优化对比报告\")\n\tfmt.Println(\"========================================\")\n\tfmt.Println(\"\")\n\n\t// 测试1: 单线程顺序写入\n\tt.Run(\"1.单线程顺序写入1000条\", func(t *testing.T) {\n\t\tcount := 1000\n\n\t\t// 同步\n\t\tvar buf1 bytes.Buffer\n\t\thandler1 := slog.NewTextHandler(&buf1, nil)\n\t\tstart1 := time.Now()\n\t\tfor i := 0; i < count; i++ {\n\t\t\t_ = handler1.Handle(context.TODO(), slog.NewRecord(time.Now(), slog.LevelInfo, \"test\", 0))\n\t\t}\n\t\tsync1 := time.Since(start1)\n\n\t\t// 异步\n\t\tvar buf2 bytes.Buffer\n\t\thandler2 := newAsyncHandler(&buf2, 50, 100*time.Millisecond)\n\t\tstart2 := time.Now()\n\t\tfor i := 0; i < count; i++ {\n\t\t\thandler2.log(slog.LevelInfo, \"test\")\n\t\t}\n\t\thandler2.close()\n\t\tasync := time.Since(start2)\n\n\t\timprovement := float64(sync1-async) / float64(sync1) * 100\n\t\tfmt.Printf(\"  同步: %v\\n\", sync1)\n\t\tfmt.Printf(\"  异步: %v\\n\", async)\n\t\tfmt.Printf(\"  提升: %.1f%%\\n\\n\", improvement)\n\t})\n\n\t// 测试2: 高并发场景\n\tt.Run(\"2.100个goroutine并发写入\", func(t *testing.T) {\n\t\tgoroutines := 100\n\t\tlogsPerGoroutine := 100\n\n\t\t// 同步\n\t\tvar buf1 bytes.Buffer\n\t\thandler1 := slog.NewTextHandler(&buf1, nil)\n\t\tstart1 := time.Now()\n\t\tvar wg1 sync.WaitGroup\n\t\tfor i := 0; i < goroutines; i++ {\n\t\t\twg1.Add(1)\n\t\t\tgo func() {\n\t\t\t\tdefer wg1.Done()\n\t\t\t\tfor j := 0; j < logsPerGoroutine; j++ {\n\t\t\t\t\t_ = handler1.Handle(context.TODO(), slog.NewRecord(time.Now(), slog.LevelInfo, \"test\", 0))\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t\twg1.Wait()\n\t\tsync1 := time.Since(start1)\n\n\t\t// 异步\n\t\tvar buf2 bytes.Buffer\n\t\thandler2 := newAsyncHandler(&buf2, 50, 100*time.Millisecond)\n\t\tstart2 := time.Now()\n\t\tvar wg2 sync.WaitGroup\n\t\tfor i := 0; i < goroutines; i++ {\n\t\t\twg2.Add(1)\n\t\t\tgo func() {\n\t\t\t\tdefer wg2.Done()\n\t\t\t\tfor j := 0; j < logsPerGoroutine; j++ {\n\t\t\t\t\thandler2.log(slog.LevelInfo, \"test\")\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t\twg2.Wait()\n\t\thandler2.close()\n\t\tasync := time.Since(start2)\n\n\t\timprovement := float64(sync1-async) / float64(sync1) * 100\n\t\tfmt.Printf(\"  同步: %v\\n\", sync1)\n\t\tfmt.Printf(\"  异步: %v\\n\", async)\n\t\tfmt.Printf(\"  提升: %.1f%%\\n\\n\", improvement)\n\t})\n\n\t// 测试3: 模拟真实任务场景\n\tt.Run(\"3.模拟50个任务执行(每任务20条日志)\", func(t *testing.T) {\n\t\ttasks := 50\n\t\tlogsPerTask := 20\n\n\t\t// 同步\n\t\tvar buf1 bytes.Buffer\n\t\thandler1 := slog.NewTextHandler(&buf1, nil)\n\t\tstart1 := time.Now()\n\t\tvar wg1 sync.WaitGroup\n\t\tfor i := 0; i < tasks; i++ {\n\t\t\twg1.Add(1)\n\t\t\tgo func(taskID int) {\n\t\t\t\tdefer wg1.Done()\n\t\t\t\t// 模拟任务执行\n\t\t\t\tfor j := 0; j < logsPerTask; j++ {\n\t\t\t\t\t_ = handler1.Handle(context.TODO(), slog.NewRecord(time.Now(), slog.LevelInfo, fmt.Sprintf(\"Task %d executing step %d\", taskID, j), 0))\n\t\t\t\t\ttime.Sleep(10 * time.Microsecond) // 模拟任务处理\n\t\t\t\t}\n\t\t\t}(i)\n\t\t}\n\t\twg1.Wait()\n\t\tsync1 := time.Since(start1)\n\n\t\t// 异步\n\t\tvar buf2 bytes.Buffer\n\t\thandler2 := newAsyncHandler(&buf2, 50, 100*time.Millisecond)\n\t\tstart2 := time.Now()\n\t\tvar wg2 sync.WaitGroup\n\t\tfor i := 0; i < tasks; i++ {\n\t\t\twg2.Add(1)\n\t\t\tgo func(taskID int) {\n\t\t\t\tdefer wg2.Done()\n\t\t\t\t// 模拟任务执行\n\t\t\t\tfor j := 0; j < logsPerTask; j++ {\n\t\t\t\t\thandler2.log(slog.LevelInfo, fmt.Sprintf(\"Task %d executing step %d\", taskID, j))\n\t\t\t\t\ttime.Sleep(10 * time.Microsecond) // 模拟任务处理\n\t\t\t\t}\n\t\t\t}(i)\n\t\t}\n\t\twg2.Wait()\n\t\thandler2.close()\n\t\tasync := time.Since(start2)\n\n\t\timprovement := float64(sync1-async) / float64(sync1) * 100\n\t\tfmt.Printf(\"  同步: %v\\n\", sync1)\n\t\tfmt.Printf(\"  异步: %v\\n\", async)\n\t\tfmt.Printf(\"  提升: %.1f%%\\n\\n\", improvement)\n\t})\n\n\t// 测试4: 批量写入效率\n\tt.Run(\"4.批量写入效率测试\", func(t *testing.T) {\n\t\tcount := 5000\n\n\t\t// 同步 - 每次都写入\n\t\tvar buf1 bytes.Buffer\n\t\thandler1 := slog.NewTextHandler(&buf1, nil)\n\t\tstart1 := time.Now()\n\t\tfor i := 0; i < count; i++ {\n\t\t\t_ = handler1.Handle(context.TODO(), slog.NewRecord(time.Now(), slog.LevelInfo, \"test\", 0))\n\t\t}\n\t\tsync1 := time.Since(start1)\n\n\t\t// 异步 - 批量写入\n\t\tvar buf2 bytes.Buffer\n\t\thandler2 := newAsyncHandler(&buf2, 50, 100*time.Millisecond)\n\t\tstart2 := time.Now()\n\t\tfor i := 0; i < count; i++ {\n\t\t\thandler2.log(slog.LevelInfo, \"test\")\n\t\t}\n\t\thandler2.close()\n\t\tasync := time.Since(start2)\n\n\t\timprovement := float64(sync1-async) / float64(sync1) * 100\n\t\tfmt.Printf(\"  同步: %v (每次写入)\\n\", sync1)\n\t\tfmt.Printf(\"  异步: %v (批量写入)\\n\", async)\n\t\tfmt.Printf(\"  提升: %.1f%%\\n\\n\", improvement)\n\t})\n\n\tfmt.Println(\"========================================\")\n\tfmt.Println(\"优化总结:\")\n\tfmt.Println(\"1. 异步日志不阻塞业务逻辑\")\n\tfmt.Println(\"2. 批量写入减少I/O次数\")\n\tfmt.Println(\"3. 高并发场景性能提升明显\")\n\tfmt.Println(\"4. 内存开销可控(channel缓冲)\")\n\tfmt.Println(\"========================================\")\n\tfmt.Println(\"\")\n}\n"
  },
  {
    "path": "internal/modules/logger/performance_test.go",
    "content": "package logger\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"log/slog\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\n// 高并发场景测试\nfunc BenchmarkConcurrentSync(b *testing.B) {\n\tvar buf bytes.Buffer\n\thandler := slog.NewTextHandler(&buf, nil)\n\n\tb.ResetTimer()\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\t_ = handler.Handle(context.TODO(), slog.NewRecord(time.Now(), slog.LevelInfo, \"test\", 0))\n\t\t}\n\t})\n}\n\nfunc BenchmarkConcurrentAsync(b *testing.B) {\n\tvar buf bytes.Buffer\n\thandler := newAsyncHandler(&buf, 50, 100*time.Millisecond)\n\tdefer handler.close()\n\n\tb.ResetTimer()\n\tb.RunParallel(func(pb *testing.PB) {\n\t\tfor pb.Next() {\n\t\t\thandler.log(slog.LevelInfo, \"test\")\n\t\t}\n\t})\n}\n\n// 真实场景：模拟任务执行中的日志写入\nfunc TestRealWorldScenario(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\ttaskNum     int\n\t\tlogsPerTask int\n\t}{\n\t\t{\"10任务x10日志\", 10, 10},\n\t\t{\"100任务x10日志\", 100, 10},\n\t\t{\"100任务x100日志\", 100, 100},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name+\"-同步\", func(t *testing.T) {\n\t\t\tvar buf bytes.Buffer\n\t\t\thandler := slog.NewTextHandler(&buf, nil)\n\n\t\t\tstart := time.Now()\n\t\t\tvar wg sync.WaitGroup\n\t\t\tfor i := 0; i < tt.taskNum; i++ {\n\t\t\t\twg.Add(1)\n\t\t\t\tgo func() {\n\t\t\t\t\tdefer wg.Done()\n\t\t\t\t\tfor j := 0; j < tt.logsPerTask; j++ {\n\t\t\t\t\t\t_ = handler.Handle(context.TODO(), slog.NewRecord(time.Now(), slog.LevelInfo, \"task executing\", 0))\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t}\n\t\t\twg.Wait()\n\t\t\telapsed := time.Since(start)\n\t\t\tt.Logf(\"同步日志耗时: %v\", elapsed)\n\t\t})\n\n\t\tt.Run(tt.name+\"-异步\", func(t *testing.T) {\n\t\t\tvar buf bytes.Buffer\n\t\t\thandler := newAsyncHandler(&buf, 50, 100*time.Millisecond)\n\t\t\tdefer handler.close()\n\n\t\t\tstart := time.Now()\n\t\t\tvar wg sync.WaitGroup\n\t\t\tfor i := 0; i < tt.taskNum; i++ {\n\t\t\t\twg.Add(1)\n\t\t\t\tgo func() {\n\t\t\t\t\tdefer wg.Done()\n\t\t\t\t\tfor j := 0; j < tt.logsPerTask; j++ {\n\t\t\t\t\t\thandler.log(slog.LevelInfo, \"task executing\")\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t}\n\t\t\twg.Wait()\n\t\t\telapsed := time.Since(start)\n\t\t\tt.Logf(\"异步日志耗时: %v\", elapsed)\n\t\t})\n\t}\n}\n\n// 吞吐量测试\nfunc TestThroughput(t *testing.T) {\n\tduration := 1 * time.Second\n\n\tt.Run(\"同步吞吐量\", func(t *testing.T) {\n\t\tvar buf bytes.Buffer\n\t\thandler := slog.NewTextHandler(&buf, nil)\n\n\t\tcount := 0\n\t\tdone := make(chan bool)\n\n\t\tgo func() {\n\t\t\ttime.Sleep(duration)\n\t\t\tdone <- true\n\t\t}()\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-done:\n\t\t\t\tt.Logf(\"同步日志 1秒内写入: %d 条\", count)\n\t\t\t\treturn\n\t\t\tdefault:\n\t\t\t\t_ = handler.Handle(context.TODO(), slog.NewRecord(time.Now(), slog.LevelInfo, \"test\", 0))\n\t\t\t\tcount++\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"异步吞吐量\", func(t *testing.T) {\n\t\tvar buf bytes.Buffer\n\t\thandler := newAsyncHandler(&buf, 50, 100*time.Millisecond)\n\t\tdefer handler.close()\n\n\t\tcount := 0\n\t\tdone := make(chan bool)\n\n\t\tgo func() {\n\t\t\ttime.Sleep(duration)\n\t\t\tdone <- true\n\t\t}()\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-done:\n\t\t\t\tt.Logf(\"异步日志 1秒内写入: %d 条\", count)\n\t\t\t\treturn\n\t\t\tdefault:\n\t\t\t\thandler.log(slog.LevelInfo, \"test\")\n\t\t\t\tcount++\n\t\t\t}\n\t\t}\n\t})\n}\n\n// 测试写入真实文件的性能差异\nfunc BenchmarkRealFileSync(b *testing.B) {\n\twriter := io.Discard\n\thandler := slog.NewTextHandler(writer, nil)\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = handler.Handle(context.TODO(), slog.NewRecord(time.Now(), slog.LevelInfo, \"test message\", 0))\n\t}\n}\n\nfunc BenchmarkRealFileAsync(b *testing.B) {\n\twriter := io.Discard\n\thandler := newAsyncHandler(writer, 50, 100*time.Millisecond)\n\tdefer handler.close()\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\thandler.log(slog.LevelInfo, \"test message\")\n\t}\n}\n"
  },
  {
    "path": "internal/modules/notify/mail.go",
    "content": "package notify\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/go-gomail/gomail\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n)\n\n// @author qiang.ou<qingqianludao@gmail.com>\n// @date 2017/5/1-00:19\n\ntype Mail struct {\n}\n\nfunc (mail *Mail) Send(msg Message) {\n\tmodel := new(models.Setting)\n\tmailSetting, err := model.Mail()\n\tlogger.Debugf(\"%+v\", mailSetting)\n\tif err != nil {\n\t\tlogger.Error(\"#mail#从数据库获取mail配置失败\", err)\n\t\treturn\n\t}\n\tif mailSetting.Host == \"\" {\n\t\tlogger.Error(\"#mail#Host为空\")\n\t\treturn\n\t}\n\tif mailSetting.Port == 0 {\n\t\tlogger.Error(\"#mail#Port为空\")\n\t\treturn\n\t}\n\tif mailSetting.User == \"\" {\n\t\tlogger.Error(\"#mail#User为空\")\n\t\treturn\n\t}\n\tif mailSetting.Password == \"\" {\n\t\tlogger.Error(\"#mail#Password为空\")\n\t\treturn\n\t}\n\tmsg[\"content\"] = parseNotifyTemplate(mailSetting.Template, msg)\n\ttoUsers := mail.getActiveMailUsers(mailSetting, msg)\n\tmail.send(mailSetting, toUsers, msg)\n}\n\nfunc (mail *Mail) send(mailSetting models.Mail, toUsers []string, msg Message) {\n\tbody := msg[\"content\"].(string)\n\tbody = strings.Replace(body, \"\\n\", \"<br>\", -1)\n\tgomailMessage := gomail.NewMessage()\n\tgomailMessage.SetHeader(\"From\", mailSetting.User)\n\tgomailMessage.SetHeader(\"To\", toUsers...)\n\tgomailMessage.SetHeader(\"Subject\", \"gocron-定时任务通知\")\n\tgomailMessage.SetBody(\"text/html\", body)\n\tmailer := gomail.NewDialer(mailSetting.Host, mailSetting.Port,\n\t\tmailSetting.User, mailSetting.Password)\n\tmaxTimes := 3\n\ti := 0\n\tfor i < maxTimes {\n\t\terr := mailer.DialAndSend(gomailMessage)\n\t\tif err == nil {\n\t\t\tbreak\n\t\t}\n\t\ti += 1\n\t\ttime.Sleep(2 * time.Second)\n\t\tif i < maxTimes {\n\t\t\tlogger.Errorf(\"mail#发送消息失败#%s#消息内容-%s\", err.Error(), msg[\"content\"])\n\t\t}\n\t}\n}\n\nfunc (mail *Mail) getActiveMailUsers(mailSetting models.Mail, msg Message) []string {\n\ttaskReceiverIds := strings.Split(msg[\"task_receiver_id\"].(string), \",\")\n\tusers := []string{}\n\tfor _, v := range mailSetting.MailUsers {\n\t\tif utils.InStringSlice(taskReceiverIds, strconv.Itoa(v.Id)) {\n\t\t\tusers = append(users, v.Email)\n\t\t}\n\t}\n\n\treturn users\n}\n"
  },
  {
    "path": "internal/modules/notify/notify.go",
    "content": "package notify\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n)\n\ntype Message map[string]interface{}\n\ntype Notifiable interface {\n\tSend(msg Message)\n}\n\nvar queue = make(chan Message, 100)\n\nfunc init() {\n\tgo run()\n}\n\n// 把消息推入队列\nfunc Push(msg Message) {\n\tqueue <- msg\n}\n\nfunc run() {\n\tfor msg := range queue {\n\t\t// 根据任务配置发送通知\n\t\ttaskType, taskTypeOk := msg[\"task_type\"]\n\t\t_, taskReceiverIdOk := msg[\"task_receiver_id\"]\n\t\t_, nameOk := msg[\"name\"]\n\t\t_, outputOk := msg[\"output\"]\n\t\t_, statusOk := msg[\"status\"]\n\t\tif !taskTypeOk || !taskReceiverIdOk || !nameOk || !outputOk || !statusOk {\n\t\t\tlogger.Errorf(\"#notify#参数不完整#%+v\", msg)\n\t\t\tcontinue\n\t\t}\n\t\tmsg[\"content\"] = fmt.Sprintf(\"============\\n============\\n============\\n任务名称: %s\\n状态: %s\\n输出:\\n %s\\n\", msg[\"name\"], msg[\"status\"], msg[\"output\"])\n\t\tlogger.Debugf(\"%+v\", msg)\n\t\tswitch taskType.(int8) {\n\t\tcase 0:\n\t\t\t// 邮件\n\t\t\tmail := Mail{}\n\t\t\tgo mail.Send(msg)\n\t\tcase 1:\n\t\t\t// Slack\n\t\t\tslack := Slack{}\n\t\t\tgo slack.Send(msg)\n\t\tcase 2:\n\t\t\t// WebHook\n\t\t\twebHook := WebHook{}\n\t\t\tgo webHook.Send(msg)\n\t\t}\n\t\ttime.Sleep(1 * time.Second)\n\t}\n}\n\nfunc parseNotifyTemplate(notifyTemplate string, msg Message) string {\n\ttmpl, err := template.New(\"notify\").Parse(notifyTemplate)\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"解析通知模板失败: %s\", err)\n\t}\n\tvar buf bytes.Buffer\n\tif err := tmpl.Execute(&buf, map[string]interface{}{\n\t\t\"TaskId\":   msg[\"task_id\"],\n\t\t\"TaskName\": msg[\"name\"],\n\t\t\"Status\":   msg[\"status\"],\n\t\t\"Result\":   msg[\"output\"],\n\t\t\"Remark\":   msg[\"remark\"],\n\t}); err != nil {\n\t\treturn fmt.Sprintf(\"执行模板失败: %s\", err)\n\t}\n\n\treturn buf.String()\n}\n"
  },
  {
    "path": "internal/modules/notify/notify_test.go",
    "content": "package notify\n\nimport (\n\t\"testing\"\n)\n\n// TestNotifyDispatch 测试通知分发逻辑\nfunc TestNotifyDispatch(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tmsg         Message\n\t\texpectError bool\n\t\tdescription string\n\t}{\n\t\t{\n\t\t\tname: \"邮件通知-完整参数\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_type\":        int8(1),\n\t\t\t\t\"task_receiver_id\": \"1,2\",\n\t\t\t\t\"name\":             \"测试任务\",\n\t\t\t\t\"output\":           \"任务执行成功\",\n\t\t\t\t\"status\":           \"成功\",\n\t\t\t\t\"task_id\":          123,\n\t\t\t},\n\t\t\texpectError: false,\n\t\t\tdescription: \"邮件通知应该正常处理\",\n\t\t},\n\t\t{\n\t\t\tname: \"Slack通知-完整参数\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_type\":        int8(2),\n\t\t\t\t\"task_receiver_id\": \"1\",\n\t\t\t\t\"name\":             \"测试任务\",\n\t\t\t\t\"output\":           \"任务执行失败\",\n\t\t\t\t\"status\":           \"失败\",\n\t\t\t\t\"task_id\":          456,\n\t\t\t},\n\t\t\texpectError: false,\n\t\t\tdescription: \"Slack通知应该正常处理\",\n\t\t},\n\t\t{\n\t\t\tname: \"Webhook通知-完整参数\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_type\":        int8(3),\n\t\t\t\t\"task_receiver_id\": \"1,2,3\",\n\t\t\t\t\"name\":             \"测试任务\",\n\t\t\t\t\"output\":           \"任务执行成功\",\n\t\t\t\t\"status\":           \"成功\",\n\t\t\t\t\"task_id\":          789,\n\t\t\t},\n\t\t\texpectError: false,\n\t\t\tdescription: \"Webhook通知应该正常处理\",\n\t\t},\n\t\t{\n\t\t\tname: \"缺少task_type\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_receiver_id\": \"1\",\n\t\t\t\t\"name\":             \"测试任务\",\n\t\t\t\t\"output\":           \"输出\",\n\t\t\t\t\"status\":           \"成功\",\n\t\t\t},\n\t\t\texpectError: true,\n\t\t\tdescription: \"缺少task_type应该被拒绝\",\n\t\t},\n\t\t{\n\t\t\tname: \"缺少task_receiver_id\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_type\": int8(1),\n\t\t\t\t\"name\":      \"测试任务\",\n\t\t\t\t\"output\":    \"输出\",\n\t\t\t\t\"status\":    \"成功\",\n\t\t\t},\n\t\t\texpectError: true,\n\t\t\tdescription: \"缺少task_receiver_id应该被拒绝\",\n\t\t},\n\t\t{\n\t\t\tname: \"缺少name\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_type\":        int8(1),\n\t\t\t\t\"task_receiver_id\": \"1\",\n\t\t\t\t\"output\":           \"输出\",\n\t\t\t\t\"status\":           \"成功\",\n\t\t\t},\n\t\t\texpectError: true,\n\t\t\tdescription: \"缺少name应该被拒绝\",\n\t\t},\n\t\t{\n\t\t\tname: \"缺少output\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_type\":        int8(1),\n\t\t\t\t\"task_receiver_id\": \"1\",\n\t\t\t\t\"name\":             \"测试任务\",\n\t\t\t\t\"status\":           \"成功\",\n\t\t\t},\n\t\t\texpectError: true,\n\t\t\tdescription: \"缺少output应该被拒绝\",\n\t\t},\n\t\t{\n\t\t\tname: \"缺少status\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_type\":        int8(1),\n\t\t\t\t\"task_receiver_id\": \"1\",\n\t\t\t\t\"name\":             \"测试任务\",\n\t\t\t\t\"output\":           \"输出\",\n\t\t\t},\n\t\t\texpectError: true,\n\t\t\tdescription: \"缺少status应该被拒绝\",\n\t\t},\n\t\t{\n\t\t\tname: \"无效的task_type\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_type\":        int8(99),\n\t\t\t\t\"task_receiver_id\": \"1\",\n\t\t\t\t\"name\":             \"测试任务\",\n\t\t\t\t\"output\":           \"输出\",\n\t\t\t\t\"status\":           \"成功\",\n\t\t\t},\n\t\t\texpectError: false,\n\t\t\tdescription: \"无效的task_type会被忽略但不报错\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// 验证消息参数完整性\n\t\t\t_, taskTypeOk := tt.msg[\"task_type\"]\n\t\t\t_, taskReceiverIdOk := tt.msg[\"task_receiver_id\"]\n\t\t\t_, nameOk := tt.msg[\"name\"]\n\t\t\t_, outputOk := tt.msg[\"output\"]\n\t\t\t_, statusOk := tt.msg[\"status\"]\n\n\t\t\thasError := !taskTypeOk || !taskReceiverIdOk || !nameOk || !outputOk || !statusOk\n\n\t\t\tif hasError != tt.expectError {\n\t\t\t\tt.Errorf(\"%s: expected error=%v, got error=%v\", tt.description, tt.expectError, hasError)\n\t\t\t}\n\n\t\t\t// 验证task_type类型\n\t\t\tif taskTypeOk {\n\t\t\t\tif _, ok := tt.msg[\"task_type\"].(int8); !ok {\n\t\t\t\t\tt.Errorf(\"task_type should be int8\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestParseNotifyTemplate 测试通知模板解析\nfunc TestParseNotifyTemplate(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\ttemplate string\n\t\tmsg      Message\n\t\tcontains []string\n\t}{\n\t\t{\n\t\t\tname:     \"基础模板\",\n\t\t\ttemplate: \"任务: {{.TaskName}}, 状态: {{.Status}}\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_id\": 1,\n\t\t\t\t\"name\":    \"测试任务\",\n\t\t\t\t\"status\":  \"成功\",\n\t\t\t\t\"output\":  \"执行结果\",\n\t\t\t\t\"remark\":  \"备注\",\n\t\t\t},\n\t\t\tcontains: []string{\"测试任务\", \"成功\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"完整模板\",\n\t\t\ttemplate: \"任务ID: {{.TaskId}}\\n任务名称: {{.TaskName}}\\n状态: {{.Status}}\\n结果: {{.Result}}\\n备注: {{.Remark}}\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_id\": 123,\n\t\t\t\t\"name\":    \"定时任务\",\n\t\t\t\t\"status\":  \"失败\",\n\t\t\t\t\"output\":  \"错误信息\",\n\t\t\t\t\"remark\":  \"重要任务\",\n\t\t\t},\n\t\t\tcontains: []string{\"123\", \"定时任务\", \"失败\", \"错误信息\", \"重要任务\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"空模板\",\n\t\t\ttemplate: \"\",\n\t\t\tmsg: Message{\n\t\t\t\t\"task_id\": 1,\n\t\t\t\t\"name\":    \"任务\",\n\t\t\t\t\"status\":  \"成功\",\n\t\t\t\t\"output\":  \"输出\",\n\t\t\t\t\"remark\":  \"备注\",\n\t\t\t},\n\t\t\tcontains: []string{},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := parseNotifyTemplate(tt.template, tt.msg)\n\n\t\t\tfor _, expected := range tt.contains {\n\t\t\t\tif len(expected) > 0 && !contains(result, expected) {\n\t\t\t\t\tt.Errorf(\"expected result to contain '%s', got: %s\", expected, result)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestNotifyTypeValues 测试通知类型常量\nfunc TestNotifyTypeValues(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\ttypeVal  int8\n\t\ttypeName string\n\t}{\n\t\t{\"邮件通知\", 1, \"Mail\"},\n\t\t{\"Slack通知\", 2, \"Slack\"},\n\t\t{\"Webhook通知\", 3, \"Webhook\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmsg := Message{\n\t\t\t\t\"task_type\":        tt.typeVal,\n\t\t\t\t\"task_receiver_id\": \"1\",\n\t\t\t\t\"name\":             \"test\",\n\t\t\t\t\"output\":           \"output\",\n\t\t\t\t\"status\":           \"success\",\n\t\t\t}\n\n\t\t\ttaskType, ok := msg[\"task_type\"].(int8)\n\t\t\tif !ok {\n\t\t\t\tt.Errorf(\"task_type should be int8\")\n\t\t\t}\n\n\t\t\tif taskType != tt.typeVal {\n\t\t\t\tt.Errorf(\"expected task_type=%d, got %d\", tt.typeVal, taskType)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// 辅助函数：检查字符串是否包含子串\nfunc contains(s, substr string) bool {\n\treturn len(substr) == 0 || len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr))\n}\n\nfunc containsHelper(s, substr string) bool {\n\tfor i := 0; i <= len(s)-len(substr); i++ {\n\t\tif s[i:i+len(substr)] == substr {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/modules/notify/slack.go",
    "content": "package notify\n\n// 发送消息到slack\n\nimport (\n\t\"fmt\"\n\t\"html\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/httpclient\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n)\n\ntype Slack struct{}\n\nfunc (slack *Slack) Send(msg Message) {\n\tmodel := new(models.Setting)\n\tslackSetting, err := model.Slack()\n\tif err != nil {\n\t\tlogger.Error(\"#slack#从数据库获取slack配置失败\", err)\n\t\treturn\n\t}\n\tif slackSetting.Url == \"\" {\n\t\tlogger.Error(\"#slack#webhook-url为空\")\n\t\treturn\n\t}\n\tif len(slackSetting.Channels) == 0 {\n\t\tlogger.Error(\"#slack#channels配置为空\")\n\t\treturn\n\t}\n\tlogger.Debugf(\"%+v\", slackSetting)\n\tchannels := slack.getActiveSlackChannels(slackSetting, msg)\n\tlogger.Debugf(\"%+v\", channels)\n\tmsg[\"content\"] = parseNotifyTemplate(slackSetting.Template, msg)\n\tmsg[\"content\"] = html.UnescapeString(msg[\"content\"].(string))\n\tfor _, channel := range channels {\n\t\tslack.send(msg, slackSetting.Url, channel)\n\t}\n}\n\nfunc (slack *Slack) send(msg Message, slackUrl string, channel string) {\n\tformatBody := slack.format(msg[\"content\"].(string), channel)\n\ttimeout := 30\n\tmaxTimes := 3\n\ti := 0\n\tfor i < maxTimes {\n\t\tresp := httpclient.PostJson(slackUrl, formatBody, timeout)\n\t\tif resp.StatusCode == 200 {\n\t\t\tbreak\n\t\t}\n\t\ti += 1\n\t\ttime.Sleep(2 * time.Second)\n\t\tif i < maxTimes {\n\t\t\tlogger.Errorf(\"slack#发送消息失败#%s#消息内容-%s\", resp.Body, msg[\"content\"])\n\t\t}\n\t}\n}\n\nfunc (slack *Slack) getActiveSlackChannels(slackSetting models.Slack, msg Message) []string {\n\ttaskReceiverIds := strings.Split(msg[\"task_receiver_id\"].(string), \",\")\n\tchannels := []string{}\n\tfor _, v := range slackSetting.Channels {\n\t\tif utils.InStringSlice(taskReceiverIds, strconv.Itoa(v.Id)) {\n\t\t\tchannels = append(channels, v.Name)\n\t\t}\n\t}\n\n\treturn channels\n}\n\n// 格式化消息内容\nfunc (slack *Slack) format(content string, channel string) string {\n\tcontent = utils.EscapeJson(content)\n\tspecialChars := []string{\"&\", \"<\", \">\"}\n\treplaceChars := []string{\"&amp;\", \"&lt;\", \"&gt;\"}\n\tcontent = utils.ReplaceStrings(content, specialChars, replaceChars)\n\n\treturn fmt.Sprintf(`{\"text\":\"%s\",\"username\":\"gocron\", \"channel\":\"%s\"}`, content, channel)\n}\n"
  },
  {
    "path": "internal/modules/notify/webhook.go",
    "content": "package notify\n\nimport (\n\t\"html\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/httpclient\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n)\n\ntype WebHook struct{}\n\nfunc (webHook *WebHook) Send(msg Message) {\n\tmodel := new(models.Setting)\n\twebHookSetting, err := model.Webhook()\n\tif err != nil {\n\t\tlogger.Error(\"#webHook#从数据库获取webHook配置失败\", err)\n\t\treturn\n\t}\n\tif len(webHookSetting.WebhookUrls) == 0 {\n\t\tlogger.Error(\"#webHook#webhook地址列表为空\")\n\t\treturn\n\t}\n\tlogger.Debugf(\"%+v\", webHookSetting)\n\tmsg[\"name\"] = utils.EscapeJson(msg[\"name\"].(string))\n\tmsg[\"output\"] = utils.EscapeJson(msg[\"output\"].(string))\n\tmsg[\"content\"] = parseNotifyTemplate(webHookSetting.Template, msg)\n\tmsg[\"content\"] = html.UnescapeString(msg[\"content\"].(string))\n\n\t// 获取任务配置的接收者ID列表\n\tactiveUrls := webHook.getActiveWebhookUrls(webHookSetting, msg)\n\n\t// 向所有激活的webhook地址发送\n\tfor _, webhookUrl := range activeUrls {\n\t\tgo webHook.send(msg, webhookUrl.Url)\n\t}\n}\n\nfunc (webHook *WebHook) getActiveWebhookUrls(webHookSetting models.WebHook, msg Message) []models.WebhookUrl {\n\ttaskReceiverIds := strings.Split(msg[\"task_receiver_id\"].(string), \",\")\n\turls := []models.WebhookUrl{}\n\tfor _, v := range webHookSetting.WebhookUrls {\n\t\tif utils.InStringSlice(taskReceiverIds, strconv.Itoa(v.Id)) {\n\t\t\turls = append(urls, v)\n\t\t}\n\t}\n\treturn urls\n}\n\nfunc (webHook *WebHook) send(msg Message, url string) {\n\tcontent := msg[\"content\"].(string)\n\ttimeout := 30\n\tmaxTimes := 3\n\ti := 0\n\tfor i < maxTimes {\n\t\tresp := httpclient.PostJson(url, content, timeout)\n\t\tif resp.StatusCode == 200 {\n\t\t\tbreak\n\t\t}\n\t\ti += 1\n\t\ttime.Sleep(2 * time.Second)\n\t\tif i < maxTimes {\n\t\t\tlogger.Errorf(\"webHook#发送消息失败#%s#消息内容-%s\", resp.Body, msg[\"content\"])\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/modules/notify/webhook_test.go",
    "content": "package notify\n\nimport (\n\t\"testing\"\n\n\t\"github.com/gocronx-team/gocron/internal/models\"\n)\n\n// TestWebHook_getActiveWebhookUrls 测试根据任务接收者ID筛选webhook地址\nfunc TestWebHook_getActiveWebhookUrls(t *testing.T) {\n\twebHook := &WebHook{}\n\n\ttests := []struct {\n\t\tname             string\n\t\twebhookUrls      []models.WebhookUrl\n\t\ttaskReceiverIds  string\n\t\texpectedCount    int\n\t\texpectedUrlNames []string\n\t}{\n\t\t{\n\t\t\tname: \"single receiver\",\n\t\t\twebhookUrls: []models.WebhookUrl{\n\t\t\t\t{Id: 1, Name: \"Webhook 1\", Url: \"https://webhook1.example.com\"},\n\t\t\t\t{Id: 2, Name: \"Webhook 2\", Url: \"https://webhook2.example.com\"},\n\t\t\t\t{Id: 3, Name: \"Webhook 3\", Url: \"https://webhook3.example.com\"},\n\t\t\t},\n\t\t\ttaskReceiverIds:  \"2\",\n\t\t\texpectedCount:    1,\n\t\t\texpectedUrlNames: []string{\"Webhook 2\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple receivers\",\n\t\t\twebhookUrls: []models.WebhookUrl{\n\t\t\t\t{Id: 1, Name: \"Webhook 1\", Url: \"https://webhook1.example.com\"},\n\t\t\t\t{Id: 2, Name: \"Webhook 2\", Url: \"https://webhook2.example.com\"},\n\t\t\t\t{Id: 3, Name: \"Webhook 3\", Url: \"https://webhook3.example.com\"},\n\t\t\t},\n\t\t\ttaskReceiverIds:  \"1,3\",\n\t\t\texpectedCount:    2,\n\t\t\texpectedUrlNames: []string{\"Webhook 1\", \"Webhook 3\"},\n\t\t},\n\t\t{\n\t\t\tname: \"no matching receivers\",\n\t\t\twebhookUrls: []models.WebhookUrl{\n\t\t\t\t{Id: 1, Name: \"Webhook 1\", Url: \"https://webhook1.example.com\"},\n\t\t\t\t{Id: 2, Name: \"Webhook 2\", Url: \"https://webhook2.example.com\"},\n\t\t\t},\n\t\t\ttaskReceiverIds:  \"99\",\n\t\t\texpectedCount:    0,\n\t\t\texpectedUrlNames: []string{},\n\t\t},\n\t\t{\n\t\t\tname: \"empty receiver ids\",\n\t\t\twebhookUrls: []models.WebhookUrl{\n\t\t\t\t{Id: 1, Name: \"Webhook 1\", Url: \"https://webhook1.example.com\"},\n\t\t\t},\n\t\t\ttaskReceiverIds:  \"\",\n\t\t\texpectedCount:    0,\n\t\t\texpectedUrlNames: []string{},\n\t\t},\n\t\t{\n\t\t\tname: \"all receivers\",\n\t\t\twebhookUrls: []models.WebhookUrl{\n\t\t\t\t{Id: 1, Name: \"Webhook 1\", Url: \"https://webhook1.example.com\"},\n\t\t\t\t{Id: 2, Name: \"Webhook 2\", Url: \"https://webhook2.example.com\"},\n\t\t\t},\n\t\t\ttaskReceiverIds:  \"1,2\",\n\t\t\texpectedCount:    2,\n\t\t\texpectedUrlNames: []string{\"Webhook 1\", \"Webhook 2\"},\n\t\t},\n\t\t{\n\t\t\tname: \"receiver ids with spaces\",\n\t\t\twebhookUrls: []models.WebhookUrl{\n\t\t\t\t{Id: 1, Name: \"Webhook 1\", Url: \"https://webhook1.example.com\"},\n\t\t\t\t{Id: 2, Name: \"Webhook 2\", Url: \"https://webhook2.example.com\"},\n\t\t\t},\n\t\t\ttaskReceiverIds:  \" 1 , 2 \",\n\t\t\texpectedCount:    2,\n\t\t\texpectedUrlNames: []string{\"Webhook 1\", \"Webhook 2\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\twebHookSetting := models.WebHook{\n\t\t\t\tWebhookUrls: tt.webhookUrls,\n\t\t\t}\n\n\t\t\tmsg := Message{\n\t\t\t\t\"task_receiver_id\": tt.taskReceiverIds,\n\t\t\t}\n\n\t\t\tactiveUrls := webHook.getActiveWebhookUrls(webHookSetting, msg)\n\n\t\t\tif len(activeUrls) != tt.expectedCount {\n\t\t\t\tt.Errorf(\"expected %d active urls, got %d\", tt.expectedCount, len(activeUrls))\n\t\t\t}\n\n\t\t\t// 验证返回的webhook名称\n\t\t\tfoundNames := make(map[string]bool)\n\t\t\tfor _, url := range activeUrls {\n\t\t\t\tfoundNames[url.Name] = true\n\t\t\t}\n\n\t\t\tfor _, expectedName := range tt.expectedUrlNames {\n\t\t\t\tif !foundNames[expectedName] {\n\t\t\t\t\tt.Errorf(\"expected to find webhook %s, but not found\", expectedName)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestWebHook_getActiveWebhookUrls_EdgeCases 测试边界情况\nfunc TestWebHook_getActiveWebhookUrls_EdgeCases(t *testing.T) {\n\twebHook := &WebHook{}\n\n\tt.Run(\"empty webhook urls\", func(t *testing.T) {\n\t\twebHookSetting := models.WebHook{\n\t\t\tWebhookUrls: []models.WebhookUrl{},\n\t\t}\n\n\t\tmsg := Message{\n\t\t\t\"task_receiver_id\": \"1,2,3\",\n\t\t}\n\n\t\tactiveUrls := webHook.getActiveWebhookUrls(webHookSetting, msg)\n\n\t\tif len(activeUrls) != 0 {\n\t\t\tt.Errorf(\"expected 0 active urls, got %d\", len(activeUrls))\n\t\t}\n\t})\n\n\tt.Run(\"invalid receiver id format\", func(t *testing.T) {\n\t\twebHookSetting := models.WebHook{\n\t\t\tWebhookUrls: []models.WebhookUrl{\n\t\t\t\t{Id: 1, Name: \"Webhook 1\", Url: \"https://webhook1.example.com\"},\n\t\t\t},\n\t\t}\n\n\t\tmsg := Message{\n\t\t\t\"task_receiver_id\": \"abc,def\",\n\t\t}\n\n\t\tactiveUrls := webHook.getActiveWebhookUrls(webHookSetting, msg)\n\n\t\tif len(activeUrls) != 0 {\n\t\t\tt.Errorf(\"expected 0 active urls for invalid ids, got %d\", len(activeUrls))\n\t\t}\n\t})\n\n\tt.Run(\"duplicate receiver ids\", func(t *testing.T) {\n\t\twebHookSetting := models.WebHook{\n\t\t\tWebhookUrls: []models.WebhookUrl{\n\t\t\t\t{Id: 1, Name: \"Webhook 1\", Url: \"https://webhook1.example.com\"},\n\t\t\t},\n\t\t}\n\n\t\tmsg := Message{\n\t\t\t\"task_receiver_id\": \"1,1,1\",\n\t\t}\n\n\t\tactiveUrls := webHook.getActiveWebhookUrls(webHookSetting, msg)\n\n\t\t// 实际实现：遍历webhookUrls，对每个URL检查其ID是否在receiverIds中\n\t\t// 所以即使receiverIds有重复，每个webhook也只会被添加一次\n\t\tif len(activeUrls) != 1 {\n\t\t\tt.Errorf(\"expected 1 active url (no duplicates in result), got %d\", len(activeUrls))\n\t\t}\n\t})\n}\n\n// TestWebHook_getActiveWebhookUrls_LargeDataset 测试大数据集\nfunc TestWebHook_getActiveWebhookUrls_LargeDataset(t *testing.T) {\n\twebHook := &WebHook{}\n\n\t// 创建100个webhook地址\n\twebhookUrls := make([]models.WebhookUrl, 100)\n\tfor i := 0; i < 100; i++ {\n\t\twebhookUrls[i] = models.WebhookUrl{\n\t\t\tId:   i + 1,\n\t\t\tName: \"Webhook \" + string(rune(i+1)),\n\t\t\tUrl:  \"https://webhook.example.com/\" + string(rune(i+1)),\n\t\t}\n\t}\n\n\twebHookSetting := models.WebHook{\n\t\tWebhookUrls: webhookUrls,\n\t}\n\n\t// 选择前50个\n\treceiverIds := \"\"\n\tfor i := 1; i <= 50; i++ {\n\t\tif i > 1 {\n\t\t\treceiverIds += \",\"\n\t\t}\n\t\treceiverIds += string(rune(i + '0'))\n\t}\n\n\tmsg := Message{\n\t\t\"task_receiver_id\": receiverIds,\n\t}\n\n\tactiveUrls := webHook.getActiveWebhookUrls(webHookSetting, msg)\n\n\tif len(activeUrls) == 0 {\n\t\tt.Error(\"expected some active urls, got 0\")\n\t}\n}\n\n// BenchmarkWebHook_getActiveWebhookUrls 性能测试\nfunc BenchmarkWebHook_getActiveWebhookUrls(b *testing.B) {\n\twebHook := &WebHook{}\n\n\twebhookUrls := make([]models.WebhookUrl, 10)\n\tfor i := 0; i < 10; i++ {\n\t\twebhookUrls[i] = models.WebhookUrl{\n\t\t\tId:   i + 1,\n\t\t\tName: \"Webhook\",\n\t\t\tUrl:  \"https://example.com\",\n\t\t}\n\t}\n\n\twebHookSetting := models.WebHook{\n\t\tWebhookUrls: webhookUrls,\n\t}\n\n\tmsg := Message{\n\t\t\"task_receiver_id\": \"1,3,5,7,9\",\n\t}\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = webHook.getActiveWebhookUrls(webHookSetting, msg)\n\t}\n}\n\n// TestMessage_Type 测试Message类型\nfunc TestMessage_Type(t *testing.T) {\n\tmsg := Message{\n\t\t\"task_id\":          123,\n\t\t\"name\":             \"test task\",\n\t\t\"output\":           \"test output\",\n\t\t\"status\":           \"success\",\n\t\t\"task_receiver_id\": \"1,2,3\",\n\t}\n\n\t// 验证可以正确获取值\n\tif taskId, ok := msg[\"task_id\"].(int); !ok || taskId != 123 {\n\t\tt.Error(\"failed to get task_id\")\n\t}\n\n\tif name, ok := msg[\"name\"].(string); !ok || name != \"test task\" {\n\t\tt.Error(\"failed to get name\")\n\t}\n\n\tif receiverId, ok := msg[\"task_receiver_id\"].(string); !ok || receiverId != \"1,2,3\" {\n\t\tt.Error(\"failed to get task_receiver_id\")\n\t}\n}\n"
  },
  {
    "path": "internal/modules/rpc/auth/Certification.go",
    "content": "package auth\n\nimport (\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"google.golang.org/grpc/credentials\"\n)\n\ntype Certificate struct {\n\tCAFile     string\n\tCertFile   string\n\tKeyFile    string\n\tServerName string\n}\n\nfunc (c Certificate) GetTLSConfigForServer() (*tls.Config, error) {\n\tcertificate, err := tls.LoadX509KeyPair(\n\t\tc.CertFile,\n\t\tc.KeyFile,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcertPool := x509.NewCertPool()\n\tbs, err := os.ReadFile(c.CAFile)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read client ca cert: %s\", err)\n\t}\n\n\tok := certPool.AppendCertsFromPEM(bs)\n\tif !ok {\n\t\treturn nil, errors.New(\"failed to append client certs\")\n\t}\n\n\ttlsConfig := &tls.Config{\n\t\tClientAuth:   tls.RequireAndVerifyClientCert,\n\t\tCertificates: []tls.Certificate{certificate},\n\t\tClientCAs:    certPool,\n\t}\n\n\treturn tlsConfig, nil\n}\n\nfunc (c Certificate) GetTransportCredsForClient() (credentials.TransportCredentials, error) {\n\tcertificate, err := tls.LoadX509KeyPair(\n\t\tc.CertFile,\n\t\tc.KeyFile,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tcertPool := x509.NewCertPool()\n\tbs, err := os.ReadFile(c.CAFile)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read ca cert: %s\", err)\n\t}\n\n\tok := certPool.AppendCertsFromPEM(bs)\n\tif !ok {\n\t\treturn nil, errors.New(\"failed to append certs\")\n\t}\n\n\ttransportCreds := credentials.NewTLS(&tls.Config{\n\t\tServerName:   c.ServerName,\n\t\tCertificates: []tls.Certificate{certificate},\n\t\tRootCAs:      certPool,\n\t})\n\n\treturn transportCreds, nil\n}\n"
  },
  {
    "path": "internal/modules/rpc/client/client.go",
    "content": "package client\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"google.golang.org/grpc/status\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/i18n\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/rpc/grpcpool\"\n\tpb \"github.com/gocronx-team/gocron/internal/modules/rpc/proto\"\n\t\"google.golang.org/grpc/codes\"\n)\n\nvar (\n\ttaskCtxMap     sync.Map // 存储任务执行的 context.CancelFunc\n\terrUnavailable = errors.New(i18n.Translate(\"rpc_unavailable\"))\n\tErrManualStop  = errors.New(\"rpc_manual_stop\") // 特殊错误标识，用于判断是否手动停止\n)\n\nfunc generateTaskUniqueKey(ip string, port int, id int64) string {\n\treturn fmt.Sprintf(\"%s:%d:%d\", ip, port, id)\n}\n\nfunc Stop(ip string, port int, id int64) {\n\t// 异步发送停止信号，不阻塞调用者\n\tgo func() {\n\t\taddr := fmt.Sprintf(\"%s:%d\", ip, port)\n\t\tc, err := grpcpool.Pool.Get(addr)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"连接服务器失败#%s#%v\", addr, err)\n\t\t\treturn\n\t\t}\n\n\t\tctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)\n\t\tdefer cancel()\n\n\t\t_, err = c.Run(ctx, &pb.TaskRequest{\n\t\t\tCommand: \"__STOP__\",\n\t\t\tId:      id,\n\t\t})\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"发送停止信号失败#%v\", err)\n\t\t}\n\t}()\n}\n\nfunc Exec(ip string, port int, taskReq *pb.TaskRequest) (string, error) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.Error(\"panic#rpc/client.go:Exec#\", err)\n\t\t}\n\t}()\n\taddr := fmt.Sprintf(\"%s:%d\", ip, port)\n\tc, err := grpcpool.Pool.Get(addr)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif taskReq.Timeout <= 0 || taskReq.Timeout > 86400 {\n\t\ttaskReq.Timeout = 86400\n\t}\n\ttimeout := time.Duration(taskReq.Timeout) * time.Second\n\t// RPC context: 比任务超时多5秒，给服务端时间清理进程并返回输出\n\tctx, cancel := context.WithTimeout(context.Background(), timeout+5*time.Second)\n\tdefer cancel()\n\n\ttaskUniqueKey := generateTaskUniqueKey(ip, port, taskReq.Id)\n\ttaskCtxMap.Store(taskUniqueKey, cancel)\n\tdefer taskCtxMap.Delete(taskUniqueKey)\n\n\tresp, err := c.Run(ctx, taskReq)\n\n\t// 处理响应：即使有错误，也要返回已产生的输出\n\tif err != nil {\n\t\tif resp != nil && resp.Output != \"\" {\n\t\t\treturn resp.Output, parseGRPCErrorOnly(err)\n\t\t}\n\t\treturn parseGRPCError(err)\n\t}\n\n\tif resp.Error == \"\" {\n\t\treturn resp.Output, nil\n\t}\n\n\t// 检查是否是手动停止\n\tif resp.Error == \"manual stop\" {\n\t\treturn resp.Output, ErrManualStop\n\t}\n\n\treturn resp.Output, errors.New(resp.Error)\n}\n\nfunc parseGRPCError(err error) (string, error) {\n\tswitch status.Code(err) {\n\tcase codes.Unavailable:\n\t\treturn \"\", errUnavailable\n\tcase codes.DeadlineExceeded:\n\t\treturn \"\", errors.New(i18n.Translate(\"rpc_timeout\"))\n\tcase codes.Canceled:\n\t\treturn \"\", ErrManualStop\n\t}\n\treturn \"\", err\n}\n\n// parseGRPCErrorOnly 只返回错误，不返回输出\nfunc parseGRPCErrorOnly(err error) error {\n\tswitch status.Code(err) {\n\tcase codes.Unavailable:\n\t\treturn errUnavailable\n\tcase codes.DeadlineExceeded:\n\t\treturn errors.New(i18n.Translate(\"rpc_timeout\"))\n\tcase codes.Canceled:\n\t\treturn ErrManualStop\n\t}\n\treturn err\n}\n"
  },
  {
    "path": "internal/modules/rpc/grpcpool/grpc_pool.go",
    "content": "package grpcpool\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/app\"\n\t\"github.com/gocronx-team/gocron/internal/modules/rpc/auth\"\n\tpb \"github.com/gocronx-team/gocron/internal/modules/rpc/proto\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/backoff\"\n\t\"google.golang.org/grpc/credentials/insecure\"\n\t\"google.golang.org/grpc/keepalive\"\n)\n\nconst (\n\tbackOffMaxDelay = 3 * time.Second\n\tdialTimeout     = 2 * time.Second\n)\n\nvar (\n\tPool = &GRPCPool{\n\t\tconns: make(map[string]*Client),\n\t}\n\n\tkeepAliveParams = keepalive.ClientParameters{\n\t\tTime:                20 * time.Second,\n\t\tTimeout:             3 * time.Second,\n\t\tPermitWithoutStream: true,\n\t}\n)\n\ntype Client struct {\n\tconn      *grpc.ClientConn\n\trpcClient pb.TaskClient\n}\n\ntype GRPCPool struct {\n\t// map key格式 ip:port\n\tconns map[string]*Client\n\tmu    sync.RWMutex\n}\n\nfunc (p *GRPCPool) Get(addr string) (pb.TaskClient, error) {\n\tp.mu.RLock()\n\tclient, ok := p.conns[addr]\n\tp.mu.RUnlock()\n\tif ok {\n\t\treturn client.rpcClient, nil\n\t}\n\n\tclient, err := p.factory(addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn client.rpcClient, nil\n}\n\n// 释放连接\nfunc (p *GRPCPool) Release(addr string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tclient, ok := p.conns[addr]\n\tif !ok {\n\t\treturn\n\t}\n\tdelete(p.conns, addr)\n\tclient.conn.Close()\n}\n\n// 创建连接\nfunc (p *GRPCPool) factory(addr string) (*Client, error) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\n\tclient, ok := p.conns[addr]\n\tif ok {\n\t\treturn client, nil\n\t}\n\topts := []grpc.DialOption{\n\t\tgrpc.WithKeepaliveParams(keepAliveParams),\n\t\tgrpc.WithConnectParams(grpc.ConnectParams{\n\t\t\tBackoff:           backoff.Config{MaxDelay: backOffMaxDelay},\n\t\t\tMinConnectTimeout: dialTimeout,\n\t\t}),\n\t}\n\n\tif !app.Setting.EnableTLS {\n\t\topts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))\n\t} else {\n\t\tserver := strings.Split(addr, \":\")\n\t\tcertificate := auth.Certificate{\n\t\t\tCAFile:     app.Setting.CAFile,\n\t\t\tCertFile:   app.Setting.CertFile,\n\t\t\tKeyFile:    app.Setting.KeyFile,\n\t\t\tServerName: server[0],\n\t\t}\n\n\t\ttransportCreds, err := certificate.GetTransportCredsForClient()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\topts = append(opts, grpc.WithTransportCredentials(transportCreds))\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), dialTimeout)\n\tdefer cancel()\n\n\tconn, err := grpc.DialContext(ctx, addr, opts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tclient = &Client{\n\t\tconn:      conn,\n\t\trpcClient: pb.NewTaskClient(conn),\n\t}\n\n\tp.conns[addr] = client\n\n\treturn client, nil\n}\n"
  },
  {
    "path": "internal/modules/rpc/proto/task.pb.go",
    "content": "// Code generated by protoc-gen-go. DO NOT EDIT.\n// versions:\n// \tprotoc-gen-go v1.36.11\n// \tprotoc        v5.29.3\n// source: task.proto\n\npackage proto\n\nimport (\n\tprotoreflect \"google.golang.org/protobuf/reflect/protoreflect\"\n\tprotoimpl \"google.golang.org/protobuf/runtime/protoimpl\"\n\treflect \"reflect\"\n\tsync \"sync\"\n\tunsafe \"unsafe\"\n)\n\nconst (\n\t// Verify that this generated code is sufficiently up-to-date.\n\t_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)\n\t// Verify that runtime/protoimpl is sufficiently up-to-date.\n\t_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)\n)\n\ntype TaskRequest struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tCommand       string                 `protobuf:\"bytes,2,opt,name=command,proto3\" json:\"command,omitempty\"`  // 命令\n\tTimeout       int32                  `protobuf:\"varint,3,opt,name=timeout,proto3\" json:\"timeout,omitempty\"` // 任务执行超时时间\n\tId            int64                  `protobuf:\"varint,4,opt,name=id,proto3\" json:\"id,omitempty\"`           // 执行任务唯一ID\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *TaskRequest) Reset() {\n\t*x = TaskRequest{}\n\tmi := &file_task_proto_msgTypes[0]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *TaskRequest) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*TaskRequest) ProtoMessage() {}\n\nfunc (x *TaskRequest) ProtoReflect() protoreflect.Message {\n\tmi := &file_task_proto_msgTypes[0]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use TaskRequest.ProtoReflect.Descriptor instead.\nfunc (*TaskRequest) Descriptor() ([]byte, []int) {\n\treturn file_task_proto_rawDescGZIP(), []int{0}\n}\n\nfunc (x *TaskRequest) GetCommand() string {\n\tif x != nil {\n\t\treturn x.Command\n\t}\n\treturn \"\"\n}\n\nfunc (x *TaskRequest) GetTimeout() int32 {\n\tif x != nil {\n\t\treturn x.Timeout\n\t}\n\treturn 0\n}\n\nfunc (x *TaskRequest) GetId() int64 {\n\tif x != nil {\n\t\treturn x.Id\n\t}\n\treturn 0\n}\n\ntype TaskResponse struct {\n\tstate         protoimpl.MessageState `protogen:\"open.v1\"`\n\tOutput        string                 `protobuf:\"bytes,1,opt,name=output,proto3\" json:\"output,omitempty\"` // 命令标准输出\n\tError         string                 `protobuf:\"bytes,2,opt,name=error,proto3\" json:\"error,omitempty\"`   // 命令错误\n\tunknownFields protoimpl.UnknownFields\n\tsizeCache     protoimpl.SizeCache\n}\n\nfunc (x *TaskResponse) Reset() {\n\t*x = TaskResponse{}\n\tmi := &file_task_proto_msgTypes[1]\n\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\tms.StoreMessageInfo(mi)\n}\n\nfunc (x *TaskResponse) String() string {\n\treturn protoimpl.X.MessageStringOf(x)\n}\n\nfunc (*TaskResponse) ProtoMessage() {}\n\nfunc (x *TaskResponse) ProtoReflect() protoreflect.Message {\n\tmi := &file_task_proto_msgTypes[1]\n\tif x != nil {\n\t\tms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))\n\t\tif ms.LoadMessageInfo() == nil {\n\t\t\tms.StoreMessageInfo(mi)\n\t\t}\n\t\treturn ms\n\t}\n\treturn mi.MessageOf(x)\n}\n\n// Deprecated: Use TaskResponse.ProtoReflect.Descriptor instead.\nfunc (*TaskResponse) Descriptor() ([]byte, []int) {\n\treturn file_task_proto_rawDescGZIP(), []int{1}\n}\n\nfunc (x *TaskResponse) GetOutput() string {\n\tif x != nil {\n\t\treturn x.Output\n\t}\n\treturn \"\"\n}\n\nfunc (x *TaskResponse) GetError() string {\n\tif x != nil {\n\t\treturn x.Error\n\t}\n\treturn \"\"\n}\n\nvar File_task_proto protoreflect.FileDescriptor\n\nconst file_task_proto_rawDesc = \"\" +\n\t\"\\n\" +\n\t\"\\n\" +\n\t\"task.proto\\x12\\x03rpc\\\"Q\\n\" +\n\t\"\\vTaskRequest\\x12\\x18\\n\" +\n\t\"\\acommand\\x18\\x02 \\x01(\\tR\\acommand\\x12\\x18\\n\" +\n\t\"\\atimeout\\x18\\x03 \\x01(\\x05R\\atimeout\\x12\\x0e\\n\" +\n\t\"\\x02id\\x18\\x04 \\x01(\\x03R\\x02id\\\"<\\n\" +\n\t\"\\fTaskResponse\\x12\\x16\\n\" +\n\t\"\\x06output\\x18\\x01 \\x01(\\tR\\x06output\\x12\\x14\\n\" +\n\t\"\\x05error\\x18\\x02 \\x01(\\tR\\x05error24\\n\" +\n\t\"\\x04Task\\x12,\\n\" +\n\t\"\\x03Run\\x12\\x10.rpc.TaskRequest\\x1a\\x11.rpc.TaskResponse\\\"\\x00B;Z9github.com/gocronx-team/gocron/internal/modules/rpc/protob\\x06proto3\"\n\nvar (\n\tfile_task_proto_rawDescOnce sync.Once\n\tfile_task_proto_rawDescData []byte\n)\n\nfunc file_task_proto_rawDescGZIP() []byte {\n\tfile_task_proto_rawDescOnce.Do(func() {\n\t\tfile_task_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_task_proto_rawDesc), len(file_task_proto_rawDesc)))\n\t})\n\treturn file_task_proto_rawDescData\n}\n\nvar file_task_proto_msgTypes = make([]protoimpl.MessageInfo, 2)\nvar file_task_proto_goTypes = []any{\n\t(*TaskRequest)(nil),  // 0: rpc.TaskRequest\n\t(*TaskResponse)(nil), // 1: rpc.TaskResponse\n}\nvar file_task_proto_depIdxs = []int32{\n\t0, // 0: rpc.Task.Run:input_type -> rpc.TaskRequest\n\t1, // 1: rpc.Task.Run:output_type -> rpc.TaskResponse\n\t1, // [1:2] is the sub-list for method output_type\n\t0, // [0:1] is the sub-list for method input_type\n\t0, // [0:0] is the sub-list for extension type_name\n\t0, // [0:0] is the sub-list for extension extendee\n\t0, // [0:0] is the sub-list for field type_name\n}\n\nfunc init() { file_task_proto_init() }\nfunc file_task_proto_init() {\n\tif File_task_proto != nil {\n\t\treturn\n\t}\n\ttype x struct{}\n\tout := protoimpl.TypeBuilder{\n\t\tFile: protoimpl.DescBuilder{\n\t\t\tGoPackagePath: reflect.TypeOf(x{}).PkgPath(),\n\t\t\tRawDescriptor: unsafe.Slice(unsafe.StringData(file_task_proto_rawDesc), len(file_task_proto_rawDesc)),\n\t\t\tNumEnums:      0,\n\t\t\tNumMessages:   2,\n\t\t\tNumExtensions: 0,\n\t\t\tNumServices:   1,\n\t\t},\n\t\tGoTypes:           file_task_proto_goTypes,\n\t\tDependencyIndexes: file_task_proto_depIdxs,\n\t\tMessageInfos:      file_task_proto_msgTypes,\n\t}.Build()\n\tFile_task_proto = out.File\n\tfile_task_proto_goTypes = nil\n\tfile_task_proto_depIdxs = nil\n}\n"
  },
  {
    "path": "internal/modules/rpc/proto/task.proto",
    "content": "syntax = \"proto3\";\n\npackage rpc;\n\noption go_package = \"github.com/gocronx-team/gocron/internal/modules/rpc/proto\";\n\nservice Task {\n    rpc Run(TaskRequest) returns (TaskResponse) {}\n}\n\nmessage TaskRequest {\n    string command = 2; // 命令\n    int32 timeout = 3;  // 任务执行超时时间\n    int64 id = 4; // 执行任务唯一ID\n}\n\nmessage TaskResponse {\n    string output = 1; // 命令标准输出\n    string error = 2;  // 命令错误\n}"
  },
  {
    "path": "internal/modules/rpc/proto/task_grpc.pb.go",
    "content": "// Code generated by protoc-gen-go-grpc. DO NOT EDIT.\n// versions:\n// - protoc-gen-go-grpc v1.6.0\n// - protoc             v5.29.3\n// source: task.proto\n\npackage proto\n\nimport (\n\tcontext \"context\"\n\tgrpc \"google.golang.org/grpc\"\n\tcodes \"google.golang.org/grpc/codes\"\n\tstatus \"google.golang.org/grpc/status\"\n)\n\n// This is a compile-time assertion to ensure that this generated file\n// is compatible with the grpc package it is being compiled against.\n// Requires gRPC-Go v1.64.0 or later.\nconst _ = grpc.SupportPackageIsVersion9\n\nconst (\n\tTask_Run_FullMethodName = \"/rpc.Task/Run\"\n)\n\n// TaskClient is the client API for Task service.\n//\n// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.\ntype TaskClient interface {\n\tRun(ctx context.Context, in *TaskRequest, opts ...grpc.CallOption) (*TaskResponse, error)\n}\n\ntype taskClient struct {\n\tcc grpc.ClientConnInterface\n}\n\nfunc NewTaskClient(cc grpc.ClientConnInterface) TaskClient {\n\treturn &taskClient{cc}\n}\n\nfunc (c *taskClient) Run(ctx context.Context, in *TaskRequest, opts ...grpc.CallOption) (*TaskResponse, error) {\n\tcOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)\n\tout := new(TaskResponse)\n\terr := c.cc.Invoke(ctx, Task_Run_FullMethodName, in, out, cOpts...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\n// TaskServer is the server API for Task service.\n// All implementations must embed UnimplementedTaskServer\n// for forward compatibility.\ntype TaskServer interface {\n\tRun(context.Context, *TaskRequest) (*TaskResponse, error)\n\tmustEmbedUnimplementedTaskServer()\n}\n\n// UnimplementedTaskServer must be embedded to have\n// forward compatible implementations.\n//\n// NOTE: this should be embedded by value instead of pointer to avoid a nil\n// pointer dereference when methods are called.\ntype UnimplementedTaskServer struct{}\n\nfunc (UnimplementedTaskServer) Run(context.Context, *TaskRequest) (*TaskResponse, error) {\n\treturn nil, status.Error(codes.Unimplemented, \"method Run not implemented\")\n}\nfunc (UnimplementedTaskServer) mustEmbedUnimplementedTaskServer() {}\nfunc (UnimplementedTaskServer) testEmbeddedByValue()              {}\n\n// UnsafeTaskServer may be embedded to opt out of forward compatibility for this service.\n// Use of this interface is not recommended, as added methods to TaskServer will\n// result in compilation errors.\ntype UnsafeTaskServer interface {\n\tmustEmbedUnimplementedTaskServer()\n}\n\nfunc RegisterTaskServer(s grpc.ServiceRegistrar, srv TaskServer) {\n\t// If the following call panics, it indicates UnimplementedTaskServer was\n\t// embedded by pointer and is nil.  This will cause panics if an\n\t// unimplemented method is ever invoked, so we test this at initialization\n\t// time to prevent it from happening at runtime later due to I/O.\n\tif t, ok := srv.(interface{ testEmbeddedByValue() }); ok {\n\t\tt.testEmbeddedByValue()\n\t}\n\ts.RegisterService(&Task_ServiceDesc, srv)\n}\n\nfunc _Task_Run_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {\n\tin := new(TaskRequest)\n\tif err := dec(in); err != nil {\n\t\treturn nil, err\n\t}\n\tif interceptor == nil {\n\t\treturn srv.(TaskServer).Run(ctx, in)\n\t}\n\tinfo := &grpc.UnaryServerInfo{\n\t\tServer:     srv,\n\t\tFullMethod: Task_Run_FullMethodName,\n\t}\n\thandler := func(ctx context.Context, req interface{}) (interface{}, error) {\n\t\treturn srv.(TaskServer).Run(ctx, req.(*TaskRequest))\n\t}\n\treturn interceptor(ctx, in, info, handler)\n}\n\n// Task_ServiceDesc is the grpc.ServiceDesc for Task service.\n// It's only intended for direct use with grpc.RegisterService,\n// and not to be introspected or modified (even as a copy)\nvar Task_ServiceDesc = grpc.ServiceDesc{\n\tServiceName: \"rpc.Task\",\n\tHandlerType: (*TaskServer)(nil),\n\tMethods: []grpc.MethodDesc{\n\t\t{\n\t\t\tMethodName: \"Run\",\n\t\t\tHandler:    _Task_Run_Handler,\n\t\t},\n\t},\n\tStreams:  []grpc.StreamDesc{},\n\tMetadata: \"task.proto\",\n}\n"
  },
  {
    "path": "internal/modules/rpc/server/server.go",
    "content": "package server\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/rpc/auth\"\n\tpb \"github.com/gocronx-team/gocron/internal/modules/rpc/proto\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"google.golang.org/grpc\"\n\t\"google.golang.org/grpc/credentials\"\n\t\"google.golang.org/grpc/keepalive\"\n)\n\ntype Server struct {\n\tpb.UnimplementedTaskServer\n\ttaskContexts sync.Map // 存储正在运行的任务上下文\n\ttaskOutputs  sync.Map // 存储任务输出\n\tstopChans    sync.Map // 存储停止通道\n}\n\nvar keepAlivePolicy = keepalive.EnforcementPolicy{\n\tMinTime:             10 * time.Second,\n\tPermitWithoutStream: true,\n}\n\nvar keepAliveParams = keepalive.ServerParameters{\n\tMaxConnectionIdle: 30 * time.Second,\n\tTime:              30 * time.Second,\n\tTimeout:           3 * time.Second,\n}\n\nfunc (s *Server) Run(ctx context.Context, req *pb.TaskRequest) (*pb.TaskResponse, error) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlog.Error(err)\n\t\t}\n\t}()\n\n\t// 清理 HTML 实体\n\tcleanedCmd := utils.CleanHTMLEntities(req.Command)\n\n\t// 检测是否是停止信号\n\tif cleanedCmd == \"__STOP__\" {\n\t\tif ch, ok := s.stopChans.Load(req.Id); ok {\n\t\t\tclose(ch.(chan struct{}))\n\t\t}\n\t\treturn &pb.TaskResponse{\n\t\t\tOutput: \"\",\n\t\t\tError:  \"\",\n\t\t}, nil\n\t}\n\n\t// 使用任务超时创建独立的 context\n\ttimeout := time.Duration(req.Timeout) * time.Second\n\ttaskCtx, cancel := context.WithTimeout(context.Background(), timeout)\n\tdefer cancel()\n\n\t// 存储任务上下文和输出 buffer\n\toutputBuf := &bytes.Buffer{}\n\tstopChan := make(chan struct{})\n\ts.taskContexts.Store(req.Id, cancel)\n\ts.taskOutputs.Store(req.Id, outputBuf)\n\ts.stopChans.Store(req.Id, stopChan)\n\tdefer func() {\n\t\ts.taskContexts.Delete(req.Id)\n\t\ts.stopChans.Delete(req.Id)\n\t\t// 保留输出 5 秒，给 Stop 调用时间获取\n\t\ttime.AfterFunc(5*time.Second, func() {\n\t\t\ts.taskOutputs.Delete(req.Id)\n\t\t})\n\t}()\n\n\t// 监听客户端取消或停止信号\n\tvar wasStopped atomic.Bool\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tcancel()\n\t\tcase <-stopChan:\n\t\t\twasStopped.Store(true)\n\t\t\tcancel()\n\t\tcase <-taskCtx.Done():\n\t\t}\n\t}()\n\n\t// 执行命令\n\toutput, execErr := utils.ExecShell(taskCtx, cleanedCmd)\n\toutputBuf.WriteString(output)\n\n\tresp := new(pb.TaskResponse)\n\tresp.Output = output\n\tif execErr != nil {\n\t\t// 如果是手动停止，使用特定的错误信息\n\t\tif wasStopped.Load() {\n\t\t\tresp.Error = \"manual stop\"\n\t\t\tlog.Infof(\"[id: %d] Manually stopped\\n%s\", req.Id, output)\n\t\t} else {\n\t\t\tresp.Error = execErr.Error()\n\t\t\tlog.Infof(\"[id: %d] Execution failed: %s\\n%s\", req.Id, execErr.Error(), output)\n\t\t}\n\t} else {\n\t\tresp.Error = \"\"\n\t\tlog.Infof(\"[id: %d] Execution successful\\n%s\", req.Id, output)\n\t}\n\n\treturn resp, nil\n}\n\nfunc Start(addr string, enableTLS bool, certificate auth.Certificate) {\n\tl, err := net.Listen(\"tcp\", addr)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\topts := []grpc.ServerOption{\n\t\tgrpc.KeepaliveParams(keepAliveParams),\n\t\tgrpc.KeepaliveEnforcementPolicy(keepAlivePolicy),\n\t}\n\tif enableTLS {\n\t\ttlsConfig, err := certificate.GetTLSConfigForServer()\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t\topt := grpc.Creds(credentials.NewTLS(tlsConfig))\n\t\topts = append(opts, opt)\n\t}\n\tserver := grpc.NewServer(opts...)\n\tpb.RegisterTaskServer(server, &Server{})\n\tlog.Infof(\"server listen on %s\", addr)\n\n\tgo func() {\n\t\terr = server.Serve(l)\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\t}()\n\n\tc := make(chan os.Signal, 1)\n\tsignal.Notify(c, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)\n\tfor {\n\t\ts := <-c\n\t\tlog.Infoln(\"Received signal -- \", s)\n\t\tswitch s {\n\t\tcase syscall.SIGHUP:\n\t\t\tlog.Infoln(\"Received terminal disconnect signal, ignoring\")\n\t\tcase syscall.SIGINT, syscall.SIGTERM:\n\t\t\tlog.Info(\"Application preparing to exit\")\n\t\t\tserver.GracefulStop()\n\t\t\treturn\n\t\t}\n\t}\n\n}\n"
  },
  {
    "path": "internal/modules/setting/setting.go",
    "content": "package setting\n\nimport (\n\t\"errors\"\n\t\"os\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"gopkg.in/ini.v1\"\n)\n\nconst DefaultSection = \"default\"\n\ntype Setting struct {\n\tDb struct {\n\t\tEngine       string\n\t\tHost         string\n\t\tPort         int\n\t\tUser         string\n\t\tPassword     string\n\t\tDatabase     string\n\t\tPrefix       string\n\t\tCharset      string\n\t\tMaxIdleConns int\n\t\tMaxOpenConns int\n\t}\n\tAllowIps      string\n\tAppName       string\n\tApiKey        string\n\tApiSecret     string\n\tApiSignEnable bool\n\n\tEnableTLS bool\n\tCAFile    string\n\tCertFile  string\n\tKeyFile   string\n\n\tConcurrencyQueue int\n\tAuthSecret       string\n}\n\n// 读取配置\nfunc Read(filename string) (*Setting, error) {\n\tconfig, err := ini.Load(filename)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsection := config.Section(DefaultSection)\n\n\tvar s Setting\n\n\ts.Db.Engine = section.Key(\"db.engine\").MustString(\"mysql\")\n\ts.Db.Host = section.Key(\"db.host\").MustString(\"127.0.0.1\")\n\ts.Db.Port = section.Key(\"db.port\").MustInt(3306)\n\ts.Db.User = section.Key(\"db.user\").MustString(\"\")\n\ts.Db.Password = section.Key(\"db.password\").MustString(\"\")\n\ts.Db.Database = section.Key(\"db.database\").MustString(\"gocron\")\n\ts.Db.Prefix = section.Key(\"db.prefix\").MustString(\"\")\n\ts.Db.Charset = section.Key(\"db.charset\").MustString(\"utf8\")\n\ts.Db.MaxIdleConns = section.Key(\"db.max.idle.conns\").MustInt(30)\n\ts.Db.MaxOpenConns = section.Key(\"db.max.open.conns\").MustInt(100)\n\n\ts.AllowIps = section.Key(\"allow_ips\").MustString(\"\")\n\ts.AppName = section.Key(\"app.name\").MustString(\"定时任务管理系统\")\n\ts.ApiKey = section.Key(\"api.key\").MustString(\"\")\n\ts.ApiSecret = section.Key(\"api.secret\").MustString(\"\")\n\ts.ApiSignEnable = section.Key(\"api.sign.enable\").MustBool(true)\n\ts.ConcurrencyQueue = section.Key(\"concurrency.queue\").MustInt(500)\n\ts.AuthSecret = section.Key(\"auth_secret\").MustString(\"\")\n\tif s.AuthSecret == \"\" {\n\t\ts.AuthSecret = utils.RandAuthToken()\n\t}\n\n\ts.EnableTLS = section.Key(\"enable_tls\").MustBool(false)\n\ts.CAFile = section.Key(\"ca_file\").MustString(\"\")\n\ts.CertFile = section.Key(\"cert_file\").MustString(\"\")\n\ts.KeyFile = section.Key(\"key_file\").MustString(\"\")\n\n\tif s.EnableTLS {\n\t\tif !utils.FileExist(s.CAFile) {\n\t\t\tlogger.Fatalf(\"failed to read ca cert file: %s\", s.CAFile)\n\t\t}\n\n\t\tif !utils.FileExist(s.CertFile) {\n\t\t\tlogger.Fatalf(\"failed to read client cert file: %s\", s.CertFile)\n\t\t}\n\n\t\tif !utils.FileExist(s.KeyFile) {\n\t\t\tlogger.Fatalf(\"failed to read client key file: %s\", s.KeyFile)\n\t\t}\n\t}\n\n\treturn &s, nil\n}\n\n// 写入配置\nfunc Write(config []string, filename string) error {\n\tif len(config) == 0 {\n\t\treturn errors.New(\"参数不能为空\")\n\t}\n\tif len(config)%2 != 0 {\n\t\treturn errors.New(\"参数不匹配\")\n\t}\n\n\tfile := ini.Empty()\n\n\tsection, err := file.NewSection(DefaultSection)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor i := 0; i < len(config); {\n\t\t_, err = section.NewKey(config[i], config[i+1])\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ti += 2\n\t}\n\terr = file.SaveTo(filename)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// 设置配置文件权限为0600，仅所有者可读写\n\terr = os.Chmod(filename, 0600)\n\n\treturn err\n}\n"
  },
  {
    "path": "internal/modules/setting/setting_test.go",
    "content": "package setting\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"gopkg.in/ini.v1\"\n)\n\nfunc TestReadReturnsConfiguredValues(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"app.ini\")\n\tcontent := `[default]\n\t\tdb.engine=postgres\n\t\tdb.host=10.0.0.1\n\t\tdb.port=5432\n\t\tdb.user=test_user\n\t\tdb.password=test_pass\n\t\tdb.database=test_db\n\t\tdb.prefix=pre_\n\t\tdb.charset=utf8mb4\n\t\tdb.max.idle.conns=11\n\t\tdb.max.open.conns=22\n\t\tallow_ips=127.0.0.1\n\t\tapp.name=TestApp\n\t\tapi.key=key\n\t\tapi.secret=secret\n\t\tapi.sign.enable=false\n\t\tconcurrency.queue=200\n\t\tauth_secret=existing-secret\n\t\tenable_tls=false\n    `\n\tif err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {\n\t\tt.Fatalf(\"write config failed: %v\", err)\n\t}\n\n\ts, err := Read(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif s.Db.Engine != \"postgres\" || s.Db.Host != \"10.0.0.1\" || s.Db.Port != 5432 {\n\t\tt.Fatalf(\"unexpected db config: %+v\", s.Db)\n\t}\n\tif s.AppName != \"TestApp\" || s.ApiSignEnable {\n\t\tt.Fatalf(\"unexpected app config: %+v\", s)\n\t}\n\tif s.ConcurrencyQueue != 200 || s.AuthSecret != \"existing-secret\" {\n\t\tt.Fatalf(\"unexpected concurrency/auth config: %+v\", s)\n\t}\n}\n\nfunc TestReadGeneratesAuthSecretWhenMissing(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"app.ini\")\n\tcontent := `[default]\ndb.engine=mysql\n`\n\tif err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {\n\t\tt.Fatalf(\"write config failed: %v\", err)\n\t}\n\n\ts, err := Read(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif s.AuthSecret == \"\" {\n\t\tt.Fatal(\"expected generated auth secret when config missing\")\n\t}\n}\n\nfunc TestReadEnableTLSSucceedsWhenFilesExist(t *testing.T) {\n\tdir := t.TempDir()\n\tcaPath := filepath.Join(dir, \"ca.pem\")\n\tcertPath := filepath.Join(dir, \"cert.pem\")\n\tkeyPath := filepath.Join(dir, \"key.pem\")\n\tfor _, p := range []string{caPath, certPath, keyPath} {\n\t\tif err := os.WriteFile(p, []byte(\"data\"), 0o600); err != nil {\n\t\t\tt.Fatalf(\"failed to create tls file: %v\", err)\n\t\t}\n\t}\n\tconfigPath := filepath.Join(dir, \"app.ini\")\n\tcontent := `[default]\nenable_tls=true\nca_file=` + caPath + `\ncert_file=` + certPath + `\nkey_file=` + keyPath + `\n`\n\tif err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {\n\t\tt.Fatalf(\"write config failed: %v\", err)\n\t}\n\n\tif _, err := Read(configPath); err != nil {\n\t\tt.Fatalf(\"expected tls config to be read successfully, got %v\", err)\n\t}\n}\n\nfunc TestWriteValidatesArguments(t *testing.T) {\n\tif err := Write(nil, \"\"); err == nil {\n\t\tt.Fatal(\"expected error for empty config\")\n\t}\n\tif err := Write([]string{\"key\"}, \"\"); err == nil {\n\t\tt.Fatal(\"expected error for odd number of config entries\")\n\t}\n}\n\nfunc TestWritePersistsKeyValuePairs(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"app.ini\")\n\tdata := []string{\n\t\t\"db.engine\", \"sqlite\",\n\t\t\"db.host\", \"\",\n\t\t\"api.sign.enable\", \"false\",\n\t}\n\n\tif err := Write(data, configPath); err != nil {\n\t\tt.Fatalf(\"write failed: %v\", err)\n\t}\n\n\tcfg, err := ini.Load(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"load config failed: %v\", err)\n\t}\n\tsection := cfg.Section(DefaultSection)\n\tif section.Key(\"db.engine\").String() != \"sqlite\" {\n\t\tt.Fatalf(\"db.engine mismatch, got %s\", section.Key(\"db.engine\").String())\n\t}\n\tif section.Key(\"api.sign.enable\").String() != \"false\" {\n\t\tt.Fatalf(\"api.sign.enable mismatch, got %s\", section.Key(\"api.sign.enable\").String())\n\t}\n}\n\nfunc TestWriteSetsSecureFilePermissions(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"app.ini\")\n\tdata := []string{\n\t\t\"db.password\", \"secret123\",\n\t\t\"auth_secret\", \"token456\",\n\t}\n\n\tif err := Write(data, configPath); err != nil {\n\t\tt.Fatalf(\"write failed: %v\", err)\n\t}\n\n\tinfo, err := os.Stat(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"stat failed: %v\", err)\n\t}\n\n\tperm := info.Mode().Perm()\n\tif perm != 0600 {\n\t\tt.Fatalf(\"expected file permission 0600, got %#o\", perm)\n\t}\n}\n"
  },
  {
    "path": "internal/modules/utils/execshell_integration_test.go",
    "content": "package utils\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\n// 集成测试：模拟真实场景 - 任务超时但需要看到已执行的输出\nfunc TestExecShell_RealWorldScenario_Timeout(t *testing.T) {\n\tctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)\n\tdefer cancel()\n\n\t// 模拟一个数据库备份任务：先输出开始信息，然后执行耗时操作\n\tcommand := `\n\t\techo \"=== Database Backup Started ===\"\n\t\techo \"Connecting to database...\"\n\t\techo \"Dumping table: users\"\n\t\techo \"Dumping table: orders\"\n\t\tsleep 5\n\t\techo \"Backup completed\"\n\t`\n\n\toutput, err := ExecShell(ctx, command)\n\n\tif err == nil {\n\t\tt.Fatal(\"Expected timeout error\")\n\t}\n\n\t// 验证能看到超时前的所有输出\n\trequiredOutputs := []string{\n\t\t\"Database Backup Started\",\n\t\t\"Connecting to database\",\n\t\t\"Dumping table: users\",\n\t\t\"Dumping table: orders\",\n\t}\n\n\tfor _, expected := range requiredOutputs {\n\t\tif !strings.Contains(output, expected) {\n\t\t\tt.Errorf(\"Missing expected output: %s\\nGot: %s\", expected, output)\n\t\t}\n\t}\n\n\t// 不应该包含超时后的输出\n\tif strings.Contains(output, \"Backup completed\") {\n\t\tt.Error(\"Should not contain output after timeout\")\n\t}\n\n\tt.Logf(\"✓ Successfully captured partial output on timeout:\\n%s\", output)\n}\n\n// 集成测试：手动停止任务\nfunc TestExecShell_RealWorldScenario_ManualStop(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\n\tcommand := `\n\t\techo \"Task started\"\n\t\tfor i in {1..20}; do\n\t\t\techo \"Processing record $i\"\n\t\t\tsleep 0.2\n\t\tdone\n\t\techo \"Task finished\"\n\t`\n\n\tresultChan := make(chan struct {\n\t\toutput string\n\t\terr    error\n\t})\n\n\tgo func() {\n\t\toutput, err := ExecShell(ctx, command)\n\t\tresultChan <- struct {\n\t\t\toutput string\n\t\t\terr    error\n\t\t}{output, err}\n\t}()\n\n\t// 模拟用户在 1 秒后点击停止按钮\n\ttime.Sleep(1 * time.Second)\n\tcancel()\n\n\tresult := <-resultChan\n\n\tif result.err == nil {\n\t\tt.Fatal(\"Expected cancellation error\")\n\t}\n\n\t// 应该能看到部分处理记录\n\tif !strings.Contains(result.output, \"Task started\") {\n\t\tt.Error(\"Missing 'Task started'\")\n\t}\n\n\tif !strings.Contains(result.output, \"Processing record\") {\n\t\tt.Error(\"Missing processing records\")\n\t}\n\n\trecordCount := strings.Count(result.output, \"Processing record\")\n\tif recordCount < 3 {\n\t\tt.Errorf(\"Expected at least 3 records processed, got %d\", recordCount)\n\t}\n\n\tt.Logf(\"✓ Captured %d records before manual stop:\\n%s\", recordCount, result.output)\n}\n"
  },
  {
    "path": "internal/modules/utils/execshell_test.go",
    "content": "//go:build !windows\n// +build !windows\n\npackage utils\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\n// 测试正常执行完成\nfunc TestExecShell_NormalCompletion(t *testing.T) {\n\tctx := context.Background()\n\toutput, err := ExecShell(ctx, \"echo 'Hello World'\")\n\n\tif err != nil {\n\t\tt.Errorf(\"Expected no error, got: %v\", err)\n\t}\n\n\tif !strings.Contains(output, \"Hello World\") {\n\t\tt.Errorf(\"Expected output to contain 'Hello World', got: %s\", output)\n\t}\n}\n\n// 测试超时时能捕获部分输出\nfunc TestExecShell_TimeoutWithPartialOutput(t *testing.T) {\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\t// 执行一个会输出多行然后长时间运行的命令\n\tcommand := `\n\t\techo \"Line 1\"\n\t\techo \"Line 2\"\n\t\techo \"Line 3\"\n\t\tsleep 10\n\t\techo \"This should not appear\"\n\t`\n\n\toutput, err := ExecShell(ctx, command)\n\n\t// 应该返回错误（超时）\n\tif err == nil {\n\t\tt.Error(\"Expected timeout error, got nil\")\n\t}\n\n\tif !strings.Contains(err.Error(), \"timeout killed\") {\n\t\tt.Errorf(\"Expected 'timeout killed' error, got: %v\", err)\n\t}\n\n\t// 关键：应该能捕获到前面的输出\n\tif !strings.Contains(output, \"Line 1\") {\n\t\tt.Errorf(\"Expected output to contain 'Line 1', got: %s\", output)\n\t}\n\tif !strings.Contains(output, \"Line 2\") {\n\t\tt.Errorf(\"Expected output to contain 'Line 2', got: %s\", output)\n\t}\n\tif !strings.Contains(output, \"Line 3\") {\n\t\tt.Errorf(\"Expected output to contain 'Line 3', got: %s\", output)\n\t}\n\n\t// 不应该包含超时后的输出\n\tif strings.Contains(output, \"This should not appear\") {\n\t\tt.Errorf(\"Output should not contain text after timeout\")\n\t}\n\n\tt.Logf(\"Captured output on timeout:\\n%s\", output)\n}\n\n// 测试手动取消时能捕获部分输出\nfunc TestExecShell_ManualCancelWithPartialOutput(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\n\t// 启动一个会持续输出的命令\n\tcommand := `\n\t\tfor i in {1..10}; do\n\t\t\techo \"Output line $i\"\n\t\t\tsleep 0.5\n\t\tdone\n\t`\n\n\t// 在另一个 goroutine 中执行命令\n\toutputChan := make(chan string)\n\terrChan := make(chan error)\n\n\tgo func() {\n\t\toutput, err := ExecShell(ctx, command)\n\t\toutputChan <- output\n\t\terrChan <- err\n\t}()\n\n\t// 等待 1.5 秒后取消（应该能看到前几行输出）\n\ttime.Sleep(1500 * time.Millisecond)\n\tcancel()\n\n\toutput := <-outputChan\n\terr := <-errChan\n\n\t// 应该返回错误（被取消）\n\tif err == nil {\n\t\tt.Error(\"Expected timeout error, got nil\")\n\t}\n\n\tif !strings.Contains(err.Error(), \"timeout killed\") {\n\t\tt.Errorf(\"Expected 'timeout killed' error, got: %v\", err)\n\t}\n\n\t// 应该能捕获到部分输出\n\tif !strings.Contains(output, \"Output line\") {\n\t\tt.Errorf(\"Expected output to contain 'Output line', got: %s\", output)\n\t}\n\n\tt.Logf(\"Captured output on manual cancel:\\n%s\", output)\n}\n\n// 测试命令执行失败但有输出\nfunc TestExecShell_CommandFailureWithOutput(t *testing.T) {\n\tctx := context.Background()\n\n\t// 执行一个会失败的命令，但会先输出内容\n\tcommand := `\n\t\techo \"Before error\"\n\t\tls /nonexistent_directory_12345\n\t\techo \"After error\"\n\t`\n\n\toutput, err := ExecShell(ctx, command)\n\n\t// 命令失败但 bash 会继续执行后续命令，所以可能没有错误\n\t// 这取决于 shell 的行为\n\tif err != nil {\n\t\tt.Logf(\"Command returned error (expected): %v\", err)\n\t}\n\n\t// 应该能捕获到错误前的输出\n\tif !strings.Contains(output, \"Before error\") {\n\t\tt.Errorf(\"Expected output to contain 'Before error', got: %s\", output)\n\t}\n\n\t// 应该包含错误信息\n\tif !strings.Contains(output, \"No such file or directory\") && !strings.Contains(output, \"cannot access\") {\n\t\tt.Logf(\"Warning: Expected error message in output, got: %s\", output)\n\t}\n\n\tt.Logf(\"Output with error:\\n%s\", output)\n}\n\n// 测试长时间运行的命令\nfunc TestExecShell_LongRunningCommand(t *testing.T) {\n\tctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)\n\tdefer cancel()\n\n\t// 模拟一个持续输出的长任务\n\tcommand := `\n\t\tfor i in {1..100}; do\n\t\t\techo \"Processing item $i\"\n\t\t\tsleep 0.1\n\t\tdone\n\t`\n\n\toutput, err := ExecShell(ctx, command)\n\n\t// 应该超时\n\tif err == nil {\n\t\tt.Error(\"Expected timeout error, got nil\")\n\t}\n\n\t// 应该捕获到大量输出\n\tlineCount := strings.Count(output, \"Processing item\")\n\tif lineCount < 10 {\n\t\tt.Errorf(\"Expected at least 10 lines of output, got %d\", lineCount)\n\t}\n\n\tt.Logf(\"Captured %d lines before timeout\", lineCount)\n}\n\n// 测试快速完成的命令\nfunc TestExecShell_QuickCommand(t *testing.T) {\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\toutput, err := ExecShell(ctx, \"echo 'Quick test' && date\")\n\n\tif err != nil {\n\t\tt.Errorf(\"Expected no error, got: %v\", err)\n\t}\n\n\tif !strings.Contains(output, \"Quick test\") {\n\t\tt.Errorf(\"Expected output to contain 'Quick test', got: %s\", output)\n\t}\n}\n\n// 测试空命令\nfunc TestExecShell_EmptyCommand(t *testing.T) {\n\tctx := context.Background()\n\toutput, err := ExecShell(ctx, \"\")\n\n\t// 空命令应该成功执行（没有输出）\n\tif err != nil {\n\t\tt.Logf(\"Empty command returned error: %v (this is acceptable)\", err)\n\t}\n\n\tt.Logf(\"Empty command output: '%s'\", output)\n}\n\n// 测试 stderr 输出\nfunc TestExecShell_StderrOutput(t *testing.T) {\n\tctx := context.Background()\n\n\t// 同时输出到 stdout 和 stderr\n\tcommand := `\n\t\techo \"stdout message\"\n\t\techo \"stderr message\" >&2\n\t`\n\n\toutput, err := ExecShell(ctx, command)\n\n\tif err != nil {\n\t\tt.Logf(\"Command returned error: %v\", err)\n\t}\n\n\t// 应该同时捕获 stdout 和 stderr\n\tif !strings.Contains(output, \"stdout message\") {\n\t\tt.Errorf(\"Expected output to contain 'stdout message', got: %s\", output)\n\t}\n\tif !strings.Contains(output, \"stderr message\") {\n\t\tt.Errorf(\"Expected output to contain 'stderr message', got: %s\", output)\n\t}\n}\n\n// 基准测试：正常命令执行\nfunc BenchmarkExecShell_Normal(b *testing.B) {\n\tctx := context.Background()\n\tfor i := 0; i < b.N; i++ {\n\t\tExecShell(ctx, \"echo 'benchmark test'\")\n\t}\n}\n\n// 基准测试：超时场景\nfunc BenchmarkExecShell_Timeout(b *testing.B) {\n\tfor i := 0; i < b.N; i++ {\n\t\tctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)\n\t\tExecShell(ctx, \"sleep 1\")\n\t\tcancel()\n\t}\n}\n\n// 测试包含特殊字符的命令（临时脚本方式的优势）\nfunc TestExecShell_SpecialCharacters(t *testing.T) {\n\tctx := context.Background()\n\n\t// 测试包含引号、反引号等特殊字符\n\tcommand := \"echo \\\"Hello 'World'\\\" && echo 'Test \\\"quotes\\\"' && echo `date`\"\n\n\toutput, err := ExecShell(ctx, command)\n\n\tif err != nil {\n\t\tt.Errorf(\"Expected no error, got: %v\", err)\n\t}\n\n\tif !strings.Contains(output, \"Hello 'World'\") {\n\t\tt.Errorf(\"Expected output to contain mixed quotes, got: %s\", output)\n\t}\n\n\tt.Logf(\"Special characters output:\\n%s\", output)\n}\n\n// 测试多行脚本（临时脚本方式的优势）\nfunc TestExecShell_MultilineScript(t *testing.T) {\n\tctx := context.Background()\n\n\t// 测试复杂的多行脚本\n\tcommand := `\n#!/bin/bash\nfunction greet() {\n    echo \"Hello from function\"\n}\n\nfor i in 1 2 3; do\n    echo \"Loop iteration $i\"\ndone\n\ngreet\n`\n\n\toutput, err := ExecShell(ctx, command)\n\n\tif err != nil {\n\t\tt.Errorf(\"Expected no error, got: %v\", err)\n\t}\n\n\tif !strings.Contains(output, \"Loop iteration\") {\n\t\tt.Errorf(\"Expected loop output, got: %s\", output)\n\t}\n\n\tif !strings.Contains(output, \"Hello from function\") {\n\t\tt.Errorf(\"Expected function output, got: %s\", output)\n\t}\n\n\tt.Logf(\"Multiline script output:\\n%s\", output)\n}\n\n// 测试工作目录是否正确设置\nfunc TestExecShell_WorkingDirectory(t *testing.T) {\n\tctx := context.Background()\n\n\t// 打印当前工作目录\n\toutput, err := ExecShell(ctx, \"pwd\")\n\n\tif err != nil {\n\t\tt.Errorf(\"Expected no error, got: %v\", err)\n\t}\n\n\t// 工作目录应该是用户家目录，不是 /tmp\n\tif strings.Contains(output, \"/tmp\") && !strings.Contains(output, os.Getenv(\"HOME\")) {\n\t\tt.Errorf(\"Working directory should be home directory, got: %s\", output)\n\t}\n\n\tt.Logf(\"Working directory: %s\", strings.TrimSpace(output))\n}\n\n// 测试HTML实体清理（保持原有功能）\nfunc TestExecShell_HTMLEntityCleaning(t *testing.T) {\n\tctx := context.Background()\n\n\t// 测试HTML实体会被正确清理\n\tcommand := `echo &quot;test&quot;`\n\n\toutput, err := ExecShell(ctx, command)\n\n\tif err != nil {\n\t\tt.Errorf(\"Expected no error, got: %v\", err)\n\t}\n\n\t// 应该输出 \"test\" 而不是 &quot;test&quot;\n\tif !strings.Contains(output, \"test\") {\n\t\tt.Errorf(\"Expected cleaned output, got: %s\", output)\n\t}\n\n\tt.Logf(\"HTML entity cleaned output: %s\", output)\n}\n"
  },
  {
    "path": "internal/modules/utils/html_entity.go",
    "content": "package utils\n\nimport \"strings\"\n\n// CleanHTMLEntities 清理命令中的 HTML 实体编码\n// 这个函数用于修复前端可能传递过来的 HTML 实体编码问题\n// 例如: &quot; -> \", &apos; -> ', &lt; -> <, &gt; -> >, &amp; -> &\nfunc CleanHTMLEntities(command string) string {\n\t// 如果命令中不包含 HTML 实体,直接返回\n\tif !strings.Contains(command, \"&\") {\n\t\treturn command\n\t}\n\n\t// 定义 HTML 实体替换映射\n\treplacements := map[string]string{\n\t\t\"&quot;\": \"\\\"\",\n\t\t\"&apos;\": \"'\",\n\t\t\"&#39;\":  \"'\",\n\t\t\"&lt;\":   \"<\",\n\t\t\"&gt;\":   \">\",\n\t\t\"&amp;\":  \"&\", // 注意: &amp; 必须最后替换,避免重复替换\n\t}\n\n\tresult := command\n\t// 先替换除 &amp; 之外的所有实体\n\tfor entity, char := range replacements {\n\t\tif entity != \"&amp;\" {\n\t\t\tresult = strings.ReplaceAll(result, entity, char)\n\t\t}\n\t}\n\t// 最后替换 &amp;\n\tresult = strings.ReplaceAll(result, \"&amp;\", \"&\")\n\n\treturn result\n}\n\n// ContainsHTMLEntity 检测命令中是否包含 HTML 实体\nfunc ContainsHTMLEntity(command string) bool {\n\tentities := []string{\"&quot;\", \"&apos;\", \"&#39;\", \"&lt;\", \"&gt;\", \"&amp;\"}\n\tfor _, entity := range entities {\n\t\tif strings.Contains(command, entity) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/modules/utils/html_entity_test.go",
    "content": "package utils\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\n// TestHTMLEntityDetection 测试 HTML 实体检测（可在任何平台运行）\nfunc TestHTMLEntityDetection(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tcommand       string\n\t\thasHTMLEntity bool\n\t\texpectedClean string\n\t\tdescription   string\n\t}{\n\t\t{\n\t\t\tname:          \"正常的 Windows 命令（带双引号）\",\n\t\t\tcommand:       `copy \"C:\\My Documents\\report.docx\" \"D:\\Backup\"`,\n\t\t\thasHTMLEntity: false,\n\t\t\texpectedClean: `copy \"C:\\My Documents\\report.docx\" \"D:\\Backup\"`,\n\t\t\tdescription:   \"这是正确的命令，应该能在 Windows 上执行\",\n\t\t},\n\t\t{\n\t\t\tname:          \"包含 HTML 实体的命令（&quot;）\",\n\t\t\tcommand:       `copy &quot;C:\\My Documents\\report.docx&quot; &quot;D:\\Backup&quot;`,\n\t\t\thasHTMLEntity: true,\n\t\t\texpectedClean: `copy \"C:\\My Documents\\report.docx\" \"D:\\Backup\"`,\n\t\t\tdescription:   \"这个命令在 Windows 上会失败，因为 &quot; 不是有效的引号\",\n\t\t},\n\t\t{\n\t\t\tname:          \"mkdir 命令（HTML 实体）\",\n\t\t\tcommand:       `mkdir &quot;C:\\Users\\John Doe\\Projects&quot;`,\n\t\t\thasHTMLEntity: true,\n\t\t\texpectedClean: `mkdir \"C:\\Users\\John Doe\\Projects\"`,\n\t\t\tdescription:   \"mkdir 命令也会受影响\",\n\t\t},\n\t\t{\n\t\t\tname:          \"dir 命令（HTML 实体）\",\n\t\t\tcommand:       `dir &quot;C:\\Program Files (x86)&quot;`,\n\t\t\thasHTMLEntity: true,\n\t\t\texpectedClean: `dir \"C:\\Program Files (x86)\"`,\n\t\t\tdescription:   \"dir 命令也会受影响\",\n\t\t},\n\t\t{\n\t\t\tname:          \"del 命令（HTML 实体）\",\n\t\t\tcommand:       `del &quot;C:\\Temp\\old file.txt&quot;`,\n\t\t\thasHTMLEntity: true,\n\t\t\texpectedClean: `del \"C:\\Temp\\old file.txt\"`,\n\t\t\tdescription:   \"del 命令也会受影响\",\n\t\t},\n\t\t{\n\t\t\tname:          \"start 命令（混合引号）\",\n\t\t\tcommand:       `start &quot;&quot; &quot;C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe&quot; --new-window`,\n\t\t\thasHTMLEntity: true,\n\t\t\texpectedClean: `start \"\" \"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\" --new-window`,\n\t\t\tdescription:   \"start 命令的空标题也需要引号\",\n\t\t},\n\t\t{\n\t\t\tname:          \"包含其他 HTML 实体\",\n\t\t\tcommand:       `echo &lt;test&gt; &amp; &apos;hello&apos;`,\n\t\t\thasHTMLEntity: true,\n\t\t\texpectedClean: `echo <test> & 'hello'`,\n\t\t\tdescription:   \"其他 HTML 实体也应该被清理\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Logf(\"\\n描述: %s\", tt.description)\n\t\t\tt.Logf(\"原始命令: %s\", tt.command)\n\n\t\t\t// 检测是否包含 HTML 实体\n\t\t\thasEntity := ContainsHTMLEntity(tt.command)\n\t\t\tif hasEntity != tt.hasHTMLEntity {\n\t\t\t\tt.Errorf(\"HTML 实体检测错误: got %v, want %v\", hasEntity, tt.hasHTMLEntity)\n\t\t\t}\n\n\t\t\t// 清理 HTML 实体\n\t\t\tcleaned := CleanHTMLEntities(tt.command)\n\t\t\tt.Logf(\"清理后命令: %s\", cleaned)\n\n\t\t\tif cleaned != tt.expectedClean {\n\t\t\t\tt.Errorf(\"清理结果不符合预期:\\ngot:  %s\\nwant: %s\", cleaned, tt.expectedClean)\n\t\t\t}\n\n\t\t\t// 验证清理后不再包含 HTML 实体\n\t\t\tif ContainsHTMLEntity(cleaned) {\n\t\t\t\tt.Errorf(\"清理后仍然包含 HTML 实体: %s\", cleaned)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestCommandLength 测试命令长度限制\nfunc TestCommandLength(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tcommand     string\n\t\tmaxLength   int\n\t\tshouldFit   bool\n\t\tdescription string\n\t}{\n\t\t{\n\t\t\tname:        \"短命令（适合 256 字符限制）\",\n\t\t\tcommand:     `dir \"C:\\Program Files\"`,\n\t\t\tmaxLength:   256,\n\t\t\tshouldFit:   true,\n\t\t\tdescription: \"简单命令应该没问题\",\n\t\t},\n\t\t{\n\t\t\tname: \"长路径命令（适合 256 字符限制）\",\n\t\t\tcommand: `copy \"C:\\Users\\Administrator\\Documents\\Projects\\MyProject\\src\\main\\resources\\config\\application-production.properties\" ` +\n\t\t\t\t`\"D:\\Backup\\Projects\\MyProject\\config\\backup-2024-01-15\\application-production.properties\"`,\n\t\t\tmaxLength:   256,\n\t\t\tshouldFit:   true,\n\t\t\tdescription: \"这个路径实际上只有 208 字符,在 256 限制内\",\n\t\t},\n\t\t{\n\t\t\tname: \"长路径命令（适合 1024 字符限制）\",\n\t\t\tcommand: `copy \"C:\\Users\\Administrator\\Documents\\Projects\\MyProject\\src\\main\\resources\\config\\application-production.properties\" ` +\n\t\t\t\t`\"D:\\Backup\\Projects\\MyProject\\config\\backup-2024-01-15\\application-production.properties\"`,\n\t\t\tmaxLength:   1024,\n\t\t\tshouldFit:   true,\n\t\t\tdescription: \"增加到 1024 字符后应该足够\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Logf(\"\\n描述: %s\", tt.description)\n\t\t\tt.Logf(\"命令长度: %d 字符\", len(tt.command))\n\t\t\tt.Logf(\"限制长度: %d 字符\", tt.maxLength)\n\n\t\t\tfits := len(tt.command) <= tt.maxLength\n\t\t\tif fits != tt.shouldFit {\n\t\t\t\tt.Errorf(\"长度检查错误: 命令长度 %d, 限制 %d, got %v, want %v\",\n\t\t\t\t\tlen(tt.command), tt.maxLength, fits, tt.shouldFit)\n\t\t\t}\n\n\t\t\tif !fits {\n\t\t\t\tt.Logf(\"⚠️  命令超出长度限制 %d 字符\", len(tt.command)-tt.maxLength)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestWindowsCommandSimulation 模拟 Windows 命令行为\nfunc TestWindowsCommandSimulation(t *testing.T) {\n\tt.Log(\"\\n=== 模拟 Windows cmd.exe 行为 ===\\n\")\n\n\ttests := []struct {\n\t\tname     string\n\t\tcommand  string\n\t\twillWork bool\n\t\treason   string\n\t}{\n\t\t{\n\t\t\tname:     \"正确的双引号\",\n\t\t\tcommand:  `dir \"C:\\Program Files\"`,\n\t\t\twillWork: true,\n\t\t\treason:   \"Windows cmd 能正确识别双引号\",\n\t\t},\n\t\t{\n\t\t\tname:     \"HTML 实体 &quot;\",\n\t\t\tcommand:  `dir &quot;C:\\Program Files&quot;`,\n\t\t\twillWork: false,\n\t\t\treason:   \"Windows cmd 会将 &quot; 当作普通字符串，不是引号\",\n\t\t},\n\t\t{\n\t\t\tname:     \"路径中有空格但没有引号\",\n\t\t\tcommand:  `dir C:\\Program Files`,\n\t\t\twillWork: false,\n\t\t\treason:   \"Windows cmd 会将空格作为参数分隔符\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Logf(\"命令: %s\", tt.command)\n\t\t\tt.Logf(\"预期结果: %v\", tt.willWork)\n\t\t\tt.Logf(\"原因: %s\", tt.reason)\n\n\t\t\t// 模拟检查\n\t\t\thasHTMLEntity := ContainsHTMLEntity(tt.command)\n\t\t\thasSpaceWithoutQuotes := strings.Contains(tt.command, \" \") &&\n\t\t\t\t!strings.Contains(tt.command, \"\\\"\") &&\n\t\t\t\t!hasHTMLEntity\n\n\t\t\tsimulatedSuccess := !hasHTMLEntity && !hasSpaceWithoutQuotes\n\n\t\t\tif simulatedSuccess != tt.willWork {\n\t\t\t\tt.Logf(\"⚠️  模拟结果与预期不符\")\n\t\t\t} else {\n\t\t\t\tt.Logf(\"✓ 模拟结果符合预期\")\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/modules/utils/json.go",
    "content": "package utils\n\nimport (\n\t\"encoding/json\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n)\n\n// json 格式输出\n\ntype response struct {\n\tCode    int         `json:\"code\"`    // 状态码 0:成功 非0:失败\n\tMessage string      `json:\"message\"` // 信息\n\tData    interface{} `json:\"data\"`    // 数据\n}\n\ntype JsonResponse struct{}\n\nconst ResponseSuccess = 0\nconst ResponseFailure = 1\nconst UnauthorizedError = 403\nconst AuthError = 401\nconst NotFound = 404\nconst ServerError = 500\nconst AppNotInstall = 801\n\nconst SuccessContent = \"操作成功\"\nconst FailureContent = \"操作失败\"\n\nfunc JsonResponseByErr(err error) string {\n\tjsonResp := JsonResponse{}\n\tif err != nil {\n\t\treturn jsonResp.CommonFailure(FailureContent, err)\n\t}\n\n\treturn jsonResp.Success(SuccessContent, nil)\n}\n\nfunc (j *JsonResponse) Success(message string, data interface{}) string {\n\treturn j.response(ResponseSuccess, message, data)\n}\n\nfunc (j *JsonResponse) Failure(code int, message string) string {\n\treturn j.response(code, message, nil)\n}\n\nfunc (j *JsonResponse) CommonFailure(message string, err ...error) string {\n\tif len(err) > 0 {\n\t\tlogger.Warn(err)\n\t}\n\treturn j.Failure(ResponseFailure, message)\n}\n\nfunc (j *JsonResponse) response(code int, message string, data interface{}) string {\n\tresp := response{\n\t\tCode:    code,\n\t\tMessage: message,\n\t\tData:    data,\n\t}\n\n\tresult, err := json.Marshal(resp)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t}\n\n\treturn string(result)\n}\n"
  },
  {
    "path": "internal/modules/utils/login_limiter.go",
    "content": "package utils\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\nconst (\n\t// MaxLoginAttempts 最大登录失败次数\n\tMaxLoginAttempts = 5\n\t// LockDuration 账户锁定时长\n\tLockDuration = 10 * time.Minute\n\t// CleanupInterval 清理过期记录的间隔\n\tCleanupInterval = 30 * time.Minute\n)\n\n// LoginAttempt 登录尝试记录\ntype LoginAttempt struct {\n\tCount      int       // 失败次数\n\tLockedUtil time.Time // 锁定到期时间\n}\n\n// LoginLimiter 登录限制器\ntype LoginLimiter struct {\n\tattempts map[string]*LoginAttempt\n\tmu       sync.RWMutex\n}\n\nvar limiter *LoginLimiter\n\nfunc init() {\n\tlimiter = &LoginLimiter{\n\t\tattempts: make(map[string]*LoginAttempt),\n\t}\n\t// 启动定期清理\n\tgo limiter.cleanup()\n}\n\n// GetLoginLimiter 获取登录限制器实例\nfunc GetLoginLimiter() *LoginLimiter {\n\treturn limiter\n}\n\n// IsLocked 检查账户是否被锁定\nfunc (l *LoginLimiter) IsLocked(username string) (bool, time.Time) {\n\tl.mu.RLock()\n\tdefer l.mu.RUnlock()\n\n\tattempt, exists := l.attempts[username]\n\tif !exists {\n\t\treturn false, time.Time{}\n\t}\n\n\t// 检查是否达到最大失败次数且在锁定期内\n\tif attempt.Count >= MaxLoginAttempts && time.Now().Before(attempt.LockedUtil) {\n\t\treturn true, attempt.LockedUtil\n\t}\n\n\treturn false, time.Time{}\n}\n\n// RecordFailure 记录登录失败\nfunc (l *LoginLimiter) RecordFailure(username string) {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\n\tattempt, exists := l.attempts[username]\n\tif !exists {\n\t\tattempt = &LoginAttempt{Count: 0}\n\t\tl.attempts[username] = attempt\n\t}\n\n\t// 如果已过锁定期，重置计数\n\tif !attempt.LockedUtil.IsZero() && time.Now().After(attempt.LockedUtil) {\n\t\tattempt.Count = 0\n\t\tattempt.LockedUtil = time.Time{}\n\t}\n\n\tattempt.Count++\n\n\t// 达到最大失败次数，锁定账户\n\tif attempt.Count >= MaxLoginAttempts {\n\t\tattempt.LockedUtil = time.Now().Add(LockDuration)\n\t}\n}\n\n// RecordSuccess 记录登录成功，清除失败记录\nfunc (l *LoginLimiter) RecordSuccess(username string) {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\n\tdelete(l.attempts, username)\n}\n\n// GetRemainingAttempts 获取剩余尝试次数\nfunc (l *LoginLimiter) GetRemainingAttempts(username string) int {\n\tl.mu.RLock()\n\tdefer l.mu.RUnlock()\n\n\tattempt, exists := l.attempts[username]\n\tif !exists {\n\t\treturn MaxLoginAttempts\n\t}\n\n\t// 如果已过锁定期，返回最大次数\n\tif !attempt.LockedUtil.IsZero() && time.Now().After(attempt.LockedUtil) {\n\t\treturn MaxLoginAttempts\n\t}\n\n\t// 如果已经被锁定，返回0\n\tif attempt.Count >= MaxLoginAttempts {\n\t\treturn 0\n\t}\n\n\tremaining := MaxLoginAttempts - attempt.Count\n\tif remaining < 0 {\n\t\treturn 0\n\t}\n\treturn remaining\n}\n\n// cleanup 定期清理过期的记录\nfunc (l *LoginLimiter) cleanup() {\n\tticker := time.NewTicker(CleanupInterval)\n\tdefer ticker.Stop()\n\n\tfor range ticker.C {\n\t\tl.mu.Lock()\n\t\tnow := time.Now()\n\t\tfor username, attempt := range l.attempts {\n\t\t\t// 清理已过期的锁定记录\n\t\t\tif !attempt.LockedUtil.IsZero() && now.After(attempt.LockedUtil.Add(CleanupInterval)) {\n\t\t\t\tdelete(l.attempts, username)\n\t\t\t}\n\t\t}\n\t\tl.mu.Unlock()\n\t}\n}\n"
  },
  {
    "path": "internal/modules/utils/login_limiter_test.go",
    "content": "package utils\n\nimport (\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestLoginLimiter_IsLocked(t *testing.T) {\n\tlimiter := &LoginLimiter{\n\t\tattempts: make(map[string]*LoginAttempt),\n\t}\n\n\tusername := \"testuser\"\n\n\t// 初始状态不应该被锁定\n\tlocked, _ := limiter.IsLocked(username)\n\tif locked {\n\t\tt.Error(\"User should not be locked initially\")\n\t}\n\n\t// 记录5次失败\n\tfor i := 0; i < MaxLoginAttempts; i++ {\n\t\tlimiter.RecordFailure(username)\n\t}\n\n\t// 应该被锁定\n\tlocked, lockTime := limiter.IsLocked(username)\n\tif !locked {\n\t\tt.Error(\"User should be locked after max attempts\")\n\t}\n\tif lockTime.IsZero() {\n\t\tt.Error(\"Lock time should be set\")\n\t}\n}\n\nfunc TestLoginLimiter_RecordSuccess(t *testing.T) {\n\tlimiter := &LoginLimiter{\n\t\tattempts: make(map[string]*LoginAttempt),\n\t}\n\n\tusername := \"testuser\"\n\n\t// 记录几次失败\n\tlimiter.RecordFailure(username)\n\tlimiter.RecordFailure(username)\n\n\t// 记录成功，应该清除失败记录\n\tlimiter.RecordSuccess(username)\n\n\tremaining := limiter.GetRemainingAttempts(username)\n\tif remaining != MaxLoginAttempts {\n\t\tt.Errorf(\"Expected %d remaining attempts, got %d\", MaxLoginAttempts, remaining)\n\t}\n}\n\nfunc TestLoginLimiter_GetRemainingAttempts(t *testing.T) {\n\tlimiter := &LoginLimiter{\n\t\tattempts: make(map[string]*LoginAttempt),\n\t}\n\n\tusername := \"testuser\"\n\n\t// 初始应该有最大次数\n\tremaining := limiter.GetRemainingAttempts(username)\n\tif remaining != MaxLoginAttempts {\n\t\tt.Errorf(\"Expected %d remaining attempts, got %d\", MaxLoginAttempts, remaining)\n\t}\n\n\t// 记录2次失败\n\tlimiter.RecordFailure(username)\n\tlimiter.RecordFailure(username)\n\n\tremaining = limiter.GetRemainingAttempts(username)\n\texpected := MaxLoginAttempts - 2\n\tif remaining != expected {\n\t\tt.Errorf(\"Expected %d remaining attempts, got %d\", expected, remaining)\n\t}\n}\n\nfunc TestLoginLimiter_LockExpiration(t *testing.T) {\n\tlimiter := &LoginLimiter{\n\t\tattempts: make(map[string]*LoginAttempt),\n\t}\n\n\tusername := \"testuser\"\n\n\t// 手动设置一个已过期的锁定\n\tlimiter.attempts[username] = &LoginAttempt{\n\t\tCount:      MaxLoginAttempts,\n\t\tLockedUtil: time.Now().Add(-1 * time.Minute), // 1分钟前过期\n\t}\n\n\t// 应该不再被锁定\n\tlocked, _ := limiter.IsLocked(username)\n\tif locked {\n\t\tt.Error(\"User should not be locked after expiration\")\n\t}\n\n\t// 剩余次数应该恢复\n\tremaining := limiter.GetRemainingAttempts(username)\n\tif remaining != MaxLoginAttempts {\n\t\tt.Errorf(\"Expected %d remaining attempts after expiration, got %d\", MaxLoginAttempts, remaining)\n\t}\n}\n"
  },
  {
    "path": "internal/modules/utils/password.go",
    "content": "package utils\n\nimport (\n\t\"regexp\"\n\t\"unicode\"\n)\n\n// PasswordMinLength 密码最小长度\nconst PasswordMinLength = 8\n\n// ValidatePassword 验证密码复杂度\n// 要求：至少8位，包含字母和数字\nfunc ValidatePassword(password string) (bool, string) {\n\tif len(password) < PasswordMinLength {\n\t\treturn false, \"password_min_length_8\"\n\t}\n\n\thasLetter := false\n\thasDigit := false\n\n\tfor _, char := range password {\n\t\tif unicode.IsLetter(char) {\n\t\t\thasLetter = true\n\t\t}\n\t\tif unicode.IsDigit(char) {\n\t\t\thasDigit = true\n\t\t}\n\t}\n\n\tif !hasLetter || !hasDigit {\n\t\treturn false, \"password_must_contain_letter_and_digit\"\n\t}\n\n\treturn true, \"\"\n}\n\n// ValidatePasswordStrong 验证强密码\n// 要求：至少8位，包含大小写字母、数字和特殊字符\nfunc ValidatePasswordStrong(password string) (bool, string) {\n\tif len(password) < PasswordMinLength {\n\t\treturn false, \"password_min_length_8\"\n\t}\n\n\thasUpper := false\n\thasLower := false\n\thasDigit := false\n\thasSpecial := false\n\n\tspecialChars := regexp.MustCompile(`[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]`)\n\n\tfor _, char := range password {\n\t\tif unicode.IsUpper(char) {\n\t\t\thasUpper = true\n\t\t}\n\t\tif unicode.IsLower(char) {\n\t\t\thasLower = true\n\t\t}\n\t\tif unicode.IsDigit(char) {\n\t\t\thasDigit = true\n\t\t}\n\t}\n\n\tif specialChars.MatchString(password) {\n\t\thasSpecial = true\n\t}\n\n\tif !hasUpper || !hasLower || !hasDigit || !hasSpecial {\n\t\treturn false, \"password_must_contain_upper_lower_digit_special\"\n\t}\n\n\treturn true, \"\"\n}\n"
  },
  {
    "path": "internal/modules/utils/password_test.go",
    "content": "package utils\n\nimport \"testing\"\n\nfunc TestValidatePassword(t *testing.T) {\n\ttests := []struct {\n\t\tpassword string\n\t\tvalid    bool\n\t\terrKey   string\n\t}{\n\t\t{\"abc123\", false, \"password_min_length_8\"},\n\t\t{\"abcdefgh\", false, \"password_must_contain_letter_and_digit\"},\n\t\t{\"12345678\", false, \"password_must_contain_letter_and_digit\"},\n\t\t{\"abc12345\", true, \"\"},\n\t\t{\"Test1234\", true, \"\"},\n\t\t{\"password123\", true, \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tvalid, errKey := ValidatePassword(tt.password)\n\t\tif valid != tt.valid {\n\t\t\tt.Errorf(\"ValidatePassword(%q) valid = %v, want %v\", tt.password, valid, tt.valid)\n\t\t}\n\t\tif errKey != tt.errKey {\n\t\t\tt.Errorf(\"ValidatePassword(%q) errKey = %q, want %q\", tt.password, errKey, tt.errKey)\n\t\t}\n\t}\n}\n\nfunc TestValidatePasswordStrong(t *testing.T) {\n\ttests := []struct {\n\t\tpassword string\n\t\tvalid    bool\n\t\terrKey   string\n\t}{\n\t\t{\"abc123\", false, \"password_min_length_8\"},\n\t\t{\"Abcd1234\", false, \"password_must_contain_upper_lower_digit_special\"},\n\t\t{\"Abcd123!\", true, \"\"},\n\t\t{\"Test@123\", true, \"\"},\n\t\t{\"P@ssw0rd\", true, \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tvalid, errKey := ValidatePasswordStrong(tt.password)\n\t\tif valid != tt.valid {\n\t\t\tt.Errorf(\"ValidatePasswordStrong(%q) valid = %v, want %v\", tt.password, valid, tt.valid)\n\t\t}\n\t\tif errKey != tt.errKey {\n\t\t\tt.Errorf(\"ValidatePasswordStrong(%q) errKey = %q, want %q\", tt.password, errKey, tt.errKey)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/modules/utils/utils.go",
    "content": "package utils\n\nimport (\n\t\"bytes\"\n\t\"crypto/md5\"\n\tcrand \"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"os\"\n\t\"runtime\"\n\t\"strings\"\n\t\"text/template\"\n\t\"time\"\n\n\t\"golang.org/x/crypto/bcrypt\"\n\t\"golang.org/x/text/encoding/simplifiedchinese\"\n\t\"golang.org/x/text/transform\"\n)\n\nfunc RandAuthToken() string {\n\tbuf := make([]byte, 32)\n\t_, err := crand.Read(buf)\n\tif err != nil {\n\t\treturn RandString(64)\n\t}\n\n\treturn fmt.Sprintf(\"%x\", buf)\n}\n\n// 生成长度为length的随机字符串\nfunc RandString(length int64) string {\n\tsources := []byte(\"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\")\n\tvar result []byte\n\tr := rand.New(rand.NewSource(time.Now().UnixNano()))\n\tsourceLength := len(sources)\n\tvar i int64 = 0\n\tfor ; i < length; i++ {\n\t\tresult = append(result, sources[r.Intn(sourceLength)])\n\t}\n\n\treturn string(result)\n}\n\n// 生成32位MD5摘要\n// 已弃用：不应用于密码哈希，仅用于非安全场景如API签名\nfunc Md5(str string) string {\n\tm := md5.New()\n\tm.Write([]byte(str))\n\treturn hex.EncodeToString(m.Sum(nil))\n}\n\n// HashPassword 使用bcrypt安全地哈希密码\nfunc HashPassword(password string) (string, error) {\n\thashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)\n\treturn string(hashed), err\n}\n\n// VerifyPassword 验证密码（支持bcrypt和旧的MD5格式）\nfunc VerifyPassword(hashedPassword, password, salt string) bool {\n\t// bcrypt格式以$2a$, $2b$, $2y$开头\n\tif strings.HasPrefix(hashedPassword, \"$2\") {\n\t\terr := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))\n\t\treturn err == nil\n\t}\n\t// 旧的MD5格式（向后兼容）\n\treturn hashedPassword == Md5(password+salt)\n}\n\n// Sha256 生成SHA256哈希（用于API签名等非密码场景）\nfunc Sha256(str string) string {\n\th := sha256.New()\n\th.Write([]byte(str))\n\treturn hex.EncodeToString(h.Sum(nil))\n}\n\n// 生成0-max之间随机数\nfunc RandNumber(max int) int {\n\tr := rand.New(rand.NewSource(time.Now().UnixNano()))\n\n\treturn r.Intn(max)\n}\n\n// GBK编码转换为UTF8\nfunc GBK2UTF8(s string) (string, bool) {\n\tdecoder := simplifiedchinese.GBK.NewDecoder()\n\tresult, _, err := transform.String(decoder, s)\n\treturn result, err == nil\n}\n\n// 批量替换字符串\nfunc ReplaceStrings(s string, old []string, replace []string) string {\n\tif s == \"\" {\n\t\treturn s\n\t}\n\tif len(old) != len(replace) {\n\t\treturn s\n\t}\n\n\tfor i, v := range old {\n\t\ts = strings.Replace(s, v, replace[i], 1000)\n\t}\n\n\treturn s\n}\n\nfunc InStringSlice(slice []string, element string) bool {\n\telement = strings.TrimSpace(element)\n\tfor _, v := range slice {\n\t\tif strings.TrimSpace(v) == element {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// 转义json特殊字符\nfunc EscapeJson(s string) string {\n\tspecialChars := []string{\"\\\\\", \"\\b\", \"\\f\", \"\\n\", \"\\r\", \"\\t\", \"\\\"\"}\n\treplaceChars := []string{\"\\\\\\\\\", \"\\\\b\", \"\\\\f\", \"\\\\n\", \"\\\\r\", \"\\\\t\", \"\\\\\\\"\"}\n\n\treturn ReplaceStrings(s, specialChars, replaceChars)\n}\n\n// 判断文件是否存在及是否有权限访问\nfunc FileExist(file string) bool {\n\t_, err := os.Stat(file)\n\tif os.IsNotExist(err) {\n\t\treturn false\n\t}\n\tif os.IsPermission(err) {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\n// PrintAppVersion 打印应用版本\nfunc PrintAppVersion(appVersion, GitCommit, BuildDate string) {\n\tversionInfo, err := FormatAppVersion(appVersion, GitCommit, BuildDate)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tfmt.Println(versionInfo)\n}\n\n// FormatAppVersion 格式化应用版本信息\nfunc FormatAppVersion(appVersion, GitCommit, BuildDate string) (string, error) {\n\tcontent := `\n   Version: {{.Version}}\nGo Version: {{.GoVersion}}\nGit Commit: {{.GitCommit}}\n     Built: {{.BuildDate}}\n   OS/ARCH: {{.GOOS}}/{{.GOARCH}}\n`\n\ttpl, err := template.New(\"version\").Parse(content)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tvar buf bytes.Buffer\n\terr = tpl.Execute(&buf, map[string]string{\n\t\t\"Version\":   appVersion,\n\t\t\"GoVersion\": runtime.Version(),\n\t\t\"GitCommit\": GitCommit,\n\t\t\"BuildDate\": BuildDate,\n\t\t\"GOOS\":      runtime.GOOS,\n\t\t\"GOARCH\":    runtime.GOARCH,\n\t})\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn buf.String(), err\n}\n\n// PanicToError Panic转换为error\nfunc PanicToError(f func()) (err error) {\n\tdefer func() {\n\t\tif e := recover(); e != nil {\n\t\t\terr = fmt.Errorf(\"%s\", PanicTrace(e))\n\t\t}\n\t}()\n\tf()\n\treturn\n}\n\n// PanicTrace panic调用链跟踪\nfunc PanicTrace(err interface{}) string {\n\tstackBuf := make([]byte, 4096)\n\tn := runtime.Stack(stackBuf, false)\n\n\treturn fmt.Sprintf(\"panic: %v %s\", err, stackBuf[:n])\n}\n\n// IsWindows 判断是否为Windows系统\nfunc IsWindows() bool {\n\treturn runtime.GOOS == \"windows\"\n}\n"
  },
  {
    "path": "internal/modules/utils/utils_test.go",
    "content": "package utils\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"golang.org/x/text/encoding/simplifiedchinese\"\n\t\"golang.org/x/text/transform\"\n)\n\nfunc TestRandAuthToken(t *testing.T) {\n\ttoken := RandAuthToken()\n\tif len(token) != 64 {\n\t\tt.Fatalf(\"expected length 64, got %d\", len(token))\n\t}\n\tif matched := regexp.MustCompile(`^[0-9a-f]+$`).MatchString(token); !matched {\n\t\tt.Fatalf(\"token should be hex, got %s\", token)\n\t}\n}\n\nfunc TestRandString(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tlength int64\n\t}{\n\t\t{\"zero\", 0},\n\t\t{\"positive\", 16},\n\t}\n\tcharset := \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := RandString(tt.length)\n\t\t\tif int64(len(got)) != tt.length {\n\t\t\t\tt.Fatalf(\"expected length %d, got %d\", tt.length, len(got))\n\t\t\t}\n\t\t\tfor _, c := range got {\n\t\t\t\tif !strings.ContainsRune(charset, c) {\n\t\t\t\t\tt.Fatalf(\"unexpected rune %q in result %q\", c, got)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestMd5(t *testing.T) {\n\tgot := Md5(\"gocron\")\n\tconst expect = \"9a34de944ae472434f79c0eb612ca724\"\n\tif got != expect {\n\t\tt.Fatalf(\"expected %s, got %s\", expect, got)\n\t}\n}\n\nfunc TestRandNumber(t *testing.T) {\n\tconst max = 10\n\tfor i := 0; i < 100; i++ {\n\t\tn := RandNumber(max)\n\t\tif n < 0 || n >= max {\n\t\t\tt.Fatalf(\"number out of range: %d\", n)\n\t\t}\n\t}\n}\n\nfunc TestGBK2UTF8(t *testing.T) {\n\tencoder := simplifiedchinese.GBK.NewEncoder()\n\tgbkStr, _, _ := transform.String(encoder, \"你好\")\n\tutf8Str, ok := GBK2UTF8(gbkStr)\n\tif !ok {\n\t\tt.Fatal(\"expected conversion success\")\n\t}\n\tif utf8Str != \"你好\" {\n\t\tt.Fatalf(\"expected 你好, got %s\", utf8Str)\n\t}\n}\n\nfunc TestReplaceStrings(t *testing.T) {\n\tt.Run(\"empty input\", func(t *testing.T) {\n\t\tif got := ReplaceStrings(\"\", []string{\"a\"}, []string{\"b\"}); got != \"\" {\n\t\t\tt.Fatalf(\"expected empty string, got %s\", got)\n\t\t}\n\t})\n\tt.Run(\"length mismatch\", func(t *testing.T) {\n\t\toriginal := \"foo\"\n\t\tif got := ReplaceStrings(original, []string{\"f\"}, []string{\"b\", \"c\"}); got != original {\n\t\t\tt.Fatalf(\"expected original string, got %s\", got)\n\t\t}\n\t})\n\tt.Run(\"replace success\", func(t *testing.T) {\n\t\tinput := \"a\\nb\\tc\\\"\"\n\t\tgot := ReplaceStrings(input, []string{\"\\n\", \"\\t\", \"\\\"\"}, []string{\"N\", \"T\", \"Q\"})\n\t\tif got != \"aNbTcQ\" {\n\t\t\tt.Fatalf(\"unexpected replace result %s\", got)\n\t\t}\n\t})\n}\n\nfunc TestInStringSlice(t *testing.T) {\n\tif !InStringSlice([]string{\" foo \", \"bar\"}, \"foo\") {\n\t\tt.Fatal(\"expected to find trimmed element\")\n\t}\n\tif InStringSlice([]string{\"foo\"}, \"bar\") {\n\t\tt.Fatal(\"did not expect to find missing element\")\n\t}\n}\n\nfunc TestEscapeJson(t *testing.T) {\n\tinput := \"line1\\n\\\"quote\\\"\\t\\\\slash\"\n\tgot := EscapeJson(input)\n\texpect := \"line1\\\\n\\\\\\\"quote\\\\\\\"\\\\t\\\\\\\\slash\"\n\tif got != expect {\n\t\tt.Fatalf(\"expected %s, got %s\", expect, got)\n\t}\n}\n\nfunc TestFileExist(t *testing.T) {\n\ttempDir := t.TempDir()\n\tfile := filepath.Join(tempDir, \"test.txt\")\n\tif err := os.WriteFile(file, []byte(\"data\"), 0o600); err != nil {\n\t\tt.Fatalf(\"failed to write temp file: %v\", err)\n\t}\n\tif !FileExist(file) {\n\t\tt.Fatal(\"expected file to exist\")\n\t}\n\tif FileExist(filepath.Join(tempDir, \"missing.txt\")) {\n\t\tt.Fatal(\"expected missing file to return false\")\n\t}\n}\n\nfunc TestFormatAppVersion(t *testing.T) {\n\tinfo, err := FormatAppVersion(\"1.2.3\", \"abcdef\", \"2024-01-01\")\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tfor _, expect := range []string{\"1.2.3\", \"abcdef\", \"2024-01-01\", runtime.Version(), runtime.GOOS + \"/\" + runtime.GOARCH} {\n\t\tif !strings.Contains(info, expect) {\n\t\t\tt.Fatalf(\"expected output to contain %s, got %s\", expect, info)\n\t\t}\n\t}\n}\n\nfunc TestPanicToError(t *testing.T) {\n\tt.Run(\"panic captured\", func(t *testing.T) {\n\t\terr := PanicToError(func() {\n\t\t\tpanic(\"boom\")\n\t\t})\n\t\tif err == nil {\n\t\t\tt.Fatal(\"expected error\")\n\t\t}\n\t\tif !strings.Contains(err.Error(), \"boom\") {\n\t\t\tt.Fatalf(\"expected error to contain panic message, got %v\", err)\n\t\t}\n\t})\n\tt.Run(\"no panic\", func(t *testing.T) {\n\t\tif err := PanicToError(func() {}); err != nil {\n\t\t\tt.Fatalf(\"did not expect error, got %v\", err)\n\t\t}\n\t})\n}\n\nfunc TestPanicTrace(t *testing.T) {\n\ttrace := PanicTrace(\"boom\")\n\tif !strings.Contains(trace, \"panic:\") || !strings.Contains(trace, \"boom\") {\n\t\tt.Fatalf(\"unexpected panic trace: %s\", trace)\n\t}\n}\n"
  },
  {
    "path": "internal/modules/utils/utils_unix.go",
    "content": "//go:build !windows\n// +build !windows\n\npackage utils\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n)\n\ntype Result struct {\n\toutput string\n\terr    error\n}\n\n// 执行shell命令，可设置执行超时时间\n// 改进：将命令写入临时脚本执行，即使超时或被取消，也会返回已产生的输出\nfunc ExecShell(ctx context.Context, command string) (string, error) {\n\t// 清理可能存在的 HTML 实体编码\n\tcommand = CleanHTMLEntities(command)\n\t// 将换行符统一替换为Unix风格的\\n\n\tcommand = strings.ReplaceAll(command, \"\\r\\n\", \"\\n\")\n\n\t// 创建临时文件来存储命令，按照指定格式命名\n\ttmpDir := \"/tmp\"\n\ttimestamp := time.Now().Format(\"20060102150405\")\n\tscriptPattern := fmt.Sprintf(\"gocron_%s_*.sh\", timestamp)\n\n\ttmpFile, err := os.CreateTemp(tmpDir, scriptPattern)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"创建临时脚本文件失败: %w\", err)\n\t}\n\tdefer os.Remove(tmpFile.Name()) // 执行完毕后删除临时文件\n\tdefer tmpFile.Close()\n\n\t// 将命令写入临时文件\n\t_, err = tmpFile.WriteString(command)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"写入脚本内容失败: %w\", err)\n\t}\n\n\t// 确保文件写入磁盘\n\terr = tmpFile.Sync()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"同步文件失败: %w\", err)\n\t}\n\n\t// 给脚本文件添加执行权限\n\terr = os.Chmod(tmpFile.Name(), 0700)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"设置脚本执行权限失败: %w\", err)\n\t}\n\n\t// 使用 /bin/bash 命令执行脚本文件\n\tscriptPath := tmpFile.Name()\n\tcmd := exec.Command(\"/bin/bash\", scriptPath)\n\tcmd.SysProcAttr = &syscall.SysProcAttr{\n\t\tSetpgid: true,\n\t}\n\t// 设置工作目录为用户家目录，避免 getcwd 错误\n\tif homeDir, err := os.UserHomeDir(); err == nil {\n\t\tcmd.Dir = homeDir\n\t} else {\n\t\tcmd.Dir = tmpDir\n\t}\n\n\t// 使用管道实时捕获输出\n\tstdout, err := cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tstderr, err := cmd.StderrPipe()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 用于收集输出\n\tvar outputBuffer bytes.Buffer\n\tvar mu sync.Mutex\n\tvar wg sync.WaitGroup\n\n\t// 启动命令\n\tif err := cmd.Start(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 实时读取 stdout 和 stderr\n\twg.Add(2)\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tbuf := make([]byte, 1024)\n\t\tfor {\n\t\t\tn, err := stdout.Read(buf)\n\t\t\tif n > 0 {\n\t\t\t\tmu.Lock()\n\t\t\t\toutputBuffer.Write(buf[:n])\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}()\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tbuf := make([]byte, 1024)\n\t\tfor {\n\t\t\tn, err := stderr.Read(buf)\n\t\t\tif n > 0 {\n\t\t\t\tmu.Lock()\n\t\t\t\toutputBuffer.Write(buf[:n])\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}()\n\n\t// 等待命令完成或超时\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\tdone <- cmd.Wait()\n\t}()\n\n\tselect {\n\tcase <-ctx.Done():\n\t\t// 超时或被取消，尝试优雅终止\n\t\tif cmd.Process != nil && cmd.Process.Pid > 0 {\n\t\t\t// 先发送 SIGTERM，给进程清理的机会\n\t\t\t_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)\n\n\t\t\t// 等待 2 秒，看进程是否自行退出\n\t\t\ttimer := time.NewTimer(2 * time.Second)\n\t\t\tselect {\n\t\t\tcase <-done:\n\t\t\t\ttimer.Stop()\n\t\t\tcase <-timer.C:\n\t\t\t\t// 进程仍未退出，强制杀死\n\t\t\t\t_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)\n\t\t\t\t<-done // 等待 Wait() 返回\n\t\t\t}\n\t\t}\n\n\t\t// 等待 IO 读取完成\n\t\twg.Wait()\n\n\t\t// 返回已捕获的输出和错误信息\n\t\tmu.Lock()\n\t\toutput := outputBuffer.String()\n\t\tmu.Unlock()\n\t\treturn output, errors.New(\"timeout killed\")\n\n\tcase err := <-done:\n\t\t// 命令正常完成\n\t\twg.Wait()\n\t\tmu.Lock()\n\t\toutput := outputBuffer.String()\n\t\tmu.Unlock()\n\t\treturn output, err\n\t}\n}\n"
  },
  {
    "path": "internal/modules/utils/utils_unix_test.go",
    "content": "//go:build !windows\n// +build !windows\n\npackage utils\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestExecShellSuccess(t *testing.T) {\n\tctx := context.Background()\n\toutput, err := ExecShell(ctx, \"echo 'hello world'\")\n\tif err != nil {\n\t\tt.Fatalf(\"Expected no error, got: %v\", err)\n\t}\n\tif !strings.Contains(output, \"hello world\") {\n\t\tt.Fatalf(\"Expected output to contain 'hello world', got: %s\", output)\n\t}\n}\n\nfunc TestExecShellTimeout(t *testing.T) {\n\tctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)\n\tdefer cancel()\n\n\t// 运行一个会产生输出然后睡眠的命令\n\toutput, err := ExecShell(ctx, \"echo 'partial output'; sleep 1; echo 'should not see this'\")\n\n\tif err == nil {\n\t\tt.Fatal(\"Expected timeout error\")\n\t}\n\tif err.Error() != \"timeout killed\" {\n\t\tt.Fatalf(\"Expected 'timeout killed' error, got: %v\", err)\n\t}\n\tif !strings.Contains(output, \"partial output\") {\n\t\tt.Fatalf(\"Expected partial output to contain 'partial output', got: %s\", output)\n\t}\n\tif strings.Contains(output, \"should not see this\") {\n\t\tt.Fatalf(\"Should not contain output after timeout, got: %s\", output)\n\t}\n}\n\nfunc TestExecShellCancel(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\n\t// 启动一个长时间运行的命令\n\tgo func() {\n\t\ttime.Sleep(50 * time.Millisecond)\n\t\tcancel() // 手动取消\n\t}()\n\n\toutput, err := ExecShell(ctx, \"echo 'before cancel'; sleep 1; echo 'after cancel'\")\n\n\tif err == nil {\n\t\tt.Fatal(\"Expected cancel error\")\n\t}\n\tif err.Error() != \"timeout killed\" {\n\t\tt.Fatalf(\"Expected 'timeout killed' error, got: %v\", err)\n\t}\n\tif !strings.Contains(output, \"before cancel\") {\n\t\tt.Fatalf(\"Expected partial output to contain 'before cancel', got: %s\", output)\n\t}\n}\n\nfunc TestExecShellCommandError(t *testing.T) {\n\tctx := context.Background()\n\toutput, err := ExecShell(ctx, \"nonexistentcommand\")\n\n\tif err == nil {\n\t\tt.Fatal(\"Expected command error\")\n\t}\n\t// 应该有错误输出\n\tif output == \"\" {\n\t\tt.Fatal(\"Expected some error output\")\n\t}\n}\n"
  },
  {
    "path": "internal/modules/utils/utils_windows.go",
    "content": "//go:build windows\n// +build windows\n\npackage utils\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"golang.org/x/text/encoding/simplifiedchinese\"\n\t\"golang.org/x/text/transform\"\n)\n\ntype Result struct {\n\toutput string\n\terr    error\n}\n\n// 执行shell命令，可设置执行超时时间\n// 改进：将命令写入临时批处理文件执行，即使超时或被取消，也会返回已产生的输出\nfunc ExecShell(ctx context.Context, command string) (string, error) {\n\t// 清理可能存在的 HTML 实体编码,防止 &quot; 等导致命令执行失败\n\t// 例如: del &quot;C:\\file.txt&quot; -> del \"C:\\file.txt\"\n\tcommand = CleanHTMLEntities(command)\n\n\t// 将换行符统一替换为Windows风格的\\r\\n\n\tcommand = strings.ReplaceAll(command, \"\\r\\n\", \"\\n\")\n\tcommand = strings.ReplaceAll(command, \"\\n\", \"\\r\\n\")\n\n\t// 创建带时间戳的临时批处理文件名\n\ttimestamp := time.Now().Format(\"20060102150405\") // 年月日时分秒\n\n\t// 使用 os.CreateTemp 创建临时文件\n\tbatFile, err := os.CreateTemp(os.TempDir(), fmt.Sprintf(\"gocron_%s_*.bat\", timestamp))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"创建临时批处理文件失败: %w\", err)\n\t}\n\tdefer os.Remove(batFile.Name()) // 确保函数退出时删除临时文件\n\tdefer batFile.Close()\n\n\t// 将命令写入批处理文件\n\tcontent := \"@echo off\\r\\n\" + command\n\n\t// 使用 ANSI 编码 (GBK) 写入批处理文件\n\tgbkWriter := transform.NewWriter(batFile, simplifiedchinese.GBK.NewEncoder())\n\t_, err = io.WriteString(gbkWriter, content)\n\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"写入批处理文件失败: %w\", err)\n\t}\n\n\t// 确保文件内容写入磁盘\n\terr = batFile.Sync()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"同步批处理文件失败: %w\", err)\n\t}\n\n\t// 使用 cmd.exe 执行批处理文件\n\tcmd := exec.Command(\"cmd\")\n\tcmd.SysProcAttr = &syscall.SysProcAttr{\n\t\tHideWindow: true,\n\t\tCmdLine:    `cmd /c \"` + batFile.Name() + `\"`,\n\t}\n\t// 设置工作目录为用户家目录，避免 getcwd 错误\n\tif homeDir, err := os.UserHomeDir(); err == nil {\n\t\tcmd.Dir = homeDir\n\t} else {\n\t\tcmd.Dir = os.TempDir()\n\t}\n\n\t// 使用管道实时捕获输出\n\tstdout, err := cmd.StdoutPipe()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tstderr, err := cmd.StderrPipe()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 用于收集输出\n\tvar outputBuffer bytes.Buffer\n\tvar wg sync.WaitGroup\n\n\t// 启动命令\n\tif err := cmd.Start(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 实时读取 stdout 和 stderr\n\tvar mu sync.Mutex\n\twg.Add(2)\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tbuf := make([]byte, 1024)\n\t\tfor {\n\t\t\tn, err := stdout.Read(buf)\n\t\t\tif n > 0 {\n\t\t\t\tmu.Lock()\n\t\t\t\toutputBuffer.Write(buf[:n])\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}()\n\tgo func() {\n\t\tdefer wg.Done()\n\t\tbuf := make([]byte, 1024)\n\t\tfor {\n\t\t\tn, err := stderr.Read(buf)\n\t\t\tif n > 0 {\n\t\t\t\tmu.Lock()\n\t\t\t\toutputBuffer.Write(buf[:n])\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}()\n\n\t// 等待命令完成或超时\n\tdone := make(chan error, 1)\n\tgo func() {\n\t\tdone <- cmd.Wait()\n\t}()\n\n\tselect {\n\tcase <-ctx.Done():\n\t\t// 超时或被取消，尝试终止进程\n\t\tif cmd.Process != nil && cmd.Process.Pid > 0 {\n\t\t\t// Windows 下先尝试正常终止\n\t\t\tcmd.Process.Kill()\n\n\t\t\t// 等待 2 秒，看进程是否退出\n\t\t\ttimer := time.NewTimer(2 * time.Second)\n\t\t\tselect {\n\t\t\tcase <-done:\n\t\t\t\ttimer.Stop()\n\t\t\tcase <-timer.C:\n\t\t\t\t// 强制杀死进程树\n\t\t\t\texec.Command(\"taskkill\", \"/F\", \"/T\", \"/PID\", strconv.Itoa(cmd.Process.Pid)).Run()\n\t\t\t\t<-done\n\t\t\t}\n\t\t}\n\n\t\t// 等待 IO 读取完成\n\t\twg.Wait()\n\n\t\t// 返回已捕获的输出（转换编码）和错误信息\n\t\tmu.Lock()\n\t\toutput := outputBuffer.String()\n\t\tmu.Unlock()\n\t\treturn ConvertEncoding(output), errors.New(\"timeout killed\")\n\n\tcase err := <-done:\n\t\t// 命令正常完成\n\t\twg.Wait()\n\t\tmu.Lock()\n\t\toutput := outputBuffer.String()\n\t\tmu.Unlock()\n\t\treturn ConvertEncoding(output), err\n\t}\n}\n\nfunc ConvertEncoding(outputGBK string) string {\n\t// windows平台编码为gbk，需转换为utf8才能入库\n\toutputUTF8, ok := GBK2UTF8(outputGBK)\n\tif ok {\n\t\treturn outputUTF8\n\t}\n\n\treturn outputGBK\n}\n"
  },
  {
    "path": "internal/modules/utils/utils_windows_test.go",
    "content": "//go:build windows\n// +build windows\n\npackage utils\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestExecShellWithQuotes(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcommand string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:    \"Simple command without quotes\",\n\t\t\tcommand: \"echo hello\",\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"Command with double quotes\",\n\t\t\tcommand: `dir \"C:\\Program Files\"`,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"Copy command with quoted paths\",\n\t\t\tcommand: `copy \"C:\\My Documents\\report.docx\" \"D:\\Backup\"`,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"Mkdir with quoted path\",\n\t\t\tcommand: `mkdir \"C:\\Users\\John Doe\\Projects\"`,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"Command with HTML entity quotes\",\n\t\t\tcommand: `copy &quot;C:\\My Documents\\report.docx&quot; &quot;D:\\Backup&quot;`,\n\t\t\twantErr: false, // HTML实体会被CleanHTMLEntities清理，所以应该成功\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\t\tdefer cancel()\n\n\t\t\toutput, err := ExecShell(ctx, tt.command)\n\t\t\tt.Logf(\"Command: %s\\nOutput: %s\\nError: %v\", tt.command, output, err)\n\n\t\t\tif (err != nil) != tt.wantErr {\n\t\t\t\tt.Errorf(\"ExecShell() error = %v, wantErr %v\", err, tt.wantErr)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/routers/agent/agent.go",
    "content": "package agent\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/i18n\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n)\n\nconst tokenExpiration = 3 * time.Hour\n\n// GenerateToken 生成注册token\nfunc GenerateToken(c *gin.Context) {\n\ttoken := generateRandomToken()\n\texpiresAt := time.Now().Add(tokenExpiration)\n\n\tagentToken := &models.AgentToken{\n\t\tToken:     token,\n\t\tExpiresAt: expiresAt,\n\t}\n\n\tif err := agentToken.Create(); err != nil {\n\t\tlogger.Error(\"创建token失败:\", err)\n\t\tbase.RespondError(c, i18n.T(c, \"operation_failed\"), err)\n\t\treturn\n\t}\n\n\tserverURL := getServerURL(c)\n\tinstallCmdLinux := fmt.Sprintf(\"curl -fsSL '%s/api/agent/install.sh?token=%s' | bash\", serverURL, token)\n\n\tbase.RespondSuccess(c, i18n.T(c, \"operation_success\"), map[string]interface{}{\n\t\t\"token\":       token,\n\t\t\"expires_at\":  expiresAt,\n\t\t\"install_cmd\": installCmdLinux,\n\t})\n}\n\n// InstallScript 返回安装脚本\nfunc InstallScript(c *gin.Context) {\n\t// 验证token\n\ttoken := c.Query(\"token\")\n\tif token == \"\" {\n\t\tc.String(http.StatusBadRequest, \"Token is required\")\n\t\treturn\n\t}\n\n\t// 验证token有效性\n\tagentToken := &models.AgentToken{}\n\tif err := agentToken.FindByToken(token); err != nil {\n\t\tc.String(http.StatusUnauthorized, \"Invalid token\")\n\t\treturn\n\t}\n\n\tif time.Now().After(agentToken.ExpiresAt) {\n\t\tc.String(http.StatusUnauthorized, \"Token expired\")\n\t\treturn\n\t}\n\n\tscript := `#!/bin/bash\nset -e\n\n# 安全检查：禁止使用 root 用户运行\nif [ \"$(id -u)\" = \"0\" ]; then\n    echo \"Error: This script should NOT be run as root for security reasons.\"\n    echo \"Please run as a regular user with sudo privileges.\"\n    echo \"Example: su - youruser -c 'curl -fsSL ... | bash'\"\n    exit 1\nfi\n\n# Token is embedded in the script URL, extract it here\nTOKEN=\"` + token + `\"\nif [ -z \"$TOKEN\" ]; then\n    echo \"Error: Token is required\"\n    exit 1\nfi\n\nGOCRON_SERVER=\"` + getServerURL(c) + `\"\nINSTALL_DIR=\"/opt/gocron-node\"\nSERVICE_NAME=\"gocron-node\"\n\nARCH=$(uname -m)\ncase $ARCH in\n    x86_64) ARCH=\"amd64\" ;;\n    aarch64|arm64) ARCH=\"arm64\" ;;\n    *) echo \"Unsupported architecture: $ARCH\"; exit 1 ;;\nesac\n\nOS=$(uname -s | tr '[:upper:]' '[:lower:]')\nif [ \"$OS\" != \"linux\" ] && [ \"$OS\" != \"darwin\" ]; then\n    echo \"This script is for Linux/macOS. For Windows, use PowerShell script.\"\n    echo \"PowerShell command:\"\n    echo \"  iwr -useb ` + getServerURL(c) + `/api/agent/install.ps1 | iex\"\n    exit 1\nfi\n\necho \"Installing gocron-node for $OS-$ARCH...\"\n\n# 检测本地服务器是否有安装包\nLOCAL_DOWNLOAD_URL=\"${GOCRON_SERVER}/api/agent/download?os=${OS}&arch=${ARCH}\"\necho \"Checking local server for installation package...\"\n\n# 使用 HEAD 请求检测，-w %{http_code} 获取状态码，-o /dev/null 不输出内容\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" \"$LOCAL_DOWNLOAD_URL\")\n\nTMP_DIR=$(mktemp -d)\ncd \"$TMP_DIR\"\n\nif [ \"$HTTP_CODE\" = \"200\" ]; then\n    # 本地有安装包，直接下载\n    echo \"✓ Local package found, downloading from local server...\"\n    DOWNLOAD_URL=\"$LOCAL_DOWNLOAD_URL\"\nelif [ \"$HTTP_CODE\" = \"302\" ]; then\n    # 本地没有，需要从 GitHub 下载\n    echo \"✗ Local package not found on server\"\n    echo \"→ Downloading from GitHub (this may take a while or require network access)...\"\n    GITHUB_REPO=\"gocronx-team/gocron\"\n    if [ \"$OS\" = \"windows\" ]; then\n        DOWNLOAD_URL=\"https://github.com/${GITHUB_REPO}/releases/latest/download/gocron-node-${OS}-${ARCH}.zip\"\n    else\n        DOWNLOAD_URL=\"https://github.com/${GITHUB_REPO}/releases/latest/download/gocron-node-${OS}-${ARCH}.tar.gz\"\n    fi\nelse\n    echo \"✗ Failed to check server status (HTTP $HTTP_CODE)\"\n    echo \"→ Trying GitHub as fallback...\"\n    GITHUB_REPO=\"gocronx-team/gocron\"\n    if [ \"$OS\" = \"windows\" ]; then\n        DOWNLOAD_URL=\"https://github.com/${GITHUB_REPO}/releases/latest/download/gocron-node-${OS}-${ARCH}.zip\"\n    else\n        DOWNLOAD_URL=\"https://github.com/${GITHUB_REPO}/releases/latest/download/gocron-node-${OS}-${ARCH}.tar.gz\"\n    fi\nfi\n\necho \"Downloading from: $DOWNLOAD_URL\"\nif [ \"$OS\" = \"windows\" ]; then\n    curl -fsSL \"$DOWNLOAD_URL\" -o gocron-node.zip\n    unzip -q gocron-node.zip\nelse\n    curl -fsSL \"$DOWNLOAD_URL\" -o gocron-node.tar.gz\n    tar -xzf gocron-node.tar.gz\nfi\n\nsudo mkdir -p \"$INSTALL_DIR\"\nsudo cp -r gocron-node*/* \"$INSTALL_DIR/\"\nsudo chmod +x \"$INSTALL_DIR/gocron-node\"\n\necho \"Registering agent...\"\n# 获取本机IP地址，如果失败则使用hostname\nif [ \"$OS\" = \"darwin\" ]; then\n    HOSTNAME=$(ipconfig getifaddr en0 2>/dev/null || hostname)\nelif [ \"$OS\" = \"linux\" ]; then\n    HOSTNAME=$(hostname -I 2>/dev/null | awk '{print $1}' || hostname)\nelse\n    HOSTNAME=$(hostname)\nfi\necho \"Using hostname/IP: $HOSTNAME\"\nREGISTER_URL=\"${GOCRON_SERVER}/api/agent/register\"\nRESPONSE=$(curl -fsSL -X POST \"$REGISTER_URL\" \\\n    -H \"Content-Type: application/json\" \\\n    -d \"{\\\"token\\\":\\\"$TOKEN\\\",\\\"hostname\\\":\\\"$HOSTNAME\\\"}\")\n\nif echo \"$RESPONSE\" | grep -q '\"code\":0'; then\n    echo \"Agent registered successfully\"\nelse\n    echo \"Failed to register agent: $RESPONSE\"\n    exit 1\nfi\n\nif [ \"$OS\" = \"linux\" ]; then\n    sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOF\n[Unit]\nDescription=Gocron Node Agent\nAfter=network.target\n\n[Service]\nType=simple\nUser=$(whoami)\nWorkingDirectory=$INSTALL_DIR\nExecStart=$INSTALL_DIR/gocron-node\nRestart=on-failure\nRestartSec=5s\n\n[Install]\nWantedBy=multi-user.target\nEOF\n    sudo systemctl daemon-reload\n    sudo systemctl enable ${SERVICE_NAME}\n    sudo systemctl start ${SERVICE_NAME}\n    echo \"Service status:\"\n    sudo systemctl status ${SERVICE_NAME} --no-pager\nelif [ \"$OS\" = \"darwin\" ]; then\n    echo \"macOS detected. Starting gocron-node...\"\n    # 先停止已存在的进程\n    pkill -f gocron-node 2>/dev/null || true\n    sleep 1\n    nohup $INSTALL_DIR/gocron-node > /tmp/gocron-node.log 2>&1 &\n    echo \"gocron-node started in background (PID: $!)\"\n    echo \"Log file: /tmp/gocron-node.log\"\nfi\n\ncd /\nrm -rf \"$TMP_DIR\"\n\necho \"\"\necho \"========================================\"\necho \"Installation completed successfully!\"\necho \"========================================\"\necho \"\"\necho \"Agent Management Commands:\"\necho \"\"\nif [ \"$OS\" = \"linux\" ]; then\n    echo \"  Start:   sudo systemctl start ${SERVICE_NAME}\"\n    echo \"  Stop:    sudo systemctl stop ${SERVICE_NAME}\"\n    echo \"  Restart: sudo systemctl restart ${SERVICE_NAME}\"\n    echo \"  Status:  sudo systemctl status ${SERVICE_NAME}\"\n    echo \"  Logs:    sudo journalctl -u ${SERVICE_NAME} -f\"\n    echo \"\"\n    echo \"Uninstall:\"\n    echo \"  sudo systemctl stop ${SERVICE_NAME}\"\n    echo \"  sudo systemctl disable ${SERVICE_NAME}\"\n    echo \"  sudo rm /etc/systemd/system/${SERVICE_NAME}.service\"\n    echo \"  sudo systemctl daemon-reload\"\n    echo \"  sudo rm -rf ${INSTALL_DIR}\"\nelif [ \"$OS\" = \"darwin\" ]; then\n    echo \"  Stop:    pkill -f gocron-node\"\n    echo \"  Start:   nohup ${INSTALL_DIR}/gocron-node > /tmp/gocron-node.log 2>&1 &\"\n    echo \"  Logs:    tail -f /tmp/gocron-node.log\"\n    echo \"  Status:  ps aux | grep gocron-node | grep -v grep\"\n    echo \"\"\n    echo \"Uninstall:\"\n    echo \"  pkill -f gocron-node\"\n    echo \"  sudo rm -rf ${INSTALL_DIR}\"\n    echo \"  rm /tmp/gocron-node.log\"\nfi\necho \"\"\necho \"Installation directory: ${INSTALL_DIR}\"\necho \"========================================\"\n`\n\n\tc.Data(http.StatusOK, \"text/plain; charset=utf-8\", []byte(script))\n}\n\n// Register agent注册\nfunc Register(c *gin.Context) {\n\tvar req struct {\n\t\tToken    string `json:\"token\" binding:\"required\"`\n\t\tHostname string `json:\"hostname\" binding:\"required\"`\n\t}\n\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tbase.RespondError(c, \"Invalid request\", err)\n\t\treturn\n\t}\n\n\tagentToken := &models.AgentToken{}\n\tif err := agentToken.FindByToken(req.Token); err != nil {\n\t\tbase.RespondError(c, \"Invalid token\")\n\t\treturn\n\t}\n\n\tif time.Now().After(agentToken.ExpiresAt) {\n\t\tbase.RespondError(c, \"Token expired\")\n\t\treturn\n\t}\n\tif agentToken.Used {\n\t\tbase.RespondError(c, \"Token already used\")\n\t\treturn\n\t}\n\n\t// 原子地领取 token：用 WHERE used=false 的条件更新避免并发复用竞态\n\tclaim := models.Db.Model(&models.AgentToken{}).\n\t\tWhere(\"token = ? AND used = ?\", req.Token, false).\n\t\tUpdates(map[string]interface{}{\"used\": true, \"used_at\": time.Now()})\n\tif claim.Error != nil {\n\t\tbase.RespondError(c, \"Operation failed\", claim.Error)\n\t\treturn\n\t}\n\tif claim.RowsAffected == 0 {\n\t\tbase.RespondError(c, \"Token already used\")\n\t\treturn\n\t}\n\n\thost := &models.Host{\n\t\tName:   req.Hostname,\n\t\tAlias:  req.Hostname,\n\t\tPort:   5921,\n\t\tRemark: \"Auto registered\",\n\t}\n\n\texists, err := host.NameExists(req.Hostname, 0)\n\tif err != nil {\n\t\tlogger.Error(\"检查主机是否存在失败:\", err)\n\t\tbase.RespondError(c, \"Operation failed\", err)\n\t\treturn\n\t}\n\n\tif !exists {\n\t\tif _, err := host.Create(); err != nil {\n\t\t\tlogger.Error(\"创建主机失败:\", err)\n\t\t\tbase.RespondError(c, \"Failed to create host\", err)\n\t\t\treturn\n\t\t}\n\t\tlogger.Infof(\"主机注册成功: %s\", req.Hostname)\n\t} else {\n\t\tlogger.Infof(\"主机已存在，跳过创建: %s\", req.Hostname)\n\t}\n\n\tbase.RespondSuccess(c, \"Registration successful\", nil)\n}\n\n// Download 优先从本地 gocron-node-package 目录下载，如果不存在则重定向到 GitHub Release\nfunc Download(c *gin.Context) {\n\tosName := c.Query(\"os\")\n\tarch := c.Query(\"arch\")\n\n\tif osName == \"\" || arch == \"\" {\n\t\tc.String(http.StatusBadRequest, \"os and arch are required\")\n\t\treturn\n\t}\n\n\t// 安全检查: 白名单验证,防止路径遍历攻击\n\tvalidOS := map[string]bool{\n\t\t\"linux\":   true,\n\t\t\"darwin\":  true,\n\t\t\"windows\": true,\n\t}\n\tvalidArch := map[string]bool{\n\t\t\"amd64\": true,\n\t\t\"arm64\": true,\n\t\t\"386\":   true,\n\t}\n\n\tif !validOS[osName] {\n\t\tlogger.Warnf(\"非法的 os 参数: %s\", osName)\n\t\tc.String(http.StatusBadRequest, \"invalid os parameter\")\n\t\treturn\n\t}\n\n\tif !validArch[arch] {\n\t\tlogger.Warnf(\"非法的 arch 参数: %s\", arch)\n\t\tc.String(http.StatusBadRequest, \"invalid arch parameter\")\n\t\treturn\n\t}\n\n\t// 根据操作系统选择文件扩展名\n\text := \".tar.gz\"\n\tif osName == \"windows\" {\n\t\text = \".zip\"\n\t}\n\n\tfilename := fmt.Sprintf(\"gocron-node-%s-%s%s\", osName, arch, ext)\n\n\t// 获取可执行文件所在目录\n\texecPath, err := os.Executable()\n\tif err != nil {\n\t\tlogger.Errorf(\"获取可执行文件路径失败: %v\", err)\n\t\t// 降级到 GitHub\n\t\tgithubURL := fmt.Sprintf(\"https://github.com/gocronx-team/gocron/releases/latest/download/%s\", filename)\n\t\tlogger.Warnf(\"✗ 无法获取可执行文件路径，重定向到 GitHub: %s\", githubURL)\n\t\tc.Redirect(http.StatusFound, githubURL)\n\t\treturn\n\t}\n\n\texecDir := filepath.Dir(execPath)\n\n\t// 优先检查本地 gocron-node-package 目录（相对于可执行文件所在目录）\n\tpackageDir := filepath.Join(execDir, \"gocron-node-package\")\n\tlocalPath := filepath.Join(packageDir, filename)\n\n\tlogger.Infof(\"下载请求: os=%s, arch=%s, 查找路径: %s\", osName, arch, localPath)\n\n\t// 安全检查: 确保最终路径在 packageDir 内,防止路径遍历\n\tcleanPath := filepath.Clean(localPath)\n\tcleanPackageDir := filepath.Clean(packageDir)\n\n\t// 使用 filepath.Rel 检查路径关系\n\trel, err := filepath.Rel(cleanPackageDir, cleanPath)\n\tif err != nil || len(rel) > 0 && (rel[0] == '.' && len(rel) > 1 && rel[1] == '.') {\n\t\tlogger.Warnf(\"检测到路径遍历攻击尝试: %s (相对路径: %s)\", localPath, rel)\n\t\tc.String(http.StatusBadRequest, \"invalid file path\")\n\t\treturn\n\t}\n\n\t// 检查文件是否存在\n\tif _, err := os.Stat(cleanPath); err == nil {\n\t\tlogger.Infof(\"✓ 本地安装包存在，提供文件: %s\", cleanPath)\n\t\tc.File(cleanPath)\n\t\treturn\n\t}\n\n\t// 本地文件不存在，重定向到 GitHub Release\n\tgithubURL := fmt.Sprintf(\"https://github.com/gocronx-team/gocron/releases/latest/download/%s\", filename)\n\tlogger.Warnf(\"✗ 本地安装包不存在 (%s)，重定向到 GitHub: %s\", localPath, githubURL)\n\tc.Redirect(http.StatusFound, githubURL)\n}\n\nfunc generateRandomToken() string {\n\tb := make([]byte, 32)\n\t_, _ = rand.Read(b)\n\treturn hex.EncodeToString(b)\n}\n\nfunc getServerURL(c *gin.Context) string {\n\tscheme := \"http\"\n\tif c.Request.TLS != nil {\n\t\tscheme = \"https\"\n\t}\n\treturn fmt.Sprintf(\"%s://%s\", scheme, c.Request.Host)\n}\n"
  },
  {
    "path": "internal/routers/audit/audit.go",
    "content": "package audit\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n)\n\n// Index lists audit logs with pagination and optional filters.\n// Query params: page, page_size, module, action, username, start_date, end_date\nfunc Index(c *gin.Context) {\n\tauditLogModel := new(models.AuditLog)\n\tparams := models.CommonMap{}\n\tbase.ParsePageAndPageSize(c, params)\n\n\tif module := c.Query(\"module\"); module != \"\" {\n\t\tparams[\"Module\"] = module\n\t}\n\tif action := c.Query(\"action\"); action != \"\" {\n\t\tparams[\"Action\"] = action\n\t}\n\tif username := c.Query(\"username\"); username != \"\" {\n\t\tparams[\"Username\"] = username\n\t}\n\tif startDate := c.Query(\"start_date\"); startDate != \"\" {\n\t\tparams[\"StartDate\"] = startDate\n\t}\n\tif endDate := c.Query(\"end_date\"); endDate != \"\" {\n\t\tparams[\"EndDate\"] = endDate\n\t}\n\n\ttotal, err := auditLogModel.Total(params)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tlist, err := auditLogModel.List(params)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\n\tbase.RespondSuccess(c, utils.SuccessContent, map[string]interface{}{\n\t\t\"total\": total,\n\t\t\"data\":  list,\n\t})\n}\n"
  },
  {
    "path": "internal/routers/audit/audit_test.go",
    "content": "package audit\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/schema\"\n)\n\ntype apiResponse struct {\n\tCode    int             `json:\"code\"`\n\tMessage string          `json:\"message\"`\n\tData    json.RawMessage `json:\"data\"`\n}\n\ntype auditListData struct {\n\tTotal int64             `json:\"total\"`\n\tData  []models.AuditLog `json:\"data\"`\n}\n\nfunc setupAuditTestRouter(t *testing.T) (*gin.Engine, func()) {\n\tt.Helper()\n\tgin.SetMode(gin.TestMode)\n\n\toriginalDb := models.Db\n\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{\n\t\tNamingStrategy: schema.NamingStrategy{\n\t\t\tSingularTable: true,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open test database: %v\", err)\n\t}\n\n\tif err := db.AutoMigrate(&models.AuditLog{}); err != nil {\n\t\tt.Fatalf(\"failed to migrate test database: %v\", err)\n\t}\n\n\tmodels.Db = db\n\n\tr := gin.New()\n\tr.GET(\"/api/audit\", Index)\n\n\tcleanup := func() {\n\t\tmodels.Db = originalDb\n\t}\n\n\treturn r, cleanup\n}\n\nfunc TestAuditIndex_Empty(t *testing.T) {\n\tr, cleanup := setupAuditTestRouter(t)\n\tdefer cleanup()\n\n\tw := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/api/audit\", nil)\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200, got %d\", w.Code)\n\t}\n\n\tvar resp apiResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to parse response: %v\", err)\n\t}\n\n\tif resp.Code != 0 {\n\t\tt.Errorf(\"expected code 0, got %d\", resp.Code)\n\t}\n\n\tvar data auditListData\n\tif err := json.Unmarshal(resp.Data, &data); err != nil {\n\t\tt.Fatalf(\"failed to parse data: %v\", err)\n\t}\n\n\tif data.Total != 0 {\n\t\tt.Errorf(\"expected total 0, got %d\", data.Total)\n\t}\n\tif len(data.Data) != 0 {\n\t\tt.Errorf(\"expected empty list, got %d items\", len(data.Data))\n\t}\n}\n\nfunc TestAuditIndex_WithData(t *testing.T) {\n\tr, cleanup := setupAuditTestRouter(t)\n\tdefer cleanup()\n\n\t// Insert test records\n\tentries := []models.AuditLog{\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"host\", Action: \"delete\"},\n\t\t{Username: \"bob\", Ip: \"10.0.0.1\", Module: \"user\", Action: \"update\"},\n\t}\n\tfor i := range entries {\n\t\tif _, err := entries[i].Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tw := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/api/audit\", nil)\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200, got %d\", w.Code)\n\t}\n\n\tvar resp apiResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to parse response: %v\", err)\n\t}\n\n\tif resp.Code != 0 {\n\t\tt.Errorf(\"expected code 0, got %d\", resp.Code)\n\t}\n\n\tvar data auditListData\n\tif err := json.Unmarshal(resp.Data, &data); err != nil {\n\t\tt.Fatalf(\"failed to parse data: %v\", err)\n\t}\n\n\tif data.Total != 3 {\n\t\tt.Errorf(\"expected total 3, got %d\", data.Total)\n\t}\n\tif len(data.Data) != 3 {\n\t\tt.Errorf(\"expected 3 items, got %d\", len(data.Data))\n\t}\n}\n\nfunc TestAuditIndex_FilterByModule(t *testing.T) {\n\tr, cleanup := setupAuditTestRouter(t)\n\tdefer cleanup()\n\n\tentries := []models.AuditLog{\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"host\", Action: \"delete\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"delete\"},\n\t}\n\tfor i := range entries {\n\t\tif _, err := entries[i].Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tw := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/api/audit?module=task\", nil)\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200, got %d\", w.Code)\n\t}\n\n\tvar resp apiResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to parse response: %v\", err)\n\t}\n\n\tvar data auditListData\n\tif err := json.Unmarshal(resp.Data, &data); err != nil {\n\t\tt.Fatalf(\"failed to parse data: %v\", err)\n\t}\n\n\tif data.Total != 2 {\n\t\tt.Errorf(\"expected total 2, got %d\", data.Total)\n\t}\n\tfor _, item := range data.Data {\n\t\tif item.Module != \"task\" {\n\t\t\tt.Errorf(\"expected module 'task', got '%s'\", item.Module)\n\t\t}\n\t}\n}\n\nfunc TestAuditIndex_FilterByAction(t *testing.T) {\n\tr, cleanup := setupAuditTestRouter(t)\n\tdefer cleanup()\n\n\tentries := []models.AuditLog{\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"host\", Action: \"delete\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t}\n\tfor i := range entries {\n\t\tif _, err := entries[i].Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tw := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/api/audit?action=create\", nil)\n\tr.ServeHTTP(w, req)\n\n\tvar resp apiResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to parse response: %v\", err)\n\t}\n\n\tvar data auditListData\n\tif err := json.Unmarshal(resp.Data, &data); err != nil {\n\t\tt.Fatalf(\"failed to parse data: %v\", err)\n\t}\n\n\tif data.Total != 2 {\n\t\tt.Errorf(\"expected total 2 for action=create, got %d\", data.Total)\n\t}\n}\n\nfunc TestAuditIndex_FilterByUsername(t *testing.T) {\n\tr, cleanup := setupAuditTestRouter(t)\n\tdefer cleanup()\n\n\tentries := []models.AuditLog{\n\t\t{Username: \"alice\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t\t{Username: \"bob\", Ip: \"127.0.0.1\", Module: \"host\", Action: \"delete\"},\n\t\t{Username: \"alice_admin\", Ip: \"127.0.0.1\", Module: \"user\", Action: \"update\"},\n\t}\n\tfor i := range entries {\n\t\tif _, err := entries[i].Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tw := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/api/audit?username=alice\", nil)\n\tr.ServeHTTP(w, req)\n\n\tvar resp apiResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to parse response: %v\", err)\n\t}\n\n\tvar data auditListData\n\tif err := json.Unmarshal(resp.Data, &data); err != nil {\n\t\tt.Fatalf(\"failed to parse data: %v\", err)\n\t}\n\n\tif data.Total != 2 {\n\t\tt.Errorf(\"expected total 2 for username LIKE alice, got %d\", data.Total)\n\t}\n}\n\nfunc TestAuditIndex_Pagination(t *testing.T) {\n\tr, cleanup := setupAuditTestRouter(t)\n\tdefer cleanup()\n\n\tfor i := 0; i < 5; i++ {\n\t\tlog := &models.AuditLog{\n\t\t\tUsername: \"admin\",\n\t\t\tIp:       \"127.0.0.1\",\n\t\t\tModule:   \"task\",\n\t\t\tAction:   \"create\",\n\t\t}\n\t\tif _, err := log.Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tw := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/api/audit?page=1&page_size=3\", nil)\n\tr.ServeHTTP(w, req)\n\n\tvar resp apiResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to parse response: %v\", err)\n\t}\n\n\tvar data auditListData\n\tif err := json.Unmarshal(resp.Data, &data); err != nil {\n\t\tt.Fatalf(\"failed to parse data: %v\", err)\n\t}\n\n\tif data.Total != 5 {\n\t\tt.Errorf(\"expected total 5, got %d\", data.Total)\n\t}\n\tif len(data.Data) != 3 {\n\t\tt.Errorf(\"expected 3 items on page 1, got %d\", len(data.Data))\n\t}\n}\n\nfunc TestAuditIndex_MultipleFilters(t *testing.T) {\n\tr, cleanup := setupAuditTestRouter(t)\n\tdefer cleanup()\n\n\tentries := []models.AuditLog{\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"delete\"},\n\t\t{Username: \"admin\", Ip: \"127.0.0.1\", Module: \"host\", Action: \"create\"},\n\t\t{Username: \"bob\", Ip: \"127.0.0.1\", Module: \"task\", Action: \"create\"},\n\t}\n\tfor i := range entries {\n\t\tif _, err := entries[i].Create(); err != nil {\n\t\t\tt.Fatalf(\"Create failed: %v\", err)\n\t\t}\n\t}\n\n\tw := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/api/audit?module=task&action=create&username=admin\", nil)\n\tr.ServeHTTP(w, req)\n\n\tvar resp apiResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to parse response: %v\", err)\n\t}\n\n\tvar data auditListData\n\tif err := json.Unmarshal(resp.Data, &data); err != nil {\n\t\tt.Fatalf(\"failed to parse data: %v\", err)\n\t}\n\n\tif data.Total != 1 {\n\t\tt.Errorf(\"expected total 1 for combined filters, got %d\", data.Total)\n\t}\n}\n"
  },
  {
    "path": "internal/routers/base/base.go",
    "content": "package base\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n)\n\n// ParsePageAndPageSize 解析查询参数中的页数和每页数量\nfunc ParsePageAndPageSize(c *gin.Context, params models.CommonMap) {\n\tpage, _ := strconv.Atoi(c.Query(\"page\"))\n\tpageSize, _ := strconv.Atoi(c.Query(\"page_size\"))\n\tif page <= 0 {\n\t\tpage = 1\n\t}\n\tif pageSize <= 0 {\n\t\tpageSize = models.PageSize\n\t}\n\n\tparams[\"Page\"] = page\n\tparams[\"PageSize\"] = pageSize\n}\n"
  },
  {
    "path": "internal/routers/base/response.go",
    "content": "package base\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n)\n\n// RespondSuccess 返回成功响应\nfunc RespondSuccess(c *gin.Context, message string, data interface{}) {\n\tjson := utils.JsonResponse{}\n\tresult := json.Success(message, data)\n\tc.String(http.StatusOK, result)\n}\n\n// RespondSuccessWithDefaultMsg 返回成功响应（使用默认消息）\nfunc RespondSuccessWithDefaultMsg(c *gin.Context, data interface{}) {\n\tjson := utils.JsonResponse{}\n\tresult := json.Success(utils.SuccessContent, data)\n\tc.String(http.StatusOK, result)\n}\n\n// RespondError 返回错误响应\nfunc RespondError(c *gin.Context, message string, err ...error) {\n\tjson := utils.JsonResponse{}\n\tif len(err) > 0 && err[0] != nil {\n\t\tlogger.Error(err[0])\n\t}\n\tresult := json.CommonFailure(message)\n\tc.String(http.StatusOK, result)\n}\n\n// RespondErrorWithDefaultMsg 返回错误响应（使用默认消息）\nfunc RespondErrorWithDefaultMsg(c *gin.Context, err ...error) {\n\tjson := utils.JsonResponse{}\n\tif len(err) > 0 && err[0] != nil {\n\t\tlogger.Error(err[0])\n\t}\n\tresult := json.CommonFailure(utils.FailureContent)\n\tc.String(http.StatusOK, result)\n}\n\n// RespondValidationError 返回表单验证错误响应\nfunc RespondValidationError(c *gin.Context, err error) {\n\tjson := utils.JsonResponse{}\n\tresult := json.CommonFailure(utils.FailureContent, err)\n\tc.String(http.StatusOK, result)\n}\n\n// RespondAuthError 返回认证错误响应\nfunc RespondAuthError(c *gin.Context, message string) {\n\tjson := utils.JsonResponse{}\n\tresult := json.Failure(utils.AuthError, message)\n\tc.String(http.StatusOK, result)\n}\n"
  },
  {
    "path": "internal/routers/host/host.go",
    "content": "package host\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/i18n\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/rpc/client\"\n\t\"github.com/gocronx-team/gocron/internal/modules/rpc/grpcpool\"\n\trpc \"github.com/gocronx-team/gocron/internal/modules/rpc/proto\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n\t\"github.com/gocronx-team/gocron/internal/service\"\n)\n\nconst testConnectionCommand = \"echo hello\"\nconst testConnectionTimeout = 5\n\n// Index 主机列表\nfunc Index(c *gin.Context) {\n\thostModel := new(models.Host)\n\tqueryParams := parseQueryParams(c)\n\ttotal, err := hostModel.Total(queryParams)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\thosts, err := hostModel.List(queryParams)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\n\tbase.RespondSuccess(c, utils.SuccessContent, map[string]interface{}{\n\t\t\"total\": total,\n\t\t\"data\":  hosts,\n\t})\n}\n\n// All 获取所有主机\nfunc All(c *gin.Context) {\n\thostModel := new(models.Host)\n\thostModel.PageSize = -1\n\thosts, err := hostModel.List(models.CommonMap{})\n\tif err != nil {\n\t\tlogger.Error(err)\n\t}\n\n\tbase.RespondSuccess(c, utils.SuccessContent, hosts)\n}\n\n// Detail 主机详情\nfunc Detail(c *gin.Context) {\n\thostModel := new(models.Host)\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\terr := hostModel.Find(id)\n\tif err != nil || hostModel.Id == 0 {\n\t\tlogger.Errorf(\"获取主机详情失败#主机id-%d\", id)\n\t\tbase.RespondSuccess(c, utils.SuccessContent, nil)\n\t} else {\n\t\tbase.RespondSuccess(c, utils.SuccessContent, hostModel)\n\t}\n}\n\ntype HostForm struct {\n\tId     int    `form:\"id\" json:\"id\"`\n\tName   string `form:\"name\" json:\"name\" binding:\"required,max=64\"`\n\tAlias  string `form:\"alias\" json:\"alias\" binding:\"required,max=32\"`\n\tPort   int    `form:\"port\" json:\"port\" binding:\"required,min=1,max=65535\"`\n\tRemark string `form:\"remark\" json:\"remark\"`\n}\n\n// Store 保存、修改主机信息\nfunc Store(c *gin.Context) {\n\tvar form HostForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tbase.RespondValidationError(c, err)\n\t\treturn\n\t}\n\n\thostModel := new(models.Host)\n\tid := form.Id\n\tnameExist, err := hostModel.NameExists(form.Name, form.Id)\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"operation_failed\"), err)\n\t\treturn\n\t}\n\tif nameExist {\n\t\tbase.RespondError(c, i18n.T(c, \"hostname_exists\"))\n\t\treturn\n\t}\n\n\thostModel.Name = strings.TrimSpace(form.Name)\n\thostModel.Alias = strings.TrimSpace(form.Alias)\n\thostModel.Port = form.Port\n\thostModel.Remark = strings.TrimSpace(form.Remark)\n\tisCreate := false\n\toldHostModel := new(models.Host)\n\n\tif id > 0 {\n\t\terr = oldHostModel.Find(int(id))\n\t\tif err != nil {\n\t\t\tbase.RespondError(c, i18n.T(c, \"host_not_exist\"))\n\t\t\treturn\n\t\t}\n\t\t_, err = hostModel.UpdateBean(id)\n\t} else {\n\t\tisCreate = true\n\t\tid, err = hostModel.Create()\n\t}\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"save_failed\"), err)\n\t\treturn\n\t}\n\n\tif !isCreate {\n\t\toldAddr := fmt.Sprintf(\"%s:%d\", oldHostModel.Name, oldHostModel.Port)\n\t\tnewAddr := fmt.Sprintf(\"%s:%d\", hostModel.Name, hostModel.Port)\n\t\tif oldAddr != newAddr {\n\t\t\tgrpcpool.Pool.Release(oldAddr)\n\t\t}\n\n\t\ttaskModel := new(models.Task)\n\t\ttasks, err := taskModel.ActiveListByHostId(id)\n\t\tif err != nil {\n\t\t\tbase.RespondError(c, i18n.T(c, \"refresh_task_host_failed\"), err)\n\t\t\treturn\n\t\t}\n\t\tservice.ServiceTask.BatchAdd(tasks)\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"save_success\"), nil)\n}\n\n// Remove 删除主机\nfunc Remove(c *gin.Context) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"param_error\"), err)\n\t\treturn\n\t}\n\ttaskHostModel := new(models.TaskHost)\n\texist, err := taskHostModel.HostIdExist(id)\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"operation_failed\"), err)\n\t\treturn\n\t}\n\tif exist {\n\t\tbase.RespondError(c, i18n.T(c, \"host_in_use_cannot_delete\"))\n\t\treturn\n\t}\n\n\thostModel := new(models.Host)\n\terr = hostModel.Find(int(id))\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"host_not_exist\"))\n\t\treturn\n\t}\n\n\t_, err = hostModel.Delete(id)\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"operation_failed\"), err)\n\t\treturn\n\t}\n\n\taddr := fmt.Sprintf(\"%s:%d\", hostModel.Name, hostModel.Port)\n\tgrpcpool.Pool.Release(addr)\n\n\tbase.RespondSuccess(c, i18n.T(c, \"operation_success\"), nil)\n}\n\n// Ping 测试主机是否可连接\nfunc Ping(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\thostModel := new(models.Host)\n\terr := hostModel.Find(id)\n\tif err != nil || hostModel.Id <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"host_not_exist\"), err)\n\t\treturn\n\t}\n\n\ttaskReq := &rpc.TaskRequest{}\n\ttaskReq.Command = testConnectionCommand\n\ttaskReq.Timeout = testConnectionTimeout\n\toutput, err := client.Exec(hostModel.Name, hostModel.Port, taskReq)\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"connection_failed\")+\"-\"+err.Error()+\" \"+output, err)\n\t} else {\n\t\tbase.RespondSuccess(c, i18n.T(c, \"connection_success\"), nil)\n\t}\n}\n\n// 解析查询参数\nfunc parseQueryParams(c *gin.Context) models.CommonMap {\n\tvar params = models.CommonMap{}\n\tid, _ := strconv.Atoi(c.Query(\"id\"))\n\tparams[\"Id\"] = id\n\tparams[\"Name\"] = strings.TrimSpace(c.Query(\"name\"))\n\tbase.ParsePageAndPageSize(c, params)\n\n\treturn params\n}\n"
  },
  {
    "path": "internal/routers/install/install.go",
    "content": "package install\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-sql-driver/mysql\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/app\"\n\t\"github.com/gocronx-team/gocron/internal/modules/setting\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n\t\"github.com/gocronx-team/gocron/internal/service\"\n\t\"github.com/lib/pq\"\n)\n\n// 系统安装\n\ntype InstallForm struct {\n\tDbType               string `form:\"db_type\" binding:\"required,oneof=mysql postgres sqlite\"`\n\tDbHost               string `form:\"db_host\" binding:\"max=50\"`\n\tDbPort               int    `form:\"db_port\" binding:\"min=0,max=65535\"`\n\tDbUsername           string `form:\"db_username\" binding:\"max=50\"`\n\tDbPassword           string `form:\"db_password\" binding:\"max=30\"`\n\tDbName               string `form:\"db_name\" binding:\"required,max=200\"`\n\tDbTablePrefix        string `form:\"db_table_prefix\" binding:\"max=20\"`\n\tAdminUsername        string `form:\"admin_username\" binding:\"required,min=3\"`\n\tAdminPassword        string `form:\"admin_password\" binding:\"required,min=6\"`\n\tConfirmAdminPassword string `form:\"confirm_admin_password\" binding:\"required,min=6\"`\n\tAdminEmail           string `form:\"admin_email\" binding:\"required,email,max=50\"`\n}\n\n// 安装\nfunc Store(c *gin.Context) {\n\tvar form InstallForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tbase.RespondError(c, \"表单验证失败, 请检测输入\")\n\t\treturn\n\t}\n\n\tif app.Installed {\n\t\tbase.RespondError(c, \"系统已安装!\")\n\t\treturn\n\t}\n\tif form.AdminPassword != form.ConfirmAdminPassword {\n\t\tbase.RespondError(c, \"两次输入密码不匹配\")\n\t\treturn\n\t}\n\terr := testDbConnection(form)\n\tif err != nil {\n\t\tbase.RespondError(c, err.Error())\n\t\treturn\n\t}\n\t// 写入数据库配置\n\terr = writeConfig(form)\n\tif err != nil {\n\t\tbase.RespondError(c, \"数据库配置写入文件失败\", err)\n\t\treturn\n\t}\n\n\tappConfig, err := setting.Read(app.AppConfig)\n\tif err != nil {\n\t\tbase.RespondError(c, \"读取应用配置失败\", err)\n\t\treturn\n\t}\n\tapp.Setting = appConfig\n\n\tmodels.Db = models.CreateDb()\n\t// 创建数据库表\n\tmigration := new(models.Migration)\n\terr = migration.Install(form.DbName)\n\tif err != nil {\n\t\tbase.RespondError(c, fmt.Sprintf(\"创建数据库表失败-%s\", err.Error()), err)\n\t\treturn\n\t}\n\n\t// 创建管理员账号\n\terr = createAdminUser(form)\n\tif err != nil {\n\t\tbase.RespondError(c, \"创建管理员账号失败\", err)\n\t\treturn\n\t}\n\n\t// 创建安装锁\n\terr = app.CreateInstallLock()\n\tif err != nil {\n\t\tbase.RespondError(c, \"创建文件安装锁失败\", err)\n\t\treturn\n\t}\n\n\t// 更新版本号文件\n\tapp.UpdateVersionFile()\n\n\t// 标记为已安装\n\tapp.Installed = true\n\t// 初始化并启动定时任务调度器\n\tservice.ServiceTask.Initialize()\n\tservice.ServiceTask.StartScheduler()\n\n\tbase.RespondSuccess(c, \"安装成功\", nil)\n}\n\n// 配置写入文件\nfunc writeConfig(form InstallForm) error {\n\tdbHost := form.DbHost\n\tdbPort := strconv.Itoa(form.DbPort)\n\tif form.DbType == \"sqlite\" {\n\t\tdbHost = \"\"\n\t\tdbPort = \"0\"\n\t}\n\tdbConfig := []string{\n\t\t\"db.engine\", form.DbType,\n\t\t\"db.host\", dbHost,\n\t\t\"db.port\", dbPort,\n\t\t\"db.user\", form.DbUsername,\n\t\t\"db.password\", form.DbPassword,\n\t\t\"db.database\", form.DbName,\n\t\t\"db.prefix\", form.DbTablePrefix,\n\t\t\"db.charset\", \"utf8\",\n\t\t\"db.max.idle.conns\", \"5\",\n\t\t\"db.max.open.conns\", \"100\",\n\t\t\"allow_ips\", \"\",\n\t\t\"app.name\", \"定时任务管理系统\", // 应用名称\n\t\t\"api.key\", \"\",\n\t\t\"api.secret\", \"\",\n\t\t\"enable_tls\", \"false\",\n\t\t\"concurrency.queue\", \"500\",\n\t\t\"auth_secret\", utils.RandAuthToken(),\n\t\t\"ca_file\", \"\",\n\t\t\"cert_file\", \"\",\n\t\t\"key_file\", \"\",\n\t}\n\n\treturn setting.Write(dbConfig, app.AppConfig)\n}\n\n// 创建管理员账号\nfunc createAdminUser(form InstallForm) error {\n\tuser := new(models.User)\n\tuser.Name = form.AdminUsername\n\tuser.Password = form.AdminPassword\n\tuser.Email = form.AdminEmail\n\tuser.IsAdmin = 1\n\t_, err := user.Create()\n\n\treturn err\n}\n\n// 测试数据库连接\nfunc testDbConnection(form InstallForm) error {\n\tvar s setting.Setting\n\ts.Db.Engine = form.DbType\n\ts.Db.Host = form.DbHost\n\ts.Db.Port = form.DbPort\n\ts.Db.User = form.DbUsername\n\ts.Db.Password = form.DbPassword\n\ts.Db.Database = form.DbName\n\ts.Db.Charset = \"utf8\"\n\n\t// SQLite 不需要测试连接，会自动创建文件\n\tif s.Db.Engine == \"sqlite\" {\n\t\treturn nil\n\t}\n\n\tdb, err := models.CreateTmpDb(&s)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer sqlDB.Close()\n\terr = sqlDB.Ping()\n\tif s.Db.Engine == \"postgres\" && err != nil {\n\t\tpgError, ok := err.(*pq.Error)\n\t\tif ok && pgError.Code == \"3D000\" {\n\t\t\terr = errors.New(\"数据库不存在\")\n\t\t}\n\t\treturn err\n\t}\n\n\tif s.Db.Engine == \"mysql\" && err != nil {\n\t\tmysqlError, ok := err.(*mysql.MySQLError)\n\t\tif ok && mysqlError.Number == 1049 {\n\t\t\terr = errors.New(\"数据库不存在\")\n\t\t}\n\t\treturn err\n\t}\n\n\treturn err\n\n}\n"
  },
  {
    "path": "internal/routers/loginlog/login_log.go",
    "content": "package loginlog\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n)\n\nfunc Index(c *gin.Context) {\n\tloginLogModel := new(models.LoginLog)\n\tparams := models.CommonMap{}\n\tbase.ParsePageAndPageSize(c, params)\n\ttotal, err := loginLogModel.Total()\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tloginLogs, err := loginLogModel.List(params)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\n\tbase.RespondSuccess(c, utils.SuccessContent, map[string]interface{}{\n\t\t\"total\": total,\n\t\t\"data\":  loginLogs,\n\t})\n}\n"
  },
  {
    "path": "internal/routers/manage/manage.go",
    "content": "package manage\n\nimport (\n\t\"encoding/json\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n\t\"github.com/gocronx-team/gocron/internal/service\"\n)\n\nfunc Slack(c *gin.Context) {\n\tsettingModel := new(models.Setting)\n\tslack, err := settingModel.Slack()\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tbase.RespondSuccess(c, utils.SuccessContent, slack)\n}\n\nfunc UpdateSlack(c *gin.Context) {\n\tvar form UpdateSlackForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tlogger.Errorf(\"Slack配置表单验证失败: %v\", err)\n\t\tbase.RespondError(c, \"表单验证失败, 请检测输入\")\n\t\treturn\n\t}\n\n\tsettingModel := new(models.Setting)\n\terr := settingModel.UpdateSlack(form.Url, form.Template)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\nfunc CreateSlackChannel(c *gin.Context) {\n\tvar form CreateSlackChannelForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tlogger.Errorf(\"创建Slack频道表单验证失败: %v\", err)\n\t\tbase.RespondError(c, \"表单验证失败, 请检测输入\")\n\t\treturn\n\t}\n\n\tsettingModel := new(models.Setting)\n\tif settingModel.IsChannelExist(form.Channel) {\n\t\tbase.RespondError(c, \"Channel已存在\")\n\t} else {\n\t\t_, err := settingModel.CreateChannel(form.Channel)\n\t\tif err != nil {\n\t\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\t} else {\n\t\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t\t}\n\t}\n}\n\nfunc RemoveSlackChannel(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\tsettingModel := new(models.Setting)\n\t_, err := settingModel.RemoveChannel(id)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\n// endregion\n\n// region 邮件\nfunc Mail(c *gin.Context) {\n\tsettingModel := new(models.Setting)\n\tmail, err := settingModel.Mail()\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tbase.RespondSuccess(c, \"\", mail)\n}\n\ntype MailServerForm struct {\n\tHost     string `form:\"host\" json:\"host\" binding:\"required,max=100\"`\n\tPort     int    `form:\"port\" json:\"port\" binding:\"required,min=1,max=65535\"`\n\tUser     string `form:\"user\" json:\"user\" binding:\"required,max=64\"`\n\tPassword string `form:\"password\" json:\"password\" binding:\"required,max=64\"`\n\tTemplate string `form:\"template\" json:\"template\"`\n}\n\n// CreateMailUserForm 创建邮件用户表单\ntype CreateMailUserForm struct {\n\tUsername string `form:\"username\" json:\"username\" binding:\"required,max=50\"`\n\tEmail    string `form:\"email\" json:\"email\" binding:\"required,email,max=100\"`\n}\n\n// UpdateSlackForm 更新Slack配置表单\ntype UpdateSlackForm struct {\n\tUrl      string `form:\"url\" json:\"url\" binding:\"required,url,max=200\"`\n\tTemplate string `form:\"template\" json:\"template\" binding:\"required\"`\n}\n\n// UpdateWebHookForm 更新WebHook配置表单\ntype UpdateWebHookForm struct {\n\tTemplate string `form:\"template\" json:\"template\" binding:\"required\"`\n}\n\n// CreateWebhookUrlForm 创建Webhook地址表单\ntype CreateWebhookUrlForm struct {\n\tName string `form:\"name\" json:\"name\" binding:\"required,max=50\"`\n\tUrl  string `form:\"url\" json:\"url\" binding:\"required,url,max=200\"`\n}\n\n// CreateSlackChannelForm 创建Slack频道表单\ntype CreateSlackChannelForm struct {\n\tChannel string `form:\"channel\" json:\"channel\" binding:\"required,max=50\"`\n}\n\nfunc UpdateMail(c *gin.Context) {\n\tvar form MailServerForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tlogger.Errorf(\"邮件配置表单验证失败: %v\", err)\n\t\t// 提供更具体的错误信息\n\t\terrorMsg := \"表单验证失败: \"\n\t\tif strings.Contains(err.Error(), \"email\") {\n\t\t\terrorMsg += \"用户名必须是有效的邮箱地址\"\n\t\t} else if strings.Contains(err.Error(), \"required\") {\n\t\t\terrorMsg += \"请填写所有必填字段\"\n\t\t} else if strings.Contains(err.Error(), \"max\") {\n\t\t\terrorMsg += \"输入内容过长\"\n\t\t} else if strings.Contains(err.Error(), \"min\") || strings.Contains(err.Error(), \"port\") {\n\t\t\terrorMsg += \"端口号必须在1-65535之间\"\n\t\t} else {\n\t\t\terrorMsg += \"请检查输入格式\"\n\t\t}\n\t\tbase.RespondError(c, errorMsg)\n\t\treturn\n\t}\n\n\t// 从表单中提取template，单独保存\n\ttemplate := strings.TrimSpace(form.Template)\n\n\t// 将服务器配置序列化为JSON（不包含template）\n\tserverConfig := struct {\n\t\tHost     string `json:\"host\"`\n\t\tPort     int    `json:\"port\"`\n\t\tUser     string `json:\"user\"`\n\t\tPassword string `json:\"password\"`\n\t}{\n\t\tHost:     form.Host,\n\t\tPort:     form.Port,\n\t\tUser:     form.User,\n\t\tPassword: form.Password,\n\t}\n\tjsonByte, _ := json.Marshal(serverConfig)\n\n\tsettingModel := new(models.Setting)\n\terr := settingModel.UpdateMail(string(jsonByte), template)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\nfunc CreateMailUser(c *gin.Context) {\n\tvar form CreateMailUserForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tlogger.Errorf(\"创建邮件用户表单验证失败: %v\", err)\n\t\tbase.RespondError(c, \"表单验证失败, 请检测输入\")\n\t\treturn\n\t}\n\n\tsettingModel := new(models.Setting)\n\t_, err := settingModel.CreateMailUser(form.Username, form.Email)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\nfunc RemoveMailUser(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\tsettingModel := new(models.Setting)\n\t_, err := settingModel.RemoveMailUser(id)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\nfunc WebHook(c *gin.Context) {\n\tsettingModel := new(models.Setting)\n\twebHook, err := settingModel.Webhook()\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tbase.RespondSuccess(c, \"\", webHook)\n}\n\nfunc UpdateWebHook(c *gin.Context) {\n\tvar form UpdateWebHookForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tlogger.Errorf(\"Webhook配置表单验证失败: %v\", err)\n\t\tbase.RespondError(c, \"表单验证失败, 请检测输入\")\n\t\treturn\n\t}\n\n\tsettingModel := new(models.Setting)\n\terr := settingModel.UpdateWebHook(form.Template)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\nfunc CreateWebhookUrl(c *gin.Context) {\n\tvar form CreateWebhookUrlForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tlogger.Errorf(\"创建Webhook地址表单验证失败: %v\", err)\n\t\tbase.RespondError(c, \"表单验证失败, 请检测输入\")\n\t\treturn\n\t}\n\n\tsettingModel := new(models.Setting)\n\t_, err := settingModel.CreateWebhookUrl(form.Name, form.Url)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\nfunc RemoveWebhookUrl(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\tsettingModel := new(models.Setting)\n\t_, err := settingModel.RemoveWebhookUrl(id)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\n// endregion\n\n// region 系统配置\nfunc GetLogRetentionDays(c *gin.Context) {\n\tsettingModel := new(models.Setting)\n\tdays := settingModel.GetLogRetentionDays()\n\tcleanupTime := settingModel.GetLogCleanupTime()\n\tfileSizeLimit := settingModel.GetLogFileSizeLimit()\n\tbase.RespondSuccess(c, \"\", map[string]interface{}{\n\t\t\"days\":            days,\n\t\t\"cleanup_time\":    cleanupTime,\n\t\t\"file_size_limit\": fileSizeLimit,\n\t})\n}\n\nfunc UpdateLogRetentionDays(c *gin.Context) {\n\tvar form struct {\n\t\tDays          int    `json:\"days\" binding:\"min=0,max=3650\"`\n\t\tCleanupTime   string `json:\"cleanup_time\" binding:\"required\"`\n\t\tFileSizeLimit int    `json:\"file_size_limit\" binding:\"min=0,max=10240\"`\n\t}\n\tif err := c.ShouldBindJSON(&form); err != nil {\n\t\tbase.RespondError(c, \"表单验证失败, 请检测输入\")\n\t\treturn\n\t}\n\n\tsettingModel := new(models.Setting)\n\terr := settingModel.UpdateLogRetentionDays(form.Days)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\terr = settingModel.UpdateLogCleanupTime(form.CleanupTime)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\terr = settingModel.UpdateLogFileSizeLimit(form.FileSizeLimit)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\t// 重新加载日志清理任务\n\tservice.ServiceTask.ReloadLogCleanupTask()\n\tbase.RespondSuccessWithDefaultMsg(c, nil)\n}\n\n// endregion\n"
  },
  {
    "path": "internal/routers/routers.go",
    "content": "package routers\n\nimport (\n\t\"context\"\n\t\"crypto/subtle\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\tgocronembed \"github.com/gocronx-team/gocron\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/app\"\n\t\"github.com/gocronx-team/gocron/internal/modules/i18n\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/agent\"\n\t\"github.com/gocronx-team/gocron/internal/routers/audit\"\n\t\"github.com/gocronx-team/gocron/internal/routers/host\"\n\t\"github.com/gocronx-team/gocron/internal/routers/install\"\n\t\"github.com/gocronx-team/gocron/internal/routers/loginlog\"\n\t\"github.com/gocronx-team/gocron/internal/routers/manage\"\n\t\"github.com/gocronx-team/gocron/internal/routers/statistics\"\n\t\"github.com/gocronx-team/gocron/internal/routers/task\"\n\t\"github.com/gocronx-team/gocron/internal/routers/tasklog\"\n\t\"github.com/gocronx-team/gocron/internal/routers/template\"\n\t\"github.com/gocronx-team/gocron/internal/routers/user\"\n)\n\nconst (\n\turlPrefix = \"/api\"\n)\n\nvar staticFS fs.FS\n\nfunc init() {\n\tvar err error\n\tstaticFS, err = gocronembed.StaticFS()\n\tif err != nil {\n\t\tlogger.Fatal(\"初始化静态文件系统失败\", err)\n\t}\n}\n\n// Register 路由泣册\nfunc Register(r *gin.Engine) {\n\tapi := r.Group(urlPrefix)\n\n\t// 系统安装\n\tinstallGroup := api.Group(\"/install\")\n\t{\n\t\tinstallGroup.POST(\"/store\", install.Store)\n\t\tinstallGroup.GET(\"/status\", func(c *gin.Context) {\n\t\t\tjsonResp := utils.JsonResponse{}\n\t\t\tc.String(http.StatusOK, jsonResp.Success(\"\", app.Installed))\n\t\t})\n\t}\n\n\t// 用户\n\tuserGroup := api.Group(\"/user\")\n\t{\n\t\tuserGroup.GET(\"\", user.Index)\n\t\tuserGroup.GET(\"/:id\", user.Detail)\n\t\tuserGroup.POST(\"/store\", user.Store)\n\t\tuserGroup.POST(\"/remove/:id\", user.Remove)\n\t\tuserGroup.POST(\"/login\", user.ValidateLogin)\n\t\tuserGroup.POST(\"/enable/:id\", user.Enable)\n\t\tuserGroup.POST(\"/disable/:id\", user.Disable)\n\t\tuserGroup.POST(\"/editMyPassword\", user.UpdateMyPassword)\n\t\tuserGroup.POST(\"/editPassword/:id\", user.UpdatePassword)\n\t\t// 2FA相关路由\n\t\tuserGroup.GET(\"/2fa/status\", user.Get2FAStatus)\n\t\tuserGroup.GET(\"/2fa/setup\", user.Setup2FA)\n\t\tuserGroup.POST(\"/2fa/enable\", user.Enable2FA)\n\t\tuserGroup.POST(\"/2fa/disable\", user.Disable2FA)\n\t}\n\n\t// 定时任务\n\ttaskGroup := api.Group(\"/task\")\n\t{\n\t\ttaskGroup.GET(\"/versions/:id\", task.VersionList)\n\t\ttaskGroup.GET(\"/versions/:id/:version_id\", task.VersionDetail)\n\t\ttaskGroup.POST(\"/versions/:id/:version_id/rollback\", task.VersionRollback)\n\t\ttaskGroup.POST(\"/store\", task.Store)\n\t\ttaskGroup.POST(\"/cron-preview\", task.CronPreview)\n\t\ttaskGroup.GET(\"/tags\", task.GetAllTags)\n\t\ttaskGroup.GET(\"/:id\", task.Detail)\n\t\ttaskGroup.GET(\"\", task.Index)\n\t\ttaskGroup.GET(\"/log\", tasklog.Index)\n\t\ttaskGroup.POST(\"/log/clear\", tasklog.Clear)\n\t\ttaskGroup.POST(\"/log/clear/:id\", tasklog.ClearByTaskId)\n\t\ttaskGroup.POST(\"/log/stop\", tasklog.Stop)\n\t\ttaskGroup.POST(\"/remove/:id\", task.Remove)\n\t\ttaskGroup.POST(\"/enable/:id\", task.Enable)\n\t\ttaskGroup.POST(\"/disable/:id\", task.Disable)\n\t\ttaskGroup.POST(\"/batch-enable\", task.BatchEnable)\n\t\ttaskGroup.POST(\"/batch-disable\", task.BatchDisable)\n\t\ttaskGroup.POST(\"/batch-remove\", task.BatchRemove)\n\t\ttaskGroup.GET(\"/run/:id\", task.Run)\n\t}\n\n\t// 主机\n\thostGroup := api.Group(\"/host\")\n\t{\n\t\thostGroup.GET(\"/:id\", host.Detail)\n\t\thostGroup.POST(\"/store\", host.Store)\n\t\thostGroup.GET(\"\", host.Index)\n\t\thostGroup.GET(\"/all\", host.All)\n\t\thostGroup.GET(\"/ping/:id\", host.Ping)\n\t\thostGroup.POST(\"/remove/:id\", host.Remove)\n\t}\n\n\t// Agent注册\n\tagentGroup := api.Group(\"/agent\")\n\t{\n\t\tagentGroup.POST(\"/generate-token\", agent.GenerateToken)\n\t\tagentGroup.GET(\"/install.sh\", agent.InstallScript)\n\t\tagentGroup.POST(\"/register\", agent.Register)\n\t\tagentGroup.GET(\"/download\", agent.Download)\n\t}\n\n\t// 任务模板\n\ttemplateGroup := api.Group(\"/template\")\n\t{\n\t\ttemplateGroup.GET(\"\", template.Index)\n\t\ttemplateGroup.GET(\"/categories\", template.Categories)\n\t\ttemplateGroup.GET(\"/:id\", template.Detail)\n\t\ttemplateGroup.POST(\"/store\", template.Store)\n\t\ttemplateGroup.POST(\"/remove/:id\", template.Remove)\n\t\ttemplateGroup.POST(\"/apply/:id\", template.Apply)\n\t\ttemplateGroup.POST(\"/save-from-task\", template.SaveFromTask)\n\t}\n\n\t// 管理\n\tsystemGroup := api.Group(\"/system\")\n\t{\n\t\tslackGroup := systemGroup.Group(\"/slack\")\n\t\t{\n\t\t\tslackGroup.GET(\"\", manage.Slack)\n\t\t\tslackGroup.POST(\"/update\", manage.UpdateSlack)\n\t\t\tslackGroup.POST(\"/channel\", manage.CreateSlackChannel)\n\t\t\tslackGroup.POST(\"/channel/remove/:id\", manage.RemoveSlackChannel)\n\t\t}\n\t\tmailGroup := systemGroup.Group(\"/mail\")\n\t\t{\n\t\t\tmailGroup.GET(\"\", manage.Mail)\n\t\t\tmailGroup.POST(\"/update\", manage.UpdateMail)\n\t\t\tmailGroup.POST(\"/user\", manage.CreateMailUser)\n\t\t\tmailGroup.POST(\"/user/remove/:id\", manage.RemoveMailUser)\n\t\t}\n\t\twebhookGroup := systemGroup.Group(\"/webhook\")\n\t\t{\n\t\t\twebhookGroup.GET(\"\", manage.WebHook)\n\t\t\twebhookGroup.POST(\"/update\", manage.UpdateWebHook)\n\t\t\twebhookGroup.POST(\"/url\", manage.CreateWebhookUrl)\n\t\t\twebhookGroup.POST(\"/url/remove/:id\", manage.RemoveWebhookUrl)\n\t\t}\n\t\tsystemGroup.GET(\"/login-log\", loginlog.Index)\n\t\tsystemGroup.GET(\"/log-retention\", manage.GetLogRetentionDays)\n\t\tsystemGroup.POST(\"/log-retention\", manage.UpdateLogRetentionDays)\n\t}\n\n\t// 统计\n\tstatisticsGroup := api.Group(\"/statistics\")\n\t{\n\t\tstatisticsGroup.GET(\"/overview\", statistics.Overview)\n\t}\n\n\t// 审计日志（需认证）\n\tauditGroup := api.Group(\"/audit\")\n\t{\n\t\tauditGroup.GET(\"\", audit.Index)\n\t}\n\n\t// API\n\tv1Group := api.Group(\"/v1\")\n\tv1Group.Use(apiAuth)\n\t{\n\t\tv1Group.POST(\"/tasklog/remove/:id\", tasklog.Remove)\n\t\tv1Group.POST(\"/task/enable/:id\", task.Enable)\n\t\tv1Group.POST(\"/task/disable/:id\", task.Disable)\n\t}\n\n\t// 首页路由（根路径）\n\tr.GET(\"/\", func(c *gin.Context) {\n\t\tfile, err := staticFS.Open(\"index.html\")\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"读取首页文件失败: %s\", err)\n\t\t\tc.Status(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tdefer file.Close()\n\t\tc.Header(\"Content-Type\", \"text/html\")\n\t\t_, _ = io.Copy(c.Writer, file)\n\t})\n\n\t// 静态文件路由 - 必须放在最后\n\tr.NoRoute(func(c *gin.Context) {\n\t\tfilepath := c.Request.URL.Path\n\n\t\t// 移除 /public 前缀（如果存在）\n\t\tfilepath = strings.TrimPrefix(filepath, \"/public\")\n\t\tfilepath = strings.TrimPrefix(filepath, \"/\")\n\n\t\t// 尝试从 staticFS 读取文件\n\t\tfile, err := staticFS.Open(filepath)\n\t\tif err == nil {\n\t\t\tdefer file.Close()\n\n\t\t\t// 设置正确的Content-Type - 必须在写入数据之前设置\n\t\t\tif strings.HasSuffix(filepath, \".js\") {\n\t\t\t\tc.Writer.Header().Set(\"Content-Type\", \"application/javascript; charset=utf-8\")\n\t\t\t} else if strings.HasSuffix(filepath, \".css\") {\n\t\t\t\tc.Writer.Header().Set(\"Content-Type\", \"text/css; charset=utf-8\")\n\t\t\t} else if strings.HasSuffix(filepath, \".html\") {\n\t\t\t\tc.Writer.Header().Set(\"Content-Type\", \"text/html; charset=utf-8\")\n\t\t\t} else if strings.HasSuffix(filepath, \".png\") {\n\t\t\t\tc.Writer.Header().Set(\"Content-Type\", \"image/png\")\n\t\t\t} else if strings.HasSuffix(filepath, \".jpg\") || strings.HasSuffix(filepath, \".jpeg\") {\n\t\t\t\tc.Writer.Header().Set(\"Content-Type\", \"image/jpeg\")\n\t\t\t} else if strings.HasSuffix(filepath, \".svg\") {\n\t\t\t\tc.Writer.Header().Set(\"Content-Type\", \"image/svg+xml\")\n\t\t\t}\n\n\t\t\tc.Status(http.StatusOK)\n\t\t\t_, _ = io.Copy(c.Writer, file)\n\t\t\treturn\n\t\t}\n\n\t\t// 文件不存在，返回404\n\t\tjsonResp := utils.JsonResponse{}\n\t\tc.String(http.StatusNotFound, jsonResp.Failure(utils.NotFound, i18n.T(c, \"page_not_found\")))\n\t})\n}\n\n// 中间件注册\nfunc RegisterMiddleware(r *gin.Engine) {\n\t// 中间件\n\tr.Use(securityHeaders)\n\tr.Use(checkAppInstall)\n\tr.Use(ipAuth)\n\tr.Use(userAuth)\n\tr.Use(urlAuth)\n\tr.Use(auditLog)\n}\n\n// securityHeaders 设置通用的安全响应头，防御点击劫持 / MIME sniff / referrer 泄漏。\n// 不设置 CSP（需要针对前端资源单独调校）也不设置 HSTS（由反向代理决定）。\nfunc securityHeaders(c *gin.Context) {\n\tc.Header(\"X-Frame-Options\", \"DENY\")\n\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\tc.Header(\"Referrer-Policy\", \"no-referrer\")\n\tc.Next()\n}\n\n// region Custom middleware\n\n// isStaticFileRequest checks if the request is for a static file (non-API path).\n// Static files are served via NoRoute handler and never match registered API routes.\nfunc isStaticFileRequest(path string) bool {\n\treturn !strings.HasPrefix(path, urlPrefix+\"/\") && !strings.HasPrefix(path, \"/v1/\")\n}\n\n// checkAppInstall verifies the application has been installed.\nfunc checkAppInstall(c *gin.Context) {\n\tif app.Installed {\n\t\tc.Next()\n\t\treturn\n\t}\n\tpath := c.Request.URL.Path\n\t// Allow install API, root page, and static files before installation\n\tif strings.HasPrefix(path, \"/api/install\") || isStaticFileRequest(path) {\n\t\tc.Next()\n\t\treturn\n\t}\n\tjsonResp := utils.JsonResponse{}\n\tdata := jsonResp.Failure(utils.AppNotInstall, i18n.T(c, \"app_not_installed\"))\n\tc.String(http.StatusOK, data)\n\tc.Abort()\n}\n\n// IP验证, 通过反向代理访问gocron，需设置Header X-Real-IP才能获取到客户端真实IP\nfunc ipAuth(c *gin.Context) {\n\tif !app.Installed {\n\t\tc.Next()\n\t\treturn\n\t}\n\tallowIpsStr := app.Setting.AllowIps\n\tif allowIpsStr == \"\" {\n\t\tc.Next()\n\t\treturn\n\t}\n\tclientIp := c.ClientIP()\n\tallowIps := strings.Split(allowIpsStr, \",\")\n\tif utils.InStringSlice(allowIps, clientIp) {\n\t\tc.Next()\n\t\treturn\n\t}\n\tlogger.Warnf(\"非法IP访问-%s\", clientIp)\n\tjsonResp := utils.JsonResponse{}\n\tdata := jsonResp.Failure(utils.UnauthorizedError, i18n.T(c, \"unauthorized\"))\n\tc.String(http.StatusOK, data)\n\tc.Abort()\n}\n\n// userAuth authenticates the user for API requests.\nfunc userAuth(c *gin.Context) {\n\tif !app.Installed {\n\t\tc.Next()\n\t\treturn\n\t}\n\n\tpath := c.Request.URL.Path\n\t// Static files (non-API paths) don't require authentication\n\tif isStaticFileRequest(path) {\n\t\tc.Next()\n\t\treturn\n\t}\n\n\turi := strings.TrimRight(path, \"/\")\n\t// 登录接口和安装状态接口不需要认证\n\texcludePaths := []string{\"\", \"/api/user/login\", \"/api/install/status\", \"/api/agent/install.sh\", \"/api/agent/register\", \"/api/agent/download\"}\n\tfor _, p := range excludePaths {\n\t\tif uri == p {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\t}\n\n\t// v1 API接口使用单独的认证\n\tif strings.HasPrefix(uri, \"/v1\") {\n\t\tc.Next()\n\t\treturn\n\t}\n\n\t// 尝试从token恢复用户信息\n\tnewToken, err := user.RestoreToken(c)\n\tif err != nil {\n\t\tlogger.Warnf(\"token解析失败: %v, path: %s\", err, path)\n\t\tjsonResp := utils.JsonResponse{}\n\t\tdata := jsonResp.Failure(utils.AuthError, i18n.T(c, \"auth_failed\"))\n\t\tc.String(http.StatusOK, data)\n\t\tc.Abort()\n\t\treturn\n\t}\n\t// 如果token被刷新，返回新token给前端\n\tif newToken != \"\" {\n\t\tc.Header(\"New-Auth-Token\", newToken)\n\t}\n\n\tif !user.IsLogin(c) {\n\t\tjsonResp := utils.JsonResponse{}\n\t\tdata := jsonResp.Failure(utils.AuthError, i18n.T(c, \"auth_failed\"))\n\t\tc.String(http.StatusOK, data)\n\t\tc.Abort()\n\t\treturn\n\t}\n\n\tc.Next()\n}\n\n// urlAuth checks URL-level permissions (admin vs normal user).\nfunc urlAuth(c *gin.Context) {\n\tif !app.Installed {\n\t\tc.Next()\n\t\treturn\n\t}\n\n\tpath := c.Request.URL.Path\n\t// Static files (non-API paths) don't require permission checks\n\tif isStaticFileRequest(path) {\n\t\tc.Next()\n\t\treturn\n\t}\n\n\tif user.IsAdmin(c) {\n\t\tc.Next()\n\t\treturn\n\t}\n\turi := strings.TrimRight(path, \"/\")\n\tif strings.HasPrefix(uri, \"/v1\") {\n\t\tc.Next()\n\t\treturn\n\t}\n\t// 普通用户允许访问的URL地址\n\tallowPaths := []string{\n\t\t\"\",\n\t\t\"/api/install/status\",\n\t\t\"/api/task\",\n\t\t\"/api/task/tags\",\n\t\t\"/api/task/log\",\n\t\t\"/api/host\",\n\t\t\"/api/host/all\",\n\t\t\"/api/user/login\",\n\t\t\"/api/user/editMyPassword\",\n\t\t\"/api/user/2fa/status\",\n\t\t\"/api/user/2fa/setup\",\n\t\t\"/api/user/2fa/enable\",\n\t\t\"/api/user/2fa/disable\",\n\t\t\"/api/template\",\n\t\t\"/api/template/categories\",\n\t\t\"/api/statistics/overview\",\n\t\t\"/api/agent/install.sh\",\n\t\t\"/api/agent/register\",\n\t\t\"/api/agent/download\",\n\t}\n\tfor _, p := range allowPaths {\n\t\tif p == uri {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\t}\n\n\tjsonResp := utils.JsonResponse{}\n\tdata := jsonResp.Failure(utils.UnauthorizedError, i18n.T(c, \"unauthorized\"))\n\tc.String(http.StatusOK, data)\n\tc.Abort()\n}\n\n// auditLog middleware records audit log entries for write operations.\n// It runs after the handler (post-processing) and only records successful POST requests.\nfunc auditLog(c *gin.Context) {\n\tc.Next()\n\n\t// Only record POST requests\n\tif c.Request.Method != http.MethodPost {\n\t\treturn\n\t}\n\n\t// Only record successful operations (status < 400)\n\tif c.Writer.Status() >= 400 {\n\t\treturn\n\t}\n\n\tpath := c.FullPath()\n\tusername := user.Username(c)\n\tip := c.ClientIP()\n\n\tmodule, action := resolveModuleAction(path, c)\n\tif module == \"\" || action == \"\" {\n\t\treturn\n\t}\n\n\t// 获取 targetId：优先从 URL 参数，其次从 POST body\n\ttargetId := 0\n\tif idStr := c.Param(\"id\"); idStr != \"\" {\n\t\ttargetId, _ = strconv.Atoi(idStr)\n\t} else if idStr := c.PostForm(\"id\"); idStr != \"\" && idStr != \"0\" {\n\t\ttargetId, _ = strconv.Atoi(idStr)\n\t}\n\n\t// 读取 handler 设置的审计详情\n\tdetail, _ := c.Get(\"audit_detail\")\n\tdetailStr, _ := detail.(string)\n\n\tlog := &models.AuditLog{\n\t\tUsername: username,\n\t\tIp:       ip,\n\t\tModule:   module,\n\t\tAction:   action,\n\t\tTargetId: targetId,\n\t\tDetail:   detailStr,\n\t}\n\n\t// 异步查询对象名称并写入；使用独立 context 避免请求已结束后 goroutine 无界堆积\n\tgo func() {\n\t\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancel()\n\t\tlog.TargetName = resolveTargetName(ctx, module, targetId)\n\t\tif err := models.Db.WithContext(ctx).Create(log).Error; err != nil {\n\t\t\tlogger.Warnf(\"写入审计日志失败: %v\", err)\n\t\t}\n\t}()\n}\n\n// resolveModuleAction maps a Gin full path pattern to (module, action).\nfunc resolveModuleAction(path string, c *gin.Context) (module, action string) {\n\tswitch path {\n\t// Task routes\n\tcase \"/api/task/store\":\n\t\tidStr := c.PostForm(\"id\")\n\t\tif idStr == \"\" || idStr == \"0\" {\n\t\t\treturn \"task\", \"create\"\n\t\t}\n\t\treturn \"task\", \"update\"\n\tcase \"/api/task/remove/:id\":\n\t\treturn \"task\", \"delete\"\n\tcase \"/api/task/enable/:id\":\n\t\treturn \"task\", \"enable\"\n\tcase \"/api/task/disable/:id\":\n\t\treturn \"task\", \"disable\"\n\tcase \"/api/task/batch-enable\":\n\t\treturn \"task\", \"batch-enable\"\n\tcase \"/api/task/batch-disable\":\n\t\treturn \"task\", \"batch-disable\"\n\tcase \"/api/task/batch-remove\":\n\t\treturn \"task\", \"batch-remove\"\n\n\t// Host routes\n\tcase \"/api/host/store\":\n\t\tidStr := c.PostForm(\"id\")\n\t\tif idStr == \"\" || idStr == \"0\" {\n\t\t\treturn \"host\", \"create\"\n\t\t}\n\t\treturn \"host\", \"update\"\n\tcase \"/api/host/remove/:id\":\n\t\treturn \"host\", \"delete\"\n\n\t// User routes\n\tcase \"/api/user/store\":\n\t\tidStr := c.PostForm(\"id\")\n\t\tif idStr == \"\" || idStr == \"0\" {\n\t\t\treturn \"user\", \"create\"\n\t\t}\n\t\treturn \"user\", \"update\"\n\tcase \"/api/user/remove/:id\":\n\t\treturn \"user\", \"delete\"\n\tcase \"/api/user/enable/:id\":\n\t\treturn \"user\", \"enable\"\n\tcase \"/api/user/disable/:id\":\n\t\treturn \"user\", \"disable\"\n\tcase \"/api/user/editMyPassword\":\n\t\treturn \"user\", \"change-password\"\n\tcase \"/api/user/editPassword/:id\":\n\t\treturn \"user\", \"reset-password\"\n\n\t// Template routes\n\tcase \"/api/template/store\":\n\t\tidStr := c.PostForm(\"id\")\n\t\tif idStr == \"\" || idStr == \"0\" {\n\t\t\treturn \"template\", \"create\"\n\t\t}\n\t\treturn \"template\", \"update\"\n\tcase \"/api/template/remove/:id\":\n\t\treturn \"template\", \"delete\"\n\tcase \"/api/template/apply/:id\":\n\t\treturn \"template\", \"update\"\n\tcase \"/api/template/save-from-task\":\n\t\treturn \"template\", \"create\"\n\n\t// System routes — any POST under /api/system\n\tdefault:\n\t\tif strings.HasPrefix(path, \"/api/system/\") {\n\t\t\treturn \"system\", \"update\"\n\t\t}\n\t}\n\n\treturn \"\", \"\"\n}\n\n// resolveTargetName 根据 module 和 targetId 查询对象名称\nfunc resolveTargetName(ctx context.Context, module string, targetId int) string {\n\tif targetId == 0 {\n\t\treturn \"\"\n\t}\n\tdb := models.Db.WithContext(ctx)\n\tswitch module {\n\tcase \"task\":\n\t\ttask := &models.Task{}\n\t\tif err := db.Select(\"name\").First(task, targetId).Error; err == nil {\n\t\t\treturn task.Name\n\t\t}\n\tcase \"host\":\n\t\thost := &models.Host{}\n\t\tif err := db.Select(\"name\", \"alias\").First(host, targetId).Error; err == nil {\n\t\t\tif host.Alias != \"\" {\n\t\t\t\treturn host.Alias\n\t\t\t}\n\t\t\treturn host.Name\n\t\t}\n\tcase \"user\":\n\t\tu := &models.User{}\n\t\tif err := db.Select(\"name\").First(u, targetId).Error; err == nil {\n\t\t\treturn u.Name\n\t\t}\n\tcase \"template\":\n\t\ttmpl := &models.TaskTemplate{}\n\t\tif err := models.Db.Select(\"name\").First(tmpl, targetId).Error; err == nil {\n\t\t\treturn tmpl.Name\n\t\t}\n\t}\n\treturn \"\"\n}\n\n/** API接口签名验证 **/\nfunc apiAuth(c *gin.Context) {\n\tif !app.Installed {\n\t\tc.Next()\n\t\treturn\n\t}\n\tif !app.Setting.ApiSignEnable {\n\t\tc.Next()\n\t\treturn\n\t}\n\tapiKey := strings.TrimSpace(app.Setting.ApiKey)\n\tapiSecret := strings.TrimSpace(app.Setting.ApiSecret)\n\tjson := utils.JsonResponse{}\n\tif apiKey == \"\" || apiSecret == \"\" {\n\t\tmsg := json.CommonFailure(i18n.T(c, \"api_key_required\"))\n\t\tc.String(http.StatusOK, msg)\n\t\tc.Abort()\n\t\treturn\n\t}\n\tcurrentTimestamp := time.Now().Unix()\n\ttimeParam, err := strconv.ParseInt(c.Query(\"time\"), 10, 64)\n\tif err != nil || timeParam <= 0 {\n\t\tmsg := json.CommonFailure(i18n.T(c, \"param_time_required\"))\n\t\tc.String(http.StatusOK, msg)\n\t\tc.Abort()\n\t\treturn\n\t}\n\tif timeParam < (currentTimestamp - 1800) {\n\t\tmsg := json.CommonFailure(i18n.T(c, \"param_time_invalid\"))\n\t\tc.String(http.StatusOK, msg)\n\t\tc.Abort()\n\t\treturn\n\t}\n\tsign := strings.TrimSpace(c.Query(\"sign\"))\n\tif sign == \"\" {\n\t\tmsg := json.CommonFailure(i18n.T(c, \"param_sign_required\"))\n\t\tc.String(http.StatusOK, msg)\n\t\tc.Abort()\n\t\treturn\n\t}\n\traw := apiKey + strconv.FormatInt(timeParam, 10) + strings.TrimSpace(c.Request.URL.Path) + apiSecret\n\trealSign := utils.Sha256(raw)\n\tif subtle.ConstantTimeCompare([]byte(sign), []byte(realSign)) != 1 {\n\t\tmsg := json.CommonFailure(i18n.T(c, \"sign_verify_failed\"))\n\t\tc.String(http.StatusOK, msg)\n\t\tc.Abort()\n\t\treturn\n\t}\n\tc.Next()\n}\n\n// endregion\n"
  },
  {
    "path": "internal/routers/statistics/statistics.go",
    "content": "package statistics\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n)\n\n// OverviewData 概览统计数据\ntype OverviewData struct {\n\tTotalTasks      int64               `json:\"total_tasks\"`\n\tTodayExecutions int64               `json:\"today_executions\"`\n\tSuccessRate     float64             `json:\"success_rate\"`\n\tFailedCount     int64               `json:\"failed_count\"`\n\tLast7Days       []models.DailyStats `json:\"last_7_days\"`\n}\n\n// Overview 获取统计概览数据\nfunc Overview(c *gin.Context) {\n\ttaskModel := models.Task{}\n\ttaskLogModel := models.TaskLog{}\n\n\t// 1. 获取启用的任务总数\n\ttotalTasks, err := taskModel.Total(models.CommonMap{\"Status\": int(models.Enabled)})\n\tif err != nil {\n\t\tlogger.Error(\"Failed to get total tasks:\", err)\n\t\tbase.RespondError(c, \"Failed to get total tasks\", err)\n\t\treturn\n\t}\n\n\t// 2. 获取今日统计数据\n\ttodayTotal, todaySuccess, todayFailed, err := taskLogModel.GetTodayStats()\n\tif err != nil {\n\t\tlogger.Error(\"Failed to get today's statistics:\", err)\n\t\tbase.RespondError(c, \"Failed to get today's statistics\", err)\n\t\treturn\n\t}\n\n\t// 3. 计算成功率\n\tvar successRate float64\n\tif todayTotal > 0 {\n\t\tsuccessRate = float64(todaySuccess) / float64(todayTotal) * 100\n\t\t// 保留1位小数\n\t\tsuccessRate = float64(int(successRate*10)) / 10\n\t}\n\n\t// 4. 获取最近7天趋势\n\tlast7Days, err := taskLogModel.GetLast7DaysTrend()\n\tif err != nil {\n\t\tlogger.Error(\"Failed to get trend data:\", err)\n\t\tbase.RespondError(c, \"Failed to get trend data\", err)\n\t\treturn\n\t}\n\n\t// 组装返回数据\n\tdata := OverviewData{\n\t\tTotalTasks:      totalTasks,\n\t\tTodayExecutions: todayTotal,\n\t\tSuccessRate:     successRate,\n\t\tFailedCount:     todayFailed,\n\t\tLast7Days:       last7Days,\n\t}\n\n\tbase.RespondSuccess(c, utils.SuccessContent, data)\n}\n"
  },
  {
    "path": "internal/routers/task/cron_preview.go",
    "content": "package task\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n\t\"github.com/gocronx-team/gocron/internal/service\"\n)\n\ntype cronPreviewRequest struct {\n\tSpec     string `json:\"spec\" binding:\"required\"`\n\tTimezone string `json:\"timezone\"`\n\tCount    int    `json:\"count\"`\n}\n\n// CronPreview 返回给定 cron 表达式的接下来 N 次执行时间 + 一周执行分布热图。\n// 非法表达式也返回 HTTP 200，body 里 valid=false（用户边敲边预览，不用 4xx 轰炸 console）。\nfunc CronPreview(c *gin.Context) {\n\tvar req cronPreviewRequest\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tbase.RespondValidationError(c, err)\n\t\treturn\n\t}\n\n\tresult := service.PreviewCron(req.Spec, req.Timezone, req.Count)\n\n\tjsonResp := utils.JsonResponse{}\n\tc.String(200, jsonResp.Success(utils.SuccessContent, result))\n}\n"
  },
  {
    "path": "internal/routers/task/task.go",
    "content": "package task\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/cron\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/httpclient\"\n\t\"github.com/gocronx-team/gocron/internal/modules/i18n\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n\t\"github.com/gocronx-team/gocron/internal/routers/user\"\n\t\"github.com/gocronx-team/gocron/internal/service\"\n)\n\ntype TaskForm struct {\n\tId               int                         `form:\"id\" json:\"id\"`\n\tLevel            models.TaskLevel            `form:\"level\" json:\"level\" binding:\"required,oneof=1 2\"`\n\tDependencyStatus models.TaskDependencyStatus `form:\"dependency_status\" json:\"dependency_status\" binding:\"oneof=1 2\"`\n\tDependencyTaskId string                      `form:\"dependency_task_id\" json:\"dependency_task_id\"`\n\tName             string                      `form:\"name\" json:\"name\" binding:\"required,max=32\"`\n\tSpec             string                      `form:\"spec\" json:\"spec\"`\n\tProtocol         models.TaskProtocol         `form:\"protocol\" json:\"protocol\" binding:\"oneof=1 2\"`\n\tCommand          string                      `form:\"command\" json:\"command\" binding:\"required,max=65535\"`\n\tHttpMethod       models.TaskHTTPMethod       `form:\"http_method\" json:\"http_method\" binding:\"oneof=1 2\"`\n\tHttpBody         string                      `form:\"http_body\" json:\"http_body\" binding:\"max=65535\"`\n\tHttpHeaders      string                      `form:\"http_headers\" json:\"http_headers\" binding:\"max=4096\"`\n\tSuccessPattern   string                      `form:\"success_pattern\" json:\"success_pattern\" binding:\"max=512\"`\n\tTimeout          int                         `form:\"timeout\" json:\"timeout\" binding:\"min=0,max=86400\"`\n\tMulti            int8                        `form:\"multi\" json:\"multi\" binding:\"oneof=0 1\"`\n\tRetryTimes       int8                        `form:\"retry_times\" json:\"retry_times\"`\n\tRetryInterval    int16                       `form:\"retry_interval\" json:\"retry_interval\"`\n\tHostId           string                      `form:\"host_id\" json:\"host_id\"`\n\tTag              string                      `form:\"tag\" json:\"tag\"`\n\tRemark           string                      `form:\"remark\" json:\"remark\"`\n\tNotifyStatus     int8                        `form:\"notify_status\" json:\"notify_status\" binding:\"oneof=0 1 2 3\"`\n\tNotifyType       int8                        `form:\"notify_type\" json:\"notify_type\" binding:\"oneof=0 1 2\"`\n\tNotifyReceiverId string                      `form:\"notify_receiver_id\" json:\"notify_receiver_id\"`\n\tNotifyKeyword    string                      `form:\"notify_keyword\" json:\"notify_keyword\"`\n\tLogRetentionDays int                         `form:\"log_retention_days\" json:\"log_retention_days\" binding:\"min=0,max=3650\"`\n}\n\n// 首页\nfunc Index(c *gin.Context) {\n\ttaskModel := new(models.Task)\n\tqueryParams := parseQueryParams(c)\n\ttotal, err := taskModel.Total(queryParams)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\ttasks, err := taskModel.List(queryParams)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tfor i, item := range tasks {\n\t\ttasks[i].NextRunTime = models.NextRunTime(service.ServiceTask.NextRunTime(item))\n\t}\n\tjsonResp := utils.JsonResponse{}\n\tresult := jsonResp.Success(utils.SuccessContent, map[string]interface{}{\n\t\t\"total\": total,\n\t\t\"data\":  tasks,\n\t})\n\tc.String(http.StatusOK, result)\n}\n\n// Detail 任务详情\nfunc Detail(c *gin.Context) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil || id <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"param_error\"))\n\t\treturn\n\t}\n\ttaskModel := new(models.Task)\n\ttask, err := taskModel.Detail(id)\n\tjsonResp := utils.JsonResponse{}\n\tvar result string\n\tif err != nil || task.Id == 0 {\n\t\tlogger.Errorf(\"编辑任务#获取任务详情失败#任务ID-%d\", id)\n\t\tresult = jsonResp.Success(utils.SuccessContent, nil)\n\t} else {\n\t\tresult = jsonResp.Success(utils.SuccessContent, task)\n\t}\n\tc.String(http.StatusOK, result)\n}\n\n// 保存任务\nfunc Store(c *gin.Context) {\n\tvar form TaskForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tbase.RespondValidationError(c, err)\n\t\treturn\n\t}\n\n\ttaskModel := models.Task{}\n\tvar id = form.Id\n\tnameExists, err := taskModel.NameExist(form.Name, form.Id)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tif nameExists {\n\t\tbase.RespondError(c, i18n.T(c, \"task_name_exists\"))\n\t\treturn\n\t}\n\n\tif form.Protocol == models.TaskRPC && form.HostId == \"\" {\n\t\tbase.RespondError(c, i18n.T(c, \"select_hostname\"))\n\t\treturn\n\t}\n\n\ttaskModel.Name = form.Name\n\ttaskModel.Protocol = form.Protocol\n\t// 清理命令中的 HTML 实体编码\n\toriginalCmd := strings.TrimSpace(form.Command)\n\tcleanedCmd := utils.CleanHTMLEntities(originalCmd)\n\tif originalCmd != cleanedCmd {\n\t\tlogger.Infof(\"[HTML Entity Cleaned] Task: %s, Original length: %d, Cleaned length: %d\", form.Name, len(originalCmd), len(cleanedCmd))\n\t}\n\ttaskModel.Command = cleanedCmd\n\ttaskModel.Timeout = form.Timeout\n\ttaskModel.Tag = form.Tag\n\ttaskModel.Remark = form.Remark\n\ttaskModel.Multi = form.Multi\n\ttaskModel.RetryTimes = form.RetryTimes\n\ttaskModel.RetryInterval = form.RetryInterval\n\ttaskModel.NotifyStatus = form.NotifyStatus\n\ttaskModel.NotifyType = form.NotifyType\n\ttaskModel.NotifyReceiverId = form.NotifyReceiverId\n\ttaskModel.NotifyKeyword = form.NotifyKeyword\n\ttaskModel.LogRetentionDays = form.LogRetentionDays\n\ttaskModel.Spec = form.Spec\n\ttaskModel.Level = form.Level\n\ttaskModel.DependencyStatus = form.DependencyStatus\n\ttaskModel.DependencyTaskId = strings.TrimSpace(form.DependencyTaskId)\n\tif taskModel.NotifyStatus > 0 && taskModel.NotifyType != 2 && taskModel.NotifyReceiverId == \"\" {\n\t\tbase.RespondError(c, i18n.T(c, \"select_at_least_one_receiver\"))\n\t\treturn\n\t}\n\ttaskModel.HttpMethod = form.HttpMethod\n\t// 校验 HttpHeaders（JSON 格式 + 黑名单检查）\n\tif err := httpclient.ValidateHeaders(form.HttpHeaders); err != nil {\n\t\tbase.RespondError(c, \"http_headers: \"+err.Error())\n\t\treturn\n\t}\n\ttaskModel.HttpBody = form.HttpBody\n\ttaskModel.HttpHeaders = form.HttpHeaders\n\ttaskModel.SuccessPattern = form.SuccessPattern\n\tif taskModel.Protocol == models.TaskHTTP {\n\t\tcommand := strings.ToLower(taskModel.Command)\n\t\tif !strings.HasPrefix(command, \"http://\") && !strings.HasPrefix(command, \"https://\") {\n\t\t\tbase.RespondError(c, i18n.T(c, \"invalid_url\"))\n\t\t\treturn\n\t\t}\n\t}\n\n\tif taskModel.RetryTimes > 10 || taskModel.RetryTimes < 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"retry_times_range_0_10\"))\n\t\treturn\n\t}\n\n\tif taskModel.RetryInterval > 3600 || taskModel.RetryInterval < 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"retry_interval_range_0_3600\"))\n\t\treturn\n\t}\n\n\tif taskModel.DependencyStatus != models.TaskDependencyStatusStrong &&\n\t\ttaskModel.DependencyStatus != models.TaskDependencyStatusWeak {\n\t\tbase.RespondError(c, i18n.T(c, \"select_dependency\"))\n\t\treturn\n\t}\n\n\tif taskModel.Level == models.TaskLevelParent {\n\t\terr = utils.PanicToError(func() {\n\t\t\tcron.Parse(form.Spec)\n\t\t})\n\t\tif err != nil {\n\t\t\tbase.RespondError(c, i18n.T(c, \"crontab_parse_failed\"), err)\n\t\t\treturn\n\t\t}\n\t} else {\n\t\ttaskModel.DependencyTaskId = \"\"\n\t\ttaskModel.Spec = \"\"\n\t}\n\n\tif id > 0 && taskModel.DependencyTaskId != \"\" {\n\t\tdependencyTaskIds := strings.Split(taskModel.DependencyTaskId, \",\")\n\t\tif utils.InStringSlice(dependencyTaskIds, strconv.Itoa(id)) {\n\t\t\tbase.RespondError(c, i18n.T(c, \"cannot_set_self_as_child\"))\n\t\t\treturn\n\t\t}\n\t}\n\n\tif id == 0 {\n\t\ttaskModel.Status = models.Running\n\t\tlogger.Infof(\"[Task Create] Before Create - Multi: %d\", taskModel.Multi)\n\t\tid, err = taskModel.Create()\n\t\tif err == nil {\n\t\t\t// 立即读取验证\n\t\t\tverifyTask, _ := taskModel.Detail(id)\n\t\t\tlogger.Infof(\"[Task Create] After Create - ID: %d, Multi in DB: %d\", id, verifyTask.Multi)\n\t\t}\n\t} else {\n\t\t// 更新前记录旧值用于审计 diff\n\t\toldTask, _ := taskModel.Detail(id)\n\n\t\t// 保存脚本版本（命令变更时）\n\t\tif oldTask.Command != taskModel.Command {\n\t\t\tversionModel := new(models.TaskScriptVersion)\n\t\t\tlatestVersion, _ := versionModel.GetLatestVersion(id)\n\t\t\tnewVersion := &models.TaskScriptVersion{\n\t\t\t\tTaskId:   id,\n\t\t\t\tCommand:  oldTask.Command,\n\t\t\t\tUsername: user.Username(c),\n\t\t\t\tVersion:  latestVersion + 1,\n\t\t\t}\n\t\t\tif _, vErr := newVersion.Create(); vErr != nil {\n\t\t\t\tlogger.Warnf(\"保存脚本版本失败 TaskID-%d: %v\", id, vErr)\n\t\t\t}\n\t\t\tif cErr := versionModel.CleanOldVersions(id, 30); cErr != nil {\n\t\t\t\tlogger.Warnf(\"清理旧版本失败 TaskID-%d: %v\", id, cErr)\n\t\t\t}\n\t\t}\n\n\t\tlogger.Infof(\"[Task Update] Before Update - ID: %d, Multi: %d\", id, taskModel.Multi)\n\t\t_, err = taskModel.UpdateBean(id)\n\t\tif err == nil {\n\t\t\t// 立即读取验证\n\t\t\tverifyTask, _ := taskModel.Detail(id)\n\t\t\tlogger.Infof(\"[Task Update] After Update - ID: %d, Multi in DB: %d\", id, verifyTask.Multi)\n\n\t\t\t// 生成审计 diff\n\t\t\tif diff := buildTaskDiff(oldTask, verifyTask); diff != \"\" {\n\t\t\t\tc.Set(\"audit_detail\", diff)\n\t\t\t}\n\t\t}\n\t}\n\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"save_failed\"), err)\n\t\treturn\n\t}\n\n\ttaskHostModel := new(models.TaskHost)\n\tif form.Protocol == models.TaskRPC {\n\t\thostIdStrList := strings.Split(form.HostId, \",\")\n\t\thostIds := make([]int, len(hostIdStrList))\n\t\tfor i, hostIdStr := range hostIdStrList {\n\t\t\thostIds[i], _ = strconv.Atoi(hostIdStr)\n\t\t}\n\t\t_ = taskHostModel.Add(id, hostIds)\n\t} else {\n\t\t_ = taskHostModel.Remove(id)\n\t}\n\n\tstatus, _ := taskModel.GetStatus(id)\n\tif status == models.Enabled && taskModel.Level == models.TaskLevelParent {\n\t\taddTaskToTimer(id)\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"save_success\"), nil)\n}\n\n// 删除任务\nfunc Remove(c *gin.Context) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil || id <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"param_error\"))\n\t\treturn\n\t}\n\ttaskModel := new(models.Task)\n\t_, err = taskModel.Delete(id)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\ttaskHostModel := new(models.TaskHost)\n\t\t_ = taskHostModel.Remove(id)\n\t\tservice.ServiceTask.Remove(id)\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\n// 激活任务\nfunc Enable(c *gin.Context) {\n\tchangeStatus(c, models.Enabled)\n}\n\n// 暂停任务\nfunc Disable(c *gin.Context) {\n\tchangeStatus(c, models.Disabled)\n}\n\n// 手动运行任务\nfunc Run(c *gin.Context) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil || id <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"param_error\"))\n\t\treturn\n\t}\n\ttaskModel := new(models.Task)\n\ttask, err := taskModel.Detail(id)\n\tif err != nil || task.Id <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"get_task_detail_failed\"), err)\n\t} else {\n\t\ttask.Spec = i18n.T(c, \"manual_run\")\n\t\tservice.ServiceTask.Run(task)\n\t\tbase.RespondSuccess(c, i18n.T(c, \"task_started_check_log\"), nil)\n\t}\n}\n\n// 批量启用任务\nfunc BatchEnable(c *gin.Context) {\n\tbatchChangeStatus(c, models.Enabled)\n}\n\n// 批量禁用任务\nfunc BatchDisable(c *gin.Context) {\n\tbatchChangeStatus(c, models.Disabled)\n}\n\n// 批量改变任务状态\nfunc batchChangeStatus(c *gin.Context, status models.Status) {\n\tvar form struct {\n\t\tIds []int `json:\"ids\" binding:\"required\"`\n\t}\n\tif err := c.ShouldBindJSON(&form); err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"param_error\"))\n\t\treturn\n\t}\n\n\ttaskModel := new(models.Task)\n\tsuccessCount := 0\n\tfor _, id := range form.Ids {\n\t\t_, err := taskModel.Update(id, models.CommonMap{\n\t\t\t\"status\": status,\n\t\t})\n\t\tif err == nil {\n\t\t\tsuccessCount++\n\t\t\tif status == models.Enabled {\n\t\t\t\taddTaskToTimer(id)\n\t\t\t} else {\n\t\t\t\tservice.ServiceTask.Remove(id)\n\t\t\t}\n\t\t}\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"operation_success\"), map[string]interface{}{\n\t\t\"success_count\": successCount,\n\t\t\"total_count\":   len(form.Ids),\n\t})\n}\n\n// 批量删除任务\nfunc BatchRemove(c *gin.Context) {\n\tvar form struct {\n\t\tIds []int `json:\"ids\" binding:\"required\"`\n\t}\n\tif err := c.ShouldBindJSON(&form); err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"param_error\"))\n\t\treturn\n\t}\n\n\ttaskModel := new(models.Task)\n\ttaskHostModel := new(models.TaskHost)\n\tsuccessCount := 0\n\tfor _, id := range form.Ids {\n\t\t_, err := taskModel.Delete(id)\n\t\tif err == nil {\n\t\t\tsuccessCount++\n\t\t\t_ = taskHostModel.Remove(id)\n\t\t\tservice.ServiceTask.Remove(id)\n\t\t}\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"operation_success\"), map[string]interface{}{\n\t\t\"success_count\": successCount,\n\t\t\"total_count\":   len(form.Ids),\n\t})\n}\n\n// 改变任务状态\nfunc changeStatus(c *gin.Context, status models.Status) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil || id <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"param_error\"))\n\t\treturn\n\t}\n\ttaskModel := new(models.Task)\n\t_, err = taskModel.Update(id, models.CommonMap{\n\t\t\"status\": status,\n\t})\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tif status == models.Enabled {\n\t\t\taddTaskToTimer(id)\n\t\t} else {\n\t\t\tservice.ServiceTask.Remove(id)\n\t\t}\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\n// 添加任务到定时器\nfunc addTaskToTimer(id int) {\n\ttaskModel := new(models.Task)\n\ttask, err := taskModel.Detail(id)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\treturn\n\t}\n\n\tservice.ServiceTask.RemoveAndAdd(task)\n}\n\n// GetAllTags 获取所有已使用的标签列表\nfunc GetAllTags(c *gin.Context) {\n\ttaskModel := new(models.Task)\n\ttags, err := taskModel.GetAllTags()\n\tif err != nil {\n\t\tlogger.Error(err)\n\t\ttags = []string{}\n\t}\n\tjsonResp := utils.JsonResponse{}\n\tresult := jsonResp.Success(utils.SuccessContent, tags)\n\tc.String(http.StatusOK, result)\n}\n\n// 解析查询参数\nfunc parseQueryParams(c *gin.Context) models.CommonMap {\n\tvar params models.CommonMap = models.CommonMap{}\n\tid, _ := strconv.Atoi(c.Query(\"id\"))\n\thostId, _ := strconv.Atoi(c.Query(\"host_id\"))\n\tprotocol, _ := strconv.Atoi(c.Query(\"protocol\"))\n\tstatus, _ := strconv.Atoi(c.Query(\"status\"))\n\tparams[\"Id\"] = id\n\tparams[\"HostId\"] = hostId\n\tparams[\"Name\"] = strings.TrimSpace(c.Query(\"name\"))\n\tparams[\"Protocol\"] = protocol\n\tparams[\"Tag\"] = strings.TrimSpace(c.Query(\"tag\"))\n\tif status >= 0 {\n\t\tstatus -= 1\n\t}\n\tparams[\"Status\"] = status\n\tbase.ParsePageAndPageSize(c, params)\n\n\treturn params\n}\n\n// buildTaskDiff 对比任务的旧值和新值，返回可读的变更摘要\nfunc buildTaskDiff(old, new models.Task) string {\n\ttype change struct {\n\t\tField string `json:\"field\"`\n\t\tOld   string `json:\"old\"`\n\t\tNew   string `json:\"new\"`\n\t}\n\tvar changes []change\n\n\tadd := func(field, oldVal, newVal string) {\n\t\tif oldVal != newVal {\n\t\t\tchanges = append(changes, change{field, oldVal, newVal})\n\t\t}\n\t}\n\n\tadd(\"name\", old.Name, new.Name)\n\tadd(\"spec\", old.Spec, new.Spec)\n\tadd(\"command\", old.Command, new.Command)\n\tadd(\"tag\", old.Tag, new.Tag)\n\tadd(\"timeout\", strconv.Itoa(old.Timeout), strconv.Itoa(new.Timeout))\n\tadd(\"retry_times\", strconv.Itoa(int(old.RetryTimes)), strconv.Itoa(int(new.RetryTimes)))\n\tadd(\"retry_interval\", strconv.Itoa(int(old.RetryInterval)), strconv.Itoa(int(new.RetryInterval)))\n\tadd(\"remark\", old.Remark, new.Remark)\n\tadd(\"http_method\", strconv.Itoa(int(old.HttpMethod)), strconv.Itoa(int(new.HttpMethod)))\n\tadd(\"http_body\", old.HttpBody, new.HttpBody)\n\tadd(\"http_headers\", old.HttpHeaders, new.HttpHeaders)\n\tadd(\"success_pattern\", old.SuccessPattern, new.SuccessPattern)\n\tadd(\"notify_status\", strconv.Itoa(int(old.NotifyStatus)), strconv.Itoa(int(new.NotifyStatus)))\n\tadd(\"notify_keyword\", old.NotifyKeyword, new.NotifyKeyword)\n\tadd(\"log_retention_days\", strconv.Itoa(old.LogRetentionDays), strconv.Itoa(new.LogRetentionDays))\n\n\tif len(changes) == 0 {\n\t\treturn \"\"\n\t}\n\n\t// 生成简洁的文本格式\n\tvar b strings.Builder\n\tfor i, ch := range changes {\n\t\tif i > 0 {\n\t\t\tb.WriteString(\"\\n\")\n\t\t}\n\t\tb.WriteString(ch.Field)\n\t\tb.WriteString(\": \")\n\t\tb.WriteString(ch.Old)\n\t\tb.WriteString(\" → \")\n\t\tb.WriteString(ch.New)\n\t}\n\treturn b.String()\n}\n"
  },
  {
    "path": "internal/routers/task/task_tag_test.go",
    "content": "package task\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/schema\"\n)\n\nfunc setupTestRouter(t *testing.T) (*gin.Engine, func()) {\n\tt.Helper()\n\tgin.SetMode(gin.TestMode)\n\n\toriginalDb := models.Db\n\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{\n\t\tNamingStrategy: schema.NamingStrategy{\n\t\t\tSingularTable: true,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open test database: %v\", err)\n\t}\n\n\terr = db.AutoMigrate(&models.Task{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to migrate test database: %v\", err)\n\t}\n\n\tmodels.Db = db\n\n\tr := gin.New()\n\tr.GET(\"/api/task/tags\", GetAllTags)\n\n\tcleanup := func() {\n\t\tmodels.Db = originalDb\n\t}\n\n\treturn r, cleanup\n}\n\ntype apiResponse struct {\n\tCode    int             `json:\"code\"`\n\tMessage string          `json:\"message\"`\n\tData    json.RawMessage `json:\"data\"`\n}\n\nfunc TestGetAllTagsHandler_Empty(t *testing.T) {\n\tr, cleanup := setupTestRouter(t)\n\tdefer cleanup()\n\n\tw := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/api/task/tags\", nil)\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200, got %d\", w.Code)\n\t}\n\n\tvar resp apiResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to parse response: %v\", err)\n\t}\n\n\tif resp.Code != 0 {\n\t\tt.Errorf(\"expected code 0, got %d\", resp.Code)\n\t}\n\n\tvar tags []string\n\tif err := json.Unmarshal(resp.Data, &tags); err != nil {\n\t\tt.Fatalf(\"failed to parse tags data: %v\", err)\n\t}\n\n\tif len(tags) != 0 {\n\t\tt.Errorf(\"expected empty tags, got %v\", tags)\n\t}\n}\n\nfunc TestGetAllTagsHandler_WithTags(t *testing.T) {\n\tr, cleanup := setupTestRouter(t)\n\tdefer cleanup()\n\n\t// Insert test data\n\ttasks := []map[string]interface{}{\n\t\t{\"name\": \"task1\", \"tag\": \"alpha,beta\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 1\", \"status\": 1},\n\t\t{\"name\": \"task2\", \"tag\": \"beta,gamma\", \"level\": 1, \"spec\": \"* * * * *\", \"protocol\": 1, \"command\": \"echo 2\", \"status\": 1},\n\t}\n\tfor _, data := range tasks {\n\t\tif err := models.Db.Model(&models.Task{}).Create(data).Error; err != nil {\n\t\t\tt.Fatalf(\"failed to create task: %v\", err)\n\t\t}\n\t}\n\n\tw := httptest.NewRecorder()\n\treq, _ := http.NewRequest(\"GET\", \"/api/task/tags\", nil)\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200, got %d\", w.Code)\n\t}\n\n\tvar resp apiResponse\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"failed to parse response: %v\", err)\n\t}\n\n\tif resp.Code != 0 {\n\t\tt.Errorf(\"expected code 0, got %d\", resp.Code)\n\t}\n\n\tvar tags []string\n\tif err := json.Unmarshal(resp.Data, &tags); err != nil {\n\t\tt.Fatalf(\"failed to parse tags data: %v\", err)\n\t}\n\n\texpected := []string{\"alpha\", \"beta\", \"gamma\"}\n\tif len(tags) != len(expected) {\n\t\tt.Fatalf(\"expected %d tags, got %d: %v\", len(expected), len(tags), tags)\n\t}\n\tfor i, tag := range tags {\n\t\tif tag != expected[i] {\n\t\t\tt.Errorf(\"expected tag[%d] = %q, got %q\", i, expected[i], tag)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/routers/task/task_version.go",
    "content": "package task\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/i18n\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n\t\"github.com/gocronx-team/gocron/internal/routers/user\"\n\t\"github.com/gocronx-team/gocron/internal/service\"\n\t\"gorm.io/gorm\"\n)\n\n// VersionList 获取任务脚本版本列表\nfunc VersionList(c *gin.Context) {\n\ttaskId, _ := strconv.Atoi(c.Param(\"id\"))\n\tif taskId <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"param_error\"))\n\t\treturn\n\t}\n\n\tversionModel := new(models.TaskScriptVersion)\n\tparams := models.CommonMap{}\n\tbase.ParsePageAndPageSize(c, params)\n\n\ttotal, err := versionModel.Total(taskId)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\n\tlist, err := versionModel.List(taskId, params)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\n\tjsonResp := utils.JsonResponse{}\n\tresult := jsonResp.Success(utils.SuccessContent, map[string]interface{}{\n\t\t\"total\": total,\n\t\t\"data\":  list,\n\t})\n\tc.String(http.StatusOK, result)\n}\n\n// VersionDetail 获取单个版本详情\nfunc VersionDetail(c *gin.Context) {\n\ttaskId, _ := strconv.Atoi(c.Param(\"id\"))\n\tversionId, _ := strconv.Atoi(c.Param(\"version_id\"))\n\tif taskId <= 0 || versionId <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"param_error\"))\n\t\treturn\n\t}\n\n\tversionModel := new(models.TaskScriptVersion)\n\tversion, err := versionModel.Detail(versionId)\n\tif err != nil || version.TaskId != taskId {\n\t\tbase.RespondError(c, i18n.T(c, \"version_not_found\"))\n\t\treturn\n\t}\n\n\tjsonResp := utils.JsonResponse{}\n\tresult := jsonResp.Success(utils.SuccessContent, version)\n\tc.String(http.StatusOK, result)\n}\n\n// VersionRollback 回滚任务命令到指定版本\nfunc VersionRollback(c *gin.Context) {\n\ttaskId, _ := strconv.Atoi(c.Param(\"id\"))\n\tversionId, _ := strconv.Atoi(c.Param(\"version_id\"))\n\tif taskId <= 0 || versionId <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"param_error\"))\n\t\treturn\n\t}\n\n\tversionModel := new(models.TaskScriptVersion)\n\tversion, err := versionModel.Detail(versionId)\n\tif err != nil || version.TaskId != taskId {\n\t\tbase.RespondError(c, i18n.T(c, \"version_not_found\"))\n\t\treturn\n\t}\n\n\ttaskModel := new(models.Task)\n\tcurrentTask, err := taskModel.Detail(taskId)\n\tif err != nil || currentTask.Id == 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"get_task_detail_failed\"))\n\t\treturn\n\t}\n\n\t// 使用事务保证回滚操作的原子性\n\ttxErr := models.Db.Transaction(func(tx *gorm.DB) error {\n\t\t// 回滚前保存当前命令为新版本\n\t\tif currentTask.Command != version.Command {\n\t\t\tlatestVersion, _ := versionModel.GetLatestVersion(taskId)\n\t\t\tsaveVersion := &models.TaskScriptVersion{\n\t\t\t\tTaskId:   taskId,\n\t\t\t\tCommand:  currentTask.Command,\n\t\t\t\tRemark:   \"auto-save before rollback\",\n\t\t\t\tUsername: user.Username(c),\n\t\t\t\tVersion:  latestVersion + 1,\n\t\t\t}\n\t\t\tif err := tx.Create(saveVersion).Error; err != nil {\n\t\t\t\tlogger.Warnf(\"回滚前保存版本失败 TaskID-%d: %v\", taskId, err)\n\t\t\t}\n\t\t}\n\n\t\t// 更新任务命令\n\t\treturn tx.Model(&models.Task{}).Where(\"id = ?\", taskId).\n\t\t\tUpdateColumn(\"command\", version.Command).Error\n\t})\n\tif txErr != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"rollback_failed\"), txErr)\n\t\treturn\n\t}\n\n\t// 事务完成后清理旧版本（非关键操作，不需要在事务内）\n\tif cErr := versionModel.CleanOldVersions(taskId, 30); cErr != nil {\n\t\tlogger.Warnf(\"清理旧版本失败 TaskID-%d: %v\", taskId, cErr)\n\t}\n\n\t// 重新加入调度器\n\tstatus, _ := taskModel.GetStatus(taskId)\n\tif status == models.Enabled {\n\t\ttask, _ := taskModel.Detail(taskId)\n\t\tservice.ServiceTask.RemoveAndAdd(task)\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"rollback_success\"), nil)\n}\n"
  },
  {
    "path": "internal/routers/tasklog/task_log.go",
    "content": "package tasklog\n\n// 任务日志\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/i18n\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n\t\"github.com/gocronx-team/gocron/internal/service\"\n)\n\nfunc Index(c *gin.Context) {\n\tlogModel := new(models.TaskLog)\n\tqueryParams := parseQueryParams(c)\n\ttotal, err := logModel.Total(queryParams)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tlogs, err := logModel.List(queryParams)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tbase.RespondSuccess(c, utils.SuccessContent, map[string]interface{}{\n\t\t\"total\": total,\n\t\t\"data\":  logs,\n\t})\n}\n\n// 清空日志\nfunc Clear(c *gin.Context) {\n\ttaskLogModel := new(models.TaskLog)\n\t_, err := taskLogModel.Clear()\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\n// 停止运行中的任务\nfunc Stop(c *gin.Context) {\n\tid, err := strconv.ParseInt(c.PostForm(\"id\"), 10, 64)\n\tif err != nil || id <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"invalid_log_id\"))\n\t\treturn\n\t}\n\ttaskId, err := strconv.Atoi(c.PostForm(\"task_id\"))\n\tif err != nil || taskId <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"invalid_task_id\"))\n\t\treturn\n\t}\n\ttaskModel := new(models.Task)\n\ttask, err := taskModel.Detail(taskId)\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"get_task_info_failed\")+\"#\"+err.Error(), err)\n\t\treturn\n\t}\n\tif task.Protocol != models.TaskRPC {\n\t\tbase.RespondError(c, i18n.T(c, \"only_shell_task_can_stop\"))\n\t\treturn\n\t}\n\tif len(task.Hosts) == 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"task_node_list_empty\"))\n\t\treturn\n\t}\n\tfor _, host := range task.Hosts {\n\t\tservice.ServiceTask.Stop(host.Name, host.Port, id)\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"stop_task_sent\"), nil)\n}\n\n// 删除N个月前的日志\nfunc Remove(c *gin.Context) {\n\tmonth, _ := strconv.Atoi(c.Param(\"id\"))\n\tif month < 1 || month > 12 {\n\t\tbase.RespondError(c, i18n.T(c, \"param_range_1_12\"))\n\t\treturn\n\t}\n\ttaskLogModel := new(models.TaskLog)\n\t_, err := taskLogModel.Remove(month)\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"delete_failed\"), err)\n\t} else {\n\t\tbase.RespondSuccess(c, i18n.T(c, \"delete_success\"), nil)\n\t}\n}\n\n// 清空指定任务的日志\nfunc ClearByTaskId(c *gin.Context) {\n\tid, err := strconv.Atoi(c.Param(\"id\"))\n\tif err != nil || id <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"invalid_task_id\"))\n\t\treturn\n\t}\n\ttaskLogModel := new(models.TaskLog)\n\taffected, err := taskLogModel.ClearByTaskId(id)\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"delete_failed\"), err)\n\t} else {\n\t\tbase.RespondSuccess(c, i18n.T(c, \"delete_success\"), map[string]interface{}{\n\t\t\t\"affected\": affected,\n\t\t})\n\t}\n}\n\n// 解析查询参数\nfunc parseQueryParams(c *gin.Context) models.CommonMap {\n\tvar params models.CommonMap = models.CommonMap{}\n\ttaskId, _ := strconv.Atoi(c.Query(\"task_id\"))\n\tprotocol, _ := strconv.Atoi(c.Query(\"protocol\"))\n\tstatus, _ := strconv.Atoi(c.Query(\"status\"))\n\tparams[\"TaskId\"] = taskId\n\tparams[\"Protocol\"] = protocol\n\tif status >= 0 {\n\t\tstatus -= 1\n\t}\n\tparams[\"Status\"] = status\n\tbase.ParsePageAndPageSize(c, params)\n\n\treturn params\n}\n"
  },
  {
    "path": "internal/routers/tasklog/task_log_test.go",
    "content": "package tasklog\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc init() {\n\tgin.SetMode(gin.TestMode)\n}\n\nfunc setupTestDb(t *testing.T) {\n\tt.Helper()\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open in-memory sqlite: %v\", err)\n\t}\n\terr = db.AutoMigrate(&models.TaskLog{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to migrate: %v\", err)\n\t}\n\tmodels.Db = db\n}\n\nfunc TestClearByTaskId_InvalidId(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tid   string\n\t}{\n\t\t{\"non-numeric\", \"abc\"},\n\t\t{\"negative\", \"-1\"},\n\t\t{\"zero\", \"0\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tw := httptest.NewRecorder()\n\t\t\tc, r := gin.CreateTestContext(w)\n\t\t\tr.POST(\"/api/task/log/clear/:id\", ClearByTaskId)\n\n\t\t\tc.Request, _ = http.NewRequest(\"POST\", \"/api/task/log/clear/\"+tt.id, nil)\n\t\t\tr.ServeHTTP(w, c.Request)\n\n\t\t\tbody := w.Body.String()\n\t\t\tif w.Code != http.StatusOK {\n\t\t\t\tt.Errorf(\"expected status 200, got %d\", w.Code)\n\t\t\t}\n\t\t\t// The response should indicate failure (code != 0)\n\t\t\tif !strings.Contains(body, `\"code\"`) {\n\t\t\t\tt.Errorf(\"expected JSON response with code field, got: %s\", body)\n\t\t\t}\n\t\t\t// Should not contain success indicators for invalid input\n\t\t\tif strings.Contains(body, `\"code\":0`) {\n\t\t\t\tt.Errorf(\"expected error response for invalid id %q, got success: %s\", tt.id, body)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestClearByTaskId_ValidId(t *testing.T) {\n\tsetupTestDb(t)\n\n\tw := httptest.NewRecorder()\n\t_, r := gin.CreateTestContext(w)\n\tr.POST(\"/api/task/log/clear/:id\", ClearByTaskId)\n\n\treq, _ := http.NewRequest(\"POST\", \"/api/task/log/clear/1\", nil)\n\tr.ServeHTTP(w, req)\n\n\tbody := w.Body.String()\n\tif w.Code != http.StatusOK {\n\t\tt.Errorf(\"expected status 200, got %d\", w.Code)\n\t}\n\t// Should contain a successful JSON response\n\tif !strings.Contains(body, `\"code\":0`) {\n\t\tt.Errorf(\"expected success response for valid id, got: %s\", body)\n\t}\n}\n"
  },
  {
    "path": "internal/routers/template/template.go",
    "content": "package template\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/i18n\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n\t\"github.com/gocronx-team/gocron/internal/routers/user\"\n)\n\ntype TemplateForm struct {\n\tId               int    `form:\"id\" json:\"id\"`\n\tName             string `form:\"name\" json:\"name\" binding:\"required,max=64\"`\n\tDescription      string `form:\"description\" json:\"description\" binding:\"max=500\"`\n\tCategory         string `form:\"category\" json:\"category\" binding:\"required,max=32\"`\n\tProtocol         int8   `form:\"protocol\" json:\"protocol\" binding:\"oneof=1 2\"`\n\tCommand          string `form:\"command\" json:\"command\" binding:\"required,max=65535\"`\n\tHttpMethod       int8   `form:\"http_method\" json:\"http_method\" binding:\"oneof=1 2\"`\n\tHttpBody         string `form:\"http_body\" json:\"http_body\"`\n\tHttpHeaders      string `form:\"http_headers\" json:\"http_headers\"`\n\tSuccessPattern   string `form:\"success_pattern\" json:\"success_pattern\" binding:\"max=512\"`\n\tTag              string `form:\"tag\" json:\"tag\"`\n\tSpec             string `form:\"spec\" json:\"spec\"`\n\tTimeout          int    `form:\"timeout\" json:\"timeout\" binding:\"min=0,max=86400\"`\n\tMulti            int8   `form:\"multi\" json:\"multi\" binding:\"oneof=0 1\"`\n\tRetryTimes       int8   `form:\"retry_times\" json:\"retry_times\"`\n\tRetryInterval    int16  `form:\"retry_interval\" json:\"retry_interval\"`\n\tTimezone         string `form:\"timezone\" json:\"timezone\"`\n\tNotifyStatus     int8   `form:\"notify_status\" json:\"notify_status\"`\n\tNotifyType       int8   `form:\"notify_type\" json:\"notify_type\"`\n\tNotifyKeyword    string `form:\"notify_keyword\" json:\"notify_keyword\"`\n\tLogRetentionDays int    `form:\"log_retention_days\" json:\"log_retention_days\" binding:\"min=0,max=3650\"`\n}\n\ntype SaveFromTaskForm struct {\n\tTaskId      int    `form:\"task_id\" json:\"task_id\" binding:\"required\"`\n\tName        string `form:\"name\" json:\"name\" binding:\"required,max=64\"`\n\tDescription string `form:\"description\" json:\"description\" binding:\"max=500\"`\n\tCategory    string `form:\"category\" json:\"category\" binding:\"required,max=32\"`\n}\n\n// Index 模板列表\nfunc Index(c *gin.Context) {\n\ttmplModel := new(models.TaskTemplate)\n\tparams := parseQueryParams(c)\n\n\ttotal, err := tmplModel.Total(params)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\n\tlist, err := tmplModel.List(params)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\n\tjsonResp := utils.JsonResponse{}\n\tresult := jsonResp.Success(utils.SuccessContent, map[string]interface{}{\n\t\t\"total\": total,\n\t\t\"data\":  list,\n\t})\n\tc.String(http.StatusOK, result)\n}\n\n// Detail 模板详情\nfunc Detail(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\tif id <= 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"param_error\"))\n\t\treturn\n\t}\n\n\ttmplModel := new(models.TaskTemplate)\n\ttmpl, err := tmplModel.Detail(id)\n\tif err != nil || tmpl.Id == 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"template_not_found\"))\n\t\treturn\n\t}\n\n\tjsonResp := utils.JsonResponse{}\n\tc.String(http.StatusOK, jsonResp.Success(utils.SuccessContent, tmpl))\n}\n\n// Store 创建/更新模板\nfunc Store(c *gin.Context) {\n\tvar form TemplateForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tbase.RespondValidationError(c, err)\n\t\treturn\n\t}\n\n\ttmplModel := models.TaskTemplate{}\n\tid := form.Id\n\n\t// 内置模板不可修改\n\tif id > 0 {\n\t\texisting, detailErr := tmplModel.Detail(id)\n\t\tif detailErr != nil || existing.Id == 0 {\n\t\t\tbase.RespondError(c, i18n.T(c, \"template_not_found\"))\n\t\t\treturn\n\t\t}\n\t\tif existing.IsBuiltin == 1 {\n\t\t\tbase.RespondError(c, i18n.T(c, \"builtin_template_readonly\"))\n\t\t\treturn\n\t\t}\n\t}\n\n\tnameExists, err := tmplModel.NameExist(form.Name, id)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tif nameExists {\n\t\tbase.RespondError(c, i18n.T(c, \"template_name_exists\"))\n\t\treturn\n\t}\n\n\ttmplModel.Name = form.Name\n\ttmplModel.Description = form.Description\n\ttmplModel.Category = form.Category\n\ttmplModel.Protocol = form.Protocol\n\ttmplModel.Command = form.Command\n\ttmplModel.HttpMethod = form.HttpMethod\n\ttmplModel.HttpBody = form.HttpBody\n\ttmplModel.HttpHeaders = form.HttpHeaders\n\ttmplModel.SuccessPattern = form.SuccessPattern\n\ttmplModel.Tag = form.Tag\n\ttmplModel.Spec = form.Spec\n\ttmplModel.Timeout = form.Timeout\n\ttmplModel.Multi = form.Multi\n\ttmplModel.RetryTimes = form.RetryTimes\n\ttmplModel.RetryInterval = form.RetryInterval\n\ttmplModel.Timezone = form.Timezone\n\ttmplModel.NotifyStatus = form.NotifyStatus\n\ttmplModel.NotifyType = form.NotifyType\n\ttmplModel.NotifyKeyword = form.NotifyKeyword\n\ttmplModel.LogRetentionDays = form.LogRetentionDays\n\n\tif id == 0 {\n\t\ttmplModel.CreatedBy = user.Username(c)\n\t\t_, err = tmplModel.Create()\n\t} else {\n\t\t_, err = tmplModel.UpdateBean(id)\n\t}\n\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"save_failed\"), err)\n\t\treturn\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"save_success\"), nil)\n}\n\n// Remove 删除模板\nfunc Remove(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\ttmplModel := new(models.TaskTemplate)\n\n\ttmpl, err := tmplModel.Detail(id)\n\tif err != nil || tmpl.Id == 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"template_not_found\"))\n\t\treturn\n\t}\n\n\tif tmpl.IsBuiltin == 1 {\n\t\tbase.RespondError(c, i18n.T(c, \"builtin_template_no_delete\"))\n\t\treturn\n\t}\n\n\t_, err = tmplModel.Delete(id)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\n\tbase.RespondSuccessWithDefaultMsg(c, nil)\n}\n\n// Apply 应用模板（增加使用次数并返回模板数据）\nfunc Apply(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\ttmplModel := new(models.TaskTemplate)\n\n\ttmpl, err := tmplModel.Detail(id)\n\tif err != nil || tmpl.Id == 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"template_not_found\"))\n\t\treturn\n\t}\n\n\tif uErr := tmplModel.IncrementUsage(id); uErr != nil {\n\t\tlogger.Warnf(\"增加模板使用次数失败 TemplateID-%d: %v\", id, uErr)\n\t}\n\n\tjsonResp := utils.JsonResponse{}\n\tresult := jsonResp.Success(utils.SuccessContent, tmpl)\n\tc.String(http.StatusOK, result)\n}\n\n// SaveFromTask 从现有任务保存为模板\nfunc SaveFromTask(c *gin.Context) {\n\tvar form SaveFromTaskForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tbase.RespondValidationError(c, err)\n\t\treturn\n\t}\n\n\ttaskModel := new(models.Task)\n\ttask, err := taskModel.Detail(form.TaskId)\n\tif err != nil || task.Id == 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"task_not_found\"))\n\t\treturn\n\t}\n\n\ttmplModel := models.TaskTemplate{}\n\tnameExists, err := tmplModel.NameExist(form.Name, 0)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tif nameExists {\n\t\tbase.RespondError(c, i18n.T(c, \"template_name_exists\"))\n\t\treturn\n\t}\n\n\ttmplModel.Name = form.Name\n\ttmplModel.Description = form.Description\n\ttmplModel.Category = form.Category\n\ttmplModel.Protocol = int8(task.Protocol)\n\ttmplModel.Command = task.Command\n\ttmplModel.HttpMethod = int8(task.HttpMethod)\n\ttmplModel.HttpBody = task.HttpBody\n\ttmplModel.HttpHeaders = task.HttpHeaders\n\ttmplModel.SuccessPattern = task.SuccessPattern\n\ttmplModel.Tag = task.Tag\n\t// 从 spec 中解析 timezone（格式: CRON_TZ=Asia/Shanghai 0 0 2 * * *）\n\tspec := task.Spec\n\tif strings.HasPrefix(spec, \"CRON_TZ=\") || strings.HasPrefix(spec, \"TZ=\") {\n\t\tparts := strings.SplitN(spec, \" \", 2)\n\t\tif len(parts) == 2 {\n\t\t\ttzPart := parts[0]\n\t\t\tspec = parts[1]\n\t\t\ttmplModel.Timezone = strings.SplitN(tzPart, \"=\", 2)[1]\n\t\t}\n\t}\n\ttmplModel.Spec = spec\n\ttmplModel.Timeout = task.Timeout\n\ttmplModel.Multi = task.Multi\n\ttmplModel.RetryTimes = task.RetryTimes\n\ttmplModel.RetryInterval = task.RetryInterval\n\ttmplModel.NotifyStatus = task.NotifyStatus\n\ttmplModel.NotifyType = task.NotifyType\n\ttmplModel.NotifyKeyword = task.NotifyKeyword\n\ttmplModel.LogRetentionDays = task.LogRetentionDays\n\ttmplModel.CreatedBy = user.Username(c)\n\n\t_, err = tmplModel.Create()\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"save_failed\"), err)\n\t\treturn\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"save_success\"), nil)\n}\n\n// Categories 获取所有分类\nfunc Categories(c *gin.Context) {\n\ttmplModel := new(models.TaskTemplate)\n\tcategories, err := tmplModel.GetCategories()\n\tif err != nil {\n\t\tcategories = []string{}\n\t}\n\n\tjsonResp := utils.JsonResponse{}\n\tresult := jsonResp.Success(utils.SuccessContent, categories)\n\tc.String(http.StatusOK, result)\n}\n\nfunc parseQueryParams(c *gin.Context) models.CommonMap {\n\tparams := models.CommonMap{}\n\tparams[\"Category\"] = strings.TrimSpace(c.Query(\"category\"))\n\tparams[\"Name\"] = strings.TrimSpace(c.Query(\"name\"))\n\tbase.ParsePageAndPageSize(c, params)\n\treturn params\n}\n"
  },
  {
    "path": "internal/routers/user/twofa.go",
    "content": "package user\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"image/png\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/i18n\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n\t\"github.com/pquerna/otp/totp\"\n)\n\n// Setup2FA 设置2FA\nfunc Setup2FA(c *gin.Context) {\n\tuid := Uid(c)\n\tusername := Username(c)\n\n\tuserModel := new(models.User)\n\terr := userModel.Find(uid)\n\tif err != nil || userModel.Id == 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"user_not_found\"))\n\t\treturn\n\t}\n\n\t// 生成TOTP密钥\n\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\tIssuer:      \"Gocron\",\n\t\tAccountName: username,\n\t})\n\tif err != nil {\n\t\tlogger.Error(\"生成2FA密钥失败\", err)\n\t\tbase.RespondError(c, i18n.T(c, \"generate_2fa_key_failed\"))\n\t\treturn\n\t}\n\n\t// 生成二维码\n\timg, err := key.Image(200, 200)\n\tif err != nil {\n\t\tlogger.Error(\"生成二维码失败\", err)\n\t\tbase.RespondError(c, i18n.T(c, \"generate_qrcode_failed\"))\n\t\treturn\n\t}\n\n\t// 将图片转为base64\n\tvar buf bytes.Buffer\n\tif err := png.Encode(&buf, img); err != nil {\n\t\tlogger.Error(\"编码二维码失败\", err)\n\t\tbase.RespondError(c, i18n.T(c, \"generate_qrcode_failed\"))\n\t\treturn\n\t}\n\tqrCode := base64.StdEncoding.EncodeToString(buf.Bytes())\n\n\tbase.RespondSuccess(c, i18n.T(c, \"get_success\"), map[string]interface{}{\n\t\t\"secret\":  key.Secret(),\n\t\t\"qr_code\": \"data:image/png;base64,\" + qrCode,\n\t})\n}\n\n// Enable2FAForm 启用2FA表单\ntype Enable2FAForm struct {\n\tSecret string `form:\"secret\" json:\"secret\" binding:\"required\"`\n\tCode   string `form:\"code\" json:\"code\" binding:\"required,len=6\"`\n}\n\n// Enable2FA 启用2FA\nfunc Enable2FA(c *gin.Context) {\n\tvar form Enable2FAForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tbase.RespondValidationError(c, err)\n\t\treturn\n\t}\n\n\tuid := Uid(c)\n\n\t// 验证TOTP码\n\tvalid := totp.Validate(form.Code, form.Secret)\n\tif !valid {\n\t\tbase.RespondError(c, i18n.T(c, \"verification_code_error\"))\n\t\treturn\n\t}\n\n\t// 保存密钥并启用2FA\n\tuserModel := new(models.User)\n\t_, err := userModel.Update(uid, models.CommonMap{\n\t\t\"two_factor_key\": form.Secret,\n\t\t\"two_factor_on\":  1,\n\t})\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"enable_failed\"), err)\n\t\treturn\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"2fa_enabled\"), nil)\n}\n\n// Disable2FAForm 禁用2FA表单\ntype Disable2FAForm struct {\n\tCode string `form:\"code\" json:\"code\" binding:\"required,len=6\"`\n}\n\n// Disable2FA 禁用2FA\nfunc Disable2FA(c *gin.Context) {\n\tvar form Disable2FAForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tbase.RespondValidationError(c, err)\n\t\treturn\n\t}\n\n\tuid := Uid(c)\n\n\tuserModel := new(models.User)\n\terr := userModel.Find(uid)\n\tif err != nil || userModel.Id == 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"user_not_found\"))\n\t\treturn\n\t}\n\n\tif userModel.TwoFactorOn == 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"2fa_not_enabled\"))\n\t\treturn\n\t}\n\n\t// 验证TOTP码\n\tvalid := totp.Validate(form.Code, userModel.TwoFactorKey)\n\tif !valid {\n\t\tbase.RespondError(c, i18n.T(c, \"verification_code_error\"))\n\t\treturn\n\t}\n\n\t// 禁用2FA\n\t_, err = userModel.Update(uid, models.CommonMap{\n\t\t\"two_factor_key\": \"\",\n\t\t\"two_factor_on\":  0,\n\t})\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"disable_failed\"), err)\n\t\treturn\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"2fa_disabled\"), nil)\n}\n\n// Get2FAStatus 获取2FA状态\nfunc Get2FAStatus(c *gin.Context) {\n\tuid := Uid(c)\n\n\tuserModel := new(models.User)\n\terr := userModel.Find(uid)\n\tif err != nil || userModel.Id == 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"user_not_found\"))\n\t\treturn\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"get_success\"), map[string]interface{}{\n\t\t\"enabled\": userModel.TwoFactorOn == 1,\n\t})\n}\n"
  },
  {
    "path": "internal/routers/user/user.go",
    "content": "package user\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/app\"\n\t\"github.com/gocronx-team/gocron/internal/modules/i18n\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n\t\"github.com/gocronx-team/gocron/internal/routers/base\"\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/pquerna/otp/totp\"\n)\n\nconst tokenDuration = 4 * time.Hour\n\n// UserForm 用户表单\ntype UserForm struct {\n\tId              int           `form:\"id\" json:\"id\"`\n\tName            string        `form:\"name\" json:\"name\" binding:\"required,max=32\"`         // 用户名\n\tPassword        string        `form:\"password\" json:\"password\"`                           // 密码\n\tConfirmPassword string        `form:\"confirm_password\" json:\"confirm_password\"`           // 确认密码\n\tEmail           string        `form:\"email\" json:\"email\" binding:\"required,email,max=50\"` // 邮箱\n\tIsAdmin         int8          `form:\"is_admin\" json:\"is_admin\"`                           // 是否是管理员 1:管理员 0:普通用户\n\tStatus          models.Status `form:\"status\" json:\"status\"`\n}\n\n// UpdatePasswordForm 更新密码表单\ntype UpdatePasswordForm struct {\n\tNewPassword        string `form:\"new_password\" json:\"new_password\" binding:\"required,min=6\"`\n\tConfirmNewPassword string `form:\"confirm_new_password\" json:\"confirm_new_password\" binding:\"required,min=6\"`\n}\n\n// UpdateMyPasswordForm 更新我的密码表单\ntype UpdateMyPasswordForm struct {\n\tOldPassword        string `form:\"old_password\" json:\"old_password\" binding:\"required\"`\n\tNewPassword        string `form:\"new_password\" json:\"new_password\" binding:\"required,min=6\"`\n\tConfirmNewPassword string `form:\"confirm_new_password\" json:\"confirm_new_password\" binding:\"required,min=6\"`\n}\n\n// Index 用户列表页\nfunc Index(c *gin.Context) {\n\tqueryParams := parseQueryParams(c)\n\tuserModel := new(models.User)\n\tusers, err := userModel.List(queryParams)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\ttotal, err := userModel.Total()\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\n\tbase.RespondSuccess(c, utils.SuccessContent, map[string]interface{}{\n\t\t\"total\": total,\n\t\t\"data\":  users,\n\t})\n}\n\n// 解析查询参数\nfunc parseQueryParams(c *gin.Context) models.CommonMap {\n\tparams := models.CommonMap{}\n\tbase.ParsePageAndPageSize(c, params)\n\n\treturn params\n}\n\n// Detail 用户详情\nfunc Detail(c *gin.Context) {\n\tuserModel := new(models.User)\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\terr := userModel.Find(id)\n\tif err != nil {\n\t\tlogger.Error(err)\n\t}\n\tif userModel.Id == 0 {\n\t\tbase.RespondSuccess(c, utils.SuccessContent, nil)\n\t} else {\n\t\tbase.RespondSuccess(c, utils.SuccessContent, userModel)\n\t}\n}\n\n// 保存任务\nfunc Store(c *gin.Context) {\n\tvar form UserForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tbase.RespondValidationError(c, err)\n\t\treturn\n\t}\n\n\tform.Name = strings.TrimSpace(form.Name)\n\tform.Email = strings.TrimSpace(form.Email)\n\tform.Password = strings.TrimSpace(form.Password)\n\tform.ConfirmPassword = strings.TrimSpace(form.ConfirmPassword)\n\n\tuserModel := models.User{}\n\tnameExists, err := userModel.UsernameExists(form.Name, form.Id)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tif nameExists > 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"username_exists\"))\n\t\treturn\n\t}\n\n\temailExists, err := userModel.EmailExists(form.Email, form.Id)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t\treturn\n\t}\n\tif emailExists > 0 {\n\t\tbase.RespondError(c, i18n.T(c, \"email_exists\"))\n\t\treturn\n\t}\n\n\tif form.Id == 0 {\n\t\tif form.Password == \"\" {\n\t\t\tbase.RespondError(c, i18n.T(c, \"password_required\"))\n\t\t\treturn\n\t\t}\n\t\tif form.ConfirmPassword == \"\" {\n\t\t\tbase.RespondError(c, i18n.T(c, \"password_confirm_required\"))\n\t\t\treturn\n\t\t}\n\t\t// 验证密码复杂度\n\t\tif valid, errKey := utils.ValidatePassword(form.Password); !valid {\n\t\t\tbase.RespondError(c, i18n.T(c, errKey))\n\t\t\treturn\n\t\t}\n\t\tif form.Password != form.ConfirmPassword {\n\t\t\tbase.RespondError(c, i18n.T(c, \"password_mismatch\"))\n\t\t\treturn\n\t\t}\n\t}\n\tuserModel.Name = form.Name\n\tuserModel.Email = form.Email\n\tuserModel.Password = form.Password\n\tuserModel.IsAdmin = form.IsAdmin\n\tuserModel.Status = form.Status\n\n\tif form.Id == 0 {\n\t\t_, err = userModel.Create()\n\t\tif err != nil {\n\t\t\tbase.RespondError(c, i18n.T(c, \"save_failed\"), err)\n\t\t\treturn\n\t\t}\n\t} else {\n\t\t_, err = userModel.Update(form.Id, models.CommonMap{\n\t\t\t\"name\":     form.Name,\n\t\t\t\"email\":    form.Email,\n\t\t\t\"status\":   form.Status,\n\t\t\t\"is_admin\": form.IsAdmin,\n\t\t})\n\t\tif err != nil {\n\t\t\tbase.RespondError(c, i18n.T(c, \"update_failed\"), err)\n\t\t\treturn\n\t\t}\n\t}\n\n\tbase.RespondSuccess(c, i18n.T(c, \"save_success\"), nil)\n}\n\n// 删除用户\nfunc Remove(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\tuserModel := new(models.User)\n\t_, err := userModel.Delete(id)\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\n// 激活用户\nfunc Enable(c *gin.Context) {\n\tchangeStatus(c, models.Enabled)\n}\n\n// 禁用用户\nfunc Disable(c *gin.Context) {\n\tchangeStatus(c, models.Disabled)\n}\n\n// 改变任务状态\nfunc changeStatus(c *gin.Context, status models.Status) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\tuserModel := new(models.User)\n\t_, err := userModel.Update(id, models.CommonMap{\n\t\t\"status\": status,\n\t})\n\tif err != nil {\n\t\tbase.RespondErrorWithDefaultMsg(c, err)\n\t} else {\n\t\tbase.RespondSuccessWithDefaultMsg(c, nil)\n\t}\n}\n\n// UpdatePassword 更新密码\nfunc UpdatePassword(c *gin.Context) {\n\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\tvar form UpdatePasswordForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tbase.RespondValidationError(c, err)\n\t\treturn\n\t}\n\n\tif form.NewPassword != form.ConfirmNewPassword {\n\t\tbase.RespondError(c, i18n.T(c, \"password_mismatch\"))\n\t\treturn\n\t}\n\t// 验证密码复杂度\n\tif valid, errKey := utils.ValidatePassword(form.NewPassword); !valid {\n\t\tbase.RespondError(c, i18n.T(c, errKey))\n\t\treturn\n\t}\n\tuserModel := new(models.User)\n\t_, err := userModel.UpdatePassword(id, form.NewPassword)\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"update_failed\"))\n\t} else {\n\t\tbase.RespondSuccess(c, i18n.T(c, \"update_success\"), nil)\n\t}\n}\n\n// UpdateMyPassword 更新我的密码\nfunc UpdateMyPassword(c *gin.Context) {\n\tvar form UpdateMyPasswordForm\n\tif err := c.ShouldBind(&form); err != nil {\n\t\tbase.RespondValidationError(c, err)\n\t\treturn\n\t}\n\n\tif form.NewPassword != form.ConfirmNewPassword {\n\t\tbase.RespondError(c, i18n.T(c, \"password_mismatch\"))\n\t\treturn\n\t}\n\tif form.OldPassword == form.NewPassword {\n\t\tbase.RespondError(c, i18n.T(c, \"password_same_as_old\"))\n\t\treturn\n\t}\n\t// 验证密码复杂度\n\tif valid, errKey := utils.ValidatePassword(form.NewPassword); !valid {\n\t\tbase.RespondError(c, i18n.T(c, errKey))\n\t\treturn\n\t}\n\tuserModel := new(models.User)\n\tif !userModel.Match(Username(c), form.OldPassword) {\n\t\tbase.RespondError(c, i18n.T(c, \"old_password_error\"))\n\t\treturn\n\t}\n\t_, err := userModel.UpdatePassword(Uid(c), form.NewPassword)\n\tif err != nil {\n\t\tbase.RespondError(c, i18n.T(c, \"update_failed\"))\n\t} else {\n\t\tbase.RespondSuccess(c, i18n.T(c, \"update_success\"), nil)\n\t}\n}\n\n// ValidateLogin 验证用户登录\nfunc ValidateLogin(c *gin.Context) {\n\tusername := strings.TrimSpace(c.PostForm(\"username\"))\n\tpassword := strings.TrimSpace(c.PostForm(\"password\"))\n\ttwoFactorCode := strings.TrimSpace(c.PostForm(\"two_factor_code\"))\n\n\tif username == \"\" || password == \"\" {\n\t\tbase.RespondError(c, i18n.T(c, \"username_password_empty\"))\n\t\treturn\n\t}\n\n\t// 获取登录限制器\n\tlimiter := utils.GetLoginLimiter()\n\n\t// 检查账户是否被锁定\n\tif locked, lockTime := limiter.IsLocked(username); locked {\n\t\tremainingTime := int(time.Until(lockTime).Minutes())\n\t\tif remainingTime < 1 {\n\t\t\tremainingTime = 1\n\t\t}\n\t\tbase.RespondError(c, fmt.Sprintf(i18n.T(c, \"account_locked\"), remainingTime))\n\t\treturn\n\t}\n\n\tuserModel := new(models.User)\n\tif !userModel.Match(username, password) {\n\t\t// 记录登录失败\n\t\tlimiter.RecordFailure(username)\n\t\tremaining := limiter.GetRemainingAttempts(username)\n\n\t\tif remaining > 0 {\n\t\t\tbase.RespondError(c, fmt.Sprintf(i18n.T(c, \"login_failed_with_attempts\"), remaining))\n\t\t} else {\n\t\t\tbase.RespondError(c, i18n.T(c, \"username_password_error\"))\n\t\t}\n\t\treturn\n\t}\n\n\t// 检查是否启用2FA\n\tif userModel.TwoFactorOn == 1 {\n\t\tif twoFactorCode == \"\" {\n\t\t\tbase.RespondSuccess(c, i18n.T(c, \"2fa_code_required\"), map[string]interface{}{\n\t\t\t\t\"require_2fa\": true,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\t// 验证TOTP码\n\t\tvalid := totp.Validate(twoFactorCode, userModel.TwoFactorKey)\n\t\tif !valid {\n\t\t\t// 2FA验证失败也记录失败次数\n\t\t\tlimiter.RecordFailure(username)\n\t\t\tbase.RespondError(c, i18n.T(c, \"2fa_code_error\"))\n\t\t\treturn\n\t\t}\n\t}\n\n\t// 登录成功，清除失败记录\n\tlimiter.RecordSuccess(username)\n\n\tloginLogModel := new(models.LoginLog)\n\tloginLogModel.Username = userModel.Name\n\tip := c.ClientIP()\n\tif ip == \"::1\" {\n\t\tip = \"127.0.0.1\"\n\t}\n\tloginLogModel.Ip = ip\n\t_, err := loginLogModel.Create()\n\tif err != nil {\n\t\tlogger.Error(\"记录用户登录日志失败\", err)\n\t}\n\n\ttoken, err := generateToken(userModel)\n\tif err != nil {\n\t\tlogger.Errorf(\"生成jwt失败: %s\", err)\n\t\tbase.RespondAuthError(c, i18n.T(c, \"auth_failed\"))\n\t\treturn\n\t}\n\n\tbase.RespondSuccess(c, utils.SuccessContent, map[string]interface{}{\n\t\t\"token\":    token,\n\t\t\"uid\":      userModel.Id,\n\t\t\"username\": userModel.Name,\n\t\t\"is_admin\": userModel.IsAdmin,\n\t})\n}\n\n// Username 获取session中的用户名\nfunc Username(c *gin.Context) string {\n\tusernameInterface, ok := c.Get(\"username\")\n\tif !ok {\n\t\treturn \"\"\n\t}\n\tif username, ok := usernameInterface.(string); ok {\n\t\treturn username\n\t} else {\n\t\treturn \"\"\n\t}\n}\n\n// Uid 获取session中的Uid\nfunc Uid(c *gin.Context) int {\n\tuidInterface, ok := c.Get(\"uid\")\n\tif !ok {\n\t\treturn 0\n\t}\n\tif uid, ok := uidInterface.(int); ok {\n\t\treturn uid\n\t} else {\n\t\treturn 0\n\t}\n}\n\n// IsLogin 判断用户是否已登录\nfunc IsLogin(c *gin.Context) bool {\n\treturn Uid(c) > 0\n}\n\n// IsAdmin 判断当前用户是否是管理员\nfunc IsAdmin(c *gin.Context) bool {\n\tisAdmin, ok := c.Get(\"is_admin\")\n\tif !ok {\n\t\treturn false\n\t}\n\tif v, ok := isAdmin.(int); ok {\n\t\treturn v > 0\n\t} else {\n\t\treturn false\n\t}\n}\n\n// 生成jwt\nfunc generateToken(user *models.User) (string, error) {\n\tclaims := jwt.MapClaims{\n\t\t\"exp\":      time.Now().Add(tokenDuration).Unix(),\n\t\t\"uid\":      user.Id,\n\t\t\"iat\":      time.Now().Unix(),\n\t\t\"issuer\":   \"gocron\",\n\t\t\"username\": user.Name,\n\t\t\"is_admin\": user.IsAdmin,\n\t}\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)\n\n\treturn token.SignedString([]byte(app.Setting.AuthSecret))\n}\n\n// 还原jwt，如果 token 即将过期（小于1小时），则自动刷新\nfunc RestoreToken(c *gin.Context) (string, error) {\n\tauthToken := c.GetHeader(\"Auth-Token\")\n\tif authToken == \"\" {\n\t\treturn \"\", nil\n\t}\n\ttoken, err := jwt.Parse(authToken, func(token *jwt.Token) (interface{}, error) {\n\t\tif _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {\n\t\t\treturn nil, errors.New(\"unexpected signing method\")\n\t\t}\n\t\treturn []byte(app.Setting.AuthSecret), nil\n\t})\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif !token.Valid {\n\t\treturn \"\", errors.New(\"token is invalid\")\n\t}\n\n\tclaims, ok := token.Claims.(jwt.MapClaims)\n\tif !ok {\n\t\treturn \"\", errors.New(\"invalid claims\")\n\t}\n\n\tuidF, ok := claims[\"uid\"].(float64)\n\tif !ok {\n\t\treturn \"\", errors.New(\"invalid uid claim\")\n\t}\n\tusername, ok := claims[\"username\"].(string)\n\tif !ok {\n\t\treturn \"\", errors.New(\"invalid username claim\")\n\t}\n\tisAdminF, ok := claims[\"is_admin\"].(float64)\n\tif !ok {\n\t\treturn \"\", errors.New(\"invalid is_admin claim\")\n\t}\n\texpF, ok := claims[\"exp\"].(float64)\n\tif !ok {\n\t\treturn \"\", errors.New(\"invalid exp claim\")\n\t}\n\n\tc.Set(\"uid\", int(uidF))\n\tc.Set(\"username\", username)\n\tc.Set(\"is_admin\", int(isAdminF))\n\n\t// 检查 token 是否即将过期（小于 1 小时）\n\texp := int64(expF)\n\tif time.Until(time.Unix(exp, 0)) < time.Hour {\n\t\t// 生成新 token\n\t\tuserModel := &models.User{\n\t\t\tId:      int(uidF),\n\t\t\tName:    username,\n\t\t\tIsAdmin: int8(isAdminF),\n\t\t}\n\t\tnewToken, err := generateToken(userModel)\n\t\tif err != nil {\n\t\t\tlogger.Warnf(\"刷新token失败: %v\", err)\n\t\t\treturn \"\", nil\n\t\t}\n\t\tlogger.Infof(\"用户 %s 的 token 已自动刷新\", userModel.Name)\n\t\treturn newToken, nil\n\t}\n\n\treturn \"\", nil\n}\n"
  },
  {
    "path": "internal/service/cron_preview.go",
    "content": "package service\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/cron\"\n)\n\n// Cron 预览：解析表达式，返回接下来 N 次执行时间 + 未来 7 天的执行分布热图。\n// 设计要点：\n// - 复用后端 cron 库（和实际调度一致），避免前端重复实现造成语法漂移。\n// - 迭代有硬上限，防 `* * * * * *` 这类极端表达式 DoS 服务。\n// - 非法表达式不抛错，返回 Valid=false，让前端能平滑展示。\n\nconst (\n\tmaxHeatmapIterations = 2000\n\theatmapWindowHours   = 24 * 7\n\tmaxNextRunsRequested = 20\n\tdefaultNextRuns      = 10\n\tmaxSpecLength        = 128\n)\n\ntype CronRun struct {\n\tUnix    int64  `json:\"unix\"`\n\tISO     string `json:\"iso\"`\n\tWeekday int    `json:\"weekday\"` // 0=Sun..6=Sat\n}\n\ntype HeatmapCell struct {\n\tDay   int `json:\"day\"`\n\tHour  int `json:\"hour\"`\n\tCount int `json:\"count\"`\n}\n\ntype CronPreviewResult struct {\n\tValid        bool          `json:\"valid\"`\n\tError        string        `json:\"error,omitempty\"`\n\tTimezone     string        `json:\"timezone\"`\n\tNowUnix      int64         `json:\"now_unix\"`\n\tNextRuns     []CronRun     `json:\"next_runs\"`\n\tHeatmapCells []HeatmapCell `json:\"heatmap_cells\"`\n\tTruncated    bool          `json:\"truncated,omitempty\"` // heatmap 达到迭代上限提前截断\n}\n\n// PreviewCron 以当前时间为基准计算预览。\nfunc PreviewCron(spec, timezone string, count int) *CronPreviewResult {\n\treturn previewCronAt(spec, timezone, count, time.Now())\n}\n\n// previewCronAt 暴露 now 参数用于单测（时间可注入）。\nfunc previewCronAt(spec, timezone string, count int, now time.Time) *CronPreviewResult {\n\t// 1. 输入清洗\n\tspec = strings.TrimSpace(spec)\n\tresult := &CronPreviewResult{\n\t\tTimezone: timezone,\n\t\tNowUnix:  now.Unix(),\n\t}\n\tif spec == \"\" {\n\t\tresult.Error = \"spec is required\"\n\t\treturn result\n\t}\n\tif strings.ContainsAny(spec, \"\\r\\n\") {\n\t\tresult.Error = \"spec must be single-line\"\n\t\treturn result\n\t}\n\tif len(spec) > maxSpecLength {\n\t\tresult.Error = fmt.Sprintf(\"spec too long (max %d chars)\", maxSpecLength)\n\t\treturn result\n\t}\n\n\tif count <= 0 {\n\t\tcount = defaultNextRuns\n\t}\n\tif count > maxNextRunsRequested {\n\t\tcount = maxNextRunsRequested\n\t}\n\n\t// 2. 处理时区：如果显式传了 timezone，去掉 spec 里可能的前缀后再用它包\n\tfinalSpec, effectiveTZ, tzErr := resolveSpecTimezone(spec, timezone)\n\tif tzErr != \"\" {\n\t\tresult.Error = tzErr\n\t\treturn result\n\t}\n\tresult.Timezone = effectiveTZ\n\n\t// 3. 解析\n\tschedule, err := cron.ParseWithError(finalSpec)\n\tif err != nil {\n\t\tresult.Error = err.Error()\n\t\treturn result\n\t}\n\n\tresult.Valid = true\n\n\t// 4. 接下来 N 次\n\tt := now\n\tfor i := 0; i < count; i++ {\n\t\tnext := schedule.Next(t)\n\t\t// 防死循环：Next 返回零值或不推进说明永不触发\n\t\tif next.IsZero() || !next.After(t) {\n\t\t\tbreak\n\t\t}\n\t\tresult.NextRuns = append(result.NextRuns, CronRun{\n\t\t\tUnix:    next.Unix(),\n\t\t\tISO:     next.Format(time.RFC3339),\n\t\t\tWeekday: int(next.Weekday()),\n\t\t})\n\t\tt = next\n\t}\n\n\t// 5. 未来 7 天分布（稀疏格式，只返 count>0 的格子）\n\theatmapEnd := now.Add(time.Duration(heatmapWindowHours) * time.Hour)\n\tcells := make(map[[2]int]int)\n\tt = now\n\tfor iter := 0; iter < maxHeatmapIterations; iter++ {\n\t\tnext := schedule.Next(t)\n\t\tif next.IsZero() || !next.After(t) || next.After(heatmapEnd) {\n\t\t\tbreak\n\t\t}\n\t\tkey := [2]int{int(next.Weekday()), next.Hour()}\n\t\tcells[key]++\n\t\tt = next\n\t\tif iter == maxHeatmapIterations-1 {\n\t\t\t// 还有一次就触顶，再看一眼是否真到窗口末\n\t\t\tif peek := schedule.Next(t); !peek.IsZero() && peek.After(t) && peek.Before(heatmapEnd) {\n\t\t\t\tresult.Truncated = true\n\t\t\t}\n\t\t}\n\t}\n\tfor key, c := range cells {\n\t\tresult.HeatmapCells = append(result.HeatmapCells, HeatmapCell{\n\t\t\tDay: key[0], Hour: key[1], Count: c,\n\t\t})\n\t}\n\n\treturn result\n}\n\n// resolveSpecTimezone 处理 spec 和 timezone 的组合：\n// - 显式 timezone != \"\" : 剥除 spec 里已有的 CRON_TZ=/TZ= 前缀后，用显式 timezone 重新包\n// - 显式 timezone == \"\" 且 spec 带前缀：保留原样，effectiveTZ 返回前缀里的 tz\n// - 都没有：用服务器本地时区\nfunc resolveSpecTimezone(spec, timezone string) (finalSpec, effectiveTZ, errMsg string) {\n\tbareSpec, prefixTZ := stripTimezonePrefix(spec)\n\n\tif timezone != \"\" {\n\t\tif _, err := time.LoadLocation(timezone); err != nil {\n\t\t\treturn \"\", timezone, fmt.Sprintf(\"unknown timezone: %q\", timezone)\n\t\t}\n\t\treturn \"CRON_TZ=\" + timezone + \" \" + bareSpec, timezone, \"\"\n\t}\n\n\tif prefixTZ != \"\" {\n\t\t// spec 自带前缀，cron 库自己能解\n\t\tif _, err := time.LoadLocation(prefixTZ); err != nil {\n\t\t\treturn \"\", prefixTZ, fmt.Sprintf(\"unknown timezone: %q\", prefixTZ)\n\t\t}\n\t\treturn spec, prefixTZ, \"\"\n\t}\n\n\t// 都没指定，用服务器本地\n\treturn bareSpec, time.Local.String(), \"\"\n}\n\n// stripTimezonePrefix 返回 (去除前缀的 spec, 前缀中的 timezone)\n// 无前缀时 timezone 为空。\nfunc stripTimezonePrefix(spec string) (bareSpec, timezone string) {\n\tif !strings.HasPrefix(spec, \"CRON_TZ=\") && !strings.HasPrefix(spec, \"TZ=\") {\n\t\treturn spec, \"\"\n\t}\n\teqIdx := strings.IndexByte(spec, '=')\n\trest := spec[eqIdx+1:]\n\tspIdx := strings.IndexByte(rest, ' ')\n\tif spIdx < 0 {\n\t\treturn spec, \"\"\n\t}\n\treturn strings.TrimSpace(rest[spIdx+1:]), rest[:spIdx]\n}\n"
  },
  {
    "path": "internal/service/cron_preview_test.go",
    "content": "package service\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\n// 固定 now 让测试完全确定：2026-04-20 周一 12:00 UTC\nvar testNow = time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)\n\nfunc TestPreviewCron_ValidStandardExpressions(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tspec       string\n\t\ttimezone   string\n\t\twantNextN  int // 预期返回的 next_runs 条数\n\t\tfirstRunOK func(time.Time) bool\n\t}{\n\t\t{\n\t\t\tname:      \"每分钟\",\n\t\t\tspec:      \"0 * * * * *\",\n\t\t\twantNextN: 10,\n\t\t\tfirstRunOK: func(tm time.Time) bool {\n\t\t\t\t// 下次执行应该在 now 的下一分钟\n\t\t\t\treturn tm.Second() == 0 && tm.After(testNow) && tm.Sub(testNow) <= time.Minute\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"周一至周五 9:30\",\n\t\t\tspec:      \"0 30 9 * * 1-5\",\n\t\t\twantNextN: 10,\n\t\t\tfirstRunOK: func(tm time.Time) bool {\n\t\t\t\twd := tm.Weekday()\n\t\t\t\treturn tm.Hour() == 9 && tm.Minute() == 30 && wd >= time.Monday && wd <= time.Friday\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"@daily 快捷\",\n\t\t\tspec:      \"@daily\",\n\t\t\twantNextN: 10,\n\t\t\tfirstRunOK: func(tm time.Time) bool {\n\t\t\t\treturn tm.Hour() == 0 && tm.Minute() == 0 && tm.Second() == 0\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"@every 30s\",\n\t\t\tspec:      \"@every 30s\",\n\t\t\twantNextN: 10,\n\t\t\tfirstRunOK: func(tm time.Time) bool {\n\t\t\t\treturn tm.Sub(testNow) <= 30*time.Second && tm.After(testNow)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"5 段格式（省略 dow，自动补 *）\",\n\t\t\tspec:      \"0 30 9 * *\",\n\t\t\twantNextN: 10,\n\t\t\tfirstRunOK: func(tm time.Time) bool {\n\t\t\t\t// second=0 minute=30 hour=9 day=* month=* dow=*（自动补）\n\t\t\t\treturn tm.Hour() == 9 && tm.Minute() == 30 && tm.Second() == 0\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"自定义 count\",\n\t\t\tspec:      \"0 * * * * *\",\n\t\t\twantNextN: 10, // count=0 走默认 10\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := previewCronAt(tc.spec, tc.timezone, 0, testNow)\n\t\t\tif !got.Valid {\n\t\t\t\tt.Fatalf(\"expected valid=true, got error=%q\", got.Error)\n\t\t\t}\n\t\t\tif len(got.NextRuns) != tc.wantNextN {\n\t\t\t\tt.Errorf(\"next_runs length=%d want=%d\", len(got.NextRuns), tc.wantNextN)\n\t\t\t}\n\t\t\t// 单调递增\n\t\t\tfor i := 1; i < len(got.NextRuns); i++ {\n\t\t\t\tif got.NextRuns[i].Unix <= got.NextRuns[i-1].Unix {\n\t\t\t\t\tt.Errorf(\"next_runs not strictly increasing at index %d\", i)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif tc.firstRunOK != nil && len(got.NextRuns) > 0 {\n\t\t\t\tfirst := time.Unix(got.NextRuns[0].Unix, 0).UTC()\n\t\t\t\tif !tc.firstRunOK(first) {\n\t\t\t\t\tt.Errorf(\"first run %v failed validation\", first)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPreviewCron_InvalidInput(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tspec     string\n\t\ttimezone string\n\t\twantErr  string // 子串匹配\n\t}{\n\t\t{\"空字符串\", \"\", \"\", \"required\"},\n\t\t{\"只有空格\", \"   \", \"\", \"required\"},\n\t\t{\"换行注入\", \"0 * * * *\\n*\", \"\", \"single-line\"},\n\t\t{\"回车注入\", \"0 * * * *\\r*\", \"\", \"single-line\"},\n\t\t{\"超长\", strings.Repeat(\"0 \", 80), \"\", \"too long\"},\n\t\t{\"字段不够\", \"0 0\", \"\", \"expected 5 or 6 fields\"},\n\t\t{\"字段过多\", \"0 0 0 0 0 0 0 0\", \"\", \"expected 5 or 6 fields\"},\n\t\t{\"非法 @ 快捷\", \"@nonexistent\", \"\", \"unrecognized\"},\n\t\t{\"非法时区\", \"0 * * * * *\", \"Mars/Phobos\", \"unknown timezone\"},\n\t}\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := previewCronAt(tc.spec, tc.timezone, 0, testNow)\n\t\t\tif got.Valid {\n\t\t\t\tt.Fatalf(\"expected valid=false for %q\", tc.spec)\n\t\t\t}\n\t\t\tif !strings.Contains(strings.ToLower(got.Error), strings.ToLower(tc.wantErr)) {\n\t\t\t\tt.Errorf(\"error %q does not contain %q\", got.Error, tc.wantErr)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPreviewCron_Timezone(t *testing.T) {\n\tt.Run(\"显式 timezone 生效\", func(t *testing.T) {\n\t\t// Asia/Shanghai 09:30 = UTC 01:30；now=UTC 12:00\n\t\t// 下次 SH 09:30 应该是次日 UTC 01:30\n\t\tgot := previewCronAt(\"0 30 9 * * *\", \"Asia/Shanghai\", 3, testNow)\n\t\tif !got.Valid {\n\t\t\tt.Fatalf(\"expected valid, got %q\", got.Error)\n\t\t}\n\t\tif got.Timezone != \"Asia/Shanghai\" {\n\t\t\tt.Errorf(\"timezone=%q want=Asia/Shanghai\", got.Timezone)\n\t\t}\n\t\tif len(got.NextRuns) == 0 {\n\t\t\tt.Fatal(\"no next runs\")\n\t\t}\n\t\t// 验证首次执行换算到 UTC 是 01:30\n\t\tfirst := time.Unix(got.NextRuns[0].Unix, 0).UTC()\n\t\tif first.Hour() != 1 || first.Minute() != 30 {\n\t\t\tt.Errorf(\"first run in UTC = %v, expected hour=1 min=30\", first)\n\t\t}\n\t})\n\n\tt.Run(\"spec 自带 CRON_TZ 前缀\", func(t *testing.T) {\n\t\tgot := previewCronAt(\"CRON_TZ=America/New_York 0 30 9 * * *\", \"\", 3, testNow)\n\t\tif !got.Valid {\n\t\t\tt.Fatalf(\"expected valid, got %q\", got.Error)\n\t\t}\n\t\tif got.Timezone != \"America/New_York\" {\n\t\t\tt.Errorf(\"timezone=%q want=America/New_York\", got.Timezone)\n\t\t}\n\t})\n\n\tt.Run(\"显式 timezone 覆盖 spec 自带前缀\", func(t *testing.T) {\n\t\t// spec 里是 NY，参数传 SH，应该以参数为准\n\t\tgot := previewCronAt(\"CRON_TZ=America/New_York 0 30 9 * * *\", \"Asia/Shanghai\", 3, testNow)\n\t\tif !got.Valid {\n\t\t\tt.Fatalf(\"expected valid, got %q\", got.Error)\n\t\t}\n\t\tif got.Timezone != \"Asia/Shanghai\" {\n\t\t\tt.Errorf(\"timezone=%q want=Asia/Shanghai (explicit should override)\", got.Timezone)\n\t\t}\n\t})\n}\n\nfunc TestPreviewCron_Heatmap(t *testing.T) {\n\tt.Run(\"标准低频表达式\", func(t *testing.T) {\n\t\t// 每天 09:30 一次，一周 7 次\n\t\tgot := previewCronAt(\"0 30 9 * * *\", \"\", 0, testNow)\n\t\tif !got.Valid {\n\t\t\tt.Fatalf(\"expected valid, got %q\", got.Error)\n\t\t}\n\t\t// 应该只有一个 cell：9 点那列，每天都有\n\t\ttotal := 0\n\t\tfor _, c := range got.HeatmapCells {\n\t\t\ttotal += c.Count\n\t\t\tif c.Hour != 9 {\n\t\t\t\tt.Errorf(\"unexpected cell hour=%d (expected only 9)\", c.Hour)\n\t\t\t}\n\t\t}\n\t\tif total != 7 {\n\t\t\tt.Errorf(\"weekly total=%d want=7\", total)\n\t\t}\n\t\tif got.Truncated {\n\t\t\tt.Errorf(\"should not be truncated for simple daily schedule\")\n\t\t}\n\t})\n\n\tt.Run(\"高频表达式触发迭代上限\", func(t *testing.T) {\n\t\t// 每秒一次，一周 604800 次，会远超 maxHeatmapIterations=2000\n\t\tgot := previewCronAt(\"* * * * * *\", \"\", 0, testNow)\n\t\tif !got.Valid {\n\t\t\tt.Fatalf(\"expected valid, got %q\", got.Error)\n\t\t}\n\t\tif !got.Truncated {\n\t\t\tt.Errorf(\"expected Truncated=true for * * * * * *\")\n\t\t}\n\t\t// cell 数应 <= 24*7=168\n\t\tif len(got.HeatmapCells) > 168 {\n\t\t\tt.Errorf(\"cells=%d should be <=168\", len(got.HeatmapCells))\n\t\t}\n\t})\n\n\tt.Run(\"极低频（7 天内无触发）\", func(t *testing.T) {\n\t\t// 每年 1 月 1 日 00:00，从 2026-04-20 算 7 天内肯定不到\n\t\tgot := previewCronAt(\"0 0 0 1 1 *\", \"\", 0, testNow)\n\t\tif !got.Valid {\n\t\t\tt.Fatalf(\"expected valid, got %q\", got.Error)\n\t\t}\n\t\tif len(got.HeatmapCells) != 0 {\n\t\t\tt.Errorf(\"cells=%d want=0 for yearly schedule\", len(got.HeatmapCells))\n\t\t}\n\t})\n}\n\nfunc TestPreviewCron_CountClamp(t *testing.T) {\n\tt.Run(\"count<=0 用默认\", func(t *testing.T) {\n\t\tgot := previewCronAt(\"0 * * * * *\", \"\", 0, testNow)\n\t\tif len(got.NextRuns) != defaultNextRuns {\n\t\t\tt.Errorf(\"count=%d want=%d\", len(got.NextRuns), defaultNextRuns)\n\t\t}\n\t})\n\tt.Run(\"count 超上限被钳制\", func(t *testing.T) {\n\t\tgot := previewCronAt(\"0 * * * * *\", \"\", 1000, testNow)\n\t\tif len(got.NextRuns) != maxNextRunsRequested {\n\t\t\tt.Errorf(\"count=%d want=%d\", len(got.NextRuns), maxNextRunsRequested)\n\t\t}\n\t})\n\tt.Run(\"count=5\", func(t *testing.T) {\n\t\tgot := previewCronAt(\"0 * * * * *\", \"\", 5, testNow)\n\t\tif len(got.NextRuns) != 5 {\n\t\t\tt.Errorf(\"count=%d want=5\", len(got.NextRuns))\n\t\t}\n\t})\n}\n\nfunc TestStripTimezonePrefix(t *testing.T) {\n\ttests := []struct {\n\t\tin       string\n\t\twantBare string\n\t\twantTZ   string\n\t}{\n\t\t{\"0 * * * * *\", \"0 * * * * *\", \"\"},\n\t\t{\"CRON_TZ=UTC 0 * * * * *\", \"0 * * * * *\", \"UTC\"},\n\t\t{\"TZ=Asia/Shanghai 0 30 9 * * *\", \"0 30 9 * * *\", \"Asia/Shanghai\"},\n\t\t{\"CRON_TZ=UTC\", \"CRON_TZ=UTC\", \"\"}, // 没空格不算合法前缀\n\t}\n\tfor _, tc := range tests {\n\t\tgotBare, gotTZ := stripTimezonePrefix(tc.in)\n\t\tif gotBare != tc.wantBare || gotTZ != tc.wantTZ {\n\t\t\tt.Errorf(\"stripTimezonePrefix(%q) = (%q, %q), want (%q, %q)\",\n\t\t\t\ttc.in, gotBare, gotTZ, tc.wantBare, tc.wantTZ)\n\t\t}\n\t}\n}\n\n// Benchmark：`* * * * * *` 应在毫秒级完成，防 DoS\nfunc BenchmarkPreviewCron_HighFrequency(b *testing.B) {\n\tb.ReportAllocs()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = previewCronAt(\"* * * * * *\", \"\", 10, testNow)\n\t}\n}\n\nfunc BenchmarkPreviewCron_Standard(b *testing.B) {\n\tb.ReportAllocs()\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = previewCronAt(\"0 30 9 * * 1-5\", \"Asia/Shanghai\", 10, testNow)\n\t}\n}\n"
  },
  {
    "path": "internal/service/issue66_test.go",
    "content": "package service\n\n// https://github.com/gocronx-team/gocron/issues/66\n\nimport (\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/models\"\n)\n\n// TestIssue66RaceCondition 重现 Issue #66: 任务无法单实例运行\n// 问题：beforeExecJob检查和createJob添加实例标记之间存在竞态条件\nfunc TestIssue66RaceCondition(t *testing.T) {\n\tt.Run(\"旧实现-重现竞态条件bug\", func(t *testing.T) {\n\t\trunInstance = Instance{}\n\n\t\ttask := models.Task{\n\t\t\tId:    1,\n\t\t\tName:  \"测试任务\",\n\t\t\tMulti: 0, // 禁止并发\n\t\t}\n\n\t\tvar executedCount int\n\t\tvar mu sync.Mutex\n\t\tvar wg sync.WaitGroup\n\n\t\t// 模拟快速点击两次\"手动执行\" - 使用旧的实现方式\n\t\tfor i := 0; i < 2; i++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(index int) {\n\t\t\t\tdefer wg.Done()\n\n\t\t\t\t// 旧实现：分离的检查和添加\n\t\t\t\t// 1. beforeExecJob 检查\n\t\t\t\ttaskLogId := int64(0)\n\t\t\t\tif task.Multi == 0 && runInstance.has(task.Id) {\n\t\t\t\t\tt.Logf(\"执行%d: 任务已在运行中，取消本次执行\", index)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttaskLogId = int64(index + 1)\n\n\t\t\t\t// ⚠️ 竞态条件窗口：在检查和添加之间\n\t\t\t\ttime.Sleep(1 * time.Millisecond)\n\n\t\t\t\t// 2. 添加实例标记\n\t\t\t\tif task.Multi == 0 {\n\t\t\t\t\trunInstance.add(task.Id)\n\t\t\t\t\tdefer runInstance.done(task.Id)\n\t\t\t\t}\n\n\t\t\t\t// 3. 执行任务\n\t\t\t\tmu.Lock()\n\t\t\t\texecutedCount++\n\t\t\t\tmu.Unlock()\n\n\t\t\t\tt.Logf(\"执行%d: 任务开始执行, taskLogId=%d\", index, taskLogId)\n\t\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t\t\tt.Logf(\"执行%d: 任务执行完成\", index)\n\t\t\t}(i)\n\t\t}\n\n\t\twg.Wait()\n\n\t\tif executedCount > 1 {\n\t\t\tt.Logf(\"✅ 成功重现Bug：期望执行1次，实际执行了%d次\", executedCount)\n\t\t}\n\t})\n\n\tt.Run(\"新实现-修复竞态条件\", func(t *testing.T) {\n\t\trunInstance = Instance{}\n\n\t\ttask := models.Task{\n\t\t\tId:    2,\n\t\t\tName:  \"测试任务\",\n\t\t\tMulti: 0,\n\t\t}\n\n\t\tvar executedCount int\n\t\tvar canceledCount int\n\t\tvar mu sync.Mutex\n\t\tvar wg sync.WaitGroup\n\n\t\t// 模拟快速点击两次\"手动执行\" - 使用新的原子实现\n\t\tfor i := 0; i < 2; i++ {\n\t\t\twg.Add(1)\n\t\t\tgo func(index int) {\n\t\t\t\tdefer wg.Done()\n\n\t\t\t\t// 新实现：原子的检查和添加\n\t\t\t\tif task.Multi == 0 {\n\t\t\t\t\tif !runInstance.tryAdd(task.Id) {\n\t\t\t\t\t\tmu.Lock()\n\t\t\t\t\t\tcanceledCount++\n\t\t\t\t\t\tmu.Unlock()\n\t\t\t\t\t\tt.Logf(\"执行%d: 任务已在运行中，取消本次执行\", index)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tdefer runInstance.done(task.Id)\n\t\t\t\t}\n\n\t\t\t\t// 执行任务\n\t\t\t\tmu.Lock()\n\t\t\t\texecutedCount++\n\t\t\t\tmu.Unlock()\n\n\t\t\t\tt.Logf(\"执行%d: 任务开始执行\", index)\n\t\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t\t\tt.Logf(\"执行%d: 任务执行完成\", index)\n\t\t\t}(i)\n\t\t}\n\n\t\twg.Wait()\n\n\t\tif executedCount == 1 && canceledCount == 1 {\n\t\t\tt.Logf(\"✅ Bug已修复！执行%d次，取消%d次\", executedCount, canceledCount)\n\t\t} else {\n\t\t\tt.Errorf(\"❌ 修复失败！执行%d次，取消%d次（期望：执行1次，取消1次）\", executedCount, canceledCount)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "internal/service/single_instance_test.go",
    "content": "package service\n\nimport (\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/models\"\n)\n\n// TestSingleInstanceControl 测试单实例运行控制\nfunc TestSingleInstanceControl(t *testing.T) {\n\tt.Run(\"Multi=0时阻止并发执行\", func(t *testing.T) {\n\t\tinstance := &Instance{}\n\t\ttaskId := 100\n\n\t\t// 第一次检查，应该不存在\n\t\tif instance.has(taskId) {\n\t\t\tt.Error(\"任务不应该在运行中\")\n\t\t}\n\n\t\t// 添加任务\n\t\tinstance.add(taskId)\n\n\t\t// 第二次检查，应该存在\n\t\tif !instance.has(taskId) {\n\t\t\tt.Error(\"任务应该在运行中\")\n\t\t}\n\n\t\t// 完成任务\n\t\tinstance.done(taskId)\n\n\t\t// 第三次检查，应该不存在\n\t\tif instance.has(taskId) {\n\t\t\tt.Error(\"任务不应该在运行中\")\n\t\t}\n\t})\n\n\tt.Run(\"并发场景下的单实例控制\", func(t *testing.T) {\n\t\tinstance := &Instance{}\n\t\ttaskId := 200\n\t\tvar wg sync.WaitGroup\n\t\texecutionCount := 0\n\t\tvar mu sync.Mutex\n\n\t\t// 模拟10个并发请求\n\t\tfor i := 0; i < 10; i++ {\n\t\t\twg.Add(1)\n\t\t\tgo func() {\n\t\t\t\tdefer wg.Done()\n\n\t\t\t\t// 使用互斥锁保护检查和添加操作的原子性\n\t\t\t\tmu.Lock()\n\t\t\t\tif !instance.has(taskId) {\n\t\t\t\t\tinstance.add(taskId)\n\t\t\t\t\texecutionCount++\n\t\t\t\t\tmu.Unlock()\n\n\t\t\t\t\t// 模拟任务执行\n\t\t\t\t\ttime.Sleep(10 * time.Millisecond)\n\n\t\t\t\t\tinstance.done(taskId)\n\t\t\t\t} else {\n\t\t\t\t\tmu.Unlock()\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\n\t\twg.Wait()\n\n\t\t// 只有第一个请求应该执行\n\t\tif executionCount != 1 {\n\t\t\tt.Errorf(\"期望只有1次执行，实际执行了%d次\", executionCount)\n\t\t}\n\t})\n\n\tt.Run(\"不同任务ID互不影响\", func(t *testing.T) {\n\t\tinstance := &Instance{}\n\n\t\tinstance.add(1)\n\t\tinstance.add(2)\n\t\tinstance.add(3)\n\n\t\tif !instance.has(1) || !instance.has(2) || !instance.has(3) {\n\t\t\tt.Error(\"所有任务都应该在运行中\")\n\t\t}\n\n\t\tinstance.done(2)\n\n\t\tif !instance.has(1) || instance.has(2) || !instance.has(3) {\n\t\t\tt.Error(\"只有任务2应该被移除\")\n\t\t}\n\t})\n}\n\n// TestBeforeExecJobSingleInstance 测试beforeExecJob中的单实例逻辑\nfunc TestBeforeExecJobSingleInstance(t *testing.T) {\n\tt.Run(\"Multi=0且任务已运行时应该取消\", func(t *testing.T) {\n\t\t// 重置runInstance\n\t\trunInstance = Instance{}\n\n\t\ttask := models.Task{\n\t\t\tId:    1,\n\t\t\tName:  \"测试任务\",\n\t\t\tMulti: 0, // 不允许并发\n\t\t}\n\n\t\t// 模拟任务已在运行\n\t\trunInstance.add(task.Id)\n\n\t\t// 验证任务在运行中\n\t\tif !runInstance.has(task.Id) {\n\t\t\tt.Error(\"任务应该在运行中\")\n\t\t}\n\n\t\t// 模拟beforeExecJob的逻辑：如果Multi=0且任务已运行，应该取消\n\t\tshouldCancel := task.Multi == 0 && runInstance.has(task.Id)\n\t\tif !shouldCancel {\n\t\t\tt.Error(\"任务已在运行，应该取消本次执行\")\n\t\t}\n\n\t\t// 清理\n\t\trunInstance.done(task.Id)\n\t})\n\n\tt.Run(\"Multi=1时允许并发执行\", func(t *testing.T) {\n\t\t// 重置runInstance\n\t\trunInstance = Instance{}\n\n\t\ttask := models.Task{\n\t\t\tId:    2,\n\t\t\tName:  \"测试任务\",\n\t\t\tMulti: 1, // 允许并发\n\t\t}\n\n\t\t// 模拟任务已在运行\n\t\trunInstance.add(task.Id)\n\n\t\t// Multi=1时，不应该取消\n\t\tshouldCancel := task.Multi == 0 && runInstance.has(task.Id)\n\t\tif shouldCancel {\n\t\t\tt.Error(\"Multi=1时应该允许并发执行\")\n\t\t}\n\n\t\t// 清理\n\t\trunInstance.done(task.Id)\n\t})\n}\n\n// TestCreateJobSingleInstanceLogic 测试createJob中的单实例逻辑\nfunc TestCreateJobSingleInstanceLogic(t *testing.T) {\n\tt.Run(\"Multi=0时应该添加和移除实例标记\", func(t *testing.T) {\n\t\t// 重置runInstance\n\t\trunInstance = Instance{}\n\n\t\ttaskId := 100\n\n\t\t// 模拟createJob中的逻辑\n\t\tif !runInstance.has(taskId) {\n\t\t\t// Multi=0时，添加实例标记\n\t\t\trunInstance.add(taskId)\n\n\t\t\t// 验证已添加\n\t\t\tif !runInstance.has(taskId) {\n\t\t\t\tt.Error(\"应该已添加实例标记\")\n\t\t\t}\n\n\t\t\t// 模拟任务执行完成\n\t\t\trunInstance.done(taskId)\n\n\t\t\t// 验证已移除\n\t\t\tif runInstance.has(taskId) {\n\t\t\t\tt.Error(\"应该已移除实例标记\")\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"Multi=1时不应该添加实例标记\", func(t *testing.T) {\n\t\t// 重置runInstance\n\t\trunInstance = Instance{}\n\n\t\ttaskId := 200\n\t\tmulti := 1\n\n\t\t// Multi=1时，不添加实例标记\n\t\tif multi == 0 {\n\t\t\trunInstance.add(taskId)\n\t\t}\n\n\t\t// 验证未添加\n\t\tif runInstance.has(taskId) {\n\t\t\tt.Error(\"Multi=1时不应该添加实例标记\")\n\t\t}\n\t})\n}\n\n// TestInstanceThreadSafety 测试Instance的线程安全性\nfunc TestInstanceThreadSafety(t *testing.T) {\n\tinstance := &Instance{}\n\tvar wg sync.WaitGroup\n\n\t// 并发添加和删除\n\tfor i := 0; i < 100; i++ {\n\t\twg.Add(3)\n\n\t\ttaskId := i\n\n\t\t// 添加\n\t\tgo func(id int) {\n\t\t\tdefer wg.Done()\n\t\t\tinstance.add(id)\n\t\t}(taskId)\n\n\t\t// 检查\n\t\tgo func(id int) {\n\t\t\tdefer wg.Done()\n\t\t\t_ = instance.has(id)\n\t\t}(taskId)\n\n\t\t// 删除\n\t\tgo func(id int) {\n\t\t\tdefer wg.Done()\n\t\t\ttime.Sleep(1 * time.Millisecond)\n\t\t\tinstance.done(id)\n\t\t}(taskId)\n\t}\n\n\twg.Wait()\n\n\t// 测试通过表示没有发生竞态条件\n\tt.Log(\"线程安全测试通过\")\n}\n\n// TestSingleInstanceRealScenario 测试真实场景\nfunc TestSingleInstanceRealScenario(t *testing.T) {\n\tt.Run(\"定时任务触发时上次未完成\", func(t *testing.T) {\n\t\trunInstance = Instance{}\n\n\t\ttask := models.Task{\n\t\t\tId:    1,\n\t\t\tName:  \"慢速任务\",\n\t\t\tMulti: 0,\n\t\t}\n\n\t\tvar executionCount int\n\t\tvar mu sync.Mutex\n\n\t\t// 模拟第一次执行（耗时较长）\n\t\tgo func() {\n\t\t\tif task.Multi == 0 && runInstance.has(task.Id) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif task.Multi == 0 {\n\t\t\t\trunInstance.add(task.Id)\n\t\t\t\tdefer runInstance.done(task.Id)\n\t\t\t}\n\n\t\t\tmu.Lock()\n\t\t\texecutionCount++\n\t\t\tmu.Unlock()\n\n\t\t\ttime.Sleep(50 * time.Millisecond)\n\t\t}()\n\n\t\t// 等待第一次执行开始\n\t\ttime.Sleep(5 * time.Millisecond)\n\n\t\t// 模拟第二次触发（应该被阻止）\n\t\tif task.Multi == 0 && runInstance.has(task.Id) {\n\t\t\tt.Log(\"第二次执行被正确阻止\")\n\t\t} else {\n\t\t\tif task.Multi == 0 {\n\t\t\t\trunInstance.add(task.Id)\n\t\t\t\tdefer runInstance.done(task.Id)\n\t\t\t}\n\n\t\t\tmu.Lock()\n\t\t\texecutionCount++\n\t\t\tmu.Unlock()\n\t\t}\n\n\t\t// 等待第一次执行完成\n\t\ttime.Sleep(60 * time.Millisecond)\n\n\t\tmu.Lock()\n\t\tcount := executionCount\n\t\tmu.Unlock()\n\n\t\tif count != 1 {\n\t\t\tt.Errorf(\"期望执行1次，实际执行%d次\", count)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "internal/service/task.go",
    "content": "package service\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/cron\"\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/app\"\n\t\"github.com/gocronx-team/gocron/internal/modules/httpclient\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/notify\"\n\trpcClient \"github.com/gocronx-team/gocron/internal/modules/rpc/client\"\n\tpb \"github.com/gocronx-team/gocron/internal/modules/rpc/proto\"\n\t\"github.com/gocronx-team/gocron/internal/modules/utils\"\n)\n\nvar (\n\tServiceTask Task\n)\n\nvar (\n\thttpGetFunc              = httpclient.Get\n\thttpPostParamsFunc       = httpclient.PostParams\n\thttpPostJsonFunc         = httpclient.PostJson\n\thttpGetWithHeadersFunc   = httpclient.GetWithHeaders\n\thttpPostJsonWithHdrsFunc = httpclient.PostJsonWithHeaders\n\thttpPostParamsWithHdrs   = httpclient.PostParamsWithHeaders\n\tnotifyPushFunc           = notify.Push\n\tsleepFunc                = time.Sleep\n\n\t// 定时任务调度管理器\n\tserviceCron *cron.Cron\n\n\t// 同一任务是否有实例处于运行中\n\trunInstance Instance\n\n\t// 调度器运行状态\n\tschedulerMu      sync.Mutex\n\tschedulerRunning bool\n\n\t// 任务计数-正在运行的任务\n\ttaskCount TaskCount\n\n\t// 并发队列, 限制同时运行的任务数量\n\tconcurrencyQueue ConcurrencyQueue\n)\n\n// 并发队列\ntype ConcurrencyQueue struct {\n\tqueue chan struct{}\n}\n\nfunc (cq *ConcurrencyQueue) Add() {\n\tcq.queue <- struct{}{}\n}\n\nfunc (cq *ConcurrencyQueue) Done() {\n\t<-cq.queue\n}\n\n// 任务计数\ntype TaskCount struct {\n\twg   sync.WaitGroup\n\texit chan struct{}\n}\n\nfunc (tc *TaskCount) Add() {\n\ttc.wg.Add(1)\n}\n\nfunc (tc *TaskCount) Done() {\n\ttc.wg.Done()\n}\n\nfunc (tc *TaskCount) Exit() {\n\ttc.wg.Done()\n\t<-tc.exit\n}\n\nfunc (tc *TaskCount) Wait() {\n\ttc.Add()\n\ttc.wg.Wait()\n\tclose(tc.exit)\n}\n\n// 任务ID作为Key\ntype Instance struct {\n\tm sync.Map\n}\n\n// 是否有任务处于运行中\nfunc (i *Instance) has(key int) bool {\n\t_, ok := i.m.Load(key)\n\n\treturn ok\n}\n\nfunc (i *Instance) add(key int) {\n\ti.m.Store(key, struct{}{})\n}\n\nfunc (i *Instance) done(key int) {\n\ti.m.Delete(key)\n}\n\n// tryAdd 原子地尝试添加任务实例\n// 返回 true 表示成功添加（任务未在运行），false 表示任务已在运行\nfunc (i *Instance) tryAdd(key int) bool {\n\t_, loaded := i.m.LoadOrStore(key, struct{}{})\n\treturn !loaded\n}\n\ntype Task struct{}\n\ntype TaskResult struct {\n\tResult     string\n\tErr        error\n\tRetryTimes int8\n}\n\n// Initialize 初始化调度器基础设施（不加载任务）\n// 任务加载由 StartScheduler 完成，配合 leader election 使用\nfunc (task Task) Initialize() {\n\tconcurrencyQueue = ConcurrencyQueue{queue: make(chan struct{}, app.Setting.ConcurrencyQueue)}\n\ttaskCount = TaskCount{sync.WaitGroup{}, make(chan struct{})}\n\tgo taskCount.Wait()\n\tlogger.Info(\"Scheduler infrastructure initialized\")\n}\n\n// StartScheduler 启动调度器并加载所有任务（当选 leader 时调用）\nfunc (task Task) StartScheduler() {\n\tschedulerMu.Lock()\n\tdefer schedulerMu.Unlock()\n\n\tif schedulerRunning {\n\t\treturn\n\t}\n\n\tserviceCron = cron.New()\n\tserviceCron.Start()\n\n\tlogger.Info(\"Starting to load scheduled tasks (this node is leader)\")\n\ttaskModel := new(models.Task)\n\ttaskNum := 0\n\tpage := 1\n\tpageSize := 1000\n\tmaxPage := 1000\n\tfor page < maxPage {\n\t\ttaskList, err := taskModel.ActiveList(page, pageSize)\n\t\tif err != nil {\n\t\t\tlogger.Fatalf(\"Scheduled task initialization#Failed to get task list: %s\", err)\n\t\t}\n\t\tif len(taskList) == 0 {\n\t\t\tbreak\n\t\t}\n\t\tfor _, item := range taskList {\n\t\t\tlogger.Infof(\"Adding task to scheduler#ID-%d#Name-%s#Protocol-%d#Host count-%d\", item.Id, item.Name, item.Protocol, len(item.Hosts))\n\t\t\ttask.Add(item)\n\t\t\ttaskNum++\n\t\t}\n\t\tpage++\n\t}\n\tlogger.Infof(\"Scheduled task initialization completed, %d tasks added to scheduler\", taskNum)\n\n\ttask.initLogCleanupTask()\n\tschedulerRunning = true\n}\n\n// StopScheduler 停止调度器（失去 leader 时调用）\nfunc (task Task) StopScheduler() {\n\tschedulerMu.Lock()\n\tdefer schedulerMu.Unlock()\n\n\tif !schedulerRunning {\n\t\treturn\n\t}\n\n\tlogger.Info(\"Stopping scheduler (this node lost leadership)\")\n\tserviceCron.Stop()\n\tserviceCron = nil\n\tschedulerRunning = false\n}\n\n// IsSchedulerRunning 返回调度器是否正在运行\nfunc (task Task) IsSchedulerRunning() bool {\n\tschedulerMu.Lock()\n\tdefer schedulerMu.Unlock()\n\treturn schedulerRunning\n}\n\n// 初始化日志清理任务\nfunc (task Task) initLogCleanupTask() {\n\tif serviceCron == nil {\n\t\treturn\n\t}\n\tsettingModel := new(models.Setting)\n\tcleanupTime := settingModel.GetLogCleanupTime()\n\t// 解析时间 HH:MM\n\tvar hour, minute int\n\tif n, err := fmt.Sscanf(cleanupTime, \"%d:%d\", &hour, &minute); err != nil || n != 2 ||\n\t\thour < 0 || hour > 23 || minute < 0 || minute > 59 {\n\t\tlogger.Warnf(\"日志清理时间解析失败，使用默认值 00:00 (cleanupTime=%q)\", cleanupTime)\n\t\thour, minute = 0, 0\n\t}\n\t// 生成cron表达式: 秒 分 时 日 月 周\n\tcronSpec := fmt.Sprintf(\"0 %d %d * * *\", minute, hour)\n\n\tserviceCron.AddFunc(cronSpec, func() {\n\t\t// 1. Task-level log retention: clean logs for tasks with custom retention days\n\t\ttaskLogModel := new(models.TaskLog)\n\t\tpage := 1\n\t\tpageSize := 1000\n\t\tfor {\n\t\t\tvar tasks []models.Task\n\t\t\terr := models.Db.Where(\"log_retention_days > 0\").\n\t\t\t\tLimit(pageSize).Offset((page - 1) * pageSize).\n\t\t\t\tFind(&tasks).Error\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"Failed to query tasks with custom log retention: %s\", err)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif len(tasks) == 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tfor _, t := range tasks {\n\t\t\t\tcount, err := taskLogModel.RemoveByTaskIdAndDays(t.Id, t.LogRetentionDays)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlogger.Errorf(\"Failed to cleanup logs for task %d: %s\", t.Id, err)\n\t\t\t\t} else if count > 0 {\n\t\t\t\t\tlogger.Infof(\"Task %d: cleaned up %d logs older than %d days\", t.Id, count, t.LogRetentionDays)\n\t\t\t\t}\n\t\t\t}\n\t\t\tpage++\n\t\t}\n\n\t\t// 2. Global log retention: clean remaining logs\n\t\tsettingModel := new(models.Setting)\n\t\tdays := settingModel.GetLogRetentionDays()\n\t\tif days > 0 {\n\t\t\tcount, err := taskLogModel.RemoveByDaysExcludingCustomRetention(days)\n\t\t\tif err != nil {\n\t\t\t\tlogger.Errorf(\"Failed to auto-cleanup database logs: %s\", err)\n\t\t\t} else {\n\t\t\t\tlogger.Infof(\"Auto-cleanup database logs older than %d days, deleted %d records\", days, count)\n\t\t\t}\n\t\t\t// 清理日志文件\n\t\t\tcleanupLogFiles()\n\t\t}\n\t}, \"log-cleanup\")\n\tlogger.Infof(\"Log auto-cleanup task added, execution time: %s\", cleanupTime)\n}\n\n// 重新加载日志清理任务\nfunc (task Task) ReloadLogCleanupTask() {\n\tif serviceCron == nil {\n\t\treturn // follower node or scheduler not started, skip\n\t}\n\t// 先移除旧任务\n\tserviceCron.RemoveJob(\"log-cleanup\")\n\t// 重新添加任务\n\ttask.initLogCleanupTask()\n\tlogger.Info(\"Log cleanup task reloaded\")\n}\n\n// 批量添加任务\nfunc (task Task) BatchAdd(tasks []models.Task) {\n\tfor _, item := range tasks {\n\t\ttask.RemoveAndAdd(item)\n\t}\n}\n\n// 删除任务后添加\nfunc (task Task) RemoveAndAdd(taskModel models.Task) {\n\ttask.Remove(taskModel.Id)\n\ttask.Add(taskModel)\n}\n\n// 添加任务\nfunc (task Task) Add(taskModel models.Task) {\n\tif taskModel.Level == models.TaskLevelChild {\n\t\tlogger.Errorf(\"Failed to add task#Child tasks cannot be added to scheduler#Task ID-%d\", taskModel.Id)\n\t\treturn\n\t}\n\tif serviceCron == nil {\n\t\treturn // follower node, skip\n\t}\n\ttaskFunc := createJob(taskModel)\n\tif taskFunc == nil {\n\t\tlogger.Error(\"Failed to create task job#Unsupported task protocol#\", taskModel.Protocol)\n\t\treturn\n\t}\n\n\tcronName := strconv.Itoa(taskModel.Id)\n\terr := utils.PanicToError(func() {\n\t\tserviceCron.AddFunc(taskModel.Spec, taskFunc, cronName)\n\t})\n\tif err != nil {\n\t\tlogger.Error(\"Failed to add task to scheduler#\", err)\n\t}\n}\n\nfunc (task Task) NextRunTime(taskModel models.Task) time.Time {\n\tif serviceCron == nil {\n\t\treturn time.Time{}\n\t}\n\tif taskModel.Level != models.TaskLevelParent ||\n\t\ttaskModel.Status != models.Enabled {\n\t\treturn time.Time{}\n\t}\n\tentries := serviceCron.Entries()\n\ttaskName := strconv.Itoa(taskModel.Id)\n\tfor _, item := range entries {\n\t\tif item.Name == taskName {\n\t\t\treturn item.Next\n\t\t}\n\t}\n\n\treturn time.Time{}\n}\n\n// 停止运行中的任务\nfunc (task Task) Stop(ip string, port int, id int64) {\n\trpcClient.Stop(ip, port, id)\n}\n\nfunc (task Task) Remove(id int) {\n\tif serviceCron == nil {\n\t\treturn\n\t}\n\tserviceCron.RemoveJob(strconv.Itoa(id))\n}\n\n// 等待所有任务结束后退出\nfunc (task Task) WaitAndExit() {\n\tschedulerMu.Lock()\n\tif schedulerRunning && serviceCron != nil {\n\t\tserviceCron.Stop()\n\t\tschedulerRunning = false\n\t}\n\tschedulerMu.Unlock()\n\ttaskCount.Exit()\n}\n\n// 直接运行任务\nfunc (task Task) Run(taskModel models.Task) {\n\tgo createJob(taskModel)()\n}\n\ntype Handler interface {\n\tRun(taskModel models.Task, taskUniqueId int64) (string, error)\n}\n\n// HTTP任务\ntype HTTPHandler struct{}\n\n// HttpDefaultTimeout HTTP 任务默认超时（秒），用户未设置时使用\nconst HttpDefaultTimeout = 300\n\nfunc (h *HTTPHandler) Run(taskModel models.Task, taskUniqueId int64) (result string, err error) {\n\tif taskModel.Timeout <= 0 {\n\t\ttaskModel.Timeout = HttpDefaultTimeout\n\t}\n\n\theaders := strings.TrimSpace(taskModel.HttpHeaders)\n\tvar resp httpclient.ResponseWrapper\n\tif taskModel.HttpMethod == models.TaskHTTPMethodGet {\n\t\tif headers != \"\" {\n\t\t\tresp = httpGetWithHeadersFunc(taskModel.Command, headers, taskModel.Timeout)\n\t\t} else {\n\t\t\tresp = httpGetFunc(taskModel.Command, taskModel.Timeout)\n\t\t}\n\t} else {\n\t\t// POST: 优先使用 HttpBody (JSON)，否则回退到 URL query 参数\n\t\tif strings.TrimSpace(taskModel.HttpBody) != \"\" {\n\t\t\tif headers != \"\" {\n\t\t\t\tresp = httpPostJsonWithHdrsFunc(taskModel.Command, taskModel.HttpBody, headers, taskModel.Timeout)\n\t\t\t} else {\n\t\t\t\tresp = httpPostJsonFunc(taskModel.Command, taskModel.HttpBody, taskModel.Timeout)\n\t\t\t}\n\t\t} else {\n\t\t\turlFields := strings.Split(taskModel.Command, \"?\")\n\t\t\turl := urlFields[0]\n\t\t\tvar params string\n\t\t\tif len(urlFields) >= 2 {\n\t\t\t\tparams = urlFields[1]\n\t\t\t}\n\t\t\tif headers != \"\" {\n\t\t\t\tresp = httpPostParamsWithHdrs(url, params, headers, taskModel.Timeout)\n\t\t\t} else {\n\t\t\t\tresp = httpPostParamsFunc(url, params, taskModel.Timeout)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 返回状态码非200，均为失败\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn resp.Body, fmt.Errorf(\"HTTP status code is not 200-->%d\", resp.StatusCode)\n\t}\n\n\t// 响应内容断言\n\tif taskModel.SuccessPattern != \"\" {\n\t\tre, regexErr := regexp.Compile(taskModel.SuccessPattern)\n\t\tif regexErr != nil {\n\t\t\treturn resp.Body, fmt.Errorf(\"invalid success_pattern regex: %v\", regexErr)\n\t\t}\n\t\t// 先匹配原始响应体，不匹配再尝试压缩 JSON 后匹配（兼容格式化空白差异）\n\t\tif !re.MatchString(resp.Body) {\n\t\t\tcompacted := compactJSON(resp.Body)\n\t\t\tif compacted == resp.Body || !re.MatchString(compacted) {\n\t\t\t\treturn resp.Body, fmt.Errorf(\"response body does not match success_pattern: %s\", taskModel.SuccessPattern)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn resp.Body, err\n}\n\n// compactJSON 压缩 JSON 字符串，去掉格式化空白。非 JSON 则原样返回。\nfunc compactJSON(s string) string {\n\tvar buf bytes.Buffer\n\tif err := json.Compact(&buf, []byte(s)); err != nil {\n\t\treturn s\n\t}\n\treturn buf.String()\n}\n\n// RPC调用执行任务\ntype RPCHandler struct{}\n\nfunc (h *RPCHandler) Run(taskModel models.Task, taskUniqueId int64) (result string, err error) {\n\tlogger.Infof(\"RPC task execution started#Task ID-%d#Host count-%d\", taskModel.Id, len(taskModel.Hosts))\n\tif len(taskModel.Hosts) == 0 {\n\t\treturn \"\", fmt.Errorf(\"task is not associated with any host\")\n\t}\n\ttaskRequest := new(pb.TaskRequest)\n\ttaskRequest.Timeout = int32(taskModel.Timeout)\n\ttaskRequest.Command = taskModel.Command\n\ttaskRequest.Id = taskUniqueId\n\tresultChan := make(chan TaskResult, len(taskModel.Hosts))\n\tfor _, taskHost := range taskModel.Hosts {\n\t\tlogger.Infof(\"Preparing RPC call#Host-%s:%d#Command-%s\", taskHost.Name, taskHost.Port, taskModel.Command)\n\t\tgo func(th models.TaskHostDetail) {\n\t\t\toutput, err := rpcClient.Exec(th.Name, th.Port, taskRequest)\n\t\t\terrorMessage := \"\"\n\t\t\tif err != nil {\n\t\t\t\t// 如果是手动停止错误，保留原始错误以便后续判断，但显示翻译后的文本\n\t\t\t\tif errors.Is(err, rpcClient.ErrManualStop) {\n\t\t\t\t\terrorMessage = \"Manually stopped\"\n\t\t\t\t} else {\n\t\t\t\t\terrorMessage = err.Error()\n\t\t\t\t}\n\t\t\t}\n\t\t\toutput = strings.TrimSpace(output)\n\t\t\tif errorMessage != \"\" {\n\t\t\t\terrorMessage = strings.TrimSpace(errorMessage) + \"\\n\"\n\t\t\t}\n\t\t\toutputMessage := fmt.Sprintf(\"Host: [%s-%s:%d]\\n%s%s\",\n\t\t\t\tth.Alias, th.Name, th.Port, errorMessage, output,\n\t\t\t)\n\t\t\tlogger.Infof(\"RPC call completed#Host-%s:%d#Output length-%d#Error-%v\", th.Name, th.Port, len(output), err)\n\t\t\tresultChan <- TaskResult{Err: err, Result: outputMessage}\n\t\t}(taskHost)\n\t}\n\n\tvar aggregationErr error\n\tvar resultBuilder strings.Builder\n\tfor i := 0; i < len(taskModel.Hosts); i++ {\n\t\ttaskResult := <-resultChan\n\t\tresultBuilder.WriteString(taskResult.Result)\n\t\tif taskResult.Err != nil {\n\t\t\taggregationErr = taskResult.Err\n\t\t}\n\t}\n\n\treturn resultBuilder.String(), aggregationErr\n}\n\n// 创建任务日志\nfunc createTaskLog(taskModel models.Task, status models.Status) (int64, error) {\n\ttaskLogModel := new(models.TaskLog)\n\ttaskLogModel.TaskId = taskModel.Id\n\ttaskLogModel.Name = taskModel.Name\n\ttaskLogModel.Spec = taskModel.Spec\n\ttaskLogModel.Protocol = taskModel.Protocol\n\ttaskLogModel.Command = taskModel.Command\n\ttaskLogModel.Timeout = taskModel.Timeout\n\tif taskModel.Protocol == models.TaskRPC {\n\t\tvar hostBuilder strings.Builder\n\t\tfor _, host := range taskModel.Hosts {\n\t\t\thostBuilder.WriteString(host.Alias)\n\t\t\thostBuilder.WriteString(\" - \")\n\t\t\thostBuilder.WriteString(host.Name)\n\t\t\thostBuilder.WriteString(\"<br>\")\n\t\t}\n\t\ttaskLogModel.Hostname = hostBuilder.String()\n\t}\n\ttaskLogModel.StartTime = models.LocalTime(time.Now())\n\ttaskLogModel.Status = status\n\tinsertId, err := taskLogModel.Create()\n\n\treturn insertId, err\n}\n\n// 更新任务日志\nfunc updateTaskLog(taskLogId int64, taskResult TaskResult) (int64, error) {\n\ttaskLogModel := new(models.TaskLog)\n\tvar status models.Status\n\tresult := taskResult.Result\n\n\t// 根据错误类型设置状态\n\tif taskResult.Err != nil {\n\t\t// 检查是否是手动停止\n\t\tif errors.Is(taskResult.Err, rpcClient.ErrManualStop) {\n\t\t\tstatus = models.Cancel\n\t\t} else {\n\t\t\tstatus = models.Failure\n\t\t}\n\t} else {\n\t\tstatus = models.Finish\n\t}\n\n\treturn taskLogModel.Update(taskLogId, models.CommonMap{\n\t\t\"retry_times\": taskResult.RetryTimes,\n\t\t\"status\":      status,\n\t\t\"result\":      result,\n\t\t\"end_time\":    time.Now(),\n\t})\n}\n\nfunc createJob(taskModel models.Task) cron.FuncJob {\n\thandler := createHandler(taskModel)\n\tif handler == nil {\n\t\treturn nil\n\t}\n\ttaskFunc := func() {\n\t\ttaskCount.Add()\n\t\tdefer taskCount.Done()\n\n\t\ttaskLogId := beforeExecJob(taskModel)\n\t\tif taskLogId <= 0 {\n\t\t\treturn\n\t\t}\n\n\t\t// Multi=0 时，确保清理实例标记\n\t\t// 注意：beforeExecJob 已经添加了实例标记，这里只需要清理\n\t\tif taskModel.Multi == 0 {\n\t\t\tdefer runInstance.done(taskModel.Id)\n\t\t}\n\n\t\tconcurrencyQueue.Add()\n\t\tdefer concurrencyQueue.Done()\n\n\t\tlogger.Infof(\"Starting task execution#%s#Command-%s\", taskModel.Name, taskModel.Command)\n\t\ttaskResult := execJob(handler, taskModel, taskLogId)\n\t\tlogger.Infof(\"Task completed#%s#Command-%s\", taskModel.Name, taskModel.Command)\n\t\tafterExecJob(taskModel, taskResult, taskLogId)\n\t}\n\n\treturn taskFunc\n}\n\nfunc createHandler(taskModel models.Task) Handler {\n\tvar handler Handler = nil\n\tswitch taskModel.Protocol {\n\tcase models.TaskHTTP:\n\t\thandler = new(HTTPHandler)\n\tcase models.TaskRPC:\n\t\thandler = new(RPCHandler)\n\t}\n\n\treturn handler\n}\n\n// 任务前置操作\nfunc beforeExecJob(taskModel models.Task) (taskLogId int64) {\n\t// Multi=0 时，原子地检查并添加实例标记\n\tif taskModel.Multi == 0 {\n\t\tif !runInstance.tryAdd(taskModel.Id) {\n\t\t\tlogger.Infof(\"Task already running, canceling this execution#ID-%d\", taskModel.Id)\n\t\t\ttaskLogId, _ = createTaskLog(taskModel, models.Cancel)\n\t\t\treturn\n\t\t}\n\t}\n\n\ttaskLogId, err := createTaskLog(taskModel, models.Running)\n\tif err != nil {\n\t\tlogger.Error(\"Task execution started#Failed to write task log-\", err)\n\t\t// 如果创建日志失败，需要回滚实例标记\n\t\tif taskModel.Multi == 0 {\n\t\t\trunInstance.done(taskModel.Id)\n\t\t}\n\t\treturn\n\t}\n\n\treturn taskLogId\n}\n\n// 任务执行后置操作\nfunc afterExecJob(taskModel models.Task, taskResult TaskResult, taskLogId int64) {\n\t_, err := updateTaskLog(taskLogId, taskResult)\n\tif err != nil {\n\t\tlogger.Error(\"Task ended#Failed to update task log-\", err)\n\t}\n\n\t// 发送邮件\n\tgo SendNotification(taskModel, taskResult)\n\t// 执行依赖任务\n\tgo execDependencyTask(taskModel, taskResult)\n}\n\n// 执行依赖任务, 多个任务并发执行\nfunc execDependencyTask(taskModel models.Task, taskResult TaskResult) {\n\t// 父任务才能执行子任务\n\tif taskModel.Level != models.TaskLevelParent {\n\t\treturn\n\t}\n\n\t// 是否存在子任务\n\tdependencyTaskId := strings.TrimSpace(taskModel.DependencyTaskId)\n\tif dependencyTaskId == \"\" {\n\t\treturn\n\t}\n\n\t// 父子任务关系为强依赖, 父任务执行失败, 不执行依赖任务\n\tif taskModel.DependencyStatus == models.TaskDependencyStatusStrong && taskResult.Err != nil {\n\t\tlogger.Infof(\"Parent-child tasks have strong dependency, parent task failed, dependency tasks will not run#Parent task ID-%d\", taskModel.Id)\n\t\treturn\n\t}\n\n\t// 获取子任务\n\tmodel := new(models.Task)\n\ttasks, err := model.GetDependencyTaskList(dependencyTaskId)\n\tif err != nil {\n\t\tlogger.Errorf(\"Failed to get dependency tasks#Parent task ID-%d#%s\", taskModel.Id, err.Error())\n\t\treturn\n\t}\n\tif len(tasks) == 0 {\n\t\tlogger.Warnf(\"Dependency task list is empty or tasks are disabled#Parent task ID-%d#Dependency task ID-%s\", taskModel.Id, dependencyTaskId)\n\t\treturn\n\t}\n\tlogger.Infof(\"Starting dependency tasks execution#Parent task ID-%d#Dependency task count-%d\", taskModel.Id, len(tasks))\n\tfor _, task := range tasks {\n\t\tlogger.Infof(\"Executing dependency task#Parent task ID-%d#Dependency task ID-%d#Dependency task name-%s\", taskModel.Id, task.Id, task.Name)\n\t\ttask.Spec = fmt.Sprintf(\"Dependency task (Parent task ID-%d)\", taskModel.Id)\n\t\tServiceTask.Run(task)\n\t}\n}\n\n// 发送任务结果通知\nfunc SendNotification(taskModel models.Task, taskResult TaskResult) {\n\tvar statusName string\n\t// 未开启通知\n\tif taskModel.NotifyStatus == 0 {\n\t\treturn\n\t}\n\tif taskModel.NotifyStatus == 1 && taskResult.Err == nil {\n\t\t// 执行失败才发送通知\n\t\treturn\n\t}\n\tif taskModel.NotifyStatus == 3 {\n\t\t// 关键字匹配通知\n\t\tif !strings.Contains(taskResult.Result, taskModel.NotifyKeyword) {\n\t\t\treturn\n\t\t}\n\t}\n\t// NotifyType: 0=邮件, 1=Slack, 2=WebHook\n\t// WebHook(type=2)不需要receiver_id，其他类型需要\n\tif taskModel.NotifyType != 2 && taskModel.NotifyReceiverId == \"\" {\n\t\treturn\n\t}\n\tif taskResult.Err != nil {\n\t\tstatusName = \"Failed\"\n\t} else {\n\t\tstatusName = \"Success\"\n\t}\n\t// 发送通知\n\tmsg := notify.Message{\n\t\t\"task_type\":        taskModel.NotifyType,\n\t\t\"task_receiver_id\": taskModel.NotifyReceiverId,\n\t\t\"name\":             taskModel.Name,\n\t\t\"output\":           taskResult.Result,\n\t\t\"status\":           statusName,\n\t\t\"task_id\":          taskModel.Id,\n\t\t\"remark\":           taskModel.Remark,\n\t}\n\tnotifyPushFunc(msg)\n}\n\n// 执行具体任务\nfunc execJob(handler Handler, taskModel models.Task, taskUniqueId int64) (result TaskResult) {\n\tdefer func() {\n\t\tif err := recover(); err != nil {\n\t\t\tlogger.Error(\"panic#service/task.go:execJob#\", err)\n\t\t\t// 确保 panic 不会被误判为成功：返回失败结果\n\t\t\tresult = TaskResult{Err: fmt.Errorf(\"panic: %v\", err)}\n\t\t}\n\t}()\n\t// 默认只运行任务一次\n\tvar execTimes int8 = 1\n\tif taskModel.RetryTimes > 0 {\n\t\texecTimes += taskModel.RetryTimes\n\t}\n\tvar i int8 = 0\n\tvar output string\n\tvar err error\n\tfor i < execTimes {\n\t\toutput, err = handler.Run(taskModel, taskUniqueId)\n\t\tif err == nil {\n\t\t\treturn TaskResult{Result: output, Err: err, RetryTimes: i}\n\t\t}\n\t\ti++\n\t\tif i < execTimes {\n\t\t\tlogger.Warnf(\"Task execution failed#Task ID-%d#Retry attempt %d#Output-%s#Error-%s\", taskModel.Id, i, output, err.Error())\n\t\t\tif taskModel.RetryInterval > 0 {\n\t\t\t\tsleepFunc(time.Duration(taskModel.RetryInterval) * time.Second)\n\t\t\t} else {\n\t\t\t\t// 默认重试间隔时间，每次递增1分钟\n\t\t\t\tsleepFunc(time.Duration(i) * time.Minute)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn TaskResult{Result: output, Err: err, RetryTimes: taskModel.RetryTimes}\n}\n\n// 清理日志文件\nfunc cleanupLogFiles() {\n\tsettingModel := new(models.Setting)\n\tfileSizeLimit := settingModel.GetLogFileSizeLimit()\n\n\t// 如果设置为0，不清理日志文件\n\tif fileSizeLimit <= 0 {\n\t\treturn\n\t}\n\n\tlogDir := \"log\"\n\tlogFile := \"cron.log\"\n\n\t// 检查日志文件是否存在\n\tlogPath := fmt.Sprintf(\"%s/%s\", logDir, logFile)\n\tfileInfo, err := os.Stat(logPath)\n\tif err != nil {\n\t\tif !os.IsNotExist(err) {\n\t\t\tlogger.Errorf(\"Failed to check log file: %s\", err)\n\t\t}\n\t\treturn\n\t}\n\n\t// 如果文件大小超过限制，则清空\n\tmaxSize := int64(fileSizeLimit) * 1024 * 1024 // 转换为MB\n\tif fileInfo.Size() > maxSize {\n\t\terr := os.Truncate(logPath, 0)\n\t\tif err != nil {\n\t\t\tlogger.Errorf(\"Failed to truncate log file: %s\", err)\n\t\t} else {\n\t\t\tlogger.Infof(\"Log file exceeded %dMB, truncated: %s\", fileSizeLimit, logPath)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/service/task_cleanup_test.go",
    "content": "package service\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/ncruces/go-sqlite3/gormlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/logger\"\n)\n\nfunc setupCleanupTestDB(t *testing.T) func() {\n\tt.Helper()\n\toriginalDb := models.Db\n\toriginalPrefix := models.TablePrefix\n\n\tdb, err := gorm.Open(gormlite.Open(\":memory:\"), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open test db: %v\", err)\n\t}\n\n\tmodels.TablePrefix = \"\"\n\tmodels.Db = db\n\n\terr = db.AutoMigrate(&models.Task{}, &models.TaskLog{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to migrate: %v\", err)\n\t}\n\n\treturn func() {\n\t\tmodels.Db = originalDb\n\t\tmodels.TablePrefix = originalPrefix\n\t}\n}\n\n// TestTaskLevelRetentionBeforeGlobal verifies that tasks with custom retention\n// days have their logs cleaned according to their own policy, and that the\n// global policy applies to remaining logs.\nfunc TestTaskLevelRetentionBeforeGlobal(t *testing.T) {\n\tcleanup := setupCleanupTestDB(t)\n\tdefer cleanup()\n\n\tnow := time.Now()\n\n\t// Task 1: custom retention of 3 days\n\tmodels.Db.Create(&models.Task{\n\t\tName: \"task-custom\", Level: 1, Spec: \"* * * * *\",\n\t\tProtocol: 1, Command: \"echo 1\", LogRetentionDays: 3,\n\t\tStatus: models.Enabled,\n\t})\n\t// Task 2: no custom retention (uses global)\n\tmodels.Db.Create(&models.Task{\n\t\tName: \"task-global\", Level: 1, Spec: \"* * * * *\",\n\t\tProtocol: 1, Command: \"echo 2\", LogRetentionDays: 0,\n\t\tStatus: models.Enabled,\n\t})\n\n\toldTime5Days := models.LocalTime(now.AddDate(0, 0, -5))\n\toldTime2Days := models.LocalTime(now.AddDate(0, 0, -2))\n\n\t// Task 1 logs: one 5-day old, one 2-day old\n\tmodels.Db.Create(&models.TaskLog{TaskId: 1, Name: \"task-custom\", Spec: \"* * * * *\", Protocol: 1, Command: \"echo 1\", Result: \"ok\", StartTime: oldTime5Days, Status: models.Finish})\n\tmodels.Db.Create(&models.TaskLog{TaskId: 1, Name: \"task-custom\", Spec: \"* * * * *\", Protocol: 1, Command: \"echo 1\", Result: \"ok\", StartTime: oldTime2Days, Status: models.Finish})\n\n\t// Task 2 logs: one 5-day old, one 2-day old\n\tmodels.Db.Create(&models.TaskLog{TaskId: 2, Name: \"task-global\", Spec: \"* * * * *\", Protocol: 1, Command: \"echo 2\", Result: \"ok\", StartTime: oldTime5Days, Status: models.Finish})\n\tmodels.Db.Create(&models.TaskLog{TaskId: 2, Name: \"task-global\", Spec: \"* * * * *\", Protocol: 1, Command: \"echo 2\", Result: \"ok\", StartTime: oldTime2Days, Status: models.Finish})\n\n\t// Step 1: Simulate task-level cleanup for tasks with custom retention\n\tvar tasks []models.Task\n\terr := models.Db.Where(\"log_retention_days > 0\").Find(&tasks).Error\n\tif err != nil {\n\t\tt.Fatalf(\"failed to query tasks: %v\", err)\n\t}\n\tif len(tasks) != 1 {\n\t\tt.Fatalf(\"expected 1 task with custom retention, got %d\", len(tasks))\n\t}\n\tif tasks[0].Name != \"task-custom\" {\n\t\tt.Errorf(\"expected task-custom, got %s\", tasks[0].Name)\n\t}\n\n\ttaskLogModel := new(models.TaskLog)\n\tfor _, task := range tasks {\n\t\tcount, err := taskLogModel.RemoveByTaskIdAndDays(task.Id, task.LogRetentionDays)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"failed to cleanup task %d: %v\", task.Id, err)\n\t\t}\n\t\t// Task 1 with 3-day retention should delete the 5-day old log\n\t\tif count != 1 {\n\t\t\tt.Errorf(\"expected 1 deleted for task %d, got %d\", task.Id, count)\n\t\t}\n\t}\n\n\t// Verify task 1 has 1 remaining log (the 2-day old one)\n\tvar task1Count int64\n\tmodels.Db.Model(&models.TaskLog{}).Where(\"task_id = ?\", 1).Count(&task1Count)\n\tif task1Count != 1 {\n\t\tt.Errorf(\"expected 1 remaining log for task 1, got %d\", task1Count)\n\t}\n\n\t// Task 2 should still have both logs (no custom cleanup was applied)\n\tvar task2Count int64\n\tmodels.Db.Model(&models.TaskLog{}).Where(\"task_id = ?\", 2).Count(&task2Count)\n\tif task2Count != 2 {\n\t\tt.Errorf(\"expected 2 remaining logs for task 2, got %d\", task2Count)\n\t}\n\n\t// Step 2: Simulate global cleanup with 4-day retention\n\tglobalDays := 4\n\tglobalCount, err := taskLogModel.RemoveByDays(globalDays)\n\tif err != nil {\n\t\tt.Fatalf(\"global cleanup failed: %v\", err)\n\t}\n\t// Only task 2's 5-day old log should be deleted (task 1's 5-day old log was already removed)\n\tif globalCount != 1 {\n\t\tt.Errorf(\"expected 1 deleted by global cleanup, got %d\", globalCount)\n\t}\n\n\t// Final state: task 1 has 1 log, task 2 has 1 log\n\tvar finalTask1 int64\n\tmodels.Db.Model(&models.TaskLog{}).Where(\"task_id = ?\", 1).Count(&finalTask1)\n\tif finalTask1 != 1 {\n\t\tt.Errorf(\"expected 1 final log for task 1, got %d\", finalTask1)\n\t}\n\n\tvar finalTask2 int64\n\tmodels.Db.Model(&models.TaskLog{}).Where(\"task_id = ?\", 2).Count(&finalTask2)\n\tif finalTask2 != 1 {\n\t\tt.Errorf(\"expected 1 final log for task 2, got %d\", finalTask2)\n\t}\n}\n\n// TestTaskWithoutCustomRetentionUsesGlobal verifies that tasks without\n// custom retention (log_retention_days=0) are not affected by task-level\n// cleanup and only cleaned by global policy.\nfunc TestTaskWithoutCustomRetentionUsesGlobal(t *testing.T) {\n\tcleanup := setupCleanupTestDB(t)\n\tdefer cleanup()\n\n\tnow := time.Now()\n\toldTime := models.LocalTime(now.AddDate(0, 0, -10))\n\n\t// Task with no custom retention\n\tmodels.Db.Create(&models.Task{\n\t\tName: \"task-no-custom\", Level: 1, Spec: \"* * * * *\",\n\t\tProtocol: 1, Command: \"echo 1\", LogRetentionDays: 0,\n\t\tStatus: models.Enabled,\n\t})\n\n\t// Create old logs\n\tmodels.Db.Create(&models.TaskLog{TaskId: 1, Name: \"task-no-custom\", Spec: \"* * * * *\", Protocol: 1, Command: \"echo 1\", Result: \"ok\", StartTime: oldTime, Status: models.Finish})\n\n\t// Query tasks with custom retention - should find none\n\tvar tasks []models.Task\n\tmodels.Db.Where(\"log_retention_days > 0\").Find(&tasks)\n\tif len(tasks) != 0 {\n\t\tt.Errorf(\"expected 0 tasks with custom retention, got %d\", len(tasks))\n\t}\n\n\t// Logs should still exist\n\tvar count int64\n\tmodels.Db.Model(&models.TaskLog{}).Where(\"task_id = ?\", 1).Count(&count)\n\tif count != 1 {\n\t\tt.Errorf(\"expected 1 log before global cleanup, got %d\", count)\n\t}\n\n\t// Global cleanup with 5-day retention should remove it\n\ttaskLogModel := new(models.TaskLog)\n\tdeleted, err := taskLogModel.RemoveByDays(5)\n\tif err != nil {\n\t\tt.Fatalf(\"global cleanup failed: %v\", err)\n\t}\n\tif deleted != 1 {\n\t\tt.Errorf(\"expected 1 deleted by global cleanup, got %d\", deleted)\n\t}\n}\n"
  },
  {
    "path": "internal/service/task_partial_output_test.go",
    "content": "package service\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/models\"\n)\n\n// 模拟RPC处理器，用于测试部分输出功能\ntype mockRPCHandlerWithPartialOutput struct {\n\tpartialOutput string\n\terrorType     string // \"timeout\", \"manual_stop\", \"normal_error\", \"success\"\n}\n\nfunc (m *mockRPCHandlerWithPartialOutput) Run(taskModel models.Task, taskUniqueId int64) (string, error) {\n\tswitch m.errorType {\n\tcase \"timeout\":\n\t\t// 模拟超时情况，返回部分输出和超时错误\n\t\treturn m.partialOutput + \"\\n\\n[执行超时，强制结束]\",\n\t\t\tfmt.Errorf(\"执行超时, 强制结束\")\n\tcase \"manual_stop\":\n\t\t// 模拟手动停止情况，返回部分输出和手动停止错误\n\t\treturn m.partialOutput + \"\\n\\n[手动停止]\",\n\t\t\tfmt.Errorf(\"手动停止\")\n\tcase \"normal_error\":\n\t\t// 模拟普通错误，返回完整输出\n\t\treturn m.partialOutput, fmt.Errorf(\"command failed\")\n\tcase \"success\":\n\t\t// 模拟成功情况\n\t\treturn m.partialOutput, nil\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"unknown error type\")\n\t}\n}\n\nfunc TestExecJobWithPartialOutput_Timeout(t *testing.T) {\n\thandler := &mockRPCHandlerWithPartialOutput{\n\t\tpartialOutput: \"Task started\\nProcessing data...\\nPartial result: 50%\",\n\t\terrorType:     \"timeout\",\n\t}\n\n\ttask := models.Task{\n\t\tId:         1,\n\t\tName:       \"timeout-test\",\n\t\tRetryTimes: 0, // 不重试，直接测试超时\n\t}\n\n\tresult := execJob(handler, task, 1)\n\n\tif result.Err == nil {\n\t\tt.Fatal(\"Expected timeout error\")\n\t}\n\tif result.Err.Error() != \"执行超时, 强制结束\" {\n\t\tt.Fatalf(\"Expected timeout error, got: %v\", result.Err)\n\t}\n\tif !strings.Contains(result.Result, \"Task started\") {\n\t\tt.Fatalf(\"Expected partial output to contain 'Task started', got: %s\", result.Result)\n\t}\n\tif !strings.Contains(result.Result, \"Partial result: 50%\") {\n\t\tt.Fatalf(\"Expected partial output to contain 'Partial result: 50%%', got: %s\", result.Result)\n\t}\n\tif !strings.Contains(result.Result, \"[执行超时，强制结束]\") {\n\t\tt.Fatalf(\"Expected timeout marker in output, got: %s\", result.Result)\n\t}\n}\n\nfunc TestExecJobWithPartialOutput_ManualStop(t *testing.T) {\n\thandler := &mockRPCHandlerWithPartialOutput{\n\t\tpartialOutput: \"Task started\\nProcessing batch 1\\nProcessing batch 2\",\n\t\terrorType:     \"manual_stop\",\n\t}\n\n\ttask := models.Task{\n\t\tId:         2,\n\t\tName:       \"manual-stop-test\",\n\t\tRetryTimes: 0,\n\t}\n\n\tresult := execJob(handler, task, 2)\n\n\tif result.Err == nil {\n\t\tt.Fatal(\"Expected manual stop error\")\n\t}\n\tif result.Err.Error() != \"手动停止\" {\n\t\tt.Fatalf(\"Expected manual stop error, got: %v\", result.Err)\n\t}\n\tif !strings.Contains(result.Result, \"Processing batch 1\") {\n\t\tt.Fatalf(\"Expected partial output to contain 'Processing batch 1', got: %s\", result.Result)\n\t}\n\tif !strings.Contains(result.Result, \"[手动停止]\") {\n\t\tt.Fatalf(\"Expected manual stop marker in output, got: %s\", result.Result)\n\t}\n}\n\nfunc TestExecJobWithPartialOutput_NormalError(t *testing.T) {\n\thandler := &mockRPCHandlerWithPartialOutput{\n\t\tpartialOutput: \"Task started\\nError occurred: file not found\",\n\t\terrorType:     \"normal_error\",\n\t}\n\n\ttask := models.Task{\n\t\tId:         3,\n\t\tName:       \"error-test\",\n\t\tRetryTimes: 0,\n\t}\n\n\tresult := execJob(handler, task, 3)\n\n\tif result.Err == nil {\n\t\tt.Fatal(\"Expected normal error\")\n\t}\n\tif result.Err.Error() != \"command failed\" {\n\t\tt.Fatalf(\"Expected 'command failed' error, got: %v\", result.Err)\n\t}\n\t// 对于普通错误，应该返回完整输出，不添加特殊标记\n\tif !strings.Contains(result.Result, \"Error occurred: file not found\") {\n\t\tt.Fatalf(\"Expected full output for normal error, got: %s\", result.Result)\n\t}\n\tif strings.Contains(result.Result, \"[执行超时，强制结束]\") || strings.Contains(result.Result, \"[手动停止]\") {\n\t\tt.Fatalf(\"Should not contain timeout/stop markers for normal error, got: %s\", result.Result)\n\t}\n}\n\nfunc TestExecJobWithPartialOutput_Success(t *testing.T) {\n\thandler := &mockRPCHandlerWithPartialOutput{\n\t\tpartialOutput: \"Task started\\nProcessing completed\\nResult: success\",\n\t\terrorType:     \"success\",\n\t}\n\n\ttask := models.Task{\n\t\tId:         4,\n\t\tName:       \"success-test\",\n\t\tRetryTimes: 0,\n\t}\n\n\tresult := execJob(handler, task, 4)\n\n\tif result.Err != nil {\n\t\tt.Fatalf(\"Expected no error for success, got: %v\", result.Err)\n\t}\n\tif !strings.Contains(result.Result, \"Result: success\") {\n\t\tt.Fatalf(\"Expected success output, got: %s\", result.Result)\n\t}\n\tif strings.Contains(result.Result, \"[执行超时，强制结束]\") || strings.Contains(result.Result, \"[手动停止]\") {\n\t\tt.Fatalf(\"Should not contain error markers for success, got: %s\", result.Result)\n\t}\n}\n\n// 可重写的假处理器，用于测试\ntype overridableHandler struct {\n\trunFunc func(models.Task, int64) (string, error)\n}\n\nfunc (h *overridableHandler) Run(taskModel models.Task, taskUniqueId int64) (string, error) {\n\treturn h.runFunc(taskModel, taskUniqueId)\n}\n\nfunc TestExecJobWithPartialOutput_RetryWithTimeout(t *testing.T) {\n\t// 测试重试机制与部分输出的结合\n\tcallCount := 0\n\tresults := []handlerResponse{\n\t\t{result: \"First attempt\\nPartial output\\n\\n[执行超时，强制结束]\", err: fmt.Errorf(\"执行超时, 强制结束\")},\n\t\t{result: \"Second attempt\\nSuccess!\", err: nil},\n\t}\n\n\thandler := &overridableHandler{\n\t\trunFunc: func(taskModel models.Task, taskUniqueId int64) (string, error) {\n\t\t\tresult := results[callCount]\n\t\t\tcallCount++\n\t\t\treturn result.result, result.err\n\t\t},\n\t}\n\n\ttask := models.Task{\n\t\tId:            5,\n\t\tName:          \"retry-test\",\n\t\tRetryTimes:    1,\n\t\tRetryInterval: 0, // 不等待，加快测试\n\t}\n\n\t// 模拟sleep函数，避免实际等待\n\toriginalSleep := sleepFunc\n\tsleepFunc = func(d time.Duration) {\n\t\t// 不实际睡眠\n\t}\n\tdefer func() { sleepFunc = originalSleep }()\n\n\tresult := execJob(handler, task, 5)\n\n\tif result.Err != nil {\n\t\tt.Fatalf(\"Expected success after retry, got: %v\", result.Err)\n\t}\n\tif !strings.Contains(result.Result, \"Second attempt\") {\n\t\tt.Fatalf(\"Expected second attempt output, got: %s\", result.Result)\n\t}\n\tif result.RetryTimes != 1 {\n\t\tt.Fatalf(\"Expected 1 retry, got: %d\", result.RetryTimes)\n\t}\n\tif callCount != 2 {\n\t\tt.Fatalf(\"Expected 2 handler calls, got: %d\", callCount)\n\t}\n}\n"
  },
  {
    "path": "internal/service/task_test.go",
    "content": "package service\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gocronx-team/gocron/internal/models\"\n\t\"github.com/gocronx-team/gocron/internal/modules/httpclient\"\n\t\"github.com/gocronx-team/gocron/internal/modules/logger\"\n\t\"github.com/gocronx-team/gocron/internal/modules/notify\"\n)\n\nfunc TestMain(m *testing.M) {\n\t_ = os.MkdirAll(\"log\", 0o755)\n\tlogger.InitLogger()\n\tos.Exit(m.Run())\n}\n\nfunc TestHTTPHandlerRunGetUsesCustomTimeout(t *testing.T) {\n\toriginal := httpGetFunc\n\tdefer func() { httpGetFunc = original }()\n\n\tvar capturedTimeout int\n\thttpGetFunc = func(url string, timeout int) httpclient.ResponseWrapper {\n\t\tif url != \"http://example.com\" {\n\t\t\tt.Fatalf(\"unexpected url %s\", url)\n\t\t}\n\t\tcapturedTimeout = timeout\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusOK, Body: \"ok\"}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:    \"http://example.com\",\n\t\tTimeout:    1000,\n\t\tHttpMethod: models.TaskHTTPMethodGet,\n\t}\n\tresult, err := handler.Run(task, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif result != \"ok\" {\n\t\tt.Fatalf(\"unexpected result %s\", result)\n\t}\n\t// Custom timeout should be preserved (no longer capped at 300)\n\tif capturedTimeout != 1000 {\n\t\tt.Fatalf(\"expected timeout 1000, got %d\", capturedTimeout)\n\t}\n}\n\nfunc TestHTTPHandlerRunGetDefaultTimeout(t *testing.T) {\n\toriginal := httpGetFunc\n\tdefer func() { httpGetFunc = original }()\n\n\tvar capturedTimeout int\n\thttpGetFunc = func(url string, timeout int) httpclient.ResponseWrapper {\n\t\tcapturedTimeout = timeout\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusOK, Body: \"ok\"}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:    \"http://example.com\",\n\t\tTimeout:    0, // not set\n\t\tHttpMethod: models.TaskHTTPMethodGet,\n\t}\n\thandler.Run(task, 1)\n\tif capturedTimeout != HttpDefaultTimeout {\n\t\tt.Fatalf(\"expected default timeout %d, got %d\", HttpDefaultTimeout, capturedTimeout)\n\t}\n}\n\nfunc TestHTTPHandlerRunPostParsesParams(t *testing.T) {\n\toriginal := httpPostParamsFunc\n\tdefer func() { httpPostParamsFunc = original }()\n\n\tvar capturedURL, capturedParams string\n\tvar capturedTimeout int\n\thttpPostParamsFunc = func(url, params string, timeout int) httpclient.ResponseWrapper {\n\t\tcapturedURL = url\n\t\tcapturedParams = params\n\t\tcapturedTimeout = timeout\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusOK, Body: \"posted\"}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:    \"http://example.com/cmd?foo=bar\",\n\t\tTimeout:    10,\n\t\tHttpMethod: models.TaskHttpMethodPost,\n\t}\n\tresult, err := handler.Run(task, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif result != \"posted\" {\n\t\tt.Fatalf(\"unexpected result %s\", result)\n\t}\n\tif capturedURL != \"http://example.com/cmd\" || capturedParams != \"foo=bar\" {\n\t\tt.Fatalf(\"unexpected url/params %s %s\", capturedURL, capturedParams)\n\t}\n\tif capturedTimeout != 10 {\n\t\tt.Fatalf(\"expected timeout 10, got %d\", capturedTimeout)\n\t}\n}\n\nfunc TestHTTPHandlerRunReturnsErrorForNon200(t *testing.T) {\n\toriginal := httpGetFunc\n\tdefer func() { httpGetFunc = original }()\n\n\thttpGetFunc = func(url string, timeout int) httpclient.ResponseWrapper {\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusInternalServerError, Body: \"bad\"}\n\t}\n\thandler := &HTTPHandler{}\n\ttask := models.Task{Command: \"http://example.com\", HttpMethod: models.TaskHTTPMethodGet}\n\tresult, err := handler.Run(task, 1)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for non-200 response\")\n\t}\n\tif result != \"bad\" {\n\t\tt.Fatalf(\"unexpected result %s\", result)\n\t}\n}\n\nfunc TestHTTPHandlerRunPostJsonBody(t *testing.T) {\n\toriginal := httpPostJsonFunc\n\tdefer func() { httpPostJsonFunc = original }()\n\n\tvar capturedBody string\n\thttpPostJsonFunc = func(url, body string, timeout int) httpclient.ResponseWrapper {\n\t\tcapturedBody = body\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusOK, Body: \"ok\"}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:    \"http://example.com/api\",\n\t\tHttpMethod: models.TaskHttpMethodPost,\n\t\tHttpBody:   `{\"key\":\"value\"}`,\n\t\tTimeout:    10,\n\t}\n\t_, err := handler.Run(task, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif capturedBody != `{\"key\":\"value\"}` {\n\t\tt.Fatalf(\"expected JSON body, got %s\", capturedBody)\n\t}\n}\n\nfunc TestHTTPHandlerRunPostFallbackToParams(t *testing.T) {\n\toriginal := httpPostParamsFunc\n\tdefer func() { httpPostParamsFunc = original }()\n\n\tvar capturedParams string\n\thttpPostParamsFunc = func(url, params string, timeout int) httpclient.ResponseWrapper {\n\t\tcapturedParams = params\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusOK, Body: \"ok\"}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:    \"http://example.com/api?a=1\",\n\t\tHttpMethod: models.TaskHttpMethodPost,\n\t\tHttpBody:   \"\", // empty — should fallback to URL params\n\t\tTimeout:    10,\n\t}\n\t_, err := handler.Run(task, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif capturedParams != \"a=1\" {\n\t\tt.Fatalf(\"expected params 'a=1', got %s\", capturedParams)\n\t}\n}\n\nfunc TestHTTPHandlerRunSuccessPatternMatch(t *testing.T) {\n\toriginal := httpGetFunc\n\tdefer func() { httpGetFunc = original }()\n\n\thttpGetFunc = func(url string, timeout int) httpclient.ResponseWrapper {\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusOK, Body: `{\"status\":\"ok\",\"code\":0}`}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:        \"http://example.com\",\n\t\tHttpMethod:     models.TaskHTTPMethodGet,\n\t\tSuccessPattern: `\"code\":0`,\n\t\tTimeout:        10,\n\t}\n\t_, err := handler.Run(task, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"expected success, got error: %v\", err)\n\t}\n}\n\nfunc TestHTTPHandlerRunSuccessPatternNoMatch(t *testing.T) {\n\toriginal := httpGetFunc\n\tdefer func() { httpGetFunc = original }()\n\n\thttpGetFunc = func(url string, timeout int) httpclient.ResponseWrapper {\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusOK, Body: `{\"status\":\"error\",\"code\":1}`}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:        \"http://example.com\",\n\t\tHttpMethod:     models.TaskHTTPMethodGet,\n\t\tSuccessPattern: `\"code\":0`,\n\t\tTimeout:        10,\n\t}\n\t_, err := handler.Run(task, 1)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for non-matching pattern\")\n\t}\n\tif !strings.Contains(err.Error(), \"does not match success_pattern\") {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\nfunc TestHTTPHandlerRunSuccessPatternInvalidRegex(t *testing.T) {\n\toriginal := httpGetFunc\n\tdefer func() { httpGetFunc = original }()\n\n\thttpGetFunc = func(url string, timeout int) httpclient.ResponseWrapper {\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusOK, Body: \"ok\"}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:        \"http://example.com\",\n\t\tHttpMethod:     models.TaskHTTPMethodGet,\n\t\tSuccessPattern: `[invalid`,\n\t\tTimeout:        10,\n\t}\n\t_, err := handler.Run(task, 1)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for invalid regex\")\n\t}\n\tif !strings.Contains(err.Error(), \"invalid success_pattern regex\") {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\nfunc TestHTTPHandlerRunSuccessPatternMatchCompactJSON(t *testing.T) {\n\toriginal := httpGetFunc\n\tdefer func() { httpGetFunc = original }()\n\n\t// 模拟 pretty-printed JSON 响应（如 httpbin.org）\n\thttpGetFunc = func(url string, timeout int) httpclient.ResponseWrapper {\n\t\treturn httpclient.ResponseWrapper{\n\t\t\tStatusCode: http.StatusOK,\n\t\t\tBody: `{\n  \"json\": {\n    \"key\": \"value\"\n  },\n  \"data\": \"{\\\"key\\\":\\\"value\\\"}\"\n}`,\n\t\t}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:        \"http://example.com\",\n\t\tHttpMethod:     models.TaskHTTPMethodGet,\n\t\tSuccessPattern: `\"key\":\"value\"`,\n\t\tTimeout:        10,\n\t}\n\t_, err := handler.Run(task, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"expected success with compacted JSON matching, got error: %v\", err)\n\t}\n}\n\nfunc TestHTTPHandlerRunEmptyPatternSkipsCheck(t *testing.T) {\n\toriginal := httpGetFunc\n\tdefer func() { httpGetFunc = original }()\n\n\thttpGetFunc = func(url string, timeout int) httpclient.ResponseWrapper {\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusOK, Body: \"anything\"}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:        \"http://example.com\",\n\t\tHttpMethod:     models.TaskHTTPMethodGet,\n\t\tSuccessPattern: \"\", // empty — should skip assertion\n\t\tTimeout:        10,\n\t}\n\t_, err := handler.Run(task, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"expected success with empty pattern, got: %v\", err)\n\t}\n}\n\nfunc TestHTTPHandlerRunGetWithHeaders(t *testing.T) {\n\toriginal := httpGetWithHeadersFunc\n\tdefer func() { httpGetWithHeadersFunc = original }()\n\n\tvar capturedHeaders string\n\thttpGetWithHeadersFunc = func(url, headers string, timeout int) httpclient.ResponseWrapper {\n\t\tcapturedHeaders = headers\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusOK, Body: \"ok\"}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:     \"http://example.com\",\n\t\tHttpMethod:  models.TaskHTTPMethodGet,\n\t\tHttpHeaders: `{\"Authorization\":\"Bearer abc\"}`,\n\t\tTimeout:     10,\n\t}\n\t_, err := handler.Run(task, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif capturedHeaders != `{\"Authorization\":\"Bearer abc\"}` {\n\t\tt.Fatalf(\"expected headers passed through, got %s\", capturedHeaders)\n\t}\n}\n\nfunc TestHTTPHandlerRunPostJsonWithHeaders(t *testing.T) {\n\toriginal := httpPostJsonWithHdrsFunc\n\tdefer func() { httpPostJsonWithHdrsFunc = original }()\n\n\tvar capturedBody, capturedHeaders string\n\thttpPostJsonWithHdrsFunc = func(url, body, headers string, timeout int) httpclient.ResponseWrapper {\n\t\tcapturedBody = body\n\t\tcapturedHeaders = headers\n\t\treturn httpclient.ResponseWrapper{StatusCode: http.StatusOK, Body: \"ok\"}\n\t}\n\n\thandler := &HTTPHandler{}\n\ttask := models.Task{\n\t\tCommand:     \"http://example.com/api\",\n\t\tHttpMethod:  models.TaskHttpMethodPost,\n\t\tHttpBody:    `{\"key\":\"val\"}`,\n\t\tHttpHeaders: `{\"X-Token\":\"secret\"}`,\n\t\tTimeout:     10,\n\t}\n\t_, err := handler.Run(task, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif capturedBody != `{\"key\":\"val\"}` {\n\t\tt.Fatalf(\"expected body, got %s\", capturedBody)\n\t}\n\tif capturedHeaders != `{\"X-Token\":\"secret\"}` {\n\t\tt.Fatalf(\"expected headers, got %s\", capturedHeaders)\n\t}\n}\n\ntype fakeHandler struct {\n\tresults   []handlerResponse\n\tcallCount int\n}\n\ntype handlerResponse struct {\n\tresult string\n\terr    error\n}\n\nfunc (f *fakeHandler) Run(taskModel models.Task, taskUniqueId int64) (string, error) {\n\tres := f.results[f.callCount]\n\tf.callCount++\n\treturn res.result, res.err\n}\n\nfunc TestExecJobRetriesUntilSuccess(t *testing.T) {\n\toriginalSleep := sleepFunc\n\tdefer func() { sleepFunc = originalSleep }()\n\n\tsleepCalls := 0\n\tsleepFunc = func(d time.Duration) {\n\t\tsleepCalls++\n\t}\n\n\thandler := &fakeHandler{\n\t\tresults: []handlerResponse{\n\t\t\t{result: \"first\", err: errors.New(\"fail1\")},\n\t\t\t{result: \"second\", err: nil},\n\t\t},\n\t}\n\ttask := models.Task{Id: 1, RetryTimes: 1, RetryInterval: 1}\n\tresult := execJob(handler, task, 1)\n\tif result.Result != \"second\" || result.Err != nil {\n\t\tt.Fatalf(\"unexpected result: %+v\", result)\n\t}\n\tif result.RetryTimes != 1 {\n\t\tt.Fatalf(\"expected RetryTimes 1, got %d\", result.RetryTimes)\n\t}\n\tif handler.callCount != 2 {\n\t\tt.Fatalf(\"expected 2 handler calls, got %d\", handler.callCount)\n\t}\n\tif sleepCalls != 1 {\n\t\tt.Fatalf(\"expected 1 sleep call, got %d\", sleepCalls)\n\t}\n}\n\nfunc TestExecJobReturnsErrorAfterRetriesExhausted(t *testing.T) {\n\toriginalSleep := sleepFunc\n\tdefer func() { sleepFunc = originalSleep }()\n\tsleepCount := 0\n\tsleepFunc = func(d time.Duration) {\n\t\tsleepCount++\n\t}\n\n\thandler := &fakeHandler{\n\t\tresults: []handlerResponse{\n\t\t\t{result: \"first\", err: errors.New(\"fail1\")},\n\t\t\t{result: \"second\", err: errors.New(\"fail2\")},\n\t\t\t{result: \"third\", err: errors.New(\"fail3\")},\n\t\t},\n\t}\n\ttask := models.Task{Id: 2, RetryTimes: 2, RetryInterval: 1}\n\tresult := execJob(handler, task, 1)\n\tif result.Err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tif result.RetryTimes != task.RetryTimes {\n\t\tt.Fatalf(\"expected retryTimes %d, got %d\", task.RetryTimes, result.RetryTimes)\n\t}\n\tif handler.callCount != 3 {\n\t\tt.Fatalf(\"expected 3 handler calls, got %d\", handler.callCount)\n\t}\n\tif sleepCount != 2 {\n\t\tt.Fatalf(\"expected 2 sleep calls, got %d\", sleepCount)\n\t}\n}\n\nfunc TestSendNotificationBehavior(t *testing.T) {\n\ttype expectation struct {\n\t\tname   string\n\t\ttask   models.Task\n\t\tresult TaskResult\n\t\tcount  int\n\t\tcheck  func(t *testing.T, msg notify.Message)\n\t}\n\ttests := []expectation{\n\t\t{\n\t\t\tname:  \"disabled\",\n\t\t\ttask:  models.Task{NotifyStatus: 0},\n\t\t\tcount: 0,\n\t\t},\n\t\t{\n\t\t\tname:   \"failOnlySuccess\",\n\t\t\ttask:   models.Task{NotifyStatus: 1, NotifyType: 1, NotifyReceiverId: \"user\"},\n\t\t\tresult: TaskResult{Result: \"ok\", Err: nil},\n\t\t\tcount:  0,\n\t\t},\n\t\t{\n\t\t\tname:   \"failOnlyTriggered\",\n\t\t\ttask:   models.Task{Name: \"job\", NotifyStatus: 1, NotifyType: 1, NotifyReceiverId: \"user\"},\n\t\t\tresult: TaskResult{Result: \"bad\", Err: errors.New(\"boom\")},\n\t\t\tcount:  1,\n\t\t},\n\t\t{\n\t\t\tname:   \"keywordMismatch\",\n\t\t\ttask:   models.Task{NotifyStatus: 3, NotifyType: 2, NotifyKeyword: \"ERROR\"},\n\t\t\tresult: TaskResult{Result: \"all good\"},\n\t\t\tcount:  0,\n\t\t},\n\t\t{\n\t\t\tname:   \"keywordMatch\",\n\t\t\ttask:   models.Task{Name: \"job\", NotifyStatus: 3, NotifyType: 2, NotifyKeyword: \"ERROR\"},\n\t\t\tresult: TaskResult{Result: \"found ERROR\", Err: nil},\n\t\t\tcount:  1,\n\t\t\tcheck: func(t *testing.T, msg notify.Message) {\n\t\t\t\tif msg[\"status\"] != \"Success\" {\n\t\t\t\t\tt.Fatalf(\"expected status Success, got %v\", msg[\"status\"])\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"missingReceiverForMail\",\n\t\t\ttask:   models.Task{NotifyStatus: 2, NotifyType: 1, NotifyReceiverId: \"\"},\n\t\t\tresult: TaskResult{Result: \"any\"},\n\t\t\tcount:  0,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcaptured := stubNotifyPush(t)\n\t\t\tSendNotification(tt.task, tt.result)\n\t\t\tif len(*captured) != tt.count {\n\t\t\t\tt.Fatalf(\"expected %d notifications, got %d\", tt.count, len(*captured))\n\t\t\t}\n\t\t\tif tt.count > 0 && tt.check != nil {\n\t\t\t\ttt.check(t, (*captured)[0])\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc stubNotifyPush(t *testing.T) *[]notify.Message {\n\tt.Helper()\n\tvar captured []notify.Message\n\toriginal := notifyPushFunc\n\tnotifyPushFunc = func(msg notify.Message) {\n\t\tmsgCopy := notify.Message{}\n\t\tfor k, v := range msg {\n\t\t\tmsgCopy[k] = v\n\t\t}\n\t\tcaptured = append(captured, msgCopy)\n\t}\n\tt.Cleanup(func() { notifyPushFunc = original })\n\treturn &captured\n}\n\n// 测试依赖任务执行逻辑 - 简化版本，直接测试逻辑分支\nfunc TestExecDependencyTaskLogic(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tparentTask models.Task\n\t\ttaskResult TaskResult\n\t\tshouldExit bool // 是否应该提前退出（不查询数据库）\n\t\treason     string\n\t}{\n\t\t{\n\t\t\tname: \"子任务不应该触发依赖任务\",\n\t\t\tparentTask: models.Task{\n\t\t\t\tId:               1,\n\t\t\t\tLevel:            models.TaskLevelChild,\n\t\t\t\tDependencyTaskId: \"2,3\",\n\t\t\t},\n\t\t\ttaskResult: TaskResult{Err: nil},\n\t\t\tshouldExit: true,\n\t\t\treason:     \"Level is Child\",\n\t\t},\n\t\t{\n\t\t\tname: \"没有依赖任务ID\",\n\t\t\tparentTask: models.Task{\n\t\t\t\tId:               1,\n\t\t\t\tLevel:            models.TaskLevelParent,\n\t\t\t\tDependencyTaskId: \"\",\n\t\t\t},\n\t\t\ttaskResult: TaskResult{Err: nil},\n\t\t\tshouldExit: true,\n\t\t\treason:     \"Empty DependencyTaskId\",\n\t\t},\n\t\t{\n\t\t\tname: \"强依赖且父任务失败\",\n\t\t\tparentTask: models.Task{\n\t\t\t\tId:               1,\n\t\t\t\tLevel:            models.TaskLevelParent,\n\t\t\t\tDependencyTaskId: \"2,3\",\n\t\t\t\tDependencyStatus: models.TaskDependencyStatusStrong,\n\t\t\t},\n\t\t\ttaskResult: TaskResult{Err: errors.New(\"parent failed\")},\n\t\t\tshouldExit: true,\n\t\t\treason:     \"Strong dependency and parent failed\",\n\t\t},\n\t\t{\n\t\t\tname: \"弱依赖且父任务失败应该继续\",\n\t\t\tparentTask: models.Task{\n\t\t\t\tId:               1,\n\t\t\t\tLevel:            models.TaskLevelParent,\n\t\t\t\tDependencyTaskId: \"2\",\n\t\t\t\tDependencyStatus: models.TaskDependencyStatusWeak,\n\t\t\t},\n\t\t\ttaskResult: TaskResult{Err: errors.New(\"parent failed\")},\n\t\t\tshouldExit: false,\n\t\t\treason:     \"Weak dependency, should continue\",\n\t\t},\n\t\t{\n\t\t\tname: \"父任务成功应该继续\",\n\t\t\tparentTask: models.Task{\n\t\t\t\tId:               1,\n\t\t\t\tLevel:            models.TaskLevelParent,\n\t\t\t\tDependencyTaskId: \"2,3\",\n\t\t\t\tDependencyStatus: models.TaskDependencyStatusStrong,\n\t\t\t},\n\t\t\ttaskResult: TaskResult{Err: nil},\n\t\t\tshouldExit: false,\n\t\t\treason:     \"Parent success, should continue\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// 测试逻辑分支\n\t\t\tif tt.parentTask.Level != models.TaskLevelParent {\n\t\t\t\tif !tt.shouldExit {\n\t\t\t\t\tt.Errorf(\"Expected to exit for child task, but shouldExit is false\")\n\t\t\t\t}\n\t\t\t\tt.Logf(\"✓ Correctly exits for: %s\", tt.reason)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.parentTask.DependencyTaskId == \"\" {\n\t\t\t\tif !tt.shouldExit {\n\t\t\t\t\tt.Errorf(\"Expected to exit for empty dependency ID, but shouldExit is false\")\n\t\t\t\t}\n\t\t\t\tt.Logf(\"✓ Correctly exits for: %s\", tt.reason)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.parentTask.DependencyStatus == models.TaskDependencyStatusStrong && tt.taskResult.Err != nil {\n\t\t\t\tif !tt.shouldExit {\n\t\t\t\t\tt.Errorf(\"Expected to exit for strong dependency failure, but shouldExit is false\")\n\t\t\t\t}\n\t\t\t\tt.Logf(\"✓ Correctly exits for: %s\", tt.reason)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 如果到这里，说明应该继续执行\n\t\t\tif tt.shouldExit {\n\t\t\t\tt.Errorf(\"Should have exited but didn't for: %s\", tt.reason)\n\t\t\t} else {\n\t\t\t\tt.Logf(\"✓ Correctly continues for: %s (would query DB and execute)\", tt.reason)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "makefile",
    "content": "GO111MODULE=on\n\n# 版本信息\nVERSION ?= $(shell git describe --tags --always --dirty)\nGIT_COMMIT ?= $(shell git rev-parse --short HEAD)\nBUILD_DATE ?= $(shell date '+%Y-%m-%d %H:%M:%S')\nLDFLAGS = -w -X 'main.AppVersion=$(VERSION)' -X 'main.BuildDate=$(BUILD_DATE)' -X 'main.GitCommit=$(GIT_COMMIT)'\n\n# 构建目录\nBIN_DIR = bin\nPACKAGE_DIR = packages\n\n# 默认目标\n.DEFAULT_GOAL := build\n\n# 本地构建\n.PHONY: build\nbuild: gocron node\n\n.PHONY: build-race\nbuild-race: enable-race build\n\n.PHONY: run\nrun: build kill\n\t./$(BIN_DIR)/gocron-node &\n\t./$(BIN_DIR)/gocron web -e dev\n\n.PHONY: run-with-packages\nrun-with-packages: build-web package-all kill\n\t./$(BIN_DIR)/gocron-node &\n\t./$(BIN_DIR)/gocron web -e dev\n\n.PHONY: run-race\nrun-race: enable-race run\n\n.PHONY: kill\nkill:\n\t-killall gocron-node\n\n.PHONY: gocron\ngocron:\n\t@mkdir -p $(BIN_DIR)\n\tgo build $(RACE) -ldflags \"$(LDFLAGS)\" -o $(BIN_DIR)/gocron ./cmd/gocron\n\n.PHONY: node\nnode:\n\t@mkdir -p $(BIN_DIR)\n\tCGO_ENABLED=0 go build $(RACE) -ldflags \"$(LDFLAGS)\" -o $(BIN_DIR)/gocron-node ./cmd/node\n\n.PHONY: test\ntest:\n\tgo test $(RACE) ./...\n\n.PHONY: test-race\ntest-race: enable-race test\n\n.PHONY: enable-race\nenable-race:\n\t$(eval RACE = -race)\n\n# 多平台打包\n.PHONY: package\npackage: build-web\n\t@echo \"Building packages for current platform...\"\n\tbash ./package.sh\n\n.PHONY: package-linux\npackage-linux: build-web\n\t@echo \"Building packages for Linux...\"\n\tbash ./package.sh -p linux -a \"amd64,arm64\"\n\n.PHONY: package-linux-nosqlite\npackage-linux-nosqlite: build-web\n\t@echo \"Building Linux packages without SQLite...\"\n\tCGO_ENABLED=0 bash ./package.sh -p linux -a \"amd64,arm64\"\n\n.PHONY: package-darwin\npackage-darwin: build-web\n\t@echo \"Building packages for macOS...\"\n\tbash ./package.sh -p darwin -a \"amd64,arm64\"\n\n.PHONY: package-windows\npackage-windows: build-web\n\t@echo \"Building packages for Windows...\"\n\tbash ./package.sh -p windows -a \"amd64\"\n\n.PHONY: package-all\npackage-all: build-web\n\t@echo \"Building packages for all platforms...\"\n\tbash ./package.sh -p \"linux,darwin\" -a \"amd64,arm64\"\n\tbash ./package.sh -p \"windows\" -a \"amd64\"\n\n# 前端构建\n.PHONY: build-vue\nbuild-vue:\n\t@echo \"Installing Vue dependencies...\"\n\t@if [ -f web/vue/pnpm-lock.yaml ]; then \\\n\t\techo \"Using pnpm...\"; \\\n\t\tcd web/vue && pnpm install; \\\n\telif [ -f web/vue/yarn.lock ]; then \\\n\t\techo \"Using yarn...\"; \\\n\t\tcd web/vue && yarn install; \\\n\telse \\\n\t\techo \"Using npm...\"; \\\n\t\tcd web/vue && npm install; \\\n\tfi\n\t@echo \"Building Vue frontend...\"\n\t@if [ -f web/vue/pnpm-lock.yaml ]; then \\\n\t\tcd web/vue && pnpm run build; \\\n\telif [ -f web/vue/yarn.lock ]; then \\\n\t\tcd web/vue && yarn run build; \\\n\telse \\\n\t\tcd web/vue && npm run build; \\\n\tfi\n\t@echo \"✅ Vue build complete! Files will be embedded during Go build.\"\n\n.PHONY: install-vue\ninstall-vue:\n\t@echo \"Installing Vue dependencies...\"\n\t@if [ -f web/vue/pnpm-lock.yaml ]; then \\\n\t\tcd web/vue && pnpm install; \\\n\telif [ -f web/vue/yarn.lock ]; then \\\n\t\tcd web/vue && yarn install; \\\n\telse \\\n\t\tcd web/vue && npm install; \\\n\tfi\n\n.PHONY: run-vue\nrun-vue:\n\t@echo \"Starting Vue dev server...\"\n\t@if [ -f web/vue/pnpm-lock.yaml ]; then \\\n\t\tcd web/vue && pnpm run dev; \\\n\telif [ -f web/vue/yarn.lock ]; then \\\n\t\tcd web/vue && yarn run dev; \\\n\telse \\\n\t\tcd web/vue && npm run dev; \\\n\tfi\n\n.PHONY: build-web\nbuild-web: build-vue\n\t@echo \"Web build complete!\"\n\n# 代码质量检查\n.PHONY: check\ncheck: fmt vet test\n\t@echo \"✅ All checks passed!\"\n\n.PHONY: lint\nlint:\n\t@echo \"Running linter...\"\n\t@if command -v golangci-lint >/dev/null 2>&1; then \\\n\t\tgolangci-lint run || echo \"⚠️  Linter found issues (non-blocking)\"; \\\n\telse \\\n\t\techo \"⚠️  golangci-lint not installed, skipping...\"; \\\n\t\techo \"   Install: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest\"; \\\n\tfi\n\n.PHONY: fmt\nfmt:\n\t@echo \"Formatting code...\"\n\t@find . -name '*.go' -exec gofmt -w {} \\;\n\n.PHONY: fmt-check\nfmt-check:\n\t@echo \"Checking code formatting...\"\n\t@unformatted=$$(gofmt -l .); \\\n\tif [ -n \"$$unformatted\" ]; then \\\n\t\techo \"❌ Code not formatted:\" && echo \"$$unformatted\" && echo \"Run 'make fmt' to fix\" && exit 1; \\\n\tfi\n\t@echo \"✅ Code formatting OK\"\n\n.PHONY: vet\nvet:\n\t@echo \"Running go vet...\"\n\t@go vet ./...\n\t@echo \"✅ go vet passed\"\n\n.PHONY: test-coverage\ntest-coverage:\n\t@echo \"Running tests with coverage...\"\n\t@go test -cover -coverprofile=coverage.out ./...\n\t@go tool cover -html=coverage.out -o coverage.html\n\t@echo \"✅ Coverage report generated: coverage.html\"\n\n.PHONY: security\nsecurity:\n\t@echo \"Running security checks...\"\n\t@if command -v gosec >/dev/null 2>&1; then \\\n\t\tgosec ./... || echo \"⚠️  Security issues found (non-blocking)\"; \\\n\telse \\\n\t\techo \"⚠️  gosec not installed, skipping...\"; \\\n\t\techo \"   Install: go install github.com/securego/gosec/v2/cmd/gosec@latest\"; \\\n\tfi\n\n# 预发布检查（完整检查）\n.PHONY: pre-release\npre-release: clean\n\t@echo \"==========================================\"\n\t@echo \"Running pre-release checks...\"\n\t@echo \"==========================================\"\n\t@$(MAKE) fmt-check\n\t@$(MAKE) vet\n\t@$(MAKE) test\n\t@echo \"\"\n\t@echo \"==========================================\"\n\t@echo \"✅ All pre-release checks passed!\"\n\t@echo \"==========================================\"\n\t@echo \"\"\n\t@echo \"Optional checks (run manually if needed):\"\n\t@echo \"  make lint      - Code quality linter\"\n\t@echo \"  make security  - Security vulnerability scan\"\n\n# 清理\n.PHONY: clean\nclean:\n\t@echo \"Cleaning build artifacts...\"\n\t-rm -rf $(BIN_DIR)\n\t-rm -rf $(PACKAGE_DIR)\n\t-rm -rf gocron-package\n\t-rm -rf gocron-node-package\n\t-rm -rf gocron-build\n\t-rm -rf gocron-node-build\n\t-rm -f coverage.out coverage.html\n\n.PHONY: clean-web\nclean-web:\n\t@echo \"Cleaning web build artifacts...\"\n\t-rm -rf web/vue/dist\n\t-rm -rf web/public/static\n\t-rm -f web/public/index.html\n\n# 开发工具\n.PHONY: dev-deps\ndev-deps:\n\t@echo \"Installing development dependencies...\"\n\tgo install github.com/cosmtrek/air@latest\n\tgo install github.com/golangci/golangci-lint/cmd/golangci-lint@latest\n\tgo install github.com/securego/gosec/v2/cmd/gosec@latest\n\n# 版本管理\n.PHONY: version\nversion:\n\t@echo \"Current version: $(VERSION)\"\n\t@echo \"Recent releases:\"\n\t@git tag -l \"v*.*.*\" | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$$' | sort -V | tail -5\n\n.PHONY: release\nrelease:\n\t@if [ -z \"$(VERSION)\" ]; then \\\n\t\techo \"Error: VERSION is required. Usage: make release VERSION=v1.3.18\"; \\\n\t\texit 1; \\\n\tfi\n\t@echo \"Creating release $(VERSION)...\"\n\t@if git rev-parse $(VERSION) >/dev/null 2>&1; then \\\n\t\techo \"Error: Tag $(VERSION) already exists!\"; \\\n\t\texit 1; \\\n\tfi\n\t@git tag -a $(VERSION) -m \"Release $(VERSION)\"\n\t@git push origin $(VERSION)\n\t@echo \"✅ Release $(VERSION) created and pushed successfully!\"\n\n.PHONY: release-patch\nrelease-patch:\n\t@echo \"Creating patch release...\"\n\t@$(MAKE) release VERSION=$$($(MAKE) next-patch)\n\n.PHONY: release-minor\nrelease-minor:\n\t@echo \"Creating minor release...\"\n\t@$(MAKE) release VERSION=$$($(MAKE) next-minor)\n\n.PHONY: release-major\nrelease-major:\n\t@echo \"Creating major release...\"\n\t@$(MAKE) release VERSION=$$($(MAKE) next-major)\n\n.PHONY: next-patch\nnext-patch:\n\t@latest=$$(git tag -l \"v*.*.*\" | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$$' | sort -V | tail -1); \\\n\tif [ -z \"$$latest\" ]; then \\\n\t\techo \"v1.0.0\"; \\\n\telse \\\n\t\techo $$latest | sed 's/^v//' | awk -F. '{printf \"v%d.%d.%d\", $$1, $$2, $$3+1}'; \\\n\tfi\n\n.PHONY: next-minor\nnext-minor:\n\t@latest=$$(git tag -l \"v*.*.*\" | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$$' | sort -V | tail -1); \\\n\tif [ -z \"$$latest\" ]; then \\\n\t\techo \"v1.0.0\"; \\\n\telse \\\n\t\techo $$latest | sed 's/^v//' | awk -F. '{printf \"v%d.%d.0\", $$1, $$2+1}'; \\\n\tfi\n\n.PHONY: next-major\nnext-major:\n\t@latest=$$(git tag -l \"v*.*.*\" | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$$' | sort -V | tail -1); \\\n\tif [ -z \"$$latest\" ]; then \\\n\t\techo \"v1.0.0\"; \\\n\telse \\\n\t\techo $$latest | sed 's/^v//' | awk -F. '{printf \"v%d.0.0\", $$1+1}'; \\\n\tfi\n\n.PHONY: delete-tag\ndelete-tag:\n\t@if [ -z \"$(VERSION)\" ]; then \\\n\t\techo \"Error: VERSION is required. Usage: make delete-tag VERSION=v1.3.18\"; \\\n\t\texit 1; \\\n\tfi\n\t@echo \"Deleting tag $(VERSION)...\"\n\t@git tag -d $(VERSION)\n\t@git push origin :refs/tags/$(VERSION)\n\t@echo \"✅ Tag $(VERSION) deleted locally and remotely\"\n\n# 帮助信息\n.PHONY: help\nhelp:\n\t@echo \"Available targets:\"\n\t@echo \"\"\n\t@echo \"Build:\"\n\t@echo \"  build          - Build gocron and gocron-node for current platform\"\n\t@echo \"  run            - Build and run in development mode\"\n\t@echo \"  test           - Run tests\"\n\t@echo \"  package-all    - Build packages for all platforms\"\n\t@echo \"\"\n\t@echo \"Code Quality:\"\n\t@echo \"  check          - Run fmt + vet + test\"\n\t@echo \"  pre-release    - Run all checks before release\"\n\t@echo \"  fmt            - Format code\"\n\t@echo \"  fmt-check      - Check code formatting\"\n\t@echo \"  vet            - Run go vet\"\n\t@echo \"  lint           - Run linter (golangci-lint)\"\n\t@echo \"  test-coverage  - Run tests with coverage report\"\n\t@echo \"  security       - Run security checks (gosec)\"\n\t@echo \"\"\n\t@echo \"Version Management:\"\n\t@echo \"  version        - Show current version\"\n\t@echo \"  release        - Create release tag (VERSION=v1.3.18)\"\n\t@echo \"  release-patch  - Auto increment patch version\"\n\t@echo \"\"\n\t@echo \"Development:\"\n\t@echo \"  dev-deps       - Install development dependencies\"\n\t@echo \"  clean          - Clean build artifacts\"\n\t@echo \"  help           - Show this help message\"\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"gocron\",\n  \"version\": \"2.0.0\",\n  \"description\": \"定时任务管理系统\",\n  \"author\": \"gocronx <gocronx@gmail.com>\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"prepare\": \"husky\",\n    \"commit\": \"git-cz\",\n    \"lint:lint-staged\": \"lint-staged\"\n  },\n  \"config\": {\n    \"commitizen\": {\n      \"path\": \"node_modules/cz-git\"\n    }\n  },\n  \"lint-staged\": {\n    \"web/vue/**/*.{js,ts,vue}\": [\n      \"cd web/vue && pnpm exec eslint --fix\",\n      \"cd web/vue && pnpm exec prettier --write\"\n    ],\n    \"*.{json,md,yml,yaml}\": [\n      \"cd web/vue && pnpm exec prettier --write\"\n    ]\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"lodash\": \">=4.18.0\",\n      \"tmp@<0.2.4\": \"0.2.4\",\n      \"flatted\": \">=3.4.2\"\n    }\n  },\n  \"devDependencies\": {\n    \"@commitlint/cli\": \"^20.5.0\",\n    \"@commitlint/config-conventional\": \"^20.5.0\",\n    \"commitizen\": \"^4.3.1\",\n    \"cz-git\": \"^1.12.0\",\n    \"eslint\": \"^10.1.0\",\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.4.0\",\n    \"prettier\": \"^3.8.1\"\n  }\n}\n"
  },
  {
    "path": "package.sh",
    "content": "#!/usr/bin/env bash\n \n# 生成压缩包 xx.tar.gz或xx.zip\n# 使用 ./package.sh -a amd664 -p linux -v v2.0.0\n \n# 任何命令返回非0值退出\nset -o errexit\n# 使用未定义的变量退出\nset -o nounset\n# 管道中任一命令执行失败退出\nset -o pipefail\n\n# 获取 Go 环境变量\nGOHOSTOS=$(go env GOHOSTOS)\nGOHOSTARCH=$(go env GOHOSTARCH)\n\n# 二进制文件名\nBINARY_NAME=''\n# main函数所在文件\nMAIN_FILE=\"\"\n \n# 提取git最新tag作为应用版本\nVERSION=''\n# 最新git commit id\nGIT_COMMIT_ID=''\n \n# 外部输入的系统\nINPUT_OS=()\n# 外部输入的架构\nINPUT_ARCH=()\n# 未指定OS，默认值\nDEFAULT_OS=${GOHOSTOS}\n# 未指定ARCH,默认值\nDEFAULT_ARCH=${GOHOSTARCH}\n# 支持的系统\nSUPPORT_OS=(linux darwin windows)\n# 支持的架构\nSUPPORT_ARCH=(386 amd64 arm64)\n \n# 编译参数\nLDFLAGS=''\n# 需要打包的文件\nINCLUDE_FILE=()\n# 打包文件生成目录\nPACKAGE_DIR=''\n# 编译文件生成目录\nBUILD_DIR=''\n \n# 获取git 最新tag name\ngit_latest_tag() {\n    local COMMIT_ID=\"\"\n    local TAG_NAME=\"\"\n    COMMIT_ID=`git rev-list --tags --max-count=1`\n    TAG_NAME=`git describe --tags \"${COMMIT_ID}\"`\n \n    echo ${TAG_NAME}\n}\n \n# 获取git 最新commit id\ngit_latest_commit() {\n    echo \"$(git rev-parse --short HEAD)\"\n}\n \n# 打印信息\nprint_message() {\n    echo \"$1\"\n}\n \n# 打印信息后推出\nprint_message_and_exit() {\n    if [[ -n $1 ]]; then\n        print_message \"$1\"\n    fi\n    exit 1\n}\n \n# 设置系统、CPU架构\nset_os_arch() {\n    if [[ ${#INPUT_OS[@]} = 0 ]];then\n        INPUT_OS=(\"${DEFAULT_OS}\")\n    fi\n \n    if [[ ${#INPUT_ARCH[@]} = 0 ]];then\n        INPUT_ARCH=(\"${DEFAULT_ARCH}\")\n    fi\n \n    for OS in \"${INPUT_OS[@]}\"; do\n        if [[  ! \"${SUPPORT_OS[*]}\" =~ ${OS} ]]; then\n            print_message_and_exit \"不支持的系统${OS}\"\n        fi\n    done\n \n    for ARCH in \"${INPUT_ARCH[@]}\";do\n        if [[ ! \"${SUPPORT_ARCH[*]}\" =~ ${ARCH} ]]; then\n            print_message_and_exit \"不支持的CPU架构${ARCH}\"\n        fi\n    done\n}\n \n# 初始化\ninit() {\n    set_os_arch\n \n    if [[ -z \"${VERSION}\" ]];then\n        VERSION=`git_latest_tag`\n    fi\n    GIT_COMMIT_ID=`git_latest_commit`\n    LDFLAGS=\"-w -X 'main.AppVersion=${VERSION}' -X 'main.BuildDate=`date '+%Y-%m-%d %H:%M:%S'`' -X 'main.GitCommit=${GIT_COMMIT_ID}'\"\n \n    PACKAGE_DIR=${BINARY_NAME}-package\n    BUILD_DIR=${BINARY_NAME}-build\n \n    # 只清理 BUILD_DIR，保留 PACKAGE_DIR 以支持增量构建\n    if [[ -d ${BUILD_DIR} ]];then\n        rm -rf ${BUILD_DIR}\n    fi\n \n    mkdir -p ${BUILD_DIR}\n    mkdir -p ${PACKAGE_DIR}\n}\n \n# 编译\nbuild() {\n    local FILENAME=''\n    for OS in \"${INPUT_OS[@]}\";do\n        for ARCH in \"${INPUT_ARCH[@]}\";do\n            if [[ \"${OS}\" = \"windows\"  ]];then\n                FILENAME=${BINARY_NAME}.exe\n            else\n                FILENAME=${BINARY_NAME}\n            fi\n\n            print_message \"编译 ${BINARY_NAME} ${OS}-${ARCH} 版本\"\n            env CGO_ENABLED=0 GOOS=${OS} GOARCH=${ARCH} go build -ldflags \"${LDFLAGS}\" -o ${BUILD_DIR}/${BINARY_NAME}-${OS}-${ARCH}/${FILENAME} ${MAIN_FILE}\n        done\n    done\n}\n \n# 打包\npackage_binary() {\n    cd ${BUILD_DIR}\n \n    for OS in \"${INPUT_OS[@]}\";do\n        for ARCH in \"${INPUT_ARCH[@]}\";do\n        package_file ${BINARY_NAME}-${OS}-${ARCH}\n        \n        # gocron-node 不使用版本号\n        if [[ \"${BINARY_NAME}\" = \"gocron-node\" ]]; then\n            if [[ \"${OS}\" = \"windows\" ]];then\n                zip -rq ../${PACKAGE_DIR}/${BINARY_NAME}-${OS}-${ARCH}.zip ${BINARY_NAME}-${OS}-${ARCH}\n            else\n                tar czf ../${PACKAGE_DIR}/${BINARY_NAME}-${OS}-${ARCH}.tar.gz ${BINARY_NAME}-${OS}-${ARCH}\n            fi\n        elif [[ -z \"${VERSION}\" ]]; then\n            if [[ \"${OS}\" = \"windows\" ]];then\n                zip -rq ../${PACKAGE_DIR}/${BINARY_NAME}-${OS}-${ARCH}.zip ${BINARY_NAME}-${OS}-${ARCH}\n            else\n                tar czf ../${PACKAGE_DIR}/${BINARY_NAME}-${OS}-${ARCH}.tar.gz ${BINARY_NAME}-${OS}-${ARCH}\n            fi\n        else\n            if [[ \"${OS}\" = \"windows\" ]];then\n                zip -rq ../${PACKAGE_DIR}/${BINARY_NAME}-${VERSION}-${OS}-${ARCH}.zip ${BINARY_NAME}-${OS}-${ARCH}\n            else\n                tar czf ../${PACKAGE_DIR}/${BINARY_NAME}-${VERSION}-${OS}-${ARCH}.tar.gz ${BINARY_NAME}-${OS}-${ARCH}\n            fi\n        fi\n        done\n    done\n \n    cd ${OLDPWD}\n}\n \n# 打包文件\npackage_file() {\n    if [[ \"${#INCLUDE_FILE[@]}\" = \"0\" ]];then\n        return\n    fi\n    for item in \"${INCLUDE_FILE[@]}\"; do\n            cp -r ../${item} $1\n    done\n}\n \n# 清理\nclean() {\n    if [[ -d ${BUILD_DIR} ]];then\n        rm -rf ${BUILD_DIR}\n    fi\n}\n \n# 运行\nrun() {\n    init\n    build\n    package_binary\n    clean\n}\n\npackage_gocron() {\n    BINARY_NAME='gocron'\n    MAIN_FILE=\"./cmd/gocron/gocron.go\"\n    INCLUDE_FILE=()\n\n    run\n}\n\npackage_gocron_node() {\n    BINARY_NAME='gocron-node'\n    MAIN_FILE=\"./cmd/node/node.go\"\n    INCLUDE_FILE=()\n\n    run\n}\n \n# p 平台 linux darwin windows\n# a 架构 386 amd64 arm64\n# v 版本号  默认取git最新tag\n# t 类型 all(默认), gocron, node\nBUILD_TYPE=\"all\"\nwhile getopts \"p:a:v:t:\" OPT;\ndo\n    case ${OPT} in\n    p) IFS=',' read -r -a INPUT_OS <<< \"${OPTARG}\"\n    ;;\n    a) IFS=',' read -r -a INPUT_ARCH <<< \"${OPTARG}\"\n    ;;\n    v) VERSION=$OPTARG\n    ;;\n    t) BUILD_TYPE=$OPTARG\n    ;;\n    *)\n    ;;\n    esac\ndone\n \n# 默认构建所有\nif [[ -z \"${BUILD_TYPE}\" ]]; then\n    BUILD_TYPE=\"all\"\nfi\n\nif [[ \"${BUILD_TYPE}\" = \"all\" ]] || [[ \"${BUILD_TYPE}\" = \"gocron\" ]]; then\n    package_gocron\nfi\n\nif [[ \"${BUILD_TYPE}\" = \"all\" ]] || [[ \"${BUILD_TYPE}\" = \"node\" ]]; then\n    package_gocron_node\nfi\n\n"
  },
  {
    "path": "release.sh",
    "content": "#!/bin/bash\n\n# 本地构建并发布到 GitHub Release\n\nset -e\n\nVERSION=\"\"\nPRERELEASE=false\nSKIP_CHECKS=false\n\n# 解析参数\nwhile [[ $# -gt 0 ]]; do\n    case $1 in\n        -v|--version)\n            VERSION=\"$2\"\n            shift 2\n            ;;\n        --prerelease)\n            PRERELEASE=true\n            shift\n            ;;\n        --skip-checks)\n            SKIP_CHECKS=true\n            shift\n            ;;\n        *)\n            echo \"Unknown option: $1\"\n            echo \"Usage: $0 -v <version> [--prerelease] [--skip-checks]\"\n            echo \"Example: $0 -v v1.3.21\"\n            exit 1\n            ;;\n    esac\ndone\n\nif [ -z \"$VERSION\" ]; then\n    echo \"Error: Version is required\"\n    echo \"Usage: $0 -v <version> [--prerelease] [--skip-checks]\"\n    exit 1\nfi\n\necho \"==========================================\"\necho \"Local Build and Release to GitHub\"\necho \"==========================================\"\necho \"Version: $VERSION\"\necho \"Prerelease: $PRERELEASE\"\necho \"Skip Checks: $SKIP_CHECKS\"\necho \"\"\n\n# 0. 代码质量检查\nif [ \"$SKIP_CHECKS\" = false ]; then\n    echo \"0. Running code quality checks...\"\n    echo \"\"\n    \n    # 格式检查\n    echo \"  → Checking code formatting...\"\n    if ! make fmt-check 2>/dev/null; then\n        echo \"❌ Code formatting check failed!\"\n        echo \"   Run 'make fmt' to fix formatting issues\"\n        exit 1\n    fi\n    \n    # go vet 检查\n    echo \"  → Running go vet...\"\n    if ! make vet 2>/dev/null; then\n        echo \"❌ go vet check failed!\"\n        exit 1\n    fi\n    \n    # 运行测试\n    echo \"  → Running tests...\"\n    if ! make test 2>/dev/null; then\n        echo \"❌ Tests failed!\"\n        exit 1\n    fi\n    \n    # 可选：linter 检查\n    echo \"  → Running linter (optional)...\"\n    make lint 2>/dev/null || echo \"⚠️  Linter check skipped\"\n    \n    echo \"\"\n    echo \"✅ All code quality checks passed!\"\n    echo \"\"\nelse\n    echo \"⚠️  Skipping code quality checks (--skip-checks flag)\"\n    echo \"\"\nfi\n\n# 1. 检查是否需要清理\necho \"1. Checking existing builds...\"\nif [ -d \"gocron-package\" ] && [ -n \"$(ls -A gocron-package 2>/dev/null)\" ]; then\n    echo \"Found existing packages. Clean and rebuild? (y/N): \"\n    read -r CLEAN_RESPONSE\n    if [[ $CLEAN_RESPONSE =~ ^[Yy]$ ]]; then\n        rm -rf gocron-package gocron-node-package gocron-build gocron-node-build\n        echo \"✓ Cleaned\"\n    else\n        echo \"✓ Keeping existing packages\"\n    fi\nelse\n    echo \"✓ No existing packages\"\nfi\necho \"\"\n\n# 2. 构建前端\necho \"2. Building frontend...\"\ncd web/vue\n\n# 检测使用哪个包管理器\nif [ -f \"pnpm-lock.yaml\" ]; then\n    echo \"Using pnpm...\"\n    pnpm install --frozen-lockfile\n    pnpm run build\nelif [ -f \"yarn.lock\" ]; then\n    echo \"Using yarn...\"\n    yarn install --frozen-lockfile\n    yarn run build\nelse\n    echo \"Using npm...\"\n    npm ci\n    npm run build\nfi\n\ncd ../..\necho \"✓ Frontend built (output: web/vue/dist/)\"\necho \"\"\n\n\n\n# 3. 构建所有平台的包\necho \"3. Building packages for all platforms...\"\nMISSING_PACKAGES=false\n\n# 检查 Linux/macOS gocron 包\nfor os in linux darwin; do\n    for arch in amd64 arm64; do\n        if [ ! -f \"gocron-package/gocron-${VERSION}-${os}-${arch}.tar.gz\" ] || \\\n           [ ! -f \"gocron-node-package/gocron-node-${os}-${arch}.tar.gz\" ]; then\n            MISSING_PACKAGES=true\n            break 2\n        fi\n    done\ndone\n\nif [ \"$MISSING_PACKAGES\" = true ]; then\n    echo \"Building Linux and macOS packages...\"\n    ./package.sh -p \"linux,darwin\" -a \"amd64,arm64\" -v \"$VERSION\"\nelse\n    echo \"Linux/macOS packages already exist, skipping...\"\nfi\n\n# 检查 Windows 包\nif [ ! -f \"gocron-package/gocron-${VERSION}-windows-amd64.zip\" ] || \\\n   [ ! -f \"gocron-node-package/gocron-node-windows-amd64.zip\" ]; then\n    echo \"Building Windows packages...\"\n    ./package.sh -p \"windows\" -a \"amd64\" -v \"$VERSION\"\nelse\n    echo \"Windows packages already exist, skipping...\"\nfi\necho \"✓ All packages built\"\necho \"\"\n\n# 4. 显示构建结果\necho \"4. Build summary:\"\necho \"\"\necho \"gocron packages:\"\nls -lh gocron-package/\necho \"\"\necho \"gocron-node packages:\"\nls -lh gocron-node-package/\necho \"\"\n\n# 5. 验证包内容\necho \"5. Verifying package contents...\"\nSAMPLE_PACKAGE=$(ls gocron-package/*.tar.gz 2>/dev/null | head -1)\nif [ -n \"$SAMPLE_PACKAGE\" ]; then\n    echo \"Checking: $SAMPLE_PACKAGE\"\n    tar tzf \"$SAMPLE_PACKAGE\" | head -5\n    echo \"✓ Package verified\"\nelse\n    SAMPLE_PACKAGE=$(ls gocron-package/*.zip 2>/dev/null | head -1)\n    if [ -n \"$SAMPLE_PACKAGE\" ]; then\n        echo \"Checking: $SAMPLE_PACKAGE\"\n        unzip -l \"$SAMPLE_PACKAGE\" | head -5\n        echo \"✓ Package verified\"\n    fi\nfi\necho \"\"\n\n# 6. 创建 Git tag\necho \"6. Creating Git tag...\"\nif git rev-parse \"$VERSION\" >/dev/null 2>&1; then\n    echo \"Tag $VERSION already exists\"\n    read -p \"Delete and recreate? (y/N): \" -n 1 -r\n    echo\n    if [[ $REPLY =~ ^[Yy]$ ]]; then\n        git tag -d \"$VERSION\"\n        git push origin \":refs/tags/$VERSION\" 2>/dev/null || true\n    else\n        echo \"Skipping tag creation\"\n    fi\nfi\n\nif ! git rev-parse \"$VERSION\" >/dev/null 2>&1; then\n    git tag -a \"$VERSION\" -m \"Release $VERSION\"\n    git push origin \"$VERSION\"\n    echo \"✓ Tag created and pushed\"\nelse\n    echo \"✓ Using existing tag\"\nfi\necho \"\"\n\n# 7. 创建 GitHub Release\necho \"7. Creating GitHub Release...\"\necho \"\"\n\nPRERELEASE_FLAG=\"\"\nif [ \"$PRERELEASE\" = true ]; then\n    PRERELEASE_FLAG=\"--prerelease\"\nfi\n\n# 生成 release notes\ncat > /tmp/release_notes.md <<EOF\n\nfeat: multi-tag support, comma-separated storage, el-select multi-picker, GET /api/task/tags\nfeat: per-task log cleanup, POST /api/task/log/clear/:id, batch delete, admin-only\nfeat: task-level log retention, log_retention_days field, excludes custom tasks from global cleanup\nfix: go run AppDir detection for dev environment\nfix: el-alert \\\\n literal rendering, use v-html with <br>\nchore: bump to v1.5.8 with migration 158\n\nEOF\n\n# 检查 gh CLI 是否安装\nif ! command -v gh &> /dev/null; then\n    echo \"Error: GitHub CLI (gh) is not installed\"\n    echo \"Install it from: https://cli.github.com/\"\n    echo \"\"\n    echo \"Packages are ready in:\"\n    echo \"  - gocron-package/\"\n    echo \"  - gocron-node-package/\"\n    echo \"\"\n    echo \"You can manually create a release on GitHub and upload these files.\"\n    exit 1\nfi\n\n# 创建 release\ngh release create \"$VERSION\" \\\n    --title \"Release $VERSION\" \\\n    --notes-file /tmp/release_notes.md \\\n    $PRERELEASE_FLAG \\\n    gocron-package/*.tar.gz \\\n    gocron-package/*.zip \\\n    gocron-node-package/*.tar.gz \\\n    gocron-node-package/*.zip\n\necho \"\"\necho \"==========================================\"\necho \"✅ Release $VERSION created successfully!\"\necho \"==========================================\"\necho \"\"\necho \"View release: https://github.com/$(git config --get remote.origin.url | sed 's/.*github.com[:/]\\(.*\\)\\.git/\\1/')/releases/tag/$VERSION\"\n"
  },
  {
    "path": "test_windows_cmd.go",
    "content": "//go:build ignore\n// +build ignore\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os/exec\"\n)\n\nfunc main() {\n\t// 测试不同的命令构造方式\n\tcommand := `dir \"C:\\Program Files (x86)\"`\n\n\tfmt.Println(\"原始命令:\", command)\n\tfmt.Println()\n\n\t// 方式1: 直接传递\n\tfmt.Println(\"方式1: cmd /C command\")\n\tcmd1 := exec.Command(\"cmd\", \"/C\", command)\n\tfmt.Printf(\"  Args: %#v\\n\", cmd1.Args)\n\tfmt.Printf(\"  String: %s\\n\\n\", cmd1.String())\n\n\t// 方式2: 使用 /S /C \"command\"\n\tfmt.Println(\"方式2: cmd /S /C \\\"command\\\"\")\n\twrappedCommand := `\"` + command + `\"`\n\tcmd2 := exec.Command(\"cmd\", \"/S\", \"/C\", wrappedCommand)\n\tfmt.Printf(\"  Args: %#v\\n\", cmd2.Args)\n\tfmt.Printf(\"  String: %s\\n\\n\", cmd2.String())\n\n\t// 方式3: 使用 /c 和完整引号包裹\n\tfmt.Println(\"方式3: cmd /c \\\"command\\\"\")\n\tcmd3 := exec.Command(\"cmd\", \"/c\", `\"`+command+`\"`)\n\tfmt.Printf(\"  Args: %#v\\n\", cmd3.Args)\n\tfmt.Printf(\"  String: %s\\n\\n\", cmd3.String())\n}\n"
  },
  {
    "path": "web/vue/.editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 2\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n"
  },
  {
    "path": "web/vue/.gitattributes",
    "content": "# Auto detect text files and perform LF normalization\n* text=auto\n\n# Source code\n*.js text eol=lf\n*.vue text eol=lf\n*.json text eol=lf\n*.css text eol=lf\n*.scss text eol=lf\n*.md text eol=lf\n\n# Binary files\n*.png binary\n*.jpg binary\n*.jpeg binary\n*.gif binary\n*.ico binary\n*.woff binary\n*.woff2 binary\n*.ttf binary\n*.eot binary\n"
  },
  {
    "path": "web/vue/.gitignore",
    "content": ".DS_Store\nnode_modules/\n/dist/\n.vite/\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Editor directories and files\n.idea\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n"
  },
  {
    "path": "web/vue/.prettierrc.json",
    "content": "{\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"printWidth\": 100,\n  \"trailingComma\": \"none\",\n  \"arrowParens\": \"avoid\"\n}\n"
  },
  {
    "path": "web/vue/README.md",
    "content": "# gocron\n\n> 分布式定时任务管理系统\n\n## Build Setup\n\n``` bash\n# install dependencies\nyarn install\n\n# serve with hot reload at localhost:8080\nyarn run dev\n\n# build for production with minification\nyarn run build\n\n# build for production and view the bundle analyzer report\nyarn run build --report\n```\n\nFor a detailed explanation on how things work, check out the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader).\n"
  },
  {
    "path": "web/vue/eslint.config.js",
    "content": "import js from '@eslint/js'\nimport pluginVue from 'eslint-plugin-vue'\n\nexport default [\n  js.configs.recommended,\n  ...pluginVue.configs['flat/recommended'],\n  {\n    languageOptions: {\n      ecmaVersion: 'latest',\n      sourceType: 'module',\n      globals: {\n        window: 'readonly',\n        document: 'readonly',\n        navigator: 'readonly',\n        console: 'readonly',\n        setTimeout: 'readonly',\n        clearTimeout: 'readonly',\n        setInterval: 'readonly',\n        clearInterval: 'readonly',\n        fetch: 'readonly',\n        URL: 'readonly',\n        URLSearchParams: 'readonly',\n        FormData: 'readonly',\n        Blob: 'readonly',\n        File: 'readonly',\n        process: 'readonly',\n        performance: 'readonly',\n        AbortController: 'readonly',\n        __dirname: 'readonly',\n        alert: 'readonly',\n        confirm: 'readonly',\n        localStorage: 'readonly',\n        sessionStorage: 'readonly',\n        history: 'readonly',\n        location: 'readonly',\n        btoa: 'readonly',\n        atob: 'readonly',\n        crypto: 'readonly',\n        structuredClone: 'readonly'\n      }\n    },\n    rules: {\n      'vue/multi-word-component-names': 'off',\n      'no-unused-vars': 'warn',\n      'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',\n      'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',\n      'vue/no-unused-components': 'warn',\n      'vue/require-default-prop': 'off'\n    }\n  },\n  {\n    ignores: ['dist/**', 'node_modules/**']\n  }\n]\n"
  },
  {
    "path": "web/vue/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">\n    <meta name=\"robots\" content=\"noindex, nofollow\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <link rel=\"icon\" href=\"/favicon.ico\">\n    <title>gocron - 分布式定时任务系统</title>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "web/vue/jsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es6\",\n    \"module\": \"es6\",\n    \"allowSyntheticDefaultImports\": true,\n    \"baseUrl\": \"./\",\n    \"paths\": {\n      \"@/*\": [\"src/*\"]\n    }\n  },\n  \"exclude\": [\"node_modules\", \"dist\"],\n  \"include\": [\"src/**/*\"]\n}\n"
  },
  {
    "path": "web/vue/package.json",
    "content": "{\n  \"name\": \"gocron\",\n  \"version\": \"2.0.0\",\n  \"description\": \"定时任务管理系统\",\n  \"author\": \"gocronx <gocronx@gmail.com>\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"build:analyze\": \"vite build --mode analyze\",\n    \"preview\": \"vite preview\",\n    \"lint\": \"eslint . --fix\",\n    \"lint:check\": \"eslint .\",\n    \"test\": \"vitest\",\n    \"test:ui\": \"vitest --ui\"\n  },\n  \"dependencies\": {\n    \"@element-plus/icons-vue\": \"^2.3.2\",\n    \"@vueuse/core\": \"^14.2.1\",\n    \"axios\": \"^1.15.0\",\n    \"dayjs\": \"^1.11.20\",\n    \"element-plus\": \"^2.13.5\",\n    \"nprogress\": \"^0.2.0\",\n    \"pinia\": \"^3.0.4\",\n    \"pinia-plugin-persistedstate\": \"^4.7.1\",\n    \"qs\": \"^6.15.0\",\n    \"vue\": \"^3.5.30\",\n    \"vue-i18n\": \"^9.14.5\",\n    \"vue-router\": \"^4.6.4\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.4\",\n    \"@vitejs/plugin-vue\": \"^6.0.4\",\n    \"@vitest/ui\": \"^3.2.4\",\n    \"@vue/test-utils\": \"^2.4.6\",\n    \"eslint\": \"^9.39.4\",\n    \"eslint-plugin-vue\": \"^10.8.0\",\n    \"jsdom\": \"^27.4.0\",\n    \"prettier\": \"^3.8.1\",\n    \"unplugin-auto-import\": \"^20.3.0\",\n    \"unplugin-vue-components\": \"^30.0.0\",\n    \"vite\": \"^7.3.2\",\n    \"vite-plugin-compression\": \"^0.5.1\",\n    \"vitest\": \"^3.2.4\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"flatted\": \">=3.4.2\",\n      \"picomatch\": \">=4.0.4\",\n      \"brace-expansion\": \"2.0.3\",\n      \"lodash\": \">=4.18.0\",\n      \"lodash-es\": \">=4.18.0\",\n      \"defu\": \">=6.1.5\",\n      \"follow-redirects\": \">=1.16.0\"\n    }\n  }\n}\n"
  },
  {
    "path": "web/vue/src/App.vue",
    "content": "<template>\n  <el-container style=\"height: 100vh\">\n    <app-sidebar v-if=\"userStore.isLogin\" />\n    <el-container style=\"flex-direction: column\">\n      <el-header\n        v-if=\"userStore.isLogin\"\n        height=\"60px\"\n      >\n        <app-header />\n      </el-header>\n      <el-main style=\"padding: 0; overflow-y: auto\">\n        <div\n          v-cloak\n          id=\"main-container\"\n        >\n          <el-config-provider :locale=\"activeLang\">\n            <router-view v-slot=\"{ Component }\">\n              <keep-alive>\n                <component :is=\"Component\" />\n              </keep-alive>\n            </router-view>\n          </el-config-provider>\n        </div>\n      </el-main>\n    </el-container>\n  </el-container>\n</template>\n\n<script setup>\nimport { computed, onMounted } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { useUserStore } from './stores/user'\nimport installService from './api/install'\nimport appHeader from './components/common/header.vue'\nimport appSidebar from './components/common/sidebar.vue'\nimport { ElConfigProvider } from 'element-plus'\nimport zhCN from 'element-plus/es/locale/lang/zh-cn'\nimport en from 'element-plus/es/locale/lang/en'\nimport { useI18n } from 'vue-i18n'\nimport { availableLanguages } from './const/index'\n\nconst { locale } = useI18n()\nconst router = useRouter()\nconst userStore = useUserStore()\n\nconst activeLang = computed(() => {\n  switch (locale.value) {\n    case availableLanguages.enUS.value:\n      return en\n    case availableLanguages.zhCN.value:\n      return zhCN\n    default:\n      return zhCN\n  }\n})\n\nonMounted(() => {\n  installService.status(data => {\n    if (!data) {\n      router.push('/install')\n    }\n  })\n})\n</script>\n\n<style>\n[v-cloak] {\n  display: none !important;\n}\nhtml,\nbody {\n  margin: 0;\n  padding: 0;\n  height: 100%;\n  overflow: hidden;\n}\n.el-header {\n  padding: 0;\n  margin: 0;\n}\n.el-container {\n  padding: 0;\n  margin: 0;\n  width: 100%;\n}\n.el-main {\n  padding: 20px;\n  margin: 0;\n  background-color: #f5f7fa;\n}\n#main-container {\n  height: 100%;\n}\n\n#main-container .el-container {\n  height: 100%;\n  background-color: transparent;\n}\n\n#main-container .el-main {\n  height: auto;\n  overflow-y: auto;\n}\n\n.custom-message-box {\n  min-width: 420px;\n}\n.custom-message-box .el-message-box__message {\n  font-size: 15px;\n  line-height: 1.6;\n}\n.el-message-box__title {\n  font-size: 18px;\n  font-weight: 600;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/api/agent.js",
    "content": "import httpClient from '../utils/httpClient'\n\nexport default {\n  generateToken (callback) {\n    httpClient.post('/agent/generate-token', {}, callback)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/api/audit.js",
    "content": "import httpClient from '../utils/httpClient'\n\nexport default {\n  list (query, callback) {\n    httpClient.get('/audit', query, callback)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/api/host.js",
    "content": "import httpClient from '../utils/httpClient'\n\nexport default {\n  // 任务列表\n  list (query, callback) {\n    httpClient.get('/host', query, callback)\n  },\n\n  all (query, callback) {\n    httpClient.get('/host/all', {}, callback)\n  },\n\n  detail (id, callback) {\n    httpClient.get(`/host/${id}`, {}, callback)\n  },\n\n  update (data, callback) {\n    httpClient.post('/host/store', data, callback)\n  },\n\n  remove (id, callback) {\n    httpClient.post(`/host/remove/${id}`, {}, callback)\n  },\n\n  ping (id, callback) {\n    httpClient.get(`/host/ping/${id}`, {}, callback)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/api/install.js",
    "content": "import httpClient from '../utils/httpClient'\n\nexport default {\n  store (data, callback) {\n    httpClient.post('/install/store', data, callback)\n  },\n  status (callback) {\n    httpClient.get('/install/status', {}, callback)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/api/notification.js",
    "content": "import httpClient from '../utils/httpClient'\n\nexport default {\n  slack(callback) {\n    httpClient.get('/system/slack', {}, callback)\n  },\n  updateSlack(data, callback) {\n    httpClient.post('/system/slack/update', data, callback)\n  },\n  createSlackChannel(channel, callback) {\n    httpClient.post('/system/slack/channel', { channel }, callback)\n  },\n  removeSlackChannel(channelId, callback) {\n    httpClient.post(`/system/slack/channel/remove/${channelId}`, {}, callback)\n  },\n  mail(callback) {\n    httpClient.get('/system/mail', {}, callback)\n  },\n  updateMail(data, callback) {\n    httpClient.post('/system/mail/update', data, callback)\n  },\n  createMailUser(data, callback) {\n    httpClient.post('/system/mail/user', data, callback)\n  },\n  removeMailUser(userId, callback) {\n    httpClient.post(`/system/mail/user/remove/${userId}`, {}, callback)\n  },\n  webhook(callback) {\n    httpClient.get('/system/webhook', {}, callback)\n  },\n  updateWebHook(data, callback) {\n    httpClient.post('/system/webhook/update', data, callback)\n  },\n  createWebhookUrl(data, callback) {\n    httpClient.post('/system/webhook/url', data, callback)\n  },\n  removeWebhookUrl(urlId, callback) {\n    httpClient.post(`/system/webhook/url/remove/${urlId}`, {}, callback)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/api/statistics.js",
    "content": "import httpClient from '../utils/httpClient'\n\nexport default {\n  getOverview (callback) {\n    httpClient.get('/statistics/overview', {}, callback)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/api/system.js",
    "content": "import httpClient from '../utils/httpClient'\n\nexport default {\n  loginLogList (query, callback) {\n    httpClient.get('/system/login-log', query, callback)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/api/task.js",
    "content": "import httpClient from '../utils/httpClient'\n\nexport default {\n  list(query, callback) {\n    httpClient.batchGet([{ uri: '/task', params: query }, { uri: '/host/all' }], callback)\n  },\n\n  detail(id, callback) {\n    if (!id) {\n      httpClient.get('/host/all', {}, hosts => {\n        callback(null, hosts)\n      })\n      return\n    }\n    httpClient.batchGet([{ uri: `/task/${id}` }, { uri: '/host/all' }], callback)\n  },\n\n  update(data, callback) {\n    httpClient.post('/task/store', data, callback)\n  },\n\n  remove(id, callback) {\n    httpClient.post(`/task/remove/${id}`, {}, callback)\n  },\n\n  enable(id, callback) {\n    httpClient.post(`/task/enable/${id}`, {}, callback)\n  },\n\n  disable(id, callback) {\n    httpClient.post(`/task/disable/${id}`, {}, callback)\n  },\n\n  run(id, callback) {\n    httpClient.get(`/task/run/${id}`, { _t: Date.now() }, callback)\n  },\n\n  allTags(callback) {\n    httpClient.get('/task/tags', {}, callback)\n  },\n\n  batchEnable(ids, callback) {\n    httpClient.postJson('/task/batch-enable', { ids }, callback)\n  },\n\n  batchDisable(ids, callback) {\n    httpClient.postJson('/task/batch-disable', { ids }, callback)\n  },\n\n  batchRemove(ids, callback) {\n    httpClient.postJson('/task/batch-remove', { ids }, callback)\n  },\n\n  /**\n   * 预览 cron 表达式：接下来 N 次执行时间 + 未来 7 天分布热图\n   * @param {{spec: string, timezone?: string, count?: number}} params\n   */\n  cronPreview(params, callback) {\n    httpClient.postJson('/task/cron-preview', params, callback)\n  },\n\n  versions(taskId, params, callback) {\n    httpClient.get(`/task/versions/${taskId}`, params, callback)\n  },\n\n  versionDetail(taskId, versionId, callback) {\n    httpClient.get(`/task/versions/${taskId}/${versionId}`, {}, callback)\n  },\n\n  versionRollback(taskId, versionId, callback) {\n    httpClient.post(`/task/versions/${taskId}/${versionId}/rollback`, {}, callback)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/api/taskLog.js",
    "content": "import httpClient from '../utils/httpClient'\n\nexport default {\n  list (query, callback) {\n    httpClient.get('/task/log', query, callback)\n  },\n\n  clear (callback) {\n    httpClient.post('/task/log/clear', {}, callback)\n  },\n\n  stop (id, taskId, callback) {\n    httpClient.post('/task/log/stop', {id, task_id: taskId}, callback)\n  },\n\n  clearByTaskId (taskId, callback) {\n    httpClient.post(`/task/log/clear/${taskId}`, {}, callback)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/api/template.js",
    "content": "import httpClient from '../utils/httpClient'\n\nexport default {\n  list(query, callback) {\n    httpClient.get('/template', query, callback)\n  },\n\n  categories(callback) {\n    httpClient.get('/template/categories', {}, callback)\n  },\n\n  detail(id, callback) {\n    httpClient.get(`/template/${id}`, {}, callback)\n  },\n\n  store(data, callback) {\n    httpClient.post('/template/store', data, callback)\n  },\n\n  remove(id, callback) {\n    httpClient.post(`/template/remove/${id}`, {}, callback)\n  },\n\n  apply(id, callback) {\n    httpClient.post(`/template/apply/${id}`, {}, callback)\n  },\n\n  saveFromTask(data, callback) {\n    httpClient.post('/template/save-from-task', data, callback)\n  },\n}\n"
  },
  {
    "path": "web/vue/src/api/user.js",
    "content": "import httpClient from '../utils/httpClient'\n\nexport default {\n  list (query, callback) {\n    httpClient.get('/user', {}, callback)\n  },\n\n  detail (id, callback) {\n    httpClient.get(`/user/${id}`, {}, callback)\n  },\n\n  update (data, callback) {\n    httpClient.post('/user/store', data, callback)\n  },\n\n  login (username, password, twoFactorCode, callback, errorCallback) {\n    const data = { username, password }\n    if (twoFactorCode) {\n      data.two_factor_code = twoFactorCode\n    }\n    httpClient.post('/user/login', data, callback, errorCallback)\n  },\n\n  enable (id, callback) {\n    httpClient.post(`/user/enable/${id}`, {}, callback)\n  },\n\n  disable (id, callback) {\n    httpClient.post(`/user/disable/${id}`, {}, callback)\n  },\n\n  remove (id, callback) {\n    httpClient.post(`/user/remove/${id}`, {}, callback)\n  },\n\n  editPassword (data, callback) {\n    httpClient.post(`/user/editPassword/${data.id}`, {\n      'new_password': data.new_password,\n      'confirm_new_password': data.confirm_new_password\n    }, callback)\n  },\n\n  editMyPassword (data, callback) {\n    httpClient.post(`/user/editMyPassword`, data, callback)\n  },\n\n  get2FAStatus (callback) {\n    httpClient.get('/user/2fa/status', {}, callback)\n  },\n\n  setup2FA (callback) {\n    httpClient.get('/user/2fa/setup', {}, callback)\n  },\n\n  enable2FA (secret, code, callback) {\n    httpClient.post('/user/2fa/enable', { secret, code }, callback)\n  },\n\n  disable2FA (code, callback, errorCallback) {\n    httpClient.post('/user/2fa/disable', { code }, callback, errorCallback)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/components/common/CronInput.vue",
    "content": "<template>\n  <el-input\n    v-model.trim=\"innerValue\"\n    :placeholder=\"t('task.cronPlaceholder')\"\n    @input=\"onInput\">\n    <template #append>\n      <el-popover\n        placement=\"bottom\"\n        :width=\"500\"\n        trigger=\"click\">\n        <template #reference>\n          <el-button>{{ t('task.cronExample') }}</el-button>\n        </template>\n        <div>\n          <h4>{{ t('task.cronStandard') }}</h4>\n          <ul style=\"padding-left: 20px; margin: 10px 0;\">\n            <li>0 * * * * * - {{ t('message.everyMinute') }}</li>\n            <li>*/20 * * * * * - {{ t('message.every20Seconds') }}</li>\n            <li>0 30 21 * * * - {{ t('message.everyDay21_30') }}</li>\n            <li>0 0 23 * * 6 - {{ t('message.everySaturday23') }}</li>\n          </ul>\n          <h4>{{ t('task.cronShortcut') }}</h4>\n          <ul style=\"padding-left: 20px; margin: 10px 0;\">\n            <li>@reboot - {{ t('message.reboot') }}</li>\n            <li>@yearly - {{ t('message.yearly') }}</li>\n            <li>@monthly - {{ t('message.monthly') }}</li>\n            <li>@weekly - {{ t('message.weekly') }}</li>\n            <li>@daily - {{ t('message.daily') }}</li>\n            <li>@hourly - {{ t('message.hourly') }}</li>\n            <li>@every 30s - {{ t('message.every30s') }}</li>\n            <li>@every 1m20s - {{ t('message.every1m20s') }}</li>\n          </ul>\n        </div>\n      </el-popover>\n    </template>\n  </el-input>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\n\nexport default {\n  name: 'CronInput',\n  props: {\n    modelValue: {\n      type: String,\n      default: ''\n    }\n  },\n  emits: ['update:modelValue'],\n  setup() {\n    const { t } = useI18n()\n    return { t }\n  },\n  computed: {\n    innerValue: {\n      get() {\n        return this.modelValue\n      },\n      set(val) {\n        this.$emit('update:modelValue', val)\n      }\n    }\n  },\n  methods: {\n    onInput(val) {\n      this.$emit('update:modelValue', val)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "web/vue/src/components/common/CronPreview.vue",
    "content": "<template>\n  <div\n    class=\"cron-preview\"\n    :class=\"{ 'is-invalid': !!displayError, 'is-loading': loading }\"\n  >\n    <!-- 无内容（刚挂载、spec 空）-->\n    <div\n      v-if=\"isEmpty\"\n      class=\"empty-state\"\n    >\n      <span>{{ t('cronPreview.waitingInput') }}</span>\n    </div>\n\n    <!-- 错误态 -->\n    <div\n      v-else-if=\"displayError\"\n      class=\"error-state\"\n    >\n      <el-icon><WarningFilled /></el-icon>\n      <span>{{ displayError }}</span>\n    </div>\n\n    <!-- 正常态（含 optimistic UI：loading 时仍展示上一次结果） -->\n    <template v-else>\n      <div\n        v-if=\"loading\"\n        class=\"loading-hint\"\n      >\n        <el-icon class=\"is-loading\">\n          <Loading />\n        </el-icon>\n        <span>{{ t('cronPreview.computing') }}</span>\n      </div>\n\n      <!-- 接下来 N 次 -->\n      <div class=\"section next-runs\">\n        <div class=\"section-title\">\n          <el-icon><Clock /></el-icon>\n          <span>{{ t('cronPreview.nextRuns', { count: result.next_runs.length }) }}</span>\n          <span\n            v-if=\"result.timezone\"\n            class=\"tz-badge\"\n          >{{ result.timezone }}</span>\n        </div>\n        <ul\n          v-if=\"result.next_runs.length > 0\"\n          class=\"run-list\"\n        >\n          <li\n            v-for=\"(run, idx) in result.next_runs\"\n            :key=\"run.unix\"\n          >\n            <span class=\"idx\">#{{ idx + 1 }}</span>\n            <span class=\"ts\">{{ formatRun(run) }}</span>\n            <span class=\"rel\">{{ relativeTime(run.unix) }}</span>\n          </li>\n        </ul>\n        <div\n          v-else\n          class=\"no-runs\"\n        >\n          {{ t('cronPreview.noUpcomingRuns') }}\n        </div>\n      </div>\n\n      <!-- 热图 -->\n      <div class=\"section heatmap\">\n        <div class=\"section-title\">\n          <el-icon><DataAnalysis /></el-icon>\n          <span>{{ t('cronPreview.weeklyDistribution') }}</span>\n          <el-tag\n            v-if=\"result.truncated\"\n            size=\"small\"\n            type=\"warning\"\n            effect=\"plain\"\n          >\n            {{ t('cronPreview.truncated') }}\n          </el-tag>\n        </div>\n        <HeatmapSvg\n          :cells=\"result.heatmap_cells || []\"\n          :truncated=\"!!result.truncated\"\n        />\n      </div>\n    </template>\n  </div>\n</template>\n\n<script setup>\nimport { ref, watch, computed, onBeforeUnmount } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { WarningFilled, Clock, Loading, DataAnalysis } from '@element-plus/icons-vue'\nimport dayjs from 'dayjs'\nimport HeatmapSvg from './HeatmapSvg.vue'\nimport taskApi from '@/api/task'\n\nconst { t, locale } = useI18n()\n\nconst props = defineProps({\n  spec: { type: String, default: '' },\n  timezone: { type: String, default: '' }\n})\n\n// ====== 前端快速校验：明显不像 cron 的输入不走后端 ======\n// 允许 @ 快捷 或 5-6 段的 [数字 * / , - ? L W #] 组合\nconst ROUGH_CRON_RE = /^(@[a-zA-Z]+(\\s+[\\d\\smhs]+)?|[\\d*\\-,/?LW# ]{3,100})$/\n\n// ====== 状态 ======\nconst loading = ref(false)\nconst result = ref({ next_runs: [], heatmap_cells: [], timezone: '', truncated: false })\nconst errorMsg = ref('')\nconst hasFetchedOnce = ref(false)\n\n// 轻量内存 cache：同一 (spec, tz) 组合复用\nconst cache = new Map()\nconst CACHE_MAX = 32\n\nlet debounceTimer = null\nlet currentRequestId = 0\n\nconst isEmpty = computed(() => {\n  return !props.spec || props.spec.trim() === ''\n})\n\n// Optimistic UI：错误只在\"没有上一次成功结果\"时才独占显示\nconst displayError = computed(() => {\n  if (!errorMsg.value) return ''\n  if (hasFetchedOnce.value && result.value.next_runs.length > 0) {\n    // 上一次有结果 → 不显示大错，沉默回落\n    return ''\n  }\n  return errorMsg.value\n})\n\nfunction fetchPreview() {\n  const spec = (props.spec || '').trim()\n  if (!spec) {\n    errorMsg.value = ''\n    return\n  }\n\n  // 1. 前端快速初筛\n  if (!ROUGH_CRON_RE.test(spec)) {\n    errorMsg.value = t('cronPreview.invalidSyntax')\n    hasFetchedOnce.value = false\n    return\n  }\n\n  // 2. Cache 命中\n  const key = `${spec}|${props.timezone || ''}`\n  if (cache.has(key)) {\n    applyResult(cache.get(key))\n    return\n  }\n\n  // 3. 发请求\n  const reqId = ++currentRequestId\n  loading.value = true\n\n  taskApi.cronPreview(\n    { spec, timezone: props.timezone || '', count: 10 },\n    (data) => {\n      // 丢弃过期响应\n      if (reqId !== currentRequestId) return\n      loading.value = false\n      if (data === null || data === undefined) {\n        errorMsg.value = t('cronPreview.requestFailed')\n        return\n      }\n      // LRU-lite：超过上限删最早\n      if (cache.size >= CACHE_MAX) {\n        const firstKey = cache.keys().next().value\n        cache.delete(firstKey)\n      }\n      cache.set(key, data)\n      applyResult(data)\n    }\n  )\n}\n\nfunction applyResult(data) {\n  if (!data.valid) {\n    errorMsg.value = data.error || t('cronPreview.invalidSyntax')\n    return\n  }\n  errorMsg.value = ''\n  result.value = data\n  hasFetchedOnce.value = true\n}\n\nfunction scheduleFetch() {\n  if (debounceTimer) clearTimeout(debounceTimer)\n  debounceTimer = setTimeout(fetchPreview, 400)\n}\n\nwatch(\n  () => [props.spec, props.timezone],\n  () => scheduleFetch(),\n  { immediate: true }\n)\n\nonBeforeUnmount(() => {\n  if (debounceTimer) clearTimeout(debounceTimer)\n  // 作废所有在途请求\n  currentRequestId++\n})\n\n// ====== 展示工具函数 ======\nfunction formatRun(run) {\n  // run.iso 是 RFC3339 with timezone，用 dayjs 直接解析保留时区偏移\n  return dayjs(run.iso).format('YYYY-MM-DD HH:mm:ss') + ' ' + weekdayName(run.weekday)\n}\n\nfunction weekdayName(wd) {\n  const keys = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']\n  return t(`cronPreview.${keys[wd]}`)\n}\n\nfunction relativeTime(unix) {\n  const diffSec = unix - Math.floor(Date.now() / 1000)\n  if (diffSec < 0) return ''\n  if (diffSec < 60) return t('cronPreview.inSeconds', { n: diffSec })\n  const diffMin = Math.floor(diffSec / 60)\n  if (diffMin < 60) return t('cronPreview.inMinutes', { n: diffMin })\n  const diffH = Math.floor(diffMin / 60)\n  const remM = diffMin % 60\n  if (diffH < 24) return remM > 0\n    ? t('cronPreview.inHoursMinutes', { h: diffH, m: remM })\n    : t('cronPreview.inHours', { n: diffH })\n  const diffD = Math.floor(diffH / 24)\n  const remH = diffH % 24\n  return remH > 0\n    ? t('cronPreview.inDaysHours', { d: diffD, h: remH })\n    : t('cronPreview.inDays', { n: diffD })\n}\n</script>\n\n<style scoped>\n.cron-preview {\n  border: 1px solid #e4e7ed;\n  border-radius: 6px;\n  padding: 12px 16px;\n  background: #fafbfc;\n  min-height: 80px;\n  transition: border-color 0.2s;\n  position: relative;\n}\n.cron-preview.is-invalid {\n  border-color: #f56c6c;\n  background: #fff5f5;\n}\n.cron-preview.is-loading {\n  opacity: 0.92;\n}\n\n.empty-state, .error-state, .no-runs {\n  color: #909399;\n  font-size: 13px;\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n.error-state {\n  color: #f56c6c;\n}\n\n.loading-hint {\n  position: absolute;\n  top: 8px;\n  right: 12px;\n  font-size: 12px;\n  color: #909399;\n  display: flex;\n  align-items: center;\n  gap: 4px;\n}\n.loading-hint .is-loading {\n  animation: rotating 1.2s linear infinite;\n}\n@keyframes rotating {\n  from { transform: rotate(0deg); }\n  to { transform: rotate(360deg); }\n}\n\n.section {\n  margin-top: 8px;\n}\n.section:first-child {\n  margin-top: 0;\n}\n.section-title {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  font-size: 13px;\n  font-weight: 500;\n  color: #303133;\n  margin-bottom: 8px;\n}\n.tz-badge {\n  font-size: 11px;\n  color: #909399;\n  background: #ebeef5;\n  padding: 1px 6px;\n  border-radius: 3px;\n  font-weight: normal;\n}\n\n.run-list {\n  list-style: none;\n  padding: 0;\n  margin: 0;\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 4px 16px;\n}\n.run-list li {\n  display: flex;\n  align-items: baseline;\n  gap: 8px;\n  font-size: 13px;\n  font-variant-numeric: tabular-nums;\n  color: #606266;\n}\n.run-list .idx {\n  color: #a8abb2;\n  font-size: 11px;\n  min-width: 22px;\n}\n.run-list .ts {\n  color: #303133;\n}\n.run-list .rel {\n  margin-left: auto;\n  color: #909399;\n  font-size: 12px;\n}\n\n@media (max-width: 768px) {\n  .run-list {\n    grid-template-columns: 1fr;\n  }\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/components/common/HeatmapSvg.vue",
    "content": "<template>\n  <div class=\"heatmap-svg\">\n    <svg\n      :width=\"totalWidth\"\n      :height=\"totalHeight\"\n      :viewBox=\"`0 0 ${totalWidth} ${totalHeight}`\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      role=\"img\"\n      :aria-label=\"t('cronPreview.heatmapAria')\"\n    >\n      <!-- 顶部小时刻度 -->\n      <g>\n        <text\n          v-for=\"h in hourLabels\"\n          :key=\"`h-${h.value}`\"\n          :x=\"labelWidth + h.value * cellSize + cellSize / 2\"\n          :y=\"topLabelHeight - 4\"\n          text-anchor=\"middle\"\n          class=\"label\"\n        >\n          {{ h.value }}\n        </text>\n      </g>\n      <!-- 左侧星期标签 + 格子 -->\n      <g\n        v-for=\"(day, rowIdx) in dayLabels\"\n        :key=\"`d-${day.value}`\"\n      >\n        <text\n          :x=\"labelWidth - 6\"\n          :y=\"topLabelHeight + rowIdx * cellSize + cellSize / 2 + 4\"\n          text-anchor=\"end\"\n          class=\"label\"\n        >\n          {{ day.label }}\n        </text>\n        <rect\n          v-for=\"hour in 24\"\n          :key=\"`c-${day.value}-${hour - 1}`\"\n          :x=\"labelWidth + (hour - 1) * cellSize\"\n          :y=\"topLabelHeight + rowIdx * cellSize\"\n          :width=\"cellSize - cellGap\"\n          :height=\"cellSize - cellGap\"\n          :fill=\"cellColor(day.value, hour - 1)\"\n          rx=\"2\"\n        >\n          <title>{{ cellTitle(day.value, hour - 1) }}</title>\n        </rect>\n      </g>\n    </svg>\n    <div\n      v-if=\"empty\"\n      class=\"empty-hint\"\n    >\n      {{ t('cronPreview.heatmapEmpty') }}\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\n\nconst { t } = useI18n()\n\nconst props = defineProps({\n  // 后端返回的稀疏格式：[{day: 0-6, hour: 0-23, count: n}]\n  cells: {\n    type: Array,\n    default: () => []\n  },\n  truncated: {\n    type: Boolean,\n    default: false\n  }\n})\n\nconst cellSize = 14\nconst cellGap = 2\nconst labelWidth = 36\nconst topLabelHeight = 20\n\nconst totalWidth = labelWidth + 24 * cellSize + 4\nconst totalHeight = topLabelHeight + 7 * cellSize + 4\n\n// 星期标签：周一到周日（符合 ISO 习惯，顶部显示周一）\nconst dayLabels = computed(() => {\n  const labels = [\n    { value: 1, label: t('cronPreview.monAbbr') },\n    { value: 2, label: t('cronPreview.tueAbbr') },\n    { value: 3, label: t('cronPreview.wedAbbr') },\n    { value: 4, label: t('cronPreview.thuAbbr') },\n    { value: 5, label: t('cronPreview.friAbbr') },\n    { value: 6, label: t('cronPreview.satAbbr') },\n    { value: 0, label: t('cronPreview.sunAbbr') }\n  ]\n  return labels\n})\n\n// 顶部只显示 0/6/12/18，其他留空，避免拥挤\nconst hourLabels = computed(() => {\n  return [0, 6, 12, 18].map((v) => ({ value: v }))\n})\n\n// 查表：day-hour -> count\nconst countMap = computed(() => {\n  const m = new Map()\n  for (const c of props.cells) {\n    m.set(`${c.day}-${c.hour}`, c.count)\n  }\n  return m\n})\n\nconst maxCount = computed(() => {\n  let max = 0\n  for (const c of props.cells) {\n    if (c.count > max) max = c.count\n  }\n  return max\n})\n\nconst empty = computed(() => props.cells.length === 0)\n\nfunction cellColor(day, hour) {\n  const count = countMap.value.get(`${day}-${hour}`) || 0\n  if (count === 0) return '#f0f2f5'\n  // 4 档橘色渐变\n  const max = maxCount.value || 1\n  const ratio = count / max\n  if (ratio > 0.66) return '#d97706'\n  if (ratio > 0.33) return '#f59e0b'\n  if (ratio > 0.1) return '#fbbf24'\n  return '#fde68a'\n}\n\nfunction cellTitle(day, hour) {\n  const count = countMap.value.get(`${day}-${hour}`) || 0\n  const dayName = dayLabels.value.find(d => d.value === day)?.label || ''\n  if (count === 0) {\n    return `${dayName} ${hour}:00 - ${t('cronPreview.noRuns')}`\n  }\n  return `${dayName} ${hour}:00 - ${count} ${t('cronPreview.runs')}`\n}\n</script>\n\n<style scoped>\n.heatmap-svg {\n  position: relative;\n  display: inline-block;\n}\n.heatmap-svg .label {\n  font-size: 10px;\n  fill: #64748b;\n  font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;\n}\n.empty-hint {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  color: #94a3b8;\n  font-size: 12px;\n  pointer-events: none;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/components/common/LanguageSwitcher.vue",
    "content": "<template>\n  <el-dropdown @command=\"handleCommand\">\n    <span class=\"language-switcher\"> 🌐 {{ currentLanguage }} </span>\n    <template #dropdown>\n      <el-dropdown-menu>\n        <el-dropdown-item\n          v-for=\"lang in availableLanguages\"\n          :key=\"lang.value\"\n          :command=\"lang.value\"\n          :disabled=\"locale === lang.value\"\n        >\n          {{ lang.label }}\n        </el-dropdown-item>\n      </el-dropdown-menu>\n    </template>\n  </el-dropdown>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { availableLanguages } from '@/const/index'\n\nconst { locale } = useI18n()\n\nconst currentLanguage = computed(() => {\n  // 根据 locale.value 查找对应的语言标签\n  const lang = Object.values(availableLanguages).find(l => l.value === locale.value)\n  return lang ? lang.label : availableLanguages.zhCN.label\n})\n\nconst handleCommand = command => {\n  locale.value = command\n  localStorage.setItem('locale', command)\n}\n</script>\n\n<style scoped>\n.language-switcher {\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 0 12px;\n  font-size: 14px;\n  white-space: nowrap;\n}\n\n.language-switcher:hover {\n  color: #409eff;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/components/common/MonacoEditor.vue",
    "content": "<template>\n  <div class=\"code-editor-wrapper\">\n    <div class=\"line-numbers\" ref=\"lineNumbers\">\n      <span v-for=\"n in lineCount\" :key=\"n\">{{ n }}</span>\n    </div>\n    <textarea\n      ref=\"textarea\"\n      class=\"code-textarea\"\n      :value=\"modelValue\"\n      :readonly=\"readOnly\"\n      :style=\"{ height: height }\"\n      spellcheck=\"false\"\n      autocomplete=\"off\"\n      autocorrect=\"off\"\n      autocapitalize=\"off\"\n      @input=\"onInput\"\n      @scroll=\"syncScroll\"\n      @keydown=\"onKeydown\"\n    ></textarea>\n  </div>\n</template>\n\n<script>\nexport default {\n  name: 'MonacoEditor',\n  props: {\n    modelValue: {\n      type: String,\n      default: ''\n    },\n    language: {\n      type: String,\n      default: 'shell'\n    },\n    height: {\n      type: String,\n      default: '200px'\n    },\n    readOnly: {\n      type: Boolean,\n      default: false\n    }\n  },\n  emits: ['update:modelValue'],\n  computed: {\n    lineCount() {\n      if (!this.modelValue) return 1\n      return this.modelValue.split('\\n').length\n    }\n  },\n  methods: {\n    onInput(e) {\n      this.$emit('update:modelValue', e.target.value)\n    },\n    syncScroll() {\n      if (this.$refs.lineNumbers && this.$refs.textarea) {\n        this.$refs.lineNumbers.scrollTop = this.$refs.textarea.scrollTop\n      }\n    },\n    onKeydown(e) {\n      // Tab 键插入两个空格\n      if (e.key === 'Tab') {\n        e.preventDefault()\n        const textarea = e.target\n        const start = textarea.selectionStart\n        const end = textarea.selectionEnd\n        const value = textarea.value\n        textarea.value = value.substring(0, start) + '  ' + value.substring(end)\n        textarea.selectionStart = textarea.selectionEnd = start + 2\n        this.$emit('update:modelValue', textarea.value)\n      }\n    }\n  }\n}\n</script>\n\n<style scoped>\n.code-editor-wrapper {\n  display: flex;\n  border: 1px solid #dcdfe6;\n  border-radius: 4px;\n  overflow: hidden;\n  background: #1e1e1e;\n}\n\n.line-numbers {\n  padding: 10px 0;\n  background: #2d2d2d;\n  color: #858585;\n  font-family: 'Menlo', 'Monaco', 'Consolas', 'Courier New', monospace;\n  font-size: 13px;\n  line-height: 1.6;\n  text-align: right;\n  user-select: none;\n  overflow: hidden;\n  min-width: 40px;\n}\n\n.line-numbers span {\n  display: block;\n  padding: 0 8px;\n}\n\n.code-textarea {\n  flex: 1;\n  padding: 10px 12px;\n  border: none;\n  outline: none;\n  resize: none;\n  background: #1e1e1e;\n  color: #d4d4d4;\n  font-family: 'Menlo', 'Monaco', 'Consolas', 'Courier New', monospace;\n  font-size: 13px;\n  line-height: 1.6;\n  tab-size: 2;\n  white-space: pre;\n  overflow: auto;\n}\n\n.code-textarea::placeholder {\n  color: #5a5a5a;\n}\n\n.code-textarea:focus {\n  outline: none;\n}\n\n.code-editor-wrapper:focus-within {\n  border-color: #409eff;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/components/common/footer.vue",
    "content": "<template>\n  <div class=\"footer-bar\">\n    © {{ currentYear }} \n    <a\n      href=\"https://github.com/gocronx-team/gocron\"\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n    >\n      gocronx-team/gocron\n    </a>\n  </div>\n</template>\n\n<script>\nimport { computed } from 'vue'\n\nexport default {\n  name: 'AppFooter',\n  setup() {\n    const currentYear = computed(() => new Date().getFullYear())\n    return {\n      currentYear\n    }\n  }\n}\n</script>\n\n<style scoped>\n.footer-bar {\n  padding: 12px 0;\n  background-color: rgba(245, 247, 250, 0.95);\n  border-top: 1px solid #e4e7ed;\n  text-align: center;\n  font-size: 13px;\n  color: #606266;\n  backdrop-filter: blur(8px);\n}\n\n.footer-bar a {\n  color: #409eff;\n  text-decoration: none;\n  transition: color 0.3s ease;\n  font-weight: 500;\n  margin-left: 4px;\n}\n\n.footer-bar a:hover {\n  color: #66b1ff;\n  text-decoration: underline;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/components/common/header.vue",
    "content": "<template>\n  <div class=\"app-header\">\n    <div class=\"header-left\">\n      <span class=\"page-title\">{{ pageTitle }}</span>\n    </div>\n    \n    <div class=\"header-right\">\n      <a\n        href=\"https://github.com/gocronx-team/gocron\"\n        target=\"_blank\"\n        class=\"github-link\"\n        title=\"GitHub\"\n      >\n        <svg\n          height=\"20\"\n          width=\"20\"\n          viewBox=\"0 0 16 16\"\n          fill=\"currentColor\"\n        >\n          <path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z\" />\n        </svg>\n      </a>\n      \n      <el-dropdown\n        v-if=\"userStore.isLogin\"\n        trigger=\"click\"\n        class=\"user-dropdown\"\n      >\n        <span class=\"user-info\">\n          <el-icon><User /></el-icon>\n          <span>{{ userStore.username }}</span>\n          <el-icon><ArrowDown /></el-icon>\n        </span>\n        <template #dropdown>\n          <el-dropdown-menu>\n            <el-dropdown-item @click=\"$router.push('/user/edit-my-password')\">\n              <el-icon><Lock /></el-icon>\n              {{ t('nav.changePassword') }}\n            </el-dropdown-item>\n            <el-dropdown-item @click=\"$router.push('/user/two-factor')\">\n              <el-icon><Key /></el-icon>\n              {{ t('nav.twoFactor') }}\n            </el-dropdown-item>\n            <el-dropdown-item\n              divided\n              @click=\"logout\"\n            >\n              <el-icon><SwitchButton /></el-icon>\n              {{ t('nav.logout') }}\n            </el-dropdown-item>\n          </el-dropdown-menu>\n        </template>\n      </el-dropdown>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport { useUserStore } from '../../stores/user'\nimport { ArrowDown, User, Lock, Key, SwitchButton } from '@element-plus/icons-vue'\n\nconst { t } = useI18n()\nconst route = useRoute()\nconst router = useRouter()\nconst userStore = useUserStore()\n\nconst pageTitle = computed(() => {\n  const path = route.path\n  if (path.startsWith('/task/log')) return t('task.log')\n  if (path.startsWith('/task')) return t('nav.taskManage')\n  if (path.startsWith('/statistics')) return t('nav.statistics')\n  if (path.startsWith('/host')) return t('nav.taskNode')\n  if (path.startsWith('/user')) return t('nav.userManage')\n  if (path.startsWith('/system')) return t('nav.systemManage')\n  return 'gocron'\n})\n\nconst logout = () => {\n  userStore.logout()\n  router.push('/user/login').then(() => {\n    window.location.reload()\n  })\n}\n</script>\n\n<style scoped>\n.app-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  height: 60px;\n  padding: 0 20px;\n  background-color: #fff;\n  border-bottom: 1px solid #e4e7ed;\n  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);\n}\n\n.header-left {\n  flex: 1;\n}\n\n.page-title {\n  font-size: 18px;\n  font-weight: 600;\n  color: #303133;\n}\n\n.header-right {\n  display: flex;\n  align-items: center;\n  gap: 20px;\n}\n\n.github-link {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: #606266;\n  text-decoration: none;\n  transition: all 0.3s;\n  padding: 8px;\n  border-radius: 4px;\n}\n\n.github-link:hover {\n  color: #409EFF;\n  background-color: #f5f7fa;\n}\n\n.user-dropdown {\n  cursor: pointer;\n}\n\n.user-info {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  color: #606266;\n  padding: 8px 12px;\n  border-radius: 4px;\n  transition: all 0.3s;\n}\n\n.user-info:hover {\n  background-color: #f5f7fa;\n  color: #409EFF;\n}\n\n:deep(.el-dropdown-menu__item) {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/components/common/navMenu.vue",
    "content": "<template>\n  <div\n    v-cloak\n    class=\"nav-container\"\n  >\n    <el-menu\n      :default-active=\"currentRoute\"\n      mode=\"horizontal\"\n      background-color=\"#545c64\"\n      text-color=\"#fff\"\n      active-text-color=\"#ffd04b\"\n      router\n    >\n      <el-menu-item index=\"/task\">\n        {{ t('nav.taskManage') }}\n      </el-menu-item>\n      <el-menu-item index=\"/host\">\n        {{ t('nav.taskNode') }}\n      </el-menu-item>\n      <el-menu-item\n        v-if=\"userStore.isAdmin\"\n        index=\"/user\"\n      >\n        {{ t('nav.userManage') }}\n      </el-menu-item>\n      <el-menu-item\n        v-if=\"userStore.isAdmin\"\n        index=\"/system\"\n      >\n        {{ t('nav.systemManage') }}\n      </el-menu-item>\n    </el-menu>\n    <a\n      href=\"https://github.com/gocronx-team/gocron\"\n      target=\"_blank\"\n      class=\"github-link\"\n      title=\"GitHub\"\n    >\n      <svg\n        height=\"24\"\n        width=\"24\"\n        viewBox=\"0 0 16 16\"\n        fill=\"currentColor\"\n      >\n        <path d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z\" />\n      </svg>\n    </a>\n    <div\n      v-if=\"userStore.isLogin\"\n      class=\"user-menu\"\n    >\n      <el-dropdown trigger=\"click\">\n        <span class=\"user-info\">\n          <el-icon><User /></el-icon>\n          <span>{{ userStore.username }}</span>\n          <el-icon><ArrowDown /></el-icon>\n        </span>\n        <template #dropdown>\n          <el-dropdown-menu>\n            <el-dropdown-item @click=\"$router.push('/user/edit-my-password')\">\n              {{ t('nav.changePassword') }}\n            </el-dropdown-item>\n            <el-dropdown-item @click=\"$router.push('/user/two-factor')\">\n              {{ t('nav.twoFactor') }}\n            </el-dropdown-item>\n            <el-dropdown-item\n              divided\n              @click=\"logout\"\n            >\n              {{ t('nav.logout') }}\n            </el-dropdown-item>\n          </el-dropdown-menu>\n        </template>\n      </el-dropdown>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport { useUserStore } from '../../stores/user'\nimport { ArrowDown, User } from '@element-plus/icons-vue'\n\nconst { t } = useI18n()\n\nconst route = useRoute()\nconst router = useRouter()\nconst userStore = useUserStore()\n\nconst currentRoute = computed(() => {\n  if (route.path === '/') return '/task'\n  const segments = route.path.split('/')\n  return `/${segments[1]}`\n})\n\nconst logout = () => {\n  userStore.logout()\n  router.push('/user/login').then(() => {\n    window.location.reload()\n  })\n}\n</script>\n\n<style scoped>\n.nav-container {\n  display: flex;\n  align-items: center;\n  background-color: #545c64;\n}\n.el-menu {\n  flex: 1;\n  border: none;\n}\n.github-link {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: #fff;\n  padding: 0 16px;\n  text-decoration: none;\n  transition: all 0.3s;\n}\n.github-link:hover {\n  color: #ffd04b;\n  transform: scale(1.1);\n}\n.user-menu {\n  padding: 0 20px;\n}\n.user-info {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  color: #fff;\n  cursor: pointer;\n  padding: 10px;\n  border-radius: 4px;\n  transition: background-color 0.3s;\n}\n.user-info:hover {\n  background-color: rgba(255, 255, 255, 0.1);\n}\n</style>"
  },
  {
    "path": "web/vue/src/components/common/notFound.vue",
    "content": "<template>\n  <el-dialog\n    v-model:visible=\"dialogVisible\"\n    title=\"您访问的页面不存在\"\n    :close-on-click-modal=\"false\"\n    :show-close=\"false\"\n    :close-on-press-escape=\"false\"\n  >\n    <el-button\n      type=\"primary\"\n      @click=\"jump\"\n    >\n      确定\n    </el-button>\n  </el-dialog>\n</template>\n\n<script>\nexport default {\n  name: 'NotFound',\n  data () {\n    return {\n      dialogVisible: true\n    }\n  },\n  methods: {\n    jump () {\n      this.$router.push('/')\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "web/vue/src/components/common/sidebar.vue",
    "content": "<template>\n  <el-aside\n    width=\"200px\"\n    class=\"global-sidebar\"\n  >\n    <div class=\"sidebar-header\">\n      <h2 class=\"app-title\">\n        gocron\n      </h2>\n    </div>\n    \n    <el-menu\n      :default-active=\"currentRoute\"\n      mode=\"vertical\"\n      background-color=\"#304156\"\n      text-color=\"#bfcbd9\"\n      active-text-color=\"#409EFF\"\n      :unique-opened=\"true\"\n      router\n    >\n      <!-- 任务管理 -->\n      <el-sub-menu index=\"task\">\n        <template #title>\n          <el-icon><Calendar /></el-icon>\n          <span>{{ t('nav.taskManage') }}</span>\n        </template>\n        <el-menu-item index=\"/task\">\n          <el-icon><List /></el-icon>\n          <span>{{ t('task.list') }}</span>\n        </el-menu-item>\n        <el-menu-item index=\"/task/log\">\n          <el-icon><Document /></el-icon>\n          <span>{{ t('task.log') }}</span>\n        </el-menu-item>\n        <el-menu-item index=\"/template\">\n          <el-icon><Files /></el-icon>\n          <span>{{ t('template.list') }}</span>\n        </el-menu-item>\n        <el-menu-item index=\"/statistics\">\n          <el-icon><TrendCharts /></el-icon>\n          <span>{{ t('nav.statistics') }}</span>\n        </el-menu-item>\n      </el-sub-menu>\n      \n      <!-- 任务节点 -->\n      <el-menu-item index=\"/host\">\n        <el-icon><Monitor /></el-icon>\n        <span>{{ t('nav.taskNode') }}</span>\n      </el-menu-item>\n      \n      <!-- 用户管理 -->\n      <el-menu-item\n        v-if=\"userStore.isAdmin\"\n        index=\"/user\"\n      >\n        <el-icon><User /></el-icon>\n        <span>{{ t('nav.userManage') }}</span>\n      </el-menu-item>\n      \n      <!-- 系统管理 -->\n      <el-sub-menu\n        v-if=\"userStore.isAdmin\"\n        index=\"system\"\n      >\n        <template #title>\n          <el-icon><Setting /></el-icon>\n          <span>{{ t('nav.systemManage') }}</span>\n        </template>\n        <el-menu-item index=\"/system\">\n          <el-icon><Bell /></el-icon>\n          <span>{{ t('system.notification') }}</span>\n        </el-menu-item>\n        <el-menu-item index=\"/system/login-log\">\n          <el-icon><Document /></el-icon>\n          <span>{{ t('system.loginLog') }}</span>\n        </el-menu-item>\n        <el-menu-item index=\"/system/audit-log\">\n          <el-icon><Notebook /></el-icon>\n          <span>{{ t('audit.log') }}</span>\n        </el-menu-item>\n        <el-menu-item index=\"/system/log-retention\">\n          <el-icon><Delete /></el-icon>\n          <span>{{ t('system.logCleanup') }}</span>\n        </el-menu-item>\n      </el-sub-menu>\n    </el-menu>\n    \n    <!-- 底部语言切换 -->\n    <div class=\"sidebar-footer\">\n      <LanguageSwitcher />\n    </div>\n  </el-aside>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport { useUserStore } from '../../stores/user'\nimport LanguageSwitcher from './LanguageSwitcher.vue'\nimport {\n  Calendar,\n  List,\n  Document,\n  Files,\n  TrendCharts,\n  Monitor,\n  User,\n  Setting,\n  Bell,\n  Delete,\n  Notebook\n} from '@element-plus/icons-vue'\n\nconst { t } = useI18n()\nconst route = useRoute()\nconst userStore = useUserStore()\n\nconst currentRoute = computed(() => {\n  const path = route.path\n  // 精确匹配路由\n  if (path === '/task/log') return '/task/log'\n  if (path === '/statistics') return '/statistics'\n  if (path.startsWith('/template')) return '/template'\n  if (path.startsWith('/task')) return '/task'\n  if (path.startsWith('/host')) return '/host'\n  if (path.startsWith('/user')) return '/user'\n  if (path.startsWith('/system')) {\n    if (path === '/system/login-log') return '/system/login-log'\n    if (path === '/system/audit-log') return '/system/audit-log'\n    if (path === '/system/log-retention') return '/system/log-retention'\n    return '/system'\n  }\n  return '/task'\n})\n</script>\n\n<style scoped>\n.global-sidebar {\n  background-color: #304156;\n  display: flex;\n  flex-direction: column;\n  height: 100vh;\n  overflow: hidden;\n}\n\n.sidebar-header {\n  padding: 20px 20px 20px 32px;\n  text-align: left;\n  border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n}\n\n.app-title {\n  margin: 0;\n  color: #409EFF;\n  font-size: 24px;\n  font-weight: bold;\n  letter-spacing: 1px;\n}\n\n.el-menu {\n  flex: 1;\n  border: none;\n  overflow-y: auto;\n}\n\n.el-menu::-webkit-scrollbar {\n  width: 6px;\n}\n\n.el-menu::-webkit-scrollbar-thumb {\n  background-color: rgba(255, 255, 255, 0.2);\n  border-radius: 3px;\n}\n\n.sidebar-footer {\n  padding: 15px;\n  border-top: 1px solid rgba(255, 255, 255, 0.1);\n  background-color: #263445;\n}\n\n.sidebar-footer :deep(.language-switcher) {\n  color: #bfcbd9;\n  font-weight: 500;\n  justify-content: center;\n  padding: 8px 12px;\n  border-radius: 4px;\n  transition: all 0.3s;\n}\n\n.sidebar-footer :deep(.language-switcher:hover) {\n  background-color: rgba(64, 158, 255, 0.1);\n  color: #409EFF;\n}\n\n/* 子菜单样式 */\n:deep(.el-sub-menu__title) {\n  color: #bfcbd9 !important;\n  display: flex;\n  align-items: center;\n  padding-left: 20px !important;\n  padding-right: 40px !important;\n}\n\n:deep(.el-sub-menu__title:hover) {\n  background-color: rgba(0, 0, 0, 0.2) !important;\n}\n\n:deep(.el-menu-item) {\n  display: flex;\n  align-items: center;\n  padding-left: 20px !important;\n  padding-right: 20px !important;\n}\n\n:deep(.el-menu-item:hover) {\n  background-color: rgba(0, 0, 0, 0.2) !important;\n}\n\n:deep(.el-menu-item.is-active) {\n  background-color: rgba(64, 158, 255, 0.2) !important;\n}\n\n/* 确保图标和文字垂直居中 */\n:deep(.el-icon) {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  vertical-align: middle;\n  margin-right: 8px;\n  flex-shrink: 0;\n}\n\n:deep(.el-sub-menu__title span),\n:deep(.el-menu-item span) {\n  vertical-align: middle;\n  line-height: normal;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n/* 子菜单的子项增加缩进 */\n:deep(.el-menu--inline .el-menu-item) {\n  padding-left: 48px !important;\n}\n\n/* 确保展开箭头不被遮挡 */\n:deep(.el-sub-menu__icon-arrow) {\n  margin-left: auto !important;\n  flex-shrink: 0;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/composables/__tests__/useDebounce.spec.js",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport { useDebounceFn } from '../useDebounce'\n\ndescribe('useDebounce', () => {\n  beforeEach(() => {\n    vi.useFakeTimers()\n  })\n\n  afterEach(() => {\n    vi.restoreAllMocks()\n  })\n\n  it('should debounce function calls', () => {\n    const mockFn = vi.fn()\n    const debouncedFn = useDebounceFn(mockFn, 300)\n\n    // 快速调用多次\n    debouncedFn('call 1')\n    debouncedFn('call 2')\n    debouncedFn('call 3')\n\n    // 还没到延迟时间，不应该被调用\n    expect(mockFn).not.toHaveBeenCalled()\n\n    // 快进时间\n    vi.advanceTimersByTime(300)\n\n    // 应该只被调用一次，使用最后一次的参数\n    expect(mockFn).toHaveBeenCalledTimes(1)\n    expect(mockFn).toHaveBeenCalledWith('call 3')\n  })\n\n  it('should use custom delay', () => {\n    const mockFn = vi.fn()\n    const debouncedFn = useDebounceFn(mockFn, 500)\n\n    debouncedFn('test')\n\n    vi.advanceTimersByTime(300)\n    expect(mockFn).not.toHaveBeenCalled()\n\n    vi.advanceTimersByTime(200)\n    expect(mockFn).toHaveBeenCalledWith('test')\n  })\n})\n"
  },
  {
    "path": "web/vue/src/composables/__tests__/useLoading.spec.js",
    "content": "import { describe, it, expect } from 'vitest'\nimport { useLoading } from '../useLoading'\n\ndescribe('useLoading', () => {\n  it('should initialize with false', () => {\n    const { loading } = useLoading()\n    expect(loading.value).toBe(false)\n  })\n\n  it('should set loading during async operation', async () => {\n    const { loading, withLoading } = useLoading()\n    \n    const promise = withLoading(async () => {\n      expect(loading.value).toBe(true)\n      return 'result'\n    })\n    \n    const result = await promise\n    expect(loading.value).toBe(false)\n    expect(result).toBe('result')\n  })\n})\n"
  },
  {
    "path": "web/vue/src/composables/__tests__/useMessage.spec.js",
    "content": "import { describe, it, expect, vi } from 'vitest'\nimport { useMessage } from '../useMessage'\nimport { ElMessage, ElMessageBox } from 'element-plus'\n\nvi.mock('element-plus', () => ({\n  ElMessage: {\n    success: vi.fn(),\n    error: vi.fn(),\n    warning: vi.fn(),\n    info: vi.fn()\n  },\n  ElMessageBox: {\n    confirm: vi.fn()\n  }\n}))\n\nvi.mock('vue-i18n', () => ({\n  useI18n: () => ({\n    t: (key) => key\n  })\n}))\n\ndescribe('useMessage', () => {\n  it('should call ElMessage.success', () => {\n    const { success } = useMessage()\n    success('test message')\n    expect(ElMessage.success).toHaveBeenCalledWith('test message')\n  })\n\n  it('should call ElMessage.error', () => {\n    const { error } = useMessage()\n    error('error message')\n    expect(ElMessage.error).toHaveBeenCalledWith('error message')\n  })\n\n  it('should call ElMessageBox.confirm with default options', () => {\n    const { confirm } = useMessage()\n    confirm('Are you sure?')\n    expect(ElMessageBox.confirm).toHaveBeenCalledWith(\n      'Are you sure?',\n      'common.tip',\n      expect.objectContaining({\n        confirmButtonText: 'common.confirm',\n        cancelButtonText: 'common.cancel',\n        type: 'warning',\n        center: true\n      })\n    )\n  })\n})\n"
  },
  {
    "path": "web/vue/src/composables/useDebounce.js",
    "content": "import { ref, watch, onUnmounted } from 'vue'\n\nexport function useDebounce(value, delay = 300) {\n  const debouncedValue = ref(value.value)\n  let timeout = null\n\n  watch(value, (newValue) => {\n    if (timeout) clearTimeout(timeout)\n    timeout = setTimeout(() => {\n      debouncedValue.value = newValue\n    }, delay)\n  })\n  \n  // 组件卸载时清理\n  onUnmounted(() => {\n    if (timeout) clearTimeout(timeout)\n  })\n\n  return debouncedValue\n}\n\nexport function useDebounceFn(fn, delay = 300) {\n  let timeout = null\n  \n  return (...args) => {\n    if (timeout) clearTimeout(timeout)\n    timeout = setTimeout(() => {\n      fn(...args)\n    }, delay)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/composables/useLoading.js",
    "content": "import { ref } from 'vue'\nimport { ElLoading } from 'element-plus'\n\nexport function useLoading(initialState = false) {\n  const loading = ref(initialState)\n  \n  const withLoading = async (fn) => {\n    loading.value = true\n    try {\n      return await fn()\n    } finally {\n      loading.value = false\n    }\n  }\n  \n  return { loading, withLoading }\n}\n\n// 全屏 loading\nexport function useFullScreenLoading() {\n  let loadingInstance = null\n  \n  const show = (text = '加载中...') => {\n    loadingInstance = ElLoading.service({\n      lock: true,\n      text,\n      background: 'rgba(0, 0, 0, 0.7)'\n    })\n  }\n  \n  const hide = () => {\n    loadingInstance?.close()\n  }\n  \n  const withLoading = async (fn, text) => {\n    show(text)\n    try {\n      return await fn()\n    } finally {\n      hide()\n    }\n  }\n  \n  return { show, hide, withLoading }\n}\n"
  },
  {
    "path": "web/vue/src/composables/useMessage.js",
    "content": "import { ElMessage, ElMessageBox } from 'element-plus'\nimport { useI18n } from 'vue-i18n'\n\nexport function useMessage() {\n  const { t } = useI18n()\n  \n  const success = (message) => {\n    ElMessage.success(message)\n  }\n  \n  const error = (message) => {\n    ElMessage.error(message)\n  }\n  \n  const warning = (message) => {\n    ElMessage.warning(message)\n  }\n  \n  const info = (message) => {\n    ElMessage.info(message)\n  }\n  \n  const confirm = (message, title, options = {}) => {\n    return ElMessageBox.confirm(\n      message,\n      title || t('common.tip'),\n      {\n        confirmButtonText: t('common.confirm'),\n        cancelButtonText: t('common.cancel'),\n        type: 'warning',\n        center: true,\n        ...options\n      }\n    )\n  }\n  \n  return {\n    success,\n    error,\n    warning,\n    info,\n    confirm\n  }\n}\n"
  },
  {
    "path": "web/vue/src/const/index.js",
    "content": "export * from './lang'\n"
  },
  {
    "path": "web/vue/src/const/lang.js",
    "content": "export const availableLanguages = {\n  zhCN: {\n    value: 'zh-CN',\n    label: '简体中文'\n  },\n  enUS: {\n    value: 'en-US',\n    label: 'English'\n  }\n}\n"
  },
  {
    "path": "web/vue/src/locales/en-US.js",
    "content": "export default {\n  select: 'Please select',\n  cronValidator: {\n    required: 'Please enter a cron expression',\n    everyFormatError: 'Invalid @every format, e.g.: @every 30s, @every 1m20s, @every 3h5m10s',\n    shortcutError: 'Invalid shortcut, click \"Examples\" to see valid ones',\n    sixFieldsRequired: 'Cron expression must have 6 fields (second minute hour day month weekday)',\n    fieldSecond: 'second',\n    fieldMinute: 'minute',\n    fieldHour: 'hour',\n    fieldDay: 'day',\n    fieldMonth: 'month',\n    fieldWeek: 'weekday',\n    illegalChar: '{field} field contains illegal characters',\n    valueOutOfRange: '{field} field value {value} is out of range [{min}-{max}]',\n    formatError: '{field} field format error',\n    rangeFormatError: '{field} field range format error',\n    rangeNotNumber: '{field} field range must be numeric',\n    rangeInvalid: '{field} field range [{start}-{end}] is invalid',\n    stepFormatError: '{field} field step format error',\n    stepNotPositive: '{field} field step must be a positive integer'\n  },\n  cronPreview: {\n    waitingInput: 'Enter a cron expression to preview upcoming executions',\n    computing: 'Computing...',\n    nextRuns: 'Next {count} executions',\n    weeklyDistribution: 'Next 7-day distribution',\n    truncated: 'Truncated (high frequency)',\n    noUpcomingRuns: 'No executions in next 7 days',\n    noRuns: 'No runs',\n    runs: 'run(s)',\n    heatmapEmpty: 'No executions in next 7 days',\n    heatmapAria: 'Weekly execution distribution heatmap',\n    requestFailed: 'Preview request failed',\n    invalidSyntax: 'Invalid cron expression',\n    inSeconds: 'in {n}s',\n    inMinutes: 'in {n}m',\n    inHours: 'in {n}h',\n    inHoursMinutes: 'in {h}h {m}m',\n    inDays: 'in {n}d',\n    inDaysHours: 'in {d}d {h}h',\n    sun: 'Sun',\n    mon: 'Mon',\n    tue: 'Tue',\n    wed: 'Wed',\n    thu: 'Thu',\n    fri: 'Fri',\n    sat: 'Sat',\n    sunAbbr: 'Sun',\n    monAbbr: 'Mon',\n    tueAbbr: 'Tue',\n    wedAbbr: 'Wed',\n    thuAbbr: 'Thu',\n    friAbbr: 'Fri',\n    satAbbr: 'Sat'\n  },\n  common: {\n    confirm: 'Confirm',\n    cancel: 'Cancel',\n    save: 'Save',\n    delete: 'Delete',\n    edit: 'Edit',\n    search: 'Search',\n    reset: 'Reset',\n    add: 'Add',\n    refresh: 'Refresh',\n    tip: 'Tip',\n    confirmOperation: 'Are you sure to perform this operation?',\n    operation: 'Operation',\n    status: 'Status',\n    enabled: 'Enabled',\n    disabled: 'Disabled',\n    yes: 'Yes',\n    no: 'No',\n    total: 'Total',\n    items: 'items',\n    date: 'Date'\n  },\n  nav: {\n    taskManage: 'Tasks',\n    taskNode: 'Nodes',\n    userManage: 'Users',\n    systemManage: 'System',\n    statistics: 'Statistics',\n    logout: 'Logout',\n    changePassword: 'Change Password',\n    twoFactor: 'Two-Factor Authentication'\n  },\n  login: {\n    title: 'User Login',\n    username: 'Username',\n    password: 'Password',\n    verifyCode: '2FA Code',\n    login: 'Login',\n    usernamePlaceholder: 'Please enter username or email',\n    passwordPlaceholder: 'Please enter password',\n    verifyCodePlaceholder: 'Please enter 6-digit code',\n    usernameRequired: 'Please enter username',\n    passwordRequired: 'Please enter password',\n    verifyCodeRequired: 'Please enter 2FA code'\n  },\n  task: {\n    list: 'Task List',\n    log: 'Task Log',\n    id: 'Task ID',\n    name: 'Task Name',\n    tag: 'Tag',\n    tagPlaceholder: 'Select or enter tags',\n    type: 'Task Type',\n    mainTask: 'Main Task',\n    childTask: 'Child Task',\n    dependency: 'Dependency',\n    strongDependency: 'Strong Dependency',\n    weakDependency: 'Weak Dependency',\n    childTaskId: 'Child Task ID',\n    childTaskIdPlaceholder: 'Multiple IDs separated by comma',\n    cronExpression: 'Cron Expression',\n    cronPlaceholder: 'Second Minute Hour Day Month Week',\n    cronExample: 'Examples',\n    timezone: 'Timezone',\n    timezoneServer: 'Server Timezone',\n    protocol: 'Method',\n    httpMethod: 'HTTP Method',\n    httpBody: 'Request Body',\n    httpBodyPlaceholder: 'JSON body for POST, e.g. {\\'{\\'}\"key\": \"value\"{\\'}\\'}',\n    httpHeaders: 'Custom Headers',\n    httpHeadersPlaceholder: 'JSON format, e.g. {\\'{\\'}\"Authorization\": \"Bearer token\"{\\'}\\'}',\n    successPattern: 'Response Assertion',\n    successPatternPlaceholder: 'Regex to match response body, leave empty to skip',\n    taskNode: 'Task Node',\n    taskNodePlaceholder: 'Please select task node',\n    command: 'Command',\n    timeout: 'Task Timeout',\n    singleInstance: 'Single Instance',\n    retryTimes: 'Retry Times on Failure',\n    retryTimesPlaceholder: '0 - 10, default 0, no retry',\n    retryInterval: 'Retry Interval on Failure',\n    retryIntervalPlaceholder: '0 - 3600 (seconds), default 0, use system default',\n    notification: 'Task Notification',\n    notifyType: 'Notification Type',\n    notifyReceiver: 'Receiver',\n    notifyReceiverPlaceholder: 'Please select',\n    notifyChannel: 'Channel',\n    notifyKeyword: 'Task Output Keyword',\n    notifyKeywordPlaceholder: 'Notification will be triggered if task output contains this keyword',\n    logRetentionDays: 'Log Retention Days',\n    logRetentionDaysTip: '0 = use global setting',\n    clearTaskLog: 'Clear Task Logs',\n    confirmClearTaskLog: 'Confirm clearing all logs for task ID {taskId}?',\n    remark: 'Remark',\n    status: 'Status',\n    nextRunTime: 'Next Run',\n    operation: 'Operation',\n    manualRun: 'Manual Run',\n    viewLog: 'View Log',\n    enable: 'Enable',\n    disable: 'Disable',\n    mainTaskTip:\n      'Main task can configure multiple child tasks. Child tasks will be executed automatically after main task completes.<br>Task type cannot be changed after creation.',\n    dependencyTip:\n      'Strong Dependency: Child tasks run only when main task succeeds<br>Weak Dependency: Child tasks run regardless of main task result',\n    timeoutTip:\n      'Force terminate task on timeout, range 0-86400 (seconds), default 3600, 0 means no limit',\n    singleInstanceTip:\n      'Single instance mode: whether to execute next scheduled task if previous task is still running',\n    cronStandard: 'Standard Syntax (Second Minute Hour Day Month Week)',\n    cronShortcut: 'Shortcut Syntax',\n    notifyDisabled: 'Disabled',\n    notifyOnFailure: 'On Failure',\n    notifyAlways: 'Always',\n    notifyKeywordMatch: 'Keyword Match',\n    notifyEmail: 'Email',\n    notifySlack: 'Slack',\n    notifyWebhook: 'WebHook',\n    createNew: 'Create Task',\n    versionHistory: 'Version History',\n    version: 'Version',\n    versionRemark: 'Change Note',\n    versionUser: 'Modified By',\n    versionTime: 'Modified Time',\n    versionRollback: 'Rollback',\n    versionRollbackConfirm: 'Are you sure you want to rollback to version {version}?',\n    versionRollbackSuccess: 'Rollback successful',\n    versionCommand: 'Command Content'\n  },\n  host: {\n    list: 'Task Nodes',\n    name: 'Host Name',\n    alias: 'Alias',\n    port: 'Port',\n    remark: 'Remark',\n    createTime: 'Create Time',\n    createNew: 'Add Node',\n    namePlaceholder: 'Please enter host name',\n    aliasPlaceholder: 'Please enter alias',\n    portPlaceholder: 'Please enter port',\n    nameRequired: 'Please enter host name',\n    portRequired: 'Please enter port',\n    aliasRequired: 'Please enter node name',\n    portInvalid: 'Invalid port',\n    autoRegister: 'Auto Register',\n    agentInstall: 'Agent Installation',\n    installCommand: 'Install Command',\n    installTip:\n      'Run the corresponding command on the target server to automatically install and register the Agent node. Note: Must be executed by a non-root user',\n    tokenExpires: 'Token Expires',\n    tokenUsage: 'Usage',\n    tokenReusable: 'This token can be reused within the validity period for batch installation',\n    bashCommand: 'Run the following command in terminal (non-root user):',\n    powershellCommand: 'Run in PowerShell (Administrator):',\n    windowsManualInstall: 'Windows Manual Installation',\n    windowsManualInstallTip:\n      'For security reasons, manual installation of gocron-node is recommended for Windows systems',\n    windowsStep1: 'Download Package',\n    windowsStep1Desc: 'Download gocron-node-windows-amd64.zip from GitHub Releases',\n    windowsStep2: 'Extract and Configure',\n    windowsStep2Desc: 'Extract to target directory and manually add node configuration in Web UI',\n    windowsStep3: 'Start Service',\n    windowsStep3Desc: 'Run gocron-node.exe or create a Windows service'\n  },\n  user: {\n    list: 'User Management',\n    username: 'Username',\n    email: 'Email',\n    role: 'Role',\n    admin: 'Administrator',\n    normalUser: 'Normal User',\n    password: 'Password',\n    confirmPassword: 'Confirm Password',\n    oldPassword: 'Old Password',\n    newPassword: 'New Password',\n    confirmNewPassword: 'Confirm New Password',\n    createNew: 'Add User',\n    changePassword: 'Change Password',\n    usernamePlaceholder: 'Please enter username',\n    emailPlaceholder: 'Please enter email',\n    passwordPlaceholder: 'At least 8 characters, letters and digits',\n    usernameRequired: 'Please enter username',\n    emailRequired: 'Please enter valid email address',\n    passwordRequired: 'Please enter password',\n    confirmPasswordRequired: 'Please enter password again',\n    oldPasswordRequired: 'Please enter old password',\n    newPasswordRequired: 'Please enter new password'\n  },\n  system: {\n    manage: 'System Management',\n    loginLog: 'Login Log',\n    logRetention: 'Log Retention',\n    notification: 'Notifications',\n    email: 'Email Notification',\n    slack: 'Slack Notification',\n    webhook: 'WebHook Notification',\n    loginTime: 'Login Time',\n    loginIp: 'Login IP',\n    retentionDays: 'Retention Days',\n    retentionDaysPlaceholder: 'Please enter retention days',\n    smtpHost: 'SMTP Host',\n    smtpPort: 'SMTP Port',\n    smtpUser: 'SMTP User',\n    smtpPassword: 'SMTP Password',\n    mailFrom: 'Mail From',\n    slackUrl: 'Slack Webhook URL',\n    webhookUrl: 'Webhook URL',\n    testSend: 'Test Send',\n    logRetentionSettings: 'Log Auto-Cleanup Settings',\n    dbLogRetentionDays: 'Database Log Retention Days',\n    dbLogRetentionTip: 'Set to 0 to disable automatic database log cleanup',\n    cleanupTime: 'Cleanup Time',\n    cleanupTimeTip:\n      'Automatically execute log cleanup at this time every day, takes effect immediately after modification',\n    selectTime: 'Select Time',\n    logFileSizeLimit: 'Log File Size Limit',\n    logFileSizeLimitTip:\n      'Set to 0 to disable log file cleanup, greater than 0 will automatically clear when log file exceeds this size',\n    logRetentionSaveSuccess: 'Saved successfully, cleanup task has been reloaded',\n    emailServerConfig: 'Email Server Configuration',\n    templateSupportsHtml: 'Notification template supports HTML',\n    template: 'Template',\n    addUser: 'Add User',\n    notificationUsers: 'Notification Users',\n    emailAddress: 'Email Address',\n    pleaseEnterEmailServer: 'Please enter email server address',\n    pleaseEnterValidPort: 'Please enter valid port',\n    pleaseEnterUserEmail: 'Please enter user email',\n    pleaseEnterTemplate: 'Please enter notification template content',\n    incompleteParameters: 'Incomplete parameters',\n    channel: 'Channel',\n    channels: 'Channels',\n    addChannel: 'Add Channel',\n    channelName: 'Channel Name',\n    pleaseEnterChannelName: 'Please enter channel name',\n    pleaseEnterValidUrl: 'Please enter valid notification URL',\n    webhookTip: 'POST request, set Header[Content-Type: application/json]',\n    addWebhookUrl: 'Add Webhook URL',\n    webhookUrls: 'Webhook URLs',\n    webhookName: 'Webhook Name',\n    logCleanup: 'Log Cleanup',\n    templateVariables: 'Template Variables',\n    taskIdVar: 'Task ID',\n    taskNameVar: 'Task Name',\n    statusVar: 'Task Execution Result Status',\n    resultVar: 'Task Execution Output',\n    emailTemplatePlaceholder:\n      'Task ID: {{.TaskId}}\\nTask Name: {{.TaskName}}\\nStatus: {{.Status}}\\nResult: {{.Result}}\\nRemark: {{.Remark}}',\n    slackTemplatePlaceholder:\n      'Task ID: {{.TaskId}}\\nTask Name: {{.TaskName}}\\nStatus: {{.Status}}\\nResult: {{.Result}}\\nRemark: {{.Remark}}',\n    webhookTemplatePlaceholder:\n      '{\"task_id\": \"{{.TaskId}}\", \"task_name\": \"{{.TaskName}}\", \"status\": \"{{.Status}}\", \"result\": \"{{.Result}}\", \"remark\": \"{{.Remark}}\"}'\n  },\n  taskLog: {\n    list: 'Task Log',\n    taskName: 'Task Name',\n    startTime: 'Start Time',\n    endTime: 'End Time',\n    duration: 'Duration',\n    result: 'Result',\n    host: 'Host',\n    output: 'Output',\n    success: 'Success',\n    failed: 'Failed',\n    viewOutput: 'View Output'\n  },\n  twoFactor: {\n    title: 'Two-Factor Authentication (2FA)',\n    status: 'Status',\n    enabled: 'Enabled',\n    disabled: 'Disabled',\n    enable: 'Enable 2FA',\n    disable: 'Disable 2FA',\n    setup: 'Enable Two-Factor Authentication',\n    qrCode: 'QR Code',\n    secret: 'Secret Key',\n    scanQR: '1. Scan the QR code below with your authenticator app:',\n    manualEntry: '2. Or manually enter the secret key:',\n    verifyCode: 'Verification Code',\n    verifyCodePlaceholder: 'Please enter 6-digit code',\n    verifyCodeStep: '3. Enter the 6-digit code displayed in your app:',\n    confirm: 'Confirm',\n    confirmDisable: 'Confirm Disable',\n    confirmDisableMsg: 'Are you sure you want to disable two-factor authentication?',\n    enableSuccess: '2FA enabled',\n    disableSuccess: '2FA disabled',\n    verifyFailed: 'Verification code is incorrect',\n    alertTitle: 'Notice',\n    alertDescription:\n      'Enabling two-factor authentication greatly enhances account security. It is recommended for all users, especially administrators.',\n    enabledAlertTitle: '2FA Enabled',\n    enabledAlertDescription: 'Your account is protected by two-factor authentication.',\n    disableDialogTitle: 'Disable Two-Factor Authentication',\n    disableDialogDescription:\n      'Please enter the 6-digit code displayed in your authenticator app to disable 2FA:',\n    copySecret: 'Copy',\n    secretCopied: 'Secret key copied to clipboard',\n    verifyCodeLength: 'Please enter 6-digit code',\n    disableFailed: 'Failed to disable 2FA'\n  },\n  install: {\n    title: 'System Installation',\n    welcome: 'Welcome to Gocron',\n    dbConfig: 'Database Configuration',\n    dbType: 'Database Type',\n    dbHost: 'Host',\n    dbPort: 'Port',\n    dbName: 'Database Name',\n    dbFilePath: 'Database File Path',\n    dbUser: 'Username',\n    dbPassword: 'Password',\n    dbTablePrefix: 'Table Prefix',\n    adminConfig: 'Administrator Account',\n    adminUsername: 'Username',\n    adminPassword: 'Password',\n    confirmPassword: 'Confirm Password',\n    adminEmail: 'Email',\n    install: 'Install',\n    installing: 'Installing...',\n    installSuccess: 'Installation Successful',\n    installFailed: 'Installation Failed',\n    dbNamePlaceholder: 'Create DB first if needed',\n    dbFilePathPlaceholder: './data/gocron.db',\n    passwordPlaceholder: '8+ chars with letters & digits',\n    selectDb: 'Please select database',\n    enterDbName: 'Please enter database name',\n    enterDbHost: 'Please enter host',\n    enterDbPort: 'Please enter port',\n    enterDbUser: 'Please enter username',\n    enterDbPassword: 'Please enter password',\n    enterAdminUsername: 'Please enter username',\n    enterAdminEmail: 'Please enter email',\n    enterAdminPassword: 'Please enter password',\n    confirmAdminPassword: 'Please confirm password',\n    passwordMinLength: 'At least 8 characters'\n  },\n  message: {\n    saveSuccess: 'Saved successfully',\n    saveFailed: 'Save failed',\n    deleteSuccess: 'Deleted successfully',\n    deleteFailed: 'Delete failed',\n    updateSuccess: 'Updated successfully',\n    updateFailed: 'Update failed',\n    operationSuccess: 'Operation successful',\n    operationFailed: 'Operation failed',\n    refreshSuccess: 'Refreshed successfully',\n    loadFailed: 'Load failed',\n    requestTimeout: 'Request timeout, please try again later',\n    authExpired: 'Login expired, please login again',\n    requestFailed: 'Request failed',\n    networkError: 'Network error',\n    serverError: 'Server error',\n    dataNotFound: 'Data not found',\n    confirmDelete: 'Are you sure you want to delete?',\n    confirmDeleteTask: 'Are you sure you want to delete task \"{name}\"?',\n    confirmRunTask: 'Are you sure you want to manually run task \"{name}\"?',\n    taskStarted: 'Task has started executing',\n    selectTaskNode: 'Please select task node',\n    selectMailReceiver: 'Please select email receiver',\n    selectSlackChannel: 'Please select Slack channel',\n    selectWebhookUrl: 'Please select Webhook URL',\n    passwordMismatch: 'Passwords do not match',\n    oldPasswordError: 'Old password is incorrect',\n    passwordSameAsOld: 'New password cannot be the same as old password',\n    usernameExists: 'Username already exists',\n    emailExists: 'Email already exists',\n    formValidationFailed: 'Form validation failed, please check your input',\n    pleaseEnterPassword: 'Please enter password',\n    pleaseEnterPasswordAgain: 'Please enter password again',\n    pleaseEnterTaskName: 'Please enter task name',\n    pleaseEnterCronExpression: 'Please enter crontab expression',\n    pleaseEnterCommand: 'Please enter command',\n    pleaseEnterValidTimeout: 'Please enter valid task timeout',\n    pleaseEnterValidRetryTimes: 'Please enter valid retry times',\n    pleaseEnterValidRetryInterval: 'Please enter valid retry interval',\n    pleaseEnterNotifyKeyword: 'Please enter notification keyword',\n    pleaseEnterUrl: 'Please enter URL',\n    pleaseEnterShellCommand: 'Please enter shell command',\n    selected: 'Selected',\n    tasks: 'tasks',\n    batchEnable: 'Batch Enable',\n    batchDisable: 'Batch Disable',\n    batchDelete: 'Batch Delete',\n    confirmBatchEnable: 'Are you sure you want to enable {count} selected tasks?',\n    confirmBatchDisable: 'Are you sure you want to disable {count} selected tasks?',\n    confirmBatchDelete:\n      'Are you sure you want to delete {count} selected tasks? This operation cannot be undone!',\n    batchEnableSuccess: 'Batch enable successful',\n    batchDisableSuccess: 'Batch disable successful',\n    batchDeleteSuccess: 'Batch delete successful',\n    pleaseSelectTask: 'Please select tasks to {action}',\n    manualRunTask: 'Manual Run Task',\n    confirmExecute: 'Confirm Execute',\n    confirmDeleteTitle: 'Tip',\n    confirmDeleteButton: 'Confirm Delete',\n    taskCreatedTime: 'Task Created Time',\n    taskType: 'Task Type',\n    singleInstanceRun: 'Single Instance',\n    timeoutTime: 'Timeout',\n    retryCount: 'Retry Times',\n    retryIntervalTime: 'Retry Interval',\n    taskNodeLabel: 'Task Node',\n    commandLabel: 'Command',\n    remarkLabel: 'Remark',\n    noLimit: 'No Limit',\n    systemDefault: 'System Default',\n    seconds: 'seconds',\n    activated: 'Activated',\n    stopped: 'Stopped',\n    confirmDeleteNode: 'Are you sure you want to delete this node?',\n    confirmDeleteUser: 'Are you sure you want to delete this user?',\n    connectionSuccess: 'Connection successful',\n    copySuccess: 'Copied successfully',\n    copyFailed: 'Copy failed',\n    all: 'All',\n    clearLog: 'Clear Log',\n    confirmClearLog: 'Are you sure you want to clear all logs?',\n    running: 'Running',\n    cancelled: 'Cancelled',\n    stopTask: 'Stop Task',\n    taskExecutionResult: 'Task Execution Result',\n    cronExamples: 'Cron Expression Examples',\n    everyMinute: 'Run at 0 seconds of every minute',\n    every20Seconds: 'Run every 20 seconds',\n    everyDay21_30: 'Run at 21:30:00 every day',\n    everySaturday23: 'Run at 23:00:00 every Saturday',\n    reboot: 'Run only once at application startup',\n    yearly: 'Run once a year',\n    monthly: 'Run once a month',\n    weekly: 'Run once a week',\n    daily: 'Run once a day',\n    hourly: 'Run once an hour',\n    every30s: 'Run every 30 seconds',\n    every1m20s: 'Run every 1 minute and 20 seconds'\n  },\n  template: {\n    list: 'Templates',\n    name: 'Template Name',\n    description: 'Description',\n    category: 'Category',\n    protocol: 'Method',\n    command: 'Command',\n    timeout: 'Timeout',\n    usageCount: 'Usage',\n    builtin: 'Built-in',\n    custom: 'Custom',\n    createNew: 'Create Template',\n    useTemplate: 'Use Template',\n    saveAsTemplate: 'Save as Template',\n    templateVarTip:\n      'Use {{variable_name}} syntax for template variables. Users will fill values when applying.',\n    applyTemplate: 'Apply Template',\n    applySuccess: 'Template applied',\n    fillVariables: 'Fill Template Variables',\n    variableName: 'Variable',\n    variableValue: 'Value',\n    noTemplates: 'No templates',\n    confirmDelete: 'Are you sure you want to delete template \"{name}\"?',\n    category_all: 'All',\n    category_backup: 'Backup',\n    category_cleanup: 'Cleanup',\n    category_monitor: 'Monitor',\n    category_deploy: 'Deploy',\n    category_api: 'API Call',\n    category_custom: 'Custom',\n    preview: 'Preview',\n    templateNamePlaceholder: 'Enter template name',\n    templateDescPlaceholder: 'Enter template description',\n    selectCategory: 'Select category',\n    saveAsTemplateName: 'Template Name',\n    saveAsTemplateDesc: 'Description',\n    saveAsTemplateCategory: 'Category',\n    securityWarning:\n      'Variable values will be stored in plaintext in the task command. Avoid entering passwords directly. Use environment variable references (e.g. $DB_PASS) instead.',\n    saveAsTemplateWarning:\n      'The command will be saved as-is into the template (visible to all users). Remove any passwords or secrets first and replace them with {{variable}} placeholders.'\n  },\n  audit: {\n    log: 'Audit Log',\n    module: 'Module',\n    action: 'Action',\n    target: 'Target',\n    detail: 'Detail',\n    module_task: 'Task',\n    module_host: 'Host',\n    module_user: 'User',\n    module_system: 'System',\n    action_create: 'Create',\n    action_update: 'Update',\n    action_delete: 'Delete',\n    action_enable: 'Enable',\n    action_disable: 'Disable',\n    action_run: 'Manual Run',\n    action_batch_enable: 'Batch Enable',\n    action_batch_disable: 'Batch Disable',\n    action_batch_remove: 'Batch Delete',\n    action_change_password: 'Change Password',\n    action_reset_password: 'Reset Password'\n  },\n  statistics: {\n    title: 'Statistics',\n    totalTasks: 'Total Tasks',\n    todayExecutions: 'Today Executions',\n    last7DaysExecutions: '7-Day Executions',\n    successRate: '7-Day Success Rate',\n    failedCount: '7-Day Failed Tasks',\n    last7DaysTrend: 'Last 7 Days Trend',\n    success: 'Success',\n    failed: 'Failed',\n    total: 'Total',\n    executionCount: 'Execution Count',\n    date: 'Date',\n    detailedData: 'Detailed Data'\n  }\n}\n"
  },
  {
    "path": "web/vue/src/locales/index.js",
    "content": "import { createI18n } from 'vue-i18n'\nimport zhCN from './zh-CN'\nimport enUS from './en-US'\nimport { availableLanguages } from '@/const/index'\n\nconst getDefaultLocale = () => {\n  const savedLocale = localStorage.getItem('locale')\n  return savedLocale || availableLanguages.zhCN.value\n}\n\nconst i18n = createI18n({\n  legacy: false,\n  locale: getDefaultLocale(),\n  fallbackLocale: availableLanguages.zhCN.value,\n  messages: {\n    [availableLanguages.zhCN.value]: zhCN,\n    [availableLanguages.enUS.value]: enUS\n  }\n})\n\nexport default i18n\n"
  },
  {
    "path": "web/vue/src/locales/zh-CN.js",
    "content": "export default {\n  select: '请选择',\n  cronValidator: {\n    required: '请输入cron表达式',\n    everyFormatError: '@every 格式错误，示例：@every 30s, @every 1m20s, @every 3h5m10s',\n    shortcutError: '快捷语法错误，请点击\"示例\"查看',\n    sixFieldsRequired: 'cron表达式需包含6段（秒 分 时 天 月 周）',\n    fieldSecond: '秒',\n    fieldMinute: '分',\n    fieldHour: '时',\n    fieldDay: '天',\n    fieldMonth: '月',\n    fieldWeek: '周',\n    illegalChar: '{field}字段包含非法字符',\n    valueOutOfRange: '{field}字段值{value}超出范围[{min}-{max}]',\n    formatError: '{field}字段格式错误',\n    rangeFormatError: '{field}字段范围格式错误',\n    rangeNotNumber: '{field}字段范围必须是数字',\n    rangeInvalid: '{field}字段范围[{start}-{end}]无效',\n    stepFormatError: '{field}字段步长格式错误',\n    stepNotPositive: '{field}字段步长必须是正整数'\n  },\n  cronPreview: {\n    waitingInput: '输入 cron 表达式后在此实时查看接下来的执行时间',\n    computing: '计算中...',\n    nextRuns: '接下来 {count} 次执行',\n    weeklyDistribution: '未来 7 天执行分布',\n    truncated: '已截断 (高频表达式)',\n    noUpcomingRuns: '未来 7 天无触发',\n    noRuns: '无执行',\n    runs: '次',\n    heatmapEmpty: '未来 7 天无触发',\n    heatmapAria: 'cron 一周执行分布热图',\n    requestFailed: '预览请求失败',\n    invalidSyntax: 'cron 表达式格式错误',\n    inSeconds: '{n} 秒后',\n    inMinutes: '{n} 分后',\n    inHours: '{n} 小时后',\n    inHoursMinutes: '{h} 小时 {m} 分后',\n    inDays: '{n} 天后',\n    inDaysHours: '{d} 天 {h} 小时后',\n    sun: '周日',\n    mon: '周一',\n    tue: '周二',\n    wed: '周三',\n    thu: '周四',\n    fri: '周五',\n    sat: '周六',\n    sunAbbr: '日',\n    monAbbr: '一',\n    tueAbbr: '二',\n    wedAbbr: '三',\n    thuAbbr: '四',\n    friAbbr: '五',\n    satAbbr: '六'\n  },\n  common: {\n    confirm: '确定',\n    cancel: '取消',\n    save: '保存',\n    delete: '删除',\n    edit: '编辑',\n    search: '搜索',\n    reset: '重置',\n    add: '新增',\n    refresh: '刷新',\n    tip: '提示',\n    confirmOperation: '确定执行此操作?',\n    operation: '操作',\n    status: '状态',\n    enabled: '启用',\n    disabled: '禁用',\n    yes: '是',\n    no: '否',\n    total: '共',\n    items: '条',\n    date: '日期'\n  },\n  nav: {\n    taskManage: '任务管理',\n    taskNode: '任务节点',\n    userManage: '用户管理',\n    systemManage: '系统管理',\n    statistics: '数据统计',\n    logout: '退出',\n    changePassword: '修改密码',\n    twoFactor: '双因素认证'\n  },\n  login: {\n    title: '用户登录',\n    username: '用户名',\n    password: '密码',\n    verifyCode: '验证码',\n    login: '登录',\n    usernamePlaceholder: '请输入用户名或邮箱',\n    passwordPlaceholder: '请输入密码',\n    verifyCodePlaceholder: '请输入6位验证码',\n    usernameRequired: '请输入用户名',\n    passwordRequired: '请输入密码',\n    verifyCodeRequired: '请输入验证码'\n  },\n  task: {\n    list: '定时任务',\n    log: '任务日志',\n    id: '任务ID',\n    name: '任务名称',\n    tag: '标签',\n    tagPlaceholder: '选择或输入标签',\n    type: '任务类型',\n    mainTask: '主任务',\n    childTask: '子任务',\n    dependency: '依赖关系',\n    strongDependency: '强依赖',\n    weakDependency: '弱依赖',\n    childTaskId: '子任务ID',\n    childTaskIdPlaceholder: '多个ID逗号分隔',\n    cronExpression: 'crontab表达式',\n    cronPlaceholder: '秒 分 时 天 月 周',\n    cronExample: '示例',\n    timezone: '时区',\n    timezoneServer: '服务器时区',\n    protocol: '执行方式',\n    httpMethod: '请求方法',\n    httpBody: '请求 Body',\n    httpBodyPlaceholder: 'POST 请求的 JSON Body，例如 {\\'{\\'}\"key\": \"value\"{\\'}\\'}',\n    httpHeaders: '自定义 Header',\n    httpHeadersPlaceholder: 'JSON 格式，例如 {\\'{\\'}\"Authorization\": \"Bearer token\"{\\'}\\'}',\n    successPattern: '响应断言',\n    successPatternPlaceholder: '正则表达式匹配响应内容，为空则不校验',\n    taskNode: '任务节点',\n    taskNodePlaceholder: '请选择任务节点',\n    command: '命令',\n    timeout: '任务超时时间',\n    singleInstance: '单实例运行',\n    retryTimes: '任务失败重试次数',\n    retryTimesPlaceholder: '0 - 10, 默认0，不重试',\n    retryInterval: '任务失败重试间隔时间',\n    retryIntervalPlaceholder: '0 - 3600 (秒), 默认0，执行系统默认策略',\n    notification: '任务通知',\n    notifyType: '通知类型',\n    notifyReceiver: '接收用户',\n    notifyReceiverPlaceholder: '请选择',\n    notifyChannel: '发送Channel',\n    notifyKeyword: '任务执行输出关键字',\n    notifyKeywordPlaceholder: '任务执行输出中包含此关键字将触发通知',\n    logRetentionDays: '日志保留天数',\n    logRetentionDaysTip: '0 = 使用全局设置',\n    clearTaskLog: '清空该任务日志',\n    confirmClearTaskLog: '确认清空任务ID为 {taskId} 的所有日志？',\n    remark: '备注',\n    status: '状态',\n    nextRunTime: '下次执行时间',\n    operation: '操作',\n    manualRun: '手动执行',\n    viewLog: '查看日志',\n    enable: '启用',\n    disable: '禁用',\n    mainTaskTip:\n      '主任务可以配置多个子任务, 当主任务执行完成后，自动执行子任务<br>任务类型新增后不能变更',\n    dependencyTip:\n      '强依赖: 主任务执行成功，才会运行子任务<br>弱依赖: 无论主任务执行是否成功，都会运行子任务',\n    timeoutTip: '任务执行超时强制结束, 取值0-86400(秒), 默认3600, 0表示不限制',\n    singleInstanceTip:\n      '单实例运行, 前次任务未执行完成，下次任务调度时间到了是否要执行, 即是否允许多进程执行同一任务',\n    cronStandard: '标准语法（秒 分 时 天 月 周）',\n    cronShortcut: '快捷语法',\n    notifyDisabled: '不通知',\n    notifyOnFailure: '失败通知',\n    notifyAlways: '总是通知',\n    notifyKeywordMatch: '关键字匹配通知',\n    notifyEmail: '邮件',\n    notifySlack: 'Slack',\n    notifyWebhook: 'WebHook',\n    createNew: '新增任务',\n    versionHistory: '版本历史',\n    version: '版本',\n    versionRemark: '变更说明',\n    versionUser: '修改人',\n    versionTime: '修改时间',\n    versionRollback: '回滚',\n    versionRollbackConfirm: '确定要回滚到版本 {version} 吗？',\n    versionRollbackSuccess: '回滚成功',\n    versionCommand: '命令内容'\n  },\n  host: {\n    list: '任务节点',\n    name: '主机名',\n    alias: '别名',\n    port: '端口',\n    remark: '备注',\n    createTime: '创建时间',\n    createNew: '新增节点',\n    namePlaceholder: '请输入主机名',\n    aliasPlaceholder: '请输入别名',\n    portPlaceholder: '请输入端口',\n    nameRequired: '请输入主机名',\n    portRequired: '请输入端口',\n    aliasRequired: '请输入节点名称',\n    portInvalid: '端口无效',\n    autoRegister: '自动注册',\n    agentInstall: 'Agent安装',\n    installCommand: '安装命令',\n    installTip:\n      '在目标服务器上执行对应的命令，将自动安装并注册Agent节点。注意：必须使用非root用户执行安装脚本',\n    tokenExpires: 'Token有效期',\n    tokenUsage: '使用说明',\n    tokenReusable: '此Token可在有效期内重复使用，适用于批量安装',\n    bashCommand: '在终端（非root用户）执行以下命令：',\n    powershellCommand: '在PowerShell（管理员权限）中执行：',\n    windowsManualInstall: 'Windows 手动安装',\n    windowsManualInstallTip: '出于安全考虑，Windows 系统建议手动安装 gocron-node',\n    windowsStep1: '下载安装包',\n    windowsStep1Desc: '从 GitHub Releases 下载对应版本的 gocron-node-windows-amd64.zip',\n    windowsStep2: '解压并配置',\n    windowsStep2Desc: '解压到目标目录，在 Web 界面手动添加节点配置',\n    windowsStep3: '启动服务',\n    windowsStep3Desc: '运行 gocron-node.exe 或创建 Windows 服务'\n  },\n  user: {\n    list: '用户管理',\n    username: '用户名',\n    email: '邮箱',\n    role: '角色',\n    admin: '管理员',\n    normalUser: '普通用户',\n    password: '密码',\n    confirmPassword: '确认密码',\n    oldPassword: '旧密码',\n    newPassword: '新密码',\n    confirmNewPassword: '确认新密码',\n    createNew: '新增用户',\n    changePassword: '修改密码',\n    usernamePlaceholder: '请输入用户名',\n    emailPlaceholder: '请输入邮箱',\n    passwordPlaceholder: '至少8位，包含字母和数字',\n    usernameRequired: '请输入用户名',\n    emailRequired: '请输入有效邮箱地址',\n    passwordRequired: '请输入密码',\n    confirmPasswordRequired: '请再次输入密码',\n    oldPasswordRequired: '请输入旧密码',\n    newPasswordRequired: '请输入新密码'\n  },\n  system: {\n    manage: '系统管理',\n    loginLog: '登录日志',\n    logRetention: '日志保留',\n    notification: '通知设置',\n    email: '邮件通知',\n    slack: 'Slack通知',\n    webhook: 'WebHook通知',\n    loginTime: '登录时间',\n    loginIp: '登录IP',\n    retentionDays: '保留天数',\n    retentionDaysPlaceholder: '请输入保留天数',\n    smtpHost: 'SMTP主机',\n    smtpPort: 'SMTP端口',\n    smtpUser: 'SMTP用户',\n    smtpPassword: 'SMTP密码',\n    mailFrom: '发件人',\n    slackUrl: 'Slack Webhook URL',\n    webhookUrl: 'Webhook URL',\n    testSend: '测试发送',\n    logRetentionSettings: '日志自动清理设置',\n    dbLogRetentionDays: '数据库日志保留天数',\n    dbLogRetentionTip: '设置为0表示不自动清理数据库日志',\n    cleanupTime: '清理时间',\n    cleanupTimeTip: '每天在此时间自动执行日志清理，修改后立即生效',\n    selectTime: '选择时间',\n    logFileSizeLimit: '日志文件大小限制',\n    logFileSizeLimitTip: '设置为0表示不清理日志文件，大于0则当日志文件超过此大小时自动清空',\n    logRetentionSaveSuccess: '保存成功，清理任务已重新加载',\n    emailServerConfig: '邮件服务器配置',\n    templateSupportsHtml: '通知模板支持html',\n    template: '模板',\n    addUser: '新增用户',\n    notificationUsers: '通知用户',\n    emailAddress: '邮箱地址',\n    pleaseEnterEmailServer: '请输入邮件服务器地址',\n    pleaseEnterValidPort: '请输入有效的端口',\n    pleaseEnterUserEmail: '请输入用户email',\n    pleaseEnterTemplate: '请输入通知模板内容',\n    incompleteParameters: '参数不完整',\n    channel: 'Channel',\n    channels: 'Channels',\n    addChannel: '新增Channel',\n    channelName: 'Channel名称',\n    pleaseEnterChannelName: '请输入Channel名称',\n    pleaseEnterValidUrl: '请输入有效的通知URL',\n    webhookTip: 'POST请求，设置Header[Content-Type: application/json]',\n    addWebhookUrl: '新增Webhook地址',\n    webhookUrls: 'Webhook地址列表',\n    webhookName: 'Webhook名称',\n    logCleanup: '日志清理',\n    templateVariables: '通知模板支持的变量',\n    taskIdVar: '任务ID',\n    taskNameVar: '任务名称',\n    statusVar: '任务执行结果状态',\n    resultVar: '任务执行输出',\n    emailTemplatePlaceholder: function () {\n      return `${this.taskIdVar}: {{.TaskId}}\\\\n${this.taskNameVar}: {{.TaskName}}\\\\n${this.statusVar}: {{.Status}}\\\\n${this.resultVar}: {{.Result}}`\n    },\n    slackTemplatePlaceholder: function () {\n      return `${this.taskIdVar}: {{.TaskId}}\\\\n${this.taskNameVar}: {{.TaskName}}\\\\n${this.statusVar}: {{.Status}}\\\\n${this.resultVar}: {{.Result}}`\n    },\n    webhookTemplatePlaceholder:\n      '{\"task_id\": \"{{.TaskId}}\", \"task_name\": \"{{.TaskName}}\", \"status\": \"{{.Status}}\", \"result\": \"{{.Result}}\", \"remark\": \"{{.Remark}}\"}'\n  },\n  taskLog: {\n    list: '任务日志',\n    taskName: '任务名称',\n    startTime: '开始时间',\n    endTime: '结束时间',\n    duration: '执行时长',\n    result: '执行结果',\n    host: '主机',\n    output: '执行输出',\n    success: '成功',\n    failed: '失败',\n    viewOutput: '查看输出'\n  },\n  twoFactor: {\n    title: '双因素认证 (2FA)',\n    status: '状态',\n    enabled: '已启用',\n    disabled: '未启用',\n    enable: '启用2FA',\n    disable: '禁用2FA',\n    setup: '启用双因素认证',\n    qrCode: '二维码',\n    secret: '密钥',\n    scanQR: '1. 使用认证APP扫描下方二维码：',\n    manualEntry: '2. 或手动输入密钥：',\n    verifyCode: '验证码',\n    verifyCodePlaceholder: '请输入6位验证码',\n    verifyCodeStep: '3. 输入APP显示的6位验证码：',\n    confirm: '确定',\n    confirmDisable: '确定禁用',\n    confirmDisableMsg: '确定要禁用双因素认证吗？',\n    enableSuccess: '2FA已启用',\n    disableSuccess: '2FA已禁用',\n    verifyFailed: '验证码错误',\n    alertTitle: '提示',\n    alertDescription: '启用双因素认证可以大大提高账户安全性。建议所有用户特别是管理员启用此功能。',\n    enabledAlertTitle: '2FA已启用',\n    enabledAlertDescription: '您的账户已启用双因素认证保护。',\n    disableDialogTitle: '禁用双因素认证',\n    disableDialogDescription: '请输入认证APP显示的6位验证码以禁用2FA：',\n    copySecret: '复制',\n    secretCopied: '密钥已复制到剪贴板',\n    verifyCodeLength: '请输入6位验证码',\n    disableFailed: '禁用2FA失败'\n  },\n  install: {\n    title: '系统安装',\n    welcome: '欢迎使用 Gocron',\n    dbConfig: '数据库配置',\n    dbType: '数据库选择',\n    dbHost: '主机名',\n    dbPort: '端口',\n    dbName: '数据库名称',\n    dbFilePath: '数据库文件路径',\n    dbUser: '用户名',\n    dbPassword: '密码',\n    dbTablePrefix: '表前缀',\n    adminConfig: '管理员账号配置',\n    adminUsername: '账号',\n    adminPassword: '密码',\n    confirmPassword: '确认密码',\n    adminEmail: '邮箱',\n    install: '安装',\n    installing: '安装中...',\n    installSuccess: '安装成功',\n    installFailed: '安装失败',\n    dbNamePlaceholder: '数据库不存在需提前创建',\n    dbFilePathPlaceholder: './data/gocron.db',\n    passwordPlaceholder: '至少8位，含字母和数字',\n    selectDb: '请选择数据库',\n    enterDbName: '请输入数据库名称',\n    enterDbHost: '请输入主机名',\n    enterDbPort: '请输入端口',\n    enterDbUser: '请输入用户名',\n    enterDbPassword: '请输入密码',\n    enterAdminUsername: '请输入账号',\n    enterAdminEmail: '请输入邮箱',\n    enterAdminPassword: '请输入密码',\n    confirmAdminPassword: '请再次输入密码',\n    passwordMinLength: '长度至少8个字符'\n  },\n  message: {\n    saveSuccess: '保存成功',\n    saveFailed: '保存失败',\n    deleteSuccess: '删除成功',\n    deleteFailed: '删除失败',\n    updateSuccess: '更新成功',\n    updateFailed: '更新失败',\n    operationSuccess: '操作成功',\n    operationFailed: '操作失败',\n    refreshSuccess: '刷新成功',\n    loadFailed: '加载失败',\n    requestTimeout: '请求超时，请稍后重试',\n    authExpired: '登录已过期，请重新登录',\n    requestFailed: '请求失败',\n    networkError: '网络错误',\n    serverError: '服务器错误',\n    dataNotFound: '数据不存在',\n    confirmDelete: '确定要删除吗？',\n    confirmDeleteTask: '确定要删除任务 \"{name}\" 吗？',\n    confirmRunTask: '确定要手动执行任务 \"{name}\" 吗？',\n    taskStarted: '任务已开始执行',\n    selectTaskNode: '请选择任务节点',\n    selectMailReceiver: '请选择邮件接收用户',\n    selectSlackChannel: '请选择Slack Channel',\n    selectWebhookUrl: '请选择Webhook地址',\n    passwordMismatch: '两次密码输入不一致',\n    oldPasswordError: '原密码输入错误',\n    passwordSameAsOld: '原密码与新密码不能相同',\n    usernameExists: '用户名已存在',\n    emailExists: '邮箱已存在',\n    formValidationFailed: '表单验证失败，请检查输入',\n    pleaseEnterPassword: '请输入密码',\n    pleaseEnterPasswordAgain: '请再次输入密码',\n    pleaseEnterTaskName: '请输入任务名称',\n    pleaseEnterCronExpression: '请输入crontab表达式',\n    pleaseEnterCommand: '请输入命令',\n    pleaseEnterValidTimeout: '请输入有效的任务超时时间',\n    pleaseEnterValidRetryTimes: '请输入有效的任务执行失败重试次数',\n    pleaseEnterValidRetryInterval: '请输入有效的任务执行失败，重试间隔时间',\n    pleaseEnterNotifyKeyword: '请输入要匹配的任务执行输出关键字',\n    pleaseEnterUrl: '请输入URL地址',\n    pleaseEnterShellCommand: '请输入shell命令',\n    selected: '已选择',\n    tasks: '个任务',\n    batchEnable: '批量启用',\n    batchDisable: '批量禁用',\n    batchDelete: '批量删除',\n    confirmBatchEnable: '确定要启用选中的 {count} 个任务吗？',\n    confirmBatchDisable: '确定要禁用选中的 {count} 个任务吗？',\n    confirmBatchDelete: '确定要删除选中的 {count} 个任务吗？此操作不可恢复！',\n    batchEnableSuccess: '批量启用成功',\n    batchDisableSuccess: '批量禁用成功',\n    batchDeleteSuccess: '批量删除成功',\n    pleaseSelectTask: '请选择要{action}的任务',\n    manualRunTask: '手动执行任务',\n    confirmExecute: '确定执行',\n    confirmDeleteTitle: '提示',\n    confirmDeleteButton: '确定删除',\n    taskCreatedTime: '任务创建时间',\n    taskType: '任务类型',\n    singleInstanceRun: '单实例运行',\n    timeoutTime: '超时时间',\n    retryCount: '重试次数',\n    retryIntervalTime: '重试间隔',\n    taskNodeLabel: '任务节点',\n    commandLabel: '命令',\n    remarkLabel: '备注',\n    noLimit: '不限制',\n    systemDefault: '系统默认',\n    seconds: '秒',\n    activated: '激活',\n    stopped: '停止',\n    confirmDeleteNode: '确定删除此节点?',\n    confirmDeleteUser: '确定删除此用户?',\n    connectionSuccess: '连接成功',\n    copySuccess: '复制成功',\n    copyFailed: '复制失败',\n    all: '全部',\n    clearLog: '清空日志',\n    confirmClearLog: '确定清空所有日志?',\n    running: '执行中',\n    cancelled: '取消',\n    stopTask: '停止任务',\n    taskExecutionResult: '任务执行结果',\n    cronExamples: 'Cron表达式示例',\n    everyMinute: '每分钟第0秒运行',\n    every20Seconds: '每隔20秒运行一次',\n    everyDay21_30: '每天晚上21:30:00运行',\n    everySaturday23: '每周六晚上23:00:00运行',\n    reboot: '仅在应用启动时执行一次',\n    yearly: '每年运行一次',\n    monthly: '每月运行一次',\n    weekly: '每周运行一次',\n    daily: '每天运行一次',\n    hourly: '每小时运行一次',\n    every30s: '每隔30秒运行一次',\n    every1m20s: '每隔1分钟20秒运行一次'\n  },\n  template: {\n    list: '任务模板',\n    name: '模板名称',\n    description: '描述',\n    category: '分类',\n    protocol: '执行方式',\n    command: '命令',\n    timeout: '超时时间',\n    usageCount: '使用次数',\n    builtin: '内置',\n    custom: '自定义',\n    createNew: '新建模板',\n    useTemplate: '使用模板',\n    saveAsTemplate: '保存为模板',\n    templateVarTip: '使用 {{变量名}} 语法定义模板变量，应用时用户将填写变量值',\n    applyTemplate: '应用模板',\n    applySuccess: '模板已应用',\n    fillVariables: '填写模板变量',\n    variableName: '变量名',\n    variableValue: '值',\n    noTemplates: '暂无模板',\n    confirmDelete: '确定删除模板 \"{name}\" 吗？',\n    category_all: '全部',\n    category_backup: '备份',\n    category_cleanup: '清理',\n    category_monitor: '监控',\n    category_deploy: '部署',\n    category_api: 'API调用',\n    category_custom: '自定义',\n    preview: '预览',\n    templateNamePlaceholder: '请输入模板名称',\n    templateDescPlaceholder: '请输入模板描述',\n    selectCategory: '请选择分类',\n    saveAsTemplateName: '模板名称',\n    saveAsTemplateDesc: '模板描述',\n    saveAsTemplateCategory: '分类',\n    securityWarning:\n      '变量值将明文存储在任务命令中。请勿填入密码等敏感信息，建议使用环境变量引用（如 $DB_PASS）。',\n    saveAsTemplateWarning:\n      '命令内容将原样保存为模板（所有用户可见）。请先移除密码、密钥等敏感信息，用 {{变量名}} 占位符替代。'\n  },\n  audit: {\n    log: '操作审计',\n    module: '模块',\n    action: '操作',\n    target: '操作对象',\n    detail: '变更详情',\n    module_task: '任务',\n    module_host: '节点',\n    module_user: '用户',\n    module_system: '系统',\n    action_create: '创建',\n    action_update: '修改',\n    action_delete: '删除',\n    action_enable: '启用',\n    action_disable: '禁用',\n    action_run: '手动执行',\n    action_batch_enable: '批量启用',\n    action_batch_disable: '批量禁用',\n    action_batch_remove: '批量删除',\n    action_change_password: '修改密码',\n    action_reset_password: '重置密码'\n  },\n  statistics: {\n    title: '数据统计',\n    totalTasks: '任务总数',\n    todayExecutions: '今日执行',\n    last7DaysExecutions: '7天执行次数',\n    successRate: '7天成功率',\n    failedCount: '7天失败任务',\n    last7DaysTrend: '最近7天趋势',\n    success: '成功',\n    failed: '失败',\n    total: '总计',\n    executionCount: '执行次数',\n    date: '日期',\n    detailedData: '详细数据'\n  }\n}\n"
  },
  {
    "path": "web/vue/src/main.js",
    "content": "import { createApp } from 'vue'\nimport { createPinia } from 'pinia'\nimport piniaPluginPersistedstate from 'pinia-plugin-persistedstate'\nimport { ElMessageBox, ElMessage } from 'element-plus'\nimport 'element-plus/dist/index.css'\nimport 'nprogress/nprogress.css'\nimport dayjs from 'dayjs'\nimport utc from 'dayjs/plugin/utc'\nimport timezone from 'dayjs/plugin/timezone'\nimport App from './App.vue'\nimport router from './router'\nimport i18n from './locales'\n\ndayjs.extend(utc)\ndayjs.extend(timezone)\n\nconst app = createApp(App)\nconst pinia = createPinia()\npinia.use(piniaPluginPersistedstate)\n\napp.use(pinia)\napp.use(router)\napp.use(i18n)\n\napp.directive('focus', {\n  mounted(el) {\n    el.focus()\n  }\n})\n\napp.config.globalProperties.$appConfirm = function (callback) {\n  ElMessageBox.confirm(i18n.global.t('common.confirmOperation'), i18n.global.t('common.tip'), {\n    confirmButtonText: i18n.global.t('common.confirm'),\n    cancelButtonText: i18n.global.t('common.cancel'),\n    type: 'warning',\n    center: true,\n    customClass: 'custom-message-box'\n  })\n    .then(() => {\n      callback()\n    })\n    .catch(() => {})\n}\n\napp.config.globalProperties.$message = ElMessage\n\napp.config.globalProperties.$filters = {\n  formatTime(time) {\n    if (!time) return ''\n    return dayjs(time).format('YYYY-MM-DD HH:mm:ss')\n  }\n}\n\n// 全局错误处理\napp.config.errorHandler = (err, instance, info) => {\n  if (import.meta.env.DEV) {\n    console.error('[Global Error]', err, info)\n  }\n  ElMessage.error('系统错误，请刷新页面重试')\n}\n\napp.config.warnHandler = (msg, instance, trace) => {\n  if (import.meta.env.DEV) {\n    console.warn('[Vue Warn]', msg, trace)\n  }\n}\n\n// 开发环境性能监控\nif (import.meta.env.DEV) {\n  app.config.performance = true\n}\n\n// 生产环境禁用 devtools\nif (import.meta.env.PROD) {\n  app.config.devtools = false\n}\n\napp.mount('#app')\n"
  },
  {
    "path": "web/vue/src/pages/host/edit.vue",
    "content": "<template>\n  <el-main>\n    <el-form\n      ref=\"form\"\n      :model=\"form\"\n      :rules=\"formRules\"\n      label-width=\"auto\"\n      style=\"width: 500px;\"\n    >\n      <el-form-item>\n        <el-input\n          v-model=\"form.id\"\n          type=\"hidden\"\n        />\n      </el-form-item>\n      <el-form-item\n        :label=\"t('host.alias')\"\n        prop=\"alias\"\n      >\n        <el-input v-model=\"form.alias\" />\n      </el-form-item>\n      <el-form-item\n        :label=\"t('host.name')\"\n        prop=\"name\"\n      >\n        <el-input v-model=\"form.name\" />\n      </el-form-item>\n      <el-form-item\n        :label=\"t('host.port')\"\n        prop=\"port\"\n      >\n        <el-input v-model.number=\"form.port\" />\n      </el-form-item>\n      <el-form-item :label=\"t('host.remark')\">\n        <el-input\n          v-model=\"form.remark\"\n          type=\"textarea\"\n          :rows=\"5\"\n        />\n      </el-form-item>\n      <el-form-item>\n        <el-button\n          type=\"primary\"\n          @click=\"submit()\"\n        >\n          {{ t('common.save') }}\n        </el-button>\n        <el-button @click=\"cancel\">\n          {{ t('common.cancel') }}\n        </el-button>\n      </el-form-item>\n    </el-form>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport hostService from '../../api/host'\nexport default {\n  name: 'Edit',\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale }\n  },\n  data: function () {\n    return {\n      form: {\n        id: '',\n        name: '',\n        port: 5921,\n        alias: '',\n        remark: ''\n      },\n      formRules: {}\n    }\n  },\n  computed: {\n    computedFormRules() {\n      return {\n        name: [\n          {required: true, message: this.t('host.nameRequired'), trigger: 'blur'}\n        ],\n        port: [\n          {required: true, message: this.t('host.portRequired'), trigger: 'blur'},\n          {type: 'number', message: this.t('host.portInvalid')}\n        ],\n        alias: [\n          {required: true, message: this.t('host.aliasRequired'), trigger: 'blur'}\n        ]\n      }\n    }\n  },\n  watch: {\n    computedFormRules: {\n      handler(newVal) {\n        this.formRules = newVal\n      },\n      immediate: true\n    },\n    '$route': {\n      handler() {\n        this.loadForm()\n      },\n      deep: true\n    }\n  },\n  created () {\n    this.loadForm()\n  },\n  methods: {\n    loadForm() {\n      this.resetForm()\n      const id = this.$route.params.id\n      if (!id) {\n        return\n      }\n      hostService.detail(id, (data) => {\n      if (!data) {\n        this.$message.error(this.t('message.dataNotFound'))\n        this.cancel()\n        return\n      }\n      this.form.id = data.id\n      this.form.name = data.name\n      this.form.port = data.port\n      this.form.alias = data.alias\n      this.form.remark = data.remark\n    })\n    },\n    resetForm() {\n      this.form = {\n        id: '',\n        name: '',\n        port: 5921,\n        alias: '',\n        remark: ''\n      }\n      if (this.$refs.form) {\n        this.$refs.form.clearValidate()\n      }\n    },\n    submit () {\n      this.$refs['form'].validate((valid) => {\n        if (!valid) {\n          return false\n        }\n        this.save()\n      })\n    },\n    save () {\n      hostService.update(this.form, () => {\n        this.$router.push('/host')\n      })\n    },\n    cancel () {\n      this.$router.push('/host')\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "web/vue/src/pages/host/list.vue",
    "content": "<template>\n  <el-main>\n    <el-form :inline=\"true\">\n      <el-row>\n        <el-form-item label=\"ID\">\n          <el-input v-model.trim=\"searchParams.id\" />\n        </el-form-item>\n        <el-form-item :label=\"t('host.name')\">\n          <el-input v-model.trim=\"searchParams.name\" />\n        </el-form-item>\n        <el-form-item>\n          <el-button\n            type=\"primary\"\n            @click=\"search()\"\n          >\n            {{ t('common.search') }}\n          </el-button>\n        </el-form-item>\n      </el-row>\n    </el-form>\n    <el-row\n      type=\"flex\"\n      justify=\"end\"\n      style=\"gap: 10px; margin-bottom: 15px;\"\n    >\n      <el-button\n        v-if=\"isAdmin\"\n        type=\"success\"\n        icon=\"Download\"\n        @click=\"showAgentInstall\"\n      >\n        {{ t('host.autoRegister') }}\n      </el-button>\n      <el-button\n        v-if=\"isAdmin\"\n        type=\"primary\"\n        @click=\"toEdit(null)\"\n      >\n        {{ t('common.add') }}\n      </el-button>\n      <el-button\n        type=\"info\"\n        icon=\"Refresh\"\n        @click=\"refresh\"\n      >\n        {{ t('common.refresh') }}\n      </el-button>\n    </el-row>\n    <el-pagination\n      v-model:current-page=\"searchParams.page\"\n      v-model:page-size=\"searchParams.page_size\"\n      background\n      layout=\"prev, pager, next, sizes, total\"\n      :total=\"hostTotal\"\n      @size-change=\"changePageSize\"\n      @current-change=\"changePage\"\n    />\n    <el-table\n      :data=\"hosts\"\n      tooltip-effect=\"dark\"\n      border\n      style=\"width: 100%\"\n    >\n      <el-table-column\n        prop=\"id\"\n        label=\"ID\"\n      />\n      <el-table-column\n        prop=\"alias\"\n        :label=\"t('host.alias')\"\n      />\n      <el-table-column\n        prop=\"name\"\n        :label=\"t('host.name')\"\n      />\n      <el-table-column\n        prop=\"port\"\n        :label=\"t('host.port')\"\n      />\n      <el-table-column :label=\"t('task.viewLog')\">\n        <template #default=\"scope\">\n          <el-button\n            type=\"success\"\n            @click=\"toTasks(scope.row)\"\n          >\n            {{ t('task.list') }}\n          </el-button>\n        </template>\n      </el-table-column>\n      <el-table-column\n        prop=\"remark\"\n        :label=\"t('host.remark')\"\n      />\n      <el-table-column\n        v-if=\"isAdmin\"\n        :label=\"t('common.operation')\"\n        :width=\"locale === 'zh-CN' ? 260 : 300\"\n      >\n        <template #default=\"scope\">\n          <el-button\n            type=\"primary\"\n            size=\"small\"\n            @click=\"toEdit(scope.row)\"\n          >\n            {{ t('common.edit') }}\n          </el-button>\n          <el-button\n            type=\"info\"\n            size=\"small\"\n            @click=\"ping(scope.row)\"\n          >\n            {{ t('system.testSend') }}\n          </el-button>\n          <el-button\n            type=\"danger\"\n            size=\"small\"\n            @click=\"remove(scope.row)\"\n          >\n            {{ t('common.delete') }}\n          </el-button>\n        </template>\n      </el-table-column>\n    </el-table>\n\n    <el-dialog\n      v-model=\"agentDialogVisible\"\n      :title=\"t('host.agentInstall')\"\n      width=\"750px\"\n    >\n      <div v-if=\"installCommand\">\n        <el-alert\n          :title=\"t('host.installTip')\"\n          type=\"info\"\n          :closable=\"false\"\n          style=\"margin-bottom: 20px\"\n          show-icon\n        />\n        \n        <el-tabs\n          v-model=\"activeTab\"\n          type=\"card\"\n        >\n          <el-tab-pane\n            label=\"Linux / macOS\"\n            name=\"linux\"\n          >\n            <div style=\"padding: 15px; background: #f5f7fa; border-radius: 4px;\">\n              <div style=\"margin-bottom: 10px; color: #606266; font-size: 14px;\">\n                <el-icon style=\"vertical-align: middle;\">\n                  <Monitor />\n                </el-icon>\n                {{ t('host.bashCommand') }}\n              </div>\n              <el-input\n                v-model=\"installCommand\"\n                type=\"textarea\"\n                :rows=\"3\"\n                readonly\n                style=\"font-family: monospace; font-size: 13px;\"\n              />\n              <div style=\"margin-top: 10px; text-align: right;\">\n                <el-button\n                  type=\"primary\"\n                  icon=\"DocumentCopy\"\n                  @click=\"copyCommand('linux')\"\n                >\n                  Copy\n                </el-button>\n              </div>\n            </div>\n          </el-tab-pane>\n          \n          <el-tab-pane\n            label=\"Windows\"\n            name=\"windows\"\n          >\n            <div style=\"padding: 15px;\">\n              <el-alert\n                type=\"warning\"\n                :closable=\"false\"\n                style=\"margin-bottom: 15px;\"\n              >\n                <template #title>\n                  <strong>{{ t('host.windowsManualInstall') }}</strong>\n                </template>\n                {{ t('host.windowsManualInstallTip') }}\n              </el-alert>\n              \n              <el-steps\n                direction=\"vertical\"\n                :active=\"3\"\n              >\n                <el-step\n                  :title=\"t('host.windowsStep1')\"\n                  :description=\"t('host.windowsStep1Desc')\"\n                />\n                <el-step\n                  :title=\"t('host.windowsStep2')\"\n                  :description=\"t('host.windowsStep2Desc')\"\n                />\n                <el-step\n                  :title=\"t('host.windowsStep3')\"\n                  :description=\"t('host.windowsStep3Desc')\"\n                />\n              </el-steps>\n            </div>\n          </el-tab-pane>\n        </el-tabs>\n        \n        <el-divider />\n        \n        <div style=\"padding: 10px 0;\">\n          <el-descriptions\n            :column=\"1\"\n            border\n          >\n            <el-descriptions-item :label=\"t('host.tokenExpires')\">\n              <el-tag\n                type=\"warning\"\n                effect=\"plain\"\n              >\n                {{ expiresAt }}\n              </el-tag>\n            </el-descriptions-item>\n            <el-descriptions-item :label=\"t('host.tokenUsage')\">\n              <span style=\"color: #67c23a;\">{{ t('host.tokenReusable') }}</span>\n            </el-descriptions-item>\n          </el-descriptions>\n        </div>\n      </div>\n      <div\n        v-else\n        style=\"text-align: center; padding: 20px\"\n      >\n        <el-icon\n          class=\"is-loading\"\n          :size=\"30\"\n        >\n          <Loading />\n        </el-icon>\n        <p>{{ t('common.loading') }}</p>\n      </div>\n    </el-dialog>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport { ElMessageBox } from 'element-plus'\nimport { Loading } from '@element-plus/icons-vue'\nimport hostService from '../../api/host'\nimport agentService from '../../api/agent'\nimport { useUserStore } from '../../stores/user'\n\nexport default {\n  name: 'HostList',\n  components: {\n    Loading\n  },\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale }\n  },\n  data () {\n    const userStore = useUserStore()\n    return {\n      hosts: [],\n      hostTotal: 0,\n      searchParams: {\n        page_size: 20,\n        page: 1,\n        id: '',\n        name: '',\n        alias: ''\n      },\n      isAdmin: userStore.isAdmin,\n      agentDialogVisible: false,\n      installCommand: '',\n      expiresAt: '',\n      activeTab: 'linux',\n      cachedToken: null,\n      cachedTokenExpires: null\n    }\n  },\n  watch: {\n    '$route'(to, from) {\n      if (to.path === '/host' && (from.path === '/host/create' || from.path.startsWith('/host/edit/'))) {\n        this.search()\n      }\n    }\n  },\n  created () {\n    this.search()\n  },\n  methods: {\n    changePage (page) {\n      this.searchParams.page = page\n      this.search()\n    },\n    changePageSize (pageSize) {\n      this.searchParams.page_size = pageSize\n      this.search()\n    },\n    search (callback = null) {\n      hostService.list(this.searchParams, (data) => {\n        this.hosts = data.data\n        this.hostTotal = data.total\n        if (callback) {\n          callback()\n        }\n      })\n    },\n    remove (item) {\n      ElMessageBox.confirm(this.t('message.confirmDeleteNode'), this.t('common.tip'), {\n        confirmButtonText: this.t('common.confirm'),\n        cancelButtonText: this.t('common.cancel'),\n        type: 'warning',\n        center: true\n      }).then(() => {\n        hostService.remove(item.id, () => this.refresh())\n      }).catch(() => {})\n    },\n    ping (item) {\n      if (!item.id || item.id <= 0) {\n        this.$message.error(this.t('message.dataNotFound'))\n        return\n      }\n      hostService.ping(item.id, () => {\n        this.$message.success(this.t('message.connectionSuccess'))\n      })\n    },\n    toEdit (item) {\n      let path = ''\n      if (item === null) {\n        path = '/host/create'\n      } else {\n        path = `/host/edit/${item.id}`\n      }\n      this.$router.push(path)\n    },\n    refresh () {\n      this.search(() => {\n        this.$message.success(this.t('message.refreshSuccess'))\n      })\n    },\n    toTasks (item) {\n      this.$router.push(\n        {\n          path: '/task',\n          query: {\n            host_id: item.id\n          }\n        })\n    },\n    showAgentInstall () {\n      this.agentDialogVisible = true\n      \n      // 检查是否有缓存的token且未过期\n      const now = new Date()\n      if (this.cachedToken && this.cachedTokenExpires && now < this.cachedTokenExpires) {\n        // 使用缓存的token\n        this.installCommand = this.cachedToken.install_cmd\n        this.expiresAt = this.cachedTokenExpires.toLocaleString()\n        return\n      }\n      \n      // 生成新token\n      this.installCommand = ''\n      this.expiresAt = ''\n      agentService.generateToken((data) => {\n        this.installCommand = data.install_cmd\n        const expiresDate = new Date(data.expires_at)\n        this.expiresAt = expiresDate.toLocaleString()\n        \n        // 缓存token信息\n        this.cachedToken = data\n        this.cachedTokenExpires = expiresDate\n      })\n    },\n    copyCommand (type) {\n      const cmd = type === 'windows' ? this.installCommandWindows : this.installCommand\n      navigator.clipboard.writeText(cmd).then(() => {\n        this.$message.success(this.t('message.copySuccess'))\n      }).catch(() => {\n        this.$message.error(this.t('message.copyFailed'))\n      })\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "web/vue/src/pages/install/index.vue",
    "content": "<template>\n  <el-main>\n    <div class=\"install-header\">\n      <div class=\"language-switcher\">\n        <LanguageSwitcher />\n      </div>\n    </div>\n    <el-form\n      ref=\"form\"\n      :model=\"form\"\n      :rules=\"formRules\"\n      label-width=\"150px\"\n      style=\"width: 700px;\"\n    >\n      <h3>{{ t('install.dbConfig') }}</h3>\n      <el-form-item\n        :label=\"t('install.dbType')\"\n        prop=\"db_type\"\n      >\n        <el-select\n          v-model.trim=\"form.db_type\"\n          @change=\"update_port\"\n        >\n          <el-option\n            v-for=\"item in dbList\"\n            :key=\"item.value\"\n            :label=\"item.label\"\n            :value=\"item.value\"\n          />\n        </el-select>\n      </el-form-item>\n      <el-row v-if=\"form.db_type !== 'sqlite'\">\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('install.dbHost')\"\n            prop=\"db_host\"\n          >\n            <el-input v-model=\"form.db_host\" />\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('install.dbPort')\"\n            prop=\"db_port\"\n          >\n            <el-input v-model.number=\"form.db_port\" />\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-row v-if=\"form.db_type !== 'sqlite'\">\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('install.dbUser')\"\n            prop=\"db_username\"\n          >\n            <el-input v-model=\"form.db_username\" />\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('install.dbPassword')\"\n            prop=\"db_password\"\n          >\n            <el-input\n              v-model=\"form.db_password\"\n              type=\"password\"\n            />\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-row>\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"form.db_type === 'sqlite' ? t('install.dbFilePath') : t('install.dbName')\"\n            prop=\"db_name\"\n          >\n            <el-input\n              v-model=\"form.db_name\"\n              :placeholder=\"form.db_type === 'sqlite' ? t('install.dbFilePathPlaceholder') : t('install.dbNamePlaceholder')\"\n            />\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('install.dbTablePrefix')\"\n            prop=\"db_table_prefix\"\n          >\n            <el-input v-model=\"form.db_table_prefix\" />\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <h3>{{ t('install.adminConfig') }}</h3>\n      <el-row>\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('install.adminUsername')\"\n            prop=\"admin_username\"\n          >\n            <el-input v-model=\"form.admin_username\" />\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('install.adminEmail')\"\n            prop=\"admin_email\"\n          >\n            <el-input v-model=\"form.admin_email\" />\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-row>\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('install.adminPassword')\"\n            prop=\"admin_password\"\n          >\n            <el-input\n              v-model=\"form.admin_password\"\n              type=\"password\"\n              :placeholder=\"t('install.passwordPlaceholder')\"\n            />\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('install.confirmPassword')\"\n            prop=\"confirm_admin_password\"\n          >\n            <el-input\n              v-model=\"form.confirm_admin_password\"\n              type=\"password\"\n              :placeholder=\"t('install.passwordPlaceholder')\"\n            />\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-form-item>\n        <el-button\n          type=\"primary\"\n          @click=\"submit()\"\n        >\n          {{ t('install.install') }}\n        </el-button>\n      </el-form-item>\n    </el-form>\n\n    <!-- 语言选择对话框 -->\n    <el-dialog\n      v-model=\"showLanguageDialog\"\n      :title=\"currentDialogTitle\"\n      width=\"400px\"\n      :close-on-click-modal=\"false\"\n      :close-on-press-escape=\"false\"\n      :show-close=\"false\"\n      center\n    >\n      <div class=\"language-selection\">\n        <p class=\"language-prompt\">\n          {{ currentDialogPrompt }}\n        </p>\n        <div class=\"language-options\">\n          <el-button\n            v-for=\"lang in availableLanguages\"\n            :key=\"lang.value\"\n            :type=\"selectedLanguage === lang.value ? 'primary' : 'default'\"\n            size=\"large\"\n            class=\"language-button\"\n            @click=\"selectLanguage(lang.value)\"\n          >\n            <span class=\"language-icon\">{{ lang.icon }}</span>\n            <span class=\"language-label\">{{ lang.label }}</span>\n          </el-button>\n        </div>\n      </div>\n      <template #footer>\n        <el-button\n          type=\"primary\"\n          :disabled=\"!selectedLanguage\"\n          @click=\"confirmLanguage\"\n        >\n          {{ currentConfirmText }}\n        </el-button>\n      </template>\n    </el-dialog>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport installService from '../../api/install'\nimport LanguageSwitcher from '../../components/common/LanguageSwitcher.vue'\n\nexport default {\n  name: 'Index',\n  components: { LanguageSwitcher },\n  setup() {\n    const { t, locale } = useI18n()\n    \n    // 返回一个方法来设置语言，而不是直接返回 locale\n    const setLocale = (lang) => {\n      locale.value = lang\n    }\n    \n    return { \n      t,\n      setLocale\n    }\n  },\n  data () {\n    return {\n      showLanguageDialog: false,\n      selectedLanguage: '',\n      availableLanguages: [\n        {\n          value: 'zh-CN',\n          label: '简体中文',\n          icon: '🇨🇳'\n        },\n        {\n          value: 'en-US',\n          label: 'English',\n          icon: '🇺🇸'\n        }\n      ],\n      form: {\n        db_type: 'mysql',\n        db_host: '127.0.0.1',\n        db_port: 3306,\n        db_username: '',\n        db_password: '',\n        db_name: '',\n        db_table_prefix: '',\n        admin_username: '',\n        admin_password: '',\n        confirm_admin_password: '',\n        admin_email: ''\n      },\n      formRules: {},\n      dbList: [\n        {\n          value: 'mysql',\n          label: 'MySQL'\n        },\n        {\n          value: 'postgres',\n          label: 'PostgreSql'\n        },\n        {\n          value: 'sqlite',\n          label: 'SQLite'\n        }\n      ],\n      default_ports: {\n        'mysql': 3306,\n        'postgres': 5432,\n        'sqlite': 0\n      }\n    }\n  },\n  computed: {\n    currentDialogTitle() {\n      return this.selectedLanguage === 'en-US' ? 'Select Language' : '选择语言'\n    },\n    currentDialogPrompt() {\n      return this.selectedLanguage === 'en-US' \n        ? 'Please select your preferred language' \n        : '请选择您的首选语言'\n    },\n    currentConfirmText() {\n      return this.selectedLanguage === 'en-US' ? 'Confirm' : '确认'\n    }\n  },\n  created() {\n    this.checkAndShowLanguageDialog()\n    this.initFormRules()\n  },\n  mounted() {\n    console.log('Install page mounted')\n    console.log('Saved locale:', localStorage.getItem('locale'))\n    console.log('Show dialog:', this.showLanguageDialog)\n  },\n  methods: {\n    checkAndShowLanguageDialog() {\n      // 安装页面每次都显示语言选择对话框\n      // 因为安装是一次性操作，每次进入都应该让用户确认语言\n      const savedLocale = localStorage.getItem('locale')\n      console.log('Checking language dialog, savedLocale:', savedLocale)\n      \n      // 总是显示对话框\n      console.log('Showing language selection dialog')\n      this.showLanguageDialog = true\n      // 默认英文，如果有保存的语言则使用保存的\n      this.selectedLanguage = savedLocale || 'en-US'\n    },\n    selectLanguage(lang) {\n      this.selectedLanguage = lang\n    },\n    confirmLanguage() {\n      if (this.selectedLanguage) {\n        // 使用 setup 中返回的方法来设置语言\n        this.setLocale(this.selectedLanguage)\n        localStorage.setItem('locale', this.selectedLanguage)\n        this.showLanguageDialog = false\n        \n        // 不立即更新表单规则，避免触发验证\n        // 表单规则会在用户交互时自动使用新语言\n      }\n    },\n    initFormRules() {\n      this.formRules = {\n        db_type: [\n          {required: true, message: this.t('install.selectDb'), trigger: 'blur'}\n        ],\n        db_name: [\n          {required: true, message: this.t('install.enterDbName'), trigger: 'blur'}\n        ],\n        admin_username: [\n          {required: true, message: this.t('install.enterAdminUsername'), trigger: 'blur'}\n        ],\n        admin_email: [\n          {type: 'email', required: true, message: this.t('install.enterAdminEmail'), trigger: 'blur'}\n        ],\n        admin_password: [\n          {required: true, message: this.t('install.enterAdminPassword'), trigger: 'blur'},\n          {min: 8, message: this.t('install.passwordMinLength'), trigger: 'blur'}\n        ],\n        confirm_admin_password: [\n          {required: true, message: this.t('install.confirmAdminPassword'), trigger: 'blur'},\n          {min: 8, message: this.t('install.passwordMinLength'), trigger: 'blur'}\n        ]\n      }\n    },\n    update_port (dbType) {\n      this.form['db_port'] = this.default_ports[dbType]\n      if (dbType === 'sqlite') {\n        this.form['db_host'] = ''\n        this.form['db_username'] = ''\n        this.form['db_password'] = ''\n        this.form['db_name'] = './data/gocron.db'\n      } else {\n        this.form['db_host'] = '127.0.0.1'\n        this.form['db_name'] = ''\n      }\n    },\n    submit () {\n      // 动态验证：非 SQLite 数据库需要验证主机名、端口、用户名和密码\n      if (this.form.db_type !== 'sqlite') {\n        if (!this.form.db_host) {\n          this.$message.error(this.t('install.enterDbHost'))\n          return\n        }\n        if (!this.form.db_port) {\n          this.$message.error(this.t('install.enterDbPort'))\n          return\n        }\n        if (!this.form.db_username) {\n          this.$message.error(this.t('install.enterDbUser'))\n          return\n        }\n        if (!this.form.db_password) {\n          this.$message.error(this.t('install.enterDbPassword'))\n          return\n        }\n      }\n      \n      this.$refs['form'].validate((valid) => {\n        if (!valid) {\n          return false\n        }\n        this.save()\n      })\n    },\n    save () {\n      installService.store(this.form, () => {\n        this.$router.push('/')\n      })\n    }\n  }\n}\n</script>\n\n<style scoped>\n.install-header {\n  position: relative;\n  width: 100%;\n  margin-bottom: 20px;\n}\n\n.language-switcher {\n  position: absolute;\n  top: 0;\n  right: 20px;\n}\n\n.language-selection {\n  padding: 20px 0;\n}\n\n.language-prompt {\n  text-align: center;\n  font-size: 14px;\n  color: #606266;\n  margin-bottom: 30px;\n  line-height: 1.6;\n}\n\n.language-options {\n  display: flex;\n  flex-direction: column;\n  gap: 15px;\n  align-items: center;\n}\n\n.language-button {\n  width: 280px;\n  height: 60px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 12px;\n  font-size: 16px;\n  transition: all 0.3s;\n}\n\n.language-button:hover {\n  transform: translateY(-2px);\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n}\n\n.language-icon {\n  font-size: 24px;\n}\n\n.language-label {\n  font-weight: 500;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/statistics/index.vue",
    "content": "<template>\n  <el-main class=\"statistics-main\">\n    <div class=\"page-header\">\n      <h2>{{ t('statistics.title') }}</h2>\n      <el-button\n        type=\"primary\"\n        size=\"small\"\n        @click=\"refresh\"\n      >\n        {{ t('common.refresh') }}\n      </el-button>\n    </div>\n      \n    <!-- 统计卡片 -->\n    <el-row\n      :gutter=\"16\"\n      class=\"stat-cards\"\n    >\n      <el-col :span=\"6\">\n        <el-card\n          shadow=\"hover\"\n          class=\"stat-card\"\n        >\n          <div class=\"stat-content\">\n            <div\n              class=\"stat-icon\"\n              style=\"background: #409EFF;\"\n            >\n              <el-icon :size=\"24\">\n                <Document />\n              </el-icon>\n            </div>\n            <div class=\"stat-info\">\n              <div class=\"stat-value\">\n                {{ stats.totalTasks }}\n              </div>\n              <div class=\"stat-label\">\n                {{ t('statistics.totalTasks') }}\n              </div>\n            </div>\n          </div>\n        </el-card>\n      </el-col>\n        \n      <el-col :span=\"6\">\n        <el-card\n          shadow=\"hover\"\n          class=\"stat-card\"\n        >\n          <div class=\"stat-content\">\n            <div\n              class=\"stat-icon\"\n              style=\"background: #67C23A;\"\n            >\n              <el-icon :size=\"24\">\n                <CircleCheck />\n              </el-icon>\n            </div>\n            <div class=\"stat-info\">\n              <div class=\"stat-value\">\n                {{ stats.todayExecutions }}\n              </div>\n              <div class=\"stat-label\">\n                {{ t('statistics.last7DaysExecutions') }}\n              </div>\n            </div>\n          </div>\n        </el-card>\n      </el-col>\n        \n      <el-col :span=\"6\">\n        <el-card\n          shadow=\"hover\"\n          class=\"stat-card\"\n        >\n          <div class=\"stat-content\">\n            <div\n              class=\"stat-icon\"\n              style=\"background: #E6A23C;\"\n            >\n              <el-icon :size=\"24\">\n                <TrendCharts />\n              </el-icon>\n            </div>\n            <div class=\"stat-info\">\n              <div class=\"stat-value\">\n                {{ stats.successRate }}%\n              </div>\n              <div class=\"stat-label\">\n                {{ t('statistics.successRate') }}\n              </div>\n            </div>\n          </div>\n        </el-card>\n      </el-col>\n        \n      <el-col :span=\"6\">\n        <el-card\n          shadow=\"hover\"\n          class=\"stat-card\"\n        >\n          <div class=\"stat-content\">\n            <div\n              class=\"stat-icon\"\n              style=\"background: #F56C6C;\"\n            >\n              <el-icon :size=\"24\">\n                <CircleClose />\n              </el-icon>\n            </div>\n            <div class=\"stat-info\">\n              <div class=\"stat-value\">\n                {{ stats.failedCount }}\n              </div>\n              <div class=\"stat-label\">\n                {{ t('statistics.failedCount') }}\n              </div>\n            </div>\n          </div>\n        </el-card>\n      </el-col>\n    </el-row>\n      \n    <!-- 趋势图表 -->\n    <el-card\n      shadow=\"hover\"\n      class=\"chart-card\"\n    >\n      <template #header>\n        <div class=\"card-header\">\n          <span>{{ t('statistics.last7DaysTrend') }}</span>\n        </div>\n      </template>\n        \n      <!-- 折线图可视化 -->\n      <div class=\"chart-wrapper\">\n        <svg\n          class=\"line-chart\"\n          viewBox=\"0 0 900 240\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <!-- Y轴 -->\n          <line\n            x1=\"70\"\n            y1=\"15\"\n            x2=\"70\"\n            y2=\"180\"\n            stroke=\"#909399\"\n            stroke-width=\"2\"\n          />\n          <!-- X轴 -->\n          <line\n            x1=\"70\"\n            y1=\"180\"\n            x2=\"870\"\n            y2=\"180\"\n            stroke=\"#909399\"\n            stroke-width=\"2\"\n          />\n            \n          <!-- Y轴刻度和标签 -->\n          <g\n            v-for=\"i in 6\"\n            :key=\"'y-tick-' + i\"\n          >\n            <line \n              :x1=\"65\" \n              :y1=\"180 - (i - 1) * 33\" \n              :x2=\"70\" \n              :y2=\"180 - (i - 1) * 33\" \n              stroke=\"#909399\" \n              stroke-width=\"2\" \n            />\n            <text \n              :x=\"58\" \n              :y=\"180 - (i - 1) * 33 + 4\" \n              text-anchor=\"end\" \n              font-size=\"11\" \n              fill=\"#606266\"\n            >\n              {{ Math.round((i - 1) * getMaxValue() / 5) }}\n            </text>\n            <!-- 网格线 -->\n            <line \n              :x1=\"70\" \n              :y1=\"180 - (i - 1) * 33\" \n              :x2=\"870\" \n              :y2=\"180 - (i - 1) * 33\" \n              stroke=\"#e4e7ed\" \n              stroke-width=\"1\" \n              stroke-dasharray=\"5,5\"\n            />\n          </g>\n            \n          <!-- X轴刻度和标签 -->\n          <g\n            v-for=\"(item, index) in stats.chartData\"\n            :key=\"'x-tick-' + index\"\n          >\n            <line \n              :x1=\"getChartPointX(index)\" \n              :y1=\"180\" \n              :x2=\"getChartPointX(index)\" \n              :y2=\"185\" \n              stroke=\"#909399\" \n              stroke-width=\"2\" \n            />\n            <text \n              :x=\"getChartPointX(index)\" \n              :y=\"200\" \n              text-anchor=\"middle\" \n              font-size=\"11\" \n              fill=\"#606266\"\n            >\n              {{ formatDate(item.date) }}\n            </text>\n          </g>\n            \n          <!-- 成功折线 -->\n          <polyline \n            v-if=\"stats.chartData.length > 0\"\n            :points=\"getChartLinePoints('success')\"\n            fill=\"none\"\n            stroke=\"#67C23A\"\n            stroke-width=\"3\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n          />\n            \n          <!-- 失败折线 -->\n          <polyline \n            v-if=\"stats.chartData.length > 0\"\n            :points=\"getChartLinePoints('failed')\"\n            fill=\"none\"\n            stroke=\"#F56C6C\"\n            stroke-width=\"3\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n          />\n            \n          <!-- 成功数据点 -->\n          <g\n            v-for=\"(item, index) in stats.chartData\"\n            :key=\"'success-point-' + index\"\n          >\n            <circle \n              :cx=\"getChartPointX(index)\"\n              :cy=\"getChartPointY(item.success)\"\n              r=\"6\"\n              fill=\"#67C23A\"\n              stroke=\"#fff\"\n              stroke-width=\"2\"\n              class=\"data-point\"\n            />\n            <title>{{ item.date }}: {{ t('statistics.success') }} {{ item.success }}</title>\n          </g>\n            \n          <!-- 失败数据点 -->\n          <g\n            v-for=\"(item, index) in stats.chartData\"\n            :key=\"'failed-point-' + index\"\n          >\n            <circle \n              :cx=\"getChartPointX(index)\"\n              :cy=\"getChartPointY(item.failed)\"\n              r=\"6\"\n              fill=\"#F56C6C\"\n              stroke=\"#fff\"\n              stroke-width=\"2\"\n              class=\"data-point\"\n            />\n            <title>{{ item.date }}: {{ t('statistics.failed') }} {{ item.failed }}</title>\n          </g>\n            \n          <!-- Y轴标签 -->\n          <text\n            x=\"20\"\n            y=\"97\"\n            text-anchor=\"middle\"\n            font-size=\"12\"\n            fill=\"#606266\"\n            transform=\"rotate(-90, 20, 97)\"\n          >\n            {{ t('statistics.executionCount') }}\n          </text>\n            \n          <!-- X轴标签 -->\n          <text\n            x=\"470\"\n            y=\"225\"\n            text-anchor=\"middle\"\n            font-size=\"12\"\n            fill=\"#606266\"\n          >\n            {{ t('statistics.date') }}\n          </text>\n        </svg>\n      </div>\n        \n      <!-- 图例 -->\n      <div class=\"chart-legend\">\n        <span class=\"legend-item\">\n          <span class=\"legend-color success-color\" />\n          {{ t('statistics.success') }}\n        </span>\n        <span class=\"legend-item\">\n          <span class=\"legend-color failed-color\" />\n          {{ t('statistics.failed') }}\n        </span>\n      </div>\n    </el-card>\n      \n    <!-- 详细数据表格 -->\n    <el-card\n      shadow=\"hover\"\n      class=\"table-card\"\n    >\n      <template #header>\n        <span>{{ t('statistics.last7DaysTrend') }} - {{ t('statistics.detailedData') }}</span>\n      </template>\n      <el-table\n        :data=\"stats.last7Days\"\n        border\n        style=\"width: 100%\"\n        size=\"small\"\n      >\n        <el-table-column\n          prop=\"date\"\n          :label=\"t('common.date')\"\n          width=\"180\"\n        />\n        <el-table-column\n          prop=\"total\"\n          :label=\"t('statistics.total')\"\n          width=\"120\"\n        />\n        <el-table-column\n          prop=\"success\"\n          :label=\"t('statistics.success')\"\n          width=\"120\"\n        >\n          <template #default=\"scope\">\n            <el-tag\n              type=\"success\"\n              size=\"small\"\n            >\n              {{ scope.row.success }}\n            </el-tag>\n          </template>\n        </el-table-column>\n        <el-table-column\n          prop=\"failed\"\n          :label=\"t('statistics.failed')\"\n          width=\"120\"\n        >\n          <template #default=\"scope\">\n            <el-tag\n              type=\"danger\"\n              size=\"small\"\n            >\n              {{ scope.row.failed }}\n            </el-tag>\n          </template>\n        </el-table-column>\n        <el-table-column :label=\"t('statistics.successRate')\">\n          <template #default=\"scope\">\n            <el-progress \n              :percentage=\"calculateSuccessRate(scope.row)\" \n              :color=\"getProgressColor(calculateSuccessRate(scope.row))\"\n            />\n          </template>\n        </el-table-column>\n      </el-table>\n    </el-card>\n  </el-main>\n</template>\n\n<script setup>\nimport { ref, onMounted } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { Document, CircleCheck, CircleClose, TrendCharts } from '@element-plus/icons-vue'\nimport statisticsApi from '../../api/statistics'\n\nconst { t } = useI18n()\n\nconst stats = ref({\n  totalTasks: 0,\n  todayExecutions: 0,\n  successRate: 0,\n  failedCount: 0,\n  last7Days: [], // 表格数据（新到旧）\n  chartData: []  // 折线图数据（旧到新）\n})\n\n// 获取统计数据\nconst fetchStatistics = () => {\n  statisticsApi.getOverview((data) => {\n    if (data) {\n      // 转换后端返回的下划线格式为驼峰格式\n      const last7Days = data.last_7_days || []\n      \n      // 计算最近7天的总成功数和总失败数\n      const total7DaysSuccess = last7Days.reduce((sum, item) => sum + item.success, 0)\n      const total7DaysFailed = last7Days.reduce((sum, item) => sum + item.failed, 0)\n      const total7DaysExecutions = last7Days.reduce((sum, item) => sum + item.total, 0)\n      \n      // 计算最近7天的成功率\n      let successRate7Days = 0\n      if (total7DaysExecutions > 0) {\n        successRate7Days = Math.round((total7DaysSuccess / total7DaysExecutions) * 1000) / 10\n      }\n      \n      stats.value = {\n        totalTasks: data.total_tasks || 0,\n        todayExecutions: total7DaysExecutions, // 改为显示7天总执行次数\n        successRate: successRate7Days, // 改为7天成功率\n        failedCount: total7DaysFailed, // 改为7天失败总数\n        last7Days: last7Days, // 表格数据保持DESC顺序（新到旧）\n        chartData: [...last7Days].reverse() // 折线图数据反转为ASC顺序（旧到新）\n      }\n    }\n  })\n}\n\n// 计算成功率\nconst calculateSuccessRate = (row) => {\n  if (row.total === 0) return 0\n  return Math.round((row.success / row.total) * 100)\n}\n\n// 获取进度条颜色\nconst getProgressColor = (percentage) => {\n  if (percentage >= 90) return '#67C23A'\n  if (percentage >= 70) return '#E6A23C'\n  return '#F56C6C'\n}\n\n// 获取最大值（用于折线图高度计算）\nconst getMaxValue = () => {\n  if (stats.value.chartData.length === 0) return 1\n  const allValues = stats.value.chartData.flatMap(item => [item.success, item.failed])\n  return Math.max(...allValues, 1)\n}\n\n// 计算折线图点的X坐标（图表区域：70-870）\nconst getChartPointX = (index) => {\n  const totalDays = stats.value.chartData.length\n  if (totalDays <= 1) return 470 // 中心位置\n  const chartWidth = 800 // 870 - 70\n  const spacing = chartWidth / (totalDays - 1)\n  return 70 + spacing * index\n}\n\n// 计算折线图点的Y坐标（图表区域：15-180，需要反转）\nconst getChartPointY = (value) => {\n  const maxValue = getMaxValue()\n  if (maxValue === 0) return 180\n  const chartHeight = 165 // 180 - 15\n  const ratio = value / maxValue\n  return 180 - (ratio * chartHeight)\n}\n\n// 获取折线的点坐标字符串\nconst getChartLinePoints = (type) => {\n  return stats.value.chartData.map((item, index) => {\n    const x = getChartPointX(index)\n    const y = getChartPointY(type === 'success' ? item.success : item.failed)\n    return `${x},${y}`\n  }).join(' ')\n}\n\n// 格式化日期显示\nconst formatDate = (dateStr) => {\n  const date = new Date(dateStr)\n  return `${date.getMonth() + 1}/${date.getDate()}`\n}\n\n// 刷新数据\nconst refresh = () => {\n  fetchStatistics()\n}\n\nonMounted(() => {\n  fetchStatistics()\n})\n</script>\n\n<style scoped>\n.statistics-main {\n  padding: 16px 20px;\n}\n\n.page-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 16px;\n}\n\n.page-header h2 {\n  margin: 0;\n  font-size: 20px;\n  color: #303133;\n}\n\n.stat-cards {\n  margin-bottom: 16px;\n}\n\n.stat-card {\n  cursor: pointer;\n  transition: transform 0.3s;\n}\n\n.stat-card:hover {\n  transform: translateY(-3px);\n}\n\n.stat-card :deep(.el-card__body) {\n  padding: 16px;\n}\n\n.stat-content {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.stat-icon {\n  width: 48px;\n  height: 48px;\n  border-radius: 8px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: white;\n  flex-shrink: 0;\n}\n\n.stat-info {\n  flex: 1;\n  min-width: 0;\n}\n\n.stat-value {\n  font-size: 24px;\n  font-weight: bold;\n  color: #303133;\n  margin-bottom: 4px;\n  line-height: 1;\n}\n\n.stat-label {\n  font-size: 13px;\n  color: #909399;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.chart-card {\n  margin-bottom: 16px;\n}\n\n.chart-card :deep(.el-card__body) {\n  padding: 16px 20px;\n}\n\n.table-card :deep(.el-card__body) {\n  padding: 16px 20px;\n}\n\n.card-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  font-weight: 500;\n}\n\n.chart-wrapper {\n  padding: 10px 0;\n  margin-bottom: 12px;\n}\n\n.line-chart {\n  width: 100%;\n  height: 240px;\n  display: block;\n}\n\n.data-point {\n  cursor: pointer;\n  transition: r 0.2s;\n}\n\n.data-point:hover {\n  r: 8;\n}\n\n.chart-legend {\n  display: flex;\n  justify-content: center;\n  gap: 30px;\n  margin-top: 8px;\n}\n\n.legend-item {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 13px;\n  color: #606266;\n}\n\n.legend-color {\n  width: 14px;\n  height: 14px;\n  border-radius: 2px;\n}\n\n.success-color {\n  background: #67C23A;\n}\n\n.failed-color {\n  background: #F56C6C;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/system/auditLog.vue",
    "content": "<template>\n  <el-main>\n    <el-form :inline=\"true\">\n      <el-form-item :label=\"t('audit.module')\">\n        <el-select\n          v-model.trim=\"searchParams.module\"\n          style=\"width: 150px\"\n        >\n          <el-option\n            :label=\"t('message.all')\"\n            value=\"\"\n          />\n          <el-option\n            v-for=\"item in moduleList\"\n            :key=\"item.value\"\n            :label=\"item.label\"\n            :value=\"item.value\"\n          />\n        </el-select>\n      </el-form-item>\n      <el-form-item :label=\"t('audit.action')\">\n        <el-select\n          v-model.trim=\"searchParams.action\"\n          style=\"width: 160px\"\n        >\n          <el-option\n            :label=\"t('message.all')\"\n            value=\"\"\n          />\n          <el-option\n            v-for=\"item in actionList\"\n            :key=\"item.value\"\n            :label=\"item.label\"\n            :value=\"item.value\"\n          />\n        </el-select>\n      </el-form-item>\n      <el-form-item :label=\"t('user.username')\">\n        <el-input v-model.trim=\"searchParams.username\" />\n      </el-form-item>\n      <el-form-item :label=\"t('common.date')\">\n        <el-date-picker\n          v-model=\"dateRange\"\n          type=\"daterange\"\n          value-format=\"YYYY-MM-DD\"\n          :range-separator=\"'-'\"\n          :start-placeholder=\"t('common.date')\"\n          :end-placeholder=\"t('common.date')\"\n          style=\"width: 240px\"\n        />\n      </el-form-item>\n      <el-form-item>\n        <el-button\n          type=\"primary\"\n          @click=\"search()\"\n        >\n          {{ t('common.search') }}\n        </el-button>\n      </el-form-item>\n    </el-form>\n    <el-pagination\n      v-model:current-page=\"searchParams.page\"\n      v-model:page-size=\"searchParams.page_size\"\n      background\n      layout=\"prev, pager, next, sizes, total\"\n      :total=\"logTotal\"\n      @size-change=\"changePageSize\"\n      @current-change=\"changePage\"\n    />\n    <el-table\n      ref=\"table\"\n      :data=\"logs\"\n      border\n      style=\"width: 100%\"\n    >\n      <el-table-column\n        :label=\"t('system.loginTime')\"\n        width=\"180\"\n        align=\"center\"\n      >\n        <template #default=\"scope\">\n          {{ $filters.formatTime(scope.row.created) }}\n        </template>\n      </el-table-column>\n      <el-table-column\n        prop=\"username\"\n        :label=\"t('user.username')\"\n        align=\"center\"\n      />\n      <el-table-column\n        :label=\"t('audit.module')\"\n        width=\"100\"\n        align=\"center\"\n      >\n        <template #default=\"scope\">\n          <el-tag\n            :type=\"moduleTagType(scope.row.module)\"\n            size=\"small\"\n          >\n            {{ moduleLabel(scope.row.module) }}\n          </el-tag>\n        </template>\n      </el-table-column>\n      <el-table-column\n        :label=\"t('audit.action')\"\n        width=\"120\"\n        align=\"center\"\n      >\n        <template #default=\"scope\">\n          <el-tag\n            :type=\"actionTagType(scope.row.action)\"\n            size=\"small\"\n          >\n            {{ actionLabel(scope.row.action) }}\n          </el-tag>\n        </template>\n      </el-table-column>\n      <el-table-column\n        :label=\"t('audit.target')\"\n        align=\"center\"\n      >\n        <template #default=\"scope\">\n          {{ scope.row.target_name || scope.row.target_id }}\n        </template>\n      </el-table-column>\n      <el-table-column\n        prop=\"ip\"\n        :label=\"t('system.loginIp')\"\n        align=\"center\"\n      />\n      <el-table-column\n        :label=\"t('audit.detail')\"\n        width=\"120\"\n        align=\"center\"\n      >\n        <template #default=\"scope\">\n          <el-button\n            v-if=\"scope.row.detail\"\n            type=\"info\"\n            size=\"small\"\n            @click=\"showDetail(scope.row)\"\n          >\n            {{ t('taskLog.viewOutput') }}\n          </el-button>\n        </template>\n      </el-table-column>\n    </el-table>\n    <el-dialog\n      v-model=\"dialogVisible\"\n      :title=\"t('audit.detail')\"\n      width=\"600px\"\n      align-center\n    >\n      <el-table\n        :data=\"detailRows\"\n        border\n        size=\"small\"\n        :show-header=\"true\"\n      >\n        <el-table-column\n          prop=\"field\"\n          label=\"Field\"\n          width=\"160\"\n        />\n        <el-table-column\n          prop=\"old\"\n          label=\"Before\"\n        />\n        <el-table-column\n          label=\"\"\n          width=\"40\"\n          align=\"center\"\n        >\n          <template #default>\n            &rarr;\n          </template>\n        </el-table-column>\n        <el-table-column\n          prop=\"new\"\n          label=\"After\"\n        />\n      </el-table>\n    </el-dialog>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport auditService from '../../api/audit'\n\nexport default {\n  name: 'AuditLog',\n  setup() {\n    const { t } = useI18n()\n    return { t }\n  },\n  data() {\n    return {\n      logs: [],\n      logTotal: 0,\n      searchParams: {\n        page_size: 20,\n        page: 1,\n        module: '',\n        action: '',\n        username: '',\n        start_date: '',\n        end_date: ''\n      },\n      dateRange: [],\n      dialogVisible: false,\n      detailRows: [],\n      moduleList: [],\n      actionList: []\n    }\n  },\n  computed: {\n    computedModuleList() {\n      return [\n        { value: 'task', label: this.t('audit.module_task') },\n        { value: 'host', label: this.t('audit.module_host') },\n        { value: 'user', label: this.t('audit.module_user') },\n        { value: 'system', label: this.t('audit.module_system') }\n      ]\n    },\n    computedActionList() {\n      return [\n        { value: 'create', label: this.t('audit.action_create') },\n        { value: 'update', label: this.t('audit.action_update') },\n        { value: 'delete', label: this.t('audit.action_delete') },\n        { value: 'enable', label: this.t('audit.action_enable') },\n        { value: 'disable', label: this.t('audit.action_disable') },\n        { value: 'run', label: this.t('audit.action_run') },\n        { value: 'batch-enable', label: this.t('audit.action_batch_enable') },\n        { value: 'batch-disable', label: this.t('audit.action_batch_disable') },\n        { value: 'batch-remove', label: this.t('audit.action_batch_remove') },\n        { value: 'change-password', label: this.t('audit.action_change_password') },\n        { value: 'reset-password', label: this.t('audit.action_reset_password') }\n      ]\n    }\n  },\n  watch: {\n    computedModuleList: {\n      handler(newVal) {\n        this.moduleList = newVal\n      },\n      immediate: true\n    },\n    computedActionList: {\n      handler(newVal) {\n        this.actionList = newVal\n      },\n      immediate: true\n    }\n  },\n  created() {\n    this.search()\n  },\n  methods: {\n    changePage(page) {\n      this.searchParams.page = page\n      this.search()\n    },\n    changePageSize(pageSize) {\n      this.searchParams.page_size = pageSize\n      this.search()\n    },\n    search() {\n      if (this.dateRange && this.dateRange.length === 2) {\n        this.searchParams.start_date = this.dateRange[0]\n        this.searchParams.end_date = this.dateRange[1]\n      } else {\n        this.searchParams.start_date = ''\n        this.searchParams.end_date = ''\n      }\n      auditService.list(this.searchParams, data => {\n        this.logs = data.data\n        this.logTotal = data.total\n      })\n    },\n    moduleLabel(module) {\n      const found = this.moduleList.find(item => item.value === module)\n      return found ? found.label : module\n    },\n    moduleTagType(module) {\n      const types = {\n        task: '',\n        host: 'success',\n        user: 'warning',\n        system: 'danger'\n      }\n      return types[module] || 'info'\n    },\n    actionLabel(action) {\n      const found = this.actionList.find(item => item.value === action)\n      return found ? found.label : action\n    },\n    actionTagType(action) {\n      const types = {\n        create: 'success',\n        update: 'warning',\n        delete: 'danger',\n        enable: 'success',\n        disable: 'info',\n        run: '',\n        'batch-enable': 'success',\n        'batch-disable': 'info',\n        'batch-remove': 'danger',\n        'change-password': 'warning',\n        'reset-password': 'warning'\n      }\n      return types[action] || 'info'\n    },\n    showDetail(row) {\n      this.detailRows = (row.detail || '').split('\\n').filter(Boolean).map(line => {\n        const parts = line.split(' → ')\n        const fieldAndOld = (parts[0] || '').split(': ')\n        return {\n          field: fieldAndOld[0] || '',\n          old: fieldAndOld.slice(1).join(': ') || '',\n          new: parts.slice(1).join(' → ') || ''\n        }\n      })\n      this.dialogVisible = true\n    }\n  }\n}\n</script>\n\n<style scoped>\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/system/logRetention.vue",
    "content": "<template>\n  <el-main>\n    <h3>{{ t('system.logRetentionSettings') }}</h3>\n    <el-form\n      :model=\"form\"\n      label-width=\"auto\"\n      style=\"width: 600px;\"\n    >\n      <el-form-item :label=\"t('system.dbLogRetentionDays')\">\n        <el-input-number\n          v-model=\"form.days\"\n          :min=\"0\"\n          :max=\"3650\"\n          style=\"width: 200px;\"\n        />\n        <div style=\"color: #909399; font-size: 12px; margin-top: 5px;\">\n          {{ t('system.dbLogRetentionTip') }}\n        </div>\n      </el-form-item>\n      <el-form-item :label=\"t('system.cleanupTime')\">\n        <el-time-picker\n          v-model=\"cleanupTime\"\n          format=\"HH:mm\"\n          value-format=\"HH:mm\"\n          :placeholder=\"t('system.selectTime')\"\n          style=\"width: 200px;\"\n        />\n        <div style=\"color: #909399; font-size: 12px; margin-top: 5px;\">\n          {{ t('system.cleanupTimeTip') }}\n        </div>\n      </el-form-item>\n      <el-form-item :label=\"t('system.logFileSizeLimit')\">\n        <el-input-number\n          v-model=\"form.fileSizeLimit\"\n          :min=\"0\"\n          :max=\"10240\"\n          style=\"width: 200px;\"\n        />\n        <span style=\"margin-left: 10px;\">MB</span>\n        <div style=\"color: #909399; font-size: 12px; margin-top: 5px;\">\n          {{ t('system.logFileSizeLimitTip') }}\n        </div>\n      </el-form-item>\n      <el-form-item>\n        <el-button\n          type=\"primary\"\n          @click=\"submit\"\n        >\n          {{ t('common.save') }}\n        </el-button>\n      </el-form-item>\n    </el-form>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport httpClient from '../../utils/httpClient'\n\nexport default {\n  name: 'LogRetention',\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale }\n  },\n  data() {\n    return {\n      form: {\n        days: 0,\n        fileSizeLimit: 0\n      },\n      cleanupTime: '03:00'\n    }\n  },\n  created() {\n    this.loadData()\n  },\n  methods: {\n    loadData() {\n      httpClient.get('/system/log-retention', {}, (data) => {\n        this.form.days = data.days\n        this.form.fileSizeLimit = data.file_size_limit || 0\n        this.cleanupTime = data.cleanup_time || '03:00'\n      })\n    },\n    submit() {\n      httpClient.postJson('/system/log-retention', { \n        days: this.form.days,\n        cleanup_time: this.cleanupTime,\n        file_size_limit: this.form.fileSizeLimit\n      }, () => {\n        this.$message.success(this.t('system.logRetentionSaveSuccess'))\n      })\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "web/vue/src/pages/system/loginLog.vue",
    "content": "<template>\n  <el-main>\n    <el-pagination\n      v-model:current-page=\"searchParams.page\"\n      v-model:page-size=\"searchParams.page_size\"\n      background\n      layout=\"prev, pager, next, sizes, total\"\n      :total=\"logTotal\"\n      @size-change=\"changePageSize\"\n      @current-change=\"changePage\"\n    />\n    <el-table\n      ref=\"table\"\n      :data=\"logs\"\n      border\n      style=\"width: 100%\"\n    >\n      <el-table-column\n        prop=\"id\"\n        label=\"ID\"\n      />\n      <el-table-column\n        prop=\"username\"\n        :label=\"t('user.username')\"\n      />\n      <el-table-column\n        prop=\"ip\"\n        :label=\"t('system.loginIp')\"\n      />\n      <el-table-column\n        :label=\"t('system.loginTime')\"\n        width=\"\"\n      >\n        <template #default=\"scope\">\n          {{ $filters.formatTime(scope.row.created) }}\n        </template>\n      </el-table-column>\n    </el-table>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport systemService from '../../api/system'\nexport default {\n  name: 'LoginLog',\n  setup() {\n    const { t } = useI18n()\n    return { t }\n  },\n  data () {\n    return {\n      logs: [],\n      logTotal: 0,\n      searchParams: {\n        page_size: 20,\n        page: 1\n      }\n    }\n  },\n  created () {\n    this.search()\n  },\n  methods: {\n    changePage (page) {\n      this.searchParams.page = page\n      this.search()\n    },\n    changePageSize (pageSize) {\n      this.searchParams.page_size = pageSize\n      this.search()\n    },\n    search () {\n      systemService.loginLogList(this.searchParams, (data) => {\n        this.logs = data.data\n        this.logTotal = data.total\n      })\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "web/vue/src/pages/system/notification/email.vue",
    "content": "<template>\n  <el-main>\n    <notification-tab />\n    <el-form\n      ref=\"form\"\n      :model=\"form\"\n      :rules=\"formRules\"\n      label-width=\"auto\"\n      style=\"width: 800px;\"\n    >\n      <h3>{{ t('system.emailServerConfig') }}</h3>\n      <el-row>\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('system.smtpHost')\"\n            prop=\"host\"\n          >\n            <el-input v-model=\"form.host\" />\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"10\">\n          <el-form-item\n            :label=\"t('host.port')\"\n            prop=\"port\"\n          >\n            <el-input v-model.number=\"form.port\" />\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-row>\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('user.username')\"\n            prop=\"user\"\n          >\n            <el-input v-model=\"form.user\" />\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"12\">\n          <el-form-item\n            :label=\"t('user.password')\"\n            prop=\"password\"\n          >\n            <el-input\n              v-model=\"form.password\"\n              type=\"password\"\n            />\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-form-item\n        :label=\"t('system.template')\"\n        prop=\"template\"\n      >\n        <el-input\n          v-model=\"form.template\"\n          type=\"textarea\"\n          :rows=\"6\"\n          :placeholder=\"emailPlaceholder\"\n        />\n      </el-form-item>\n      <el-form-item>\n        <el-button\n          type=\"primary\"\n          @click=\"submit()\"\n        >\n          {{ t('common.save') }}\n        </el-button>\n      </el-form-item>\n      <el-button\n        type=\"primary\"\n        @click=\"createUser\"\n      >\n        {{ t('system.addUser') }}\n      </el-button> <br><br>\n      <h3>{{ t('system.notificationUsers') }}</h3>\n      <el-tag\n        v-for=\"item in receivers\"\n        :key=\"item.email\"\n        closable\n        @close=\"deleteUser(item)\"\n      >\n        {{ item.username }} - {{ item.email }}\n      </el-tag>\n    </el-form>\n    <el-dialog\n      v-model=\"dialogVisible\"\n      :title=\"t('system.addUser')\"\n      width=\"30%\"\n    >\n      <el-form :model=\"form\">\n        <el-form-item :label=\"t('user.username')\">\n          <el-input v-model.trim=\"username\" />\n        </el-form-item>\n        <el-form-item :label=\"t('system.emailAddress')\">\n          <el-input v-model.trim=\"email\" />\n        </el-form-item>\n        <el-form-item>\n          <el-button\n            type=\"primary\"\n            @click=\"saveUser\"\n          >\n            {{ t('common.confirm') }}\n          </el-button>\n        </el-form-item>\n      </el-form>\n    </el-dialog>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport notificationTab from './tab.vue'\nimport notificationService from '../../../api/notification'\nexport default {\n  name: 'NotificationEmail',\n  components: {notificationTab},\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale }\n  },\n  data () {\n    return {\n      form: {\n        host: '',\n        port: 465,\n        user: '',\n        password: '',\n        template: ''\n      },\n      formRules: {},\n      receivers: [],\n      username: '',\n      email: '',\n      dialogVisible: false\n    }\n  },\n  computed: {\n    emailPlaceholder() {\n      return `${this.t('system.taskIdVar')}: {{.TaskId}}\n${this.t('system.taskNameVar')}: {{.TaskName}}\n${this.t('system.statusVar')}: {{.Status}}\n${this.t('system.resultVar')}: {{.Result}}\n${this.t('task.remark')}: {{.Remark}}`\n    },\n    computedFormRules() {\n      return {\n        host: [\n          {required: true, message: this.t('system.pleaseEnterEmailServer'), trigger: 'blur'}\n        ],\n        port: [\n          {type: 'number', required: true, message: this.t('system.pleaseEnterValidPort'), trigger: 'blur'}\n        ],\n        user: [\n          {required: true, message: this.t('system.pleaseEnterUserEmail'), trigger: 'blur'}\n        ],\n        password: [\n          {required: true, message: this.t('user.passwordRequired'), trigger: 'blur'}\n        ],\n        template: [\n          {required: true, message: this.t('system.pleaseEnterTemplate'), trigger: 'blur'}\n        ]\n      }\n    }\n  },\n  watch: {\n    computedFormRules: {\n      handler(newVal) {\n        this.formRules = newVal\n      },\n      immediate: true\n    }\n  },\n  created () {\n    this.init()\n  },\n  methods: {\n    createUser () {\n      this.dialogVisible = true\n    },\n    saveUser () {\n      if (this.username === '' || this.email === '') {\n        this.$message.error(this.t('system.incompleteParameters'))\n        return\n      }\n      notificationService.createMailUser({\n        username: this.username,\n        email: this.email\n      }, () => {\n        this.dialogVisible = false\n        this.init()\n      })\n    },\n    deleteUser (item) {\n      notificationService.removeMailUser(item.id, () => {\n        this.init()\n      })\n    },\n    submit () {\n      this.$refs['form'].validate((valid) => {\n        if (!valid) {\n          return false\n        }\n        this.save()\n      })\n    },\n    save () {\n      notificationService.updateMail(this.form, () => {\n        this.$message.success(this.t('message.updateSuccess'))\n        this.init()\n      })\n    },\n    init () {\n      this.username = ''\n      this.email = ''\n      notificationService.mail((data) => {\n        this.form.host = data.host || ''\n        if (data.port) {\n          this.form.port = data.port\n        }\n        this.form.user = data.user || ''\n        this.form.password = data.password || ''\n        this.form.template = data.template || ''\n        this.receivers = data.mail_users || []\n      })\n    }\n  }\n}\n</script>\n\n<style scoped>\n  .el-tag + .el-tag {\n    margin-left: 10px;\n  }\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/system/notification/slack.vue",
    "content": "<template>\n  <el-main>\n    <notification-tab />\n    <el-form\n      ref=\"form\"\n      :model=\"form\"\n      :rules=\"formRules\"\n      label-width=\"auto\"\n      style=\"width: 700px;\"\n    >\n      <el-form-item\n        :label=\"t('system.slackUrl')\"\n        prop=\"url\"\n      >\n        <el-input v-model=\"form.url\" />\n      </el-form-item>\n      <el-form-item\n        :label=\"t('system.template')\"\n        prop=\"template\"\n      >\n        <el-input\n          v-model=\"form.template\"\n          type=\"textarea\"\n          :rows=\"8\"\n          :placeholder=\"slackPlaceholder\"\n        />\n      </el-form-item>\n      <el-form-item>\n        <el-button\n          type=\"primary\"\n          @click=\"submit\"\n        >\n          {{ t('common.save') }}\n        </el-button>\n      </el-form-item>\n      <h3>{{ t('system.channels') }}</h3>\n      <el-button\n        type=\"primary\"\n        @click=\"createChannel\"\n      >\n        {{ t('system.addChannel') }}\n      </el-button> <br><br>\n      <el-tag\n        v-for=\"item in channels\"\n        :key=\"item.id\"\n        closable\n        @close=\"deleteChannel(item)\"\n      >\n        {{ item.name }}\n      </el-tag>\n    </el-form>\n    <el-dialog\n      v-model=\"dialogVisible\"\n      :title=\"t('system.addChannel')\"\n      width=\"30%\"\n    >\n      <el-form :model=\"form\">\n        <el-form-item :label=\"t('system.channelName')\">\n          <el-input\n            v-model.trim=\"channel\"\n            v-focus\n          />\n        </el-form-item>\n        <el-form-item>\n          <el-button\n            type=\"primary\"\n            @click=\"saveChannel\"\n          >\n            {{ t('common.confirm') }}\n          </el-button>\n        </el-form-item>\n      </el-form>\n    </el-dialog>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport notificationTab from './tab.vue'\nimport notificationService from '../../../api/notification'\nexport default {\n  name: 'NotificationSlack',\n  components: {notificationTab},\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale }\n  },\n  data () {\n    return {\n      dialogVisible: false,\n      form: {\n        url: '',\n        template: ''\n      },\n      formRules: {},\n      channels: [],\n      channel: ''\n    }\n  },\n  computed: {\n    slackPlaceholder() {\n      return `${this.t('system.taskIdVar')}: {{.TaskId}}\n${this.t('system.taskNameVar')}: {{.TaskName}}\n${this.t('system.statusVar')}: {{.Status}}\n${this.t('system.resultVar')}: {{.Result}}\n${this.t('task.remark')}: {{.Remark}}`\n    },\n    computedFormRules() {\n      return {\n        url: [\n          {type: 'url', required: true, message: this.t('system.pleaseEnterValidUrl'), trigger: 'blur'}\n        ],\n        template: [\n          {required: true, message: this.t('system.pleaseEnterTemplate'), trigger: 'blur'}\n        ]\n      }\n    }\n  },\n  watch: {\n    computedFormRules: {\n      handler(newVal) {\n        this.formRules = newVal\n      },\n      immediate: true\n    }\n  },\n  created () {\n    this.init()\n  },\n  methods: {\n    createChannel () {\n      this.dialogVisible = true\n    },\n    submit () {\n      this.$refs['form'].validate((valid) => {\n        if (!valid) {\n          return false\n        }\n        this.save()\n      })\n    },\n    save () {\n      notificationService.updateSlack(this.form, () => {\n        this.$message.success(this.t('message.updateSuccess'))\n        this.init()\n      })\n    },\n    saveChannel () {\n      if (this.channel === '') {\n        this.$message.error(this.t('system.pleaseEnterChannelName'))\n        return\n      }\n      notificationService.createSlackChannel(this.channel, () => {\n        this.dialogVisible = false\n        this.init()\n      })\n    },\n    deleteChannel (item) {\n      notificationService.removeSlackChannel(item.id, () => {\n        this.init()\n      })\n    },\n    init () {\n      this.channel = ''\n      notificationService.slack((data) => {\n        this.form.url = data.url\n        this.form.template = data.template\n        this.channels = data.channels\n      })\n    }\n  }\n}\n</script>\n\n<style scoped>\n  .el-tag + .el-tag {\n    margin-left: 10px;\n  }\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/system/notification/tab.vue",
    "content": "<template>\n  <div>\n    <el-tabs v-model=\"activeName\">\n      <el-tab-pane\n        :label=\"t('system.email')\"\n        name=\"email\"\n      />\n      <el-tab-pane\n        label=\"Slack\"\n        name=\"slack\"\n      />\n      <el-tab-pane\n        label=\"Webhook\"\n        name=\"webhook\"\n      />\n    </el-tabs>\n    <el-alert\n      type=\"info\"\n      :closable=\"false\"\n      style=\"margin-bottom: 15px;\"\n    >\n      <template #title>\n        <div style=\"font-weight: bold; margin-bottom: 8px;\">\n          {{ t('system.templateVariables') }}\n        </div>\n        <div style=\"font-size: 13px; line-height: 1.8;\">\n          <div><code>{{ '{{' }}TaskId{{}}}}</code> - {{ t('system.taskIdVar') }}</div>\n          <div><code>{{ '{{' }}TaskName{{}}}}</code> - {{ t('system.taskNameVar') }}</div>\n          <div><code>{{ '{{' }}Status{{}}}}</code> - {{ t('system.statusVar') }}</div>\n          <div><code>{{ '{{' }}Result{{}}}}</code> - {{ t('system.resultVar') }}</div>\n          <div><code>{{ '{{' }}Remark{{}}}}</code> - {{ t('task.remark') }}</div>\n        </div>\n      </template>\n    </el-alert>\n  </div>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nexport default {\n  name: 'NotificationTab',\n  setup() {\n    const { t } = useI18n()\n    return { t }\n  },\n  data () {\n    return {\n      activeName: ''\n    }\n  },\n  watch: {\n    activeName (newVal) {\n      if (newVal && this.$route.path !== `/system/notification/${newVal}`) {\n        this.$router.push(`/system/notification/${newVal}`)\n      }\n    },\n    '$route.path': {\n      handler(newPath) {\n        const segments = newPath.split('/')\n        if (segments.length === 4 && segments[2] === 'notification') {\n          this.activeName = segments[3]\n        }\n      },\n      immediate: false\n    }\n  },\n  created () {\n    const segments = this.$route.path.split('/')\n    if (segments.length !== 4) {\n      this.activeName = 'email'\n      return\n    }\n    this.activeName = segments[3]\n  }\n}\n</script>\n"
  },
  {
    "path": "web/vue/src/pages/system/notification/webhook.vue",
    "content": "<template>\n  <el-main>\n    <notification-tab />\n    <el-form\n      ref=\"form\"\n      :model=\"form\"\n      :rules=\"formRules\"\n      label-width=\"auto\"\n      style=\"width: 800px;\"\n    >\n      <h3>{{ t('system.webhook') }}</h3>\n      <el-alert\n        :title=\"t('system.webhookTip')\"\n        type=\"info\"\n        :closable=\"false\"\n        style=\"margin-bottom: 15px;\"\n      />\n      <el-form-item\n        :label=\"t('system.template')\"\n        prop=\"template\"\n      >\n        <el-input\n          v-model.trim=\"form.template\"\n          type=\"textarea\"\n          :rows=\"8\"\n          :placeholder=\"webhookPlaceholder\"\n        />\n      </el-form-item>\n      <el-form-item>\n        <el-button\n          type=\"primary\"\n          @click=\"submit()\"\n        >\n          {{ t('common.save') }}\n        </el-button>\n      </el-form-item>\n      <el-button\n        type=\"primary\"\n        @click=\"createUrl\"\n      >\n        {{ t('system.addWebhookUrl') }}\n      </el-button> <br><br>\n      <h3>{{ t('system.webhookUrls') }}</h3>\n      <el-tag\n        v-for=\"item in webhookUrls\"\n        :key=\"item.id\"\n        closable\n        @close=\"deleteUrl(item)\"\n      >\n        {{ item.name }} - {{ item.url }}\n      </el-tag>\n    </el-form>\n    <el-dialog\n      v-model=\"dialogVisible\"\n      :title=\"t('system.addWebhookUrl')\"\n      width=\"30%\"\n    >\n      <el-form :model=\"form\">\n        <el-form-item :label=\"t('system.webhookName')\">\n          <el-input v-model.trim=\"name\" />\n        </el-form-item>\n        <el-form-item label=\"URL\">\n          <el-input v-model.trim=\"url\" />\n        </el-form-item>\n        <el-form-item>\n          <el-button\n            type=\"primary\"\n            @click=\"saveUrl\"\n          >\n            {{ t('common.confirm') }}\n          </el-button>\n        </el-form-item>\n      </el-form>\n    </el-dialog>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport notificationTab from './tab.vue'\nimport notificationService from '../../../api/notification'\nexport default {\n  name: 'NotificationWebhook',\n  components: {notificationTab},\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale }\n  },\n  data () {\n    return {\n      form: {\n        template: ''\n      },\n      formRules: {},\n      webhookUrls: [],\n      name: '',\n      url: '',\n      dialogVisible: false\n    }\n  },\n  computed: {\n    webhookPlaceholder() {\n      return `{\"task_id\": \"{{.TaskId}}\", \"task_name\": \"{{.TaskName}}\", \"status\": \"{{.Status}}\", \"result\": \"{{.Result}}\", \"remark\": \"{{.Remark}}\"}`\n    },\n    computedFormRules() {\n      return {\n        template: [\n          {required: true, message: this.t('system.pleaseEnterTemplate'), trigger: 'blur'}\n        ]\n      }\n    }\n  },\n  watch: {\n    computedFormRules: {\n      handler(newVal) {\n        this.formRules = newVal\n      },\n      immediate: true\n    }\n  },\n  created () {\n    this.init()\n  },\n  methods: {\n    createUrl () {\n      this.dialogVisible = true\n    },\n    saveUrl () {\n      if (this.name === '' || this.url === '') {\n        this.$message.error(this.t('system.incompleteParameters'))\n        return\n      }\n      notificationService.createWebhookUrl({\n        name: this.name,\n        url: this.url\n      }, () => {\n        this.dialogVisible = false\n        this.init()\n      })\n    },\n    deleteUrl (item) {\n      notificationService.removeWebhookUrl(item.id, () => {\n        this.init()\n      })\n    },\n    submit () {\n      this.$refs['form'].validate((valid) => {\n        if (!valid) {\n          return false\n        }\n        this.save()\n      })\n    },\n    save () {\n      notificationService.updateWebHook(this.form, () => {\n        this.$message.success(this.t('message.updateSuccess'))\n        this.init()\n      })\n    },\n    init () {\n      this.name = ''\n      this.url = ''\n      notificationService.webhook((data) => {\n        this.form.template = data.template || ''\n        this.webhookUrls = data.webhook_urls || []\n      })\n    }\n  }\n}\n</script>\n\n<style scoped>\n  .el-tag + .el-tag {\n    margin-left: 10px;\n  }\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/system/sidebar.vue",
    "content": "<template>\n  <el-aside width=\"150px\">\n    <el-menu\n      :default-active=\"currentRoute\"\n      mode=\"vertical\"\n      background-color=\"#545c64\"\n      text-color=\"#fff\"\n      active-text-color=\"#ffd04b\"\n      router\n    >\n      <el-menu-item index=\"/system\">\n        {{ t('system.notification') }}\n      </el-menu-item>\n      <el-menu-item index=\"/system/login-log\">\n        {{ t('system.loginLog') }}\n      </el-menu-item>\n      <el-menu-item index=\"/system/audit-log\">\n        {{ t('audit.log') }}\n      </el-menu-item>\n      <el-menu-item index=\"/system/log-retention\">\n        {{ t('system.logCleanup') }}\n      </el-menu-item>\n    </el-menu>\n  </el-aside>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nexport default {\n  name: 'SystemSidebar',\n  setup() {\n    const { t } = useI18n()\n    return { t }\n  },\n  data () {\n    return {}\n  },\n  computed: {\n    currentRoute () {\n      if (this.$route.path === '/system/login-log') {\n        return '/system/login-log'\n      }\n      if (this.$route.path === '/system/audit-log') {\n        return '/system/audit-log'\n      }\n      if (this.$route.path === '/system/log-retention') {\n        return '/system/log-retention'\n      }\n      return '/system'\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "web/vue/src/pages/task/edit.vue",
    "content": "<template>\n  <el-main>\n    <el-row type=\"flex\" justify=\"end\" style=\"margin-bottom: 10px;\">\n      <el-button v-if=\"form.id === ''\" size=\"small\" @click=\"showTemplateDialog = true\">{{ t('template.useTemplate') }}</el-button>\n      <el-button v-if=\"form.id !== ''\" size=\"small\" @click=\"showSaveTemplateDialog = true\">{{ t('template.saveAsTemplate') }}</el-button>\n    </el-row>\n    <el-form ref=\"form\" :model=\"form\" :rules=\"formRules\" label-width=\"auto\">\n        <el-input v-model=\"form.id\" type=\"hidden\"></el-input>\n        <el-row>\n          <el-col :span=\"12\">\n            <el-form-item :label=\"t('task.name')\" prop=\"name\">\n              <el-input v-model.trim=\"form.name\"></el-input>\n            </el-form-item>\n          </el-col>\n          <el-col :span=\"12\">\n            <el-form-item :label=\"t('task.tag')\">\n              <el-select\n                v-model=\"form.tags\"\n                multiple\n                filterable\n                allow-create\n                default-first-option\n                collapse-tags\n                collapse-tags-tooltip\n                :placeholder=\"t('task.tagPlaceholder')\"\n                style=\"width: 100%\">\n                <el-option\n                  v-for=\"tag in tagOptions\"\n                  :key=\"tag\"\n                  :label=\"tag\"\n                  :value=\"tag\">\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row v-if=\"form.level === 1\">\n          <el-col>\n            <el-alert type=\"info\" :closable=\"false\">\n              <span v-html=\"t('task.mainTaskTip')\"></span>\n            </el-alert>\n            <el-alert type=\"info\" :closable=\"false\">\n              <span v-html=\"t('task.dependencyTip')\"></span>\n            </el-alert> <br>\n          </el-col>\n        </el-row>\n        <el-row>\n          <el-col :span=\"7\">\n            <el-form-item :label=\"t('task.type')\">\n              <el-select v-model.trim=\"form.level\" :disabled=\"form.id !== '' \">\n                <el-option\n                  v-for=\"item in levelList\"\n                  :key=\"item.value\"\n                  :label=\"item.label\"\n                  :value=\"item.value\">\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n          <el-col :span=\"7\" v-if=\"form.level === 1\">\n            <el-form-item :label=\"t('task.dependency')\">\n              <el-select v-model.trim=\"form.dependency_status\">\n                <el-option\n                  v-for=\"item in dependencyStatusList\"\n                  :key=\"item.value\"\n                  :label=\"item.label\"\n                  :value=\"item.value\">\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n          <el-col :span=\"10\">\n            <el-form-item :label=\"t('task.childTaskId')\" v-if=\"form.level === 1\">\n              <el-input v-model.trim=\"form.dependency_task_id\" :placeholder=\"t('task.childTaskIdPlaceholder')\"></el-input>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row v-if=\"form.level === 1\">\n          <el-col :span=\"12\">\n            <el-form-item :label=\"t('task.cronExpression')\" prop=\"spec\">\n              <CronInput v-model=\"form.spec\" />\n            </el-form-item>\n          </el-col>\n          <el-col :span=\"8\">\n            <el-form-item :label=\"t('task.timezone')\">\n              <el-select\n                v-model=\"form.timezone\"\n                filterable\n                clearable\n                :placeholder=\"t('task.timezoneServer')\">\n                <el-option-group\n                  v-for=\"group in timezoneGroups\"\n                  :key=\"group.label\"\n                  :label=\"group.label\">\n                  <el-option\n                    v-for=\"tz in group.zones\"\n                    :key=\"tz\"\n                    :label=\"tz\"\n                    :value=\"tz\">\n                  </el-option>\n                </el-option-group>\n              </el-select>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row v-if=\"form.level === 1\">\n          <el-col :span=\"24\">\n            <el-form-item label=\" \">\n              <CronPreview :spec=\"form.spec\" :timezone=\"form.timezone\" />\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row>\n          <el-col :span=\"8\">\n            <el-form-item :label=\"t('task.protocol')\">\n              <el-select v-model.trim=\"form.protocol\" @change=\"handleProtocolChange\">\n                <el-option\n                  v-for=\"item in protocolList\"\n                  :key=\"item.value\"\n                  :label=\"item.label\"\n                  :value=\"item.value\">\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n          <el-col :span=\"8\" v-if=\"form.protocol === 1 \">\n            <el-form-item :label=\"t('task.httpMethod')\">\n              <el-select key=\"http-method\" v-model.trim=\"form.http_method\">\n                <el-option\n                  v-for=\"item in httpMethods\"\n                  :key=\"item.value\"\n                  :label=\"item.label\"\n                  :value=\"item.value\">\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n          <el-col :span=\"8\" v-else>\n            <el-form-item :label=\"t('task.taskNode')\" prop=\"host_ids\">\n              <el-select\n                key=\"shell\"\n                v-model=\"form.host_ids\"\n                filterable\n                multiple\n                :placeholder=\"t('task.taskNodePlaceholder')\">\n                <el-option\n                  v-for=\"item in hosts\"\n                  :key=\"item.id\"\n                  :label=\"item.alias + ' - ' + item.name\"\n                  :value=\"item.id\">\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row>\n          <el-col :span=\"20\">\n            <el-form-item :label=\"t('task.command')\" prop=\"command\">\n              <div style=\"width: 100%;\">\n                <MonacoEditor\n                  v-model=\"form.command\"\n                  :language=\"editorLanguage\"\n                  height=\"200px\"\n                />\n                <div v-if=\"commandWarning\" class=\"command-warning\" style=\"color: #E6A23C; font-size: 12px; margin-top: 4px;\">\n                  {{ commandWarning }}\n                </div>\n              </div>\n            </el-form-item>\n          </el-col>\n          <el-col :span=\"4\" v-if=\"form.id !== ''\" style=\"padding-top: 32px; padding-left: 8px;\">\n            <el-button type=\"info\" size=\"small\" @click=\"showVersionDrawer = true\">\n              {{ t('task.versionHistory') }}\n            </el-button>\n          </el-col>\n        </el-row>\n        <el-row v-if=\"Number(form.protocol) === 1 && Number(form.http_method) === 2\">\n          <el-col :span=\"16\">\n            <el-form-item :label=\"t('task.httpBody')\">\n              <el-input\n                type=\"textarea\"\n                :rows=\"4\"\n                :placeholder=\"t('task.httpBodyPlaceholder')\"\n                v-model=\"form.http_body\">\n              </el-input>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row v-if=\"Number(form.protocol) === 1\">\n          <el-col :span=\"16\">\n            <el-form-item :label=\"t('task.httpHeaders')\">\n              <el-input\n                type=\"textarea\"\n                :rows=\"3\"\n                :placeholder=\"t('task.httpHeadersPlaceholder')\"\n                v-model=\"form.http_headers\">\n              </el-input>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row v-if=\"Number(form.protocol) === 1\">\n          <el-col :span=\"12\">\n            <el-form-item :label=\"t('task.successPattern')\">\n              <el-input\n                v-model.trim=\"form.success_pattern\"\n                :placeholder=\"t('task.successPatternPlaceholder')\">\n              </el-input>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row>\n          <el-col>\n            <el-alert\n              :title=\"t('task.timeoutTip')\"\n              type=\"info\"\n              :closable=\"false\">\n            </el-alert>\n            <el-alert\n              :title=\"t('task.singleInstanceTip')\"\n              type=\"info\"\n              :closable=\"false\">\n            </el-alert> <br>\n          </el-col>\n        </el-row>\n        <el-row>\n          <el-col :span=\"12\">\n            <el-form-item :label=\"t('task.timeout')\" prop=\"timeout\">\n              <el-input v-model.number.trim=\"form.timeout\"></el-input>\n            </el-form-item>\n          </el-col>\n          <el-col :span=\"8\">\n            <el-form-item :label=\"t('task.singleInstance')\">\n              <el-select v-model.trim=\"form.multi\">\n                <el-option\n                  v-for=\"item in runStatusList\"\n                  :key=\"item.value\"\n                  :label=\"item.label\"\n                  :value=\"item.value\">\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row>\n        <el-col :span=\"12\">\n          <el-form-item :label=\"t('task.retryTimes')\" prop=\"retry_times\">\n            <el-input v-model.number.trim=\"form.retry_times\"\n                      :placeholder=\"t('task.retryTimesPlaceholder')\"></el-input>\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"12\">\n          <el-form-item :label=\"t('task.retryInterval')\" prop=\"retry_interval\">\n            <el-input v-model.number.trim=\"form.retry_interval\" :placeholder=\"t('task.retryIntervalPlaceholder')\"></el-input>\n          </el-form-item>\n        </el-col>\n        </el-row>\n        <el-row>\n          <el-col :span=\"8\">\n            <el-form-item :label=\"t('task.notification')\">\n              <el-select v-model.trim=\"form.notify_status\">\n                <el-option\n                  v-for=\"item in notifyStatusList\"\n                  :key=\"item.value\"\n                  :label=\"item.label\"\n                  :value=\"item.value\">\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n          <el-col :span=\"8\" v-if=\"form.notify_status !== 0\">\n            <el-form-item :label=\"t('task.notifyType')\">\n              <el-select v-model.trim=\"form.notify_type\">\n                <el-option\n                  v-for=\"item in notifyTypes\"\n                  :key=\"item.value\"\n                  :label=\"item.label\"\n                  :value=\"item.value\"\n                  >\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n          <el-col :span=\"8\"\n                  v-if=\"form.notify_status !== 0 && form.notify_type === 0\">\n            <el-form-item :label=\"t('task.notifyReceiver')\">\n              <el-select key=\"notify-mail\" v-model=\"selectedMailNotifyIds\" filterable multiple :placeholder=\"t('task.notifyReceiverPlaceholder')\">\n                <el-option\n                  v-for=\"item in mailUsers\"\n                  :key=\"item.id\"\n                  :label=\"item.username\"\n                  :value=\"item.id\">\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n\n          <el-col :span=\"8\"\n                  v-if=\"form.notify_status !== 0 && form.notify_type === 1\">\n            <el-form-item :label=\"t('task.notifyChannel')\">\n              <el-select key=\"notify-slack\" v-model=\"selectedSlackNotifyIds\" filterable multiple :placeholder=\"t('task.notifyReceiverPlaceholder')\">\n                <el-option\n                  v-for=\"item in slackChannels\"\n                  :key=\"item.id\"\n                  :label=\"item.name\"\n                  selected=\"true\"\n                  :value=\"item.id\">\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n\n          <el-col :span=\"8\"\n                  v-if=\"form.notify_status !== 0 && form.notify_type === 2\">\n            <el-form-item :label=\"t('task.notifyReceiver')\">\n              <el-select key=\"notify-webhook\" v-model=\"selectedWebhookNotifyIds\" filterable multiple :placeholder=\"t('task.notifyReceiverPlaceholder')\">\n                <el-option\n                  v-for=\"item in webhookUrls\"\n                  :key=\"item.id\"\n                  :label=\"item.name\"\n                  :value=\"item.id\">\n                </el-option>\n              </el-select>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row v-if=\"form.notify_status === 3\">\n          <el-col :span=\"12\">\n            <el-form-item :label=\"t('task.notifyKeyword')\" prop=\"notify_keyword\">\n              <el-input v-model.trim=\"form.notify_keyword\" :placeholder=\"t('task.notifyKeywordPlaceholder')\"></el-input>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row>\n          <el-col :span=\"12\">\n            <el-form-item :label=\"t('task.logRetentionDays')\">\n              <el-input-number v-model=\"form.log_retention_days\" :min=\"0\" :max=\"3650\"></el-input-number>\n              <span style=\"margin-left: 8px; color: #909399; font-size: 12px;\">{{ t('task.logRetentionDaysTip') }}</span>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-row>\n          <el-col :span=\"16\">\n            <el-form-item :label=\"t('task.remark')\">\n              <el-input\n                type=\"textarea\"\n                :rows=\"3\"\n                v-model=\"form.remark\">\n              </el-input>\n            </el-form-item>\n          </el-col>\n        </el-row>\n        <el-form-item>\n          <el-button type=\"primary\" @click=\"submit\">{{ t('common.save') }}</el-button>\n          <el-button @click=\"cancel\">{{ t('common.cancel') }}</el-button>\n        </el-form-item>\n      </el-form>\n    <el-drawer v-model=\"showVersionDrawer\" :title=\"t('task.versionHistory')\" size=\"50%\">\n      <el-table :data=\"versions\" border style=\"width: 100%\">\n        <el-table-column prop=\"version\" :label=\"t('task.version')\" width=\"80\" align=\"center\"></el-table-column>\n        <el-table-column prop=\"username\" :label=\"t('task.versionUser')\" width=\"120\"></el-table-column>\n        <el-table-column prop=\"remark\" :label=\"t('task.versionRemark')\"></el-table-column>\n        <el-table-column prop=\"created_at\" :label=\"t('task.versionTime')\" width=\"180\">\n          <template #default=\"scope\">\n            {{ $filters.formatTime(scope.row.created_at) }}\n          </template>\n        </el-table-column>\n        <el-table-column :label=\"t('common.operation')\" width=\"160\" align=\"center\">\n          <template #default=\"scope\">\n            <el-button size=\"small\" @click=\"previewVersion(scope.row)\">{{ t('task.versionCommand') }}</el-button>\n            <el-button type=\"warning\" size=\"small\" @click=\"rollbackVersion(scope.row)\">{{ t('task.versionRollback') }}</el-button>\n          </template>\n        </el-table-column>\n      </el-table>\n      <el-pagination\n        v-if=\"versionTotal > 10\"\n        background\n        layout=\"prev, pager, next\"\n        :total=\"versionTotal\"\n        v-model:current-page=\"versionPage\"\n        :page-size=\"10\"\n        @current-change=\"loadVersions\"\n        style=\"margin-top: 16px;\">\n      </el-pagination>\n      <el-dialog v-model=\"showVersionCommand\" :title=\"t('task.versionCommand')\" width=\"60%\" append-to-body>\n        <pre style=\"white-space: pre-wrap; word-break: break-all; background: #f5f7fa; padding: 12px; border-radius: 4px; max-height: 400px; overflow: auto;\">{{ selectedVersionCommand }}</pre>\n      </el-dialog>\n    </el-drawer>\n\n    <!-- 从模板创建对话框 -->\n    <el-dialog v-model=\"showTemplateDialog\" :title=\"t('template.useTemplate')\" width=\"70%\">\n      <el-form :inline=\"true\" style=\"margin-bottom: 10px;\">\n        <el-form-item>\n          <el-select v-model=\"templateCategory\" size=\"small\" @change=\"loadTemplates\" style=\"width: 120px;\">\n            <el-option :label=\"t('template.category_all')\" value=\"\"></el-option>\n            <el-option v-for=\"cat in templateCategories\" :key=\"cat\" :label=\"getCategoryLabel(cat)\" :value=\"cat\"></el-option>\n          </el-select>\n        </el-form-item>\n      </el-form>\n      <el-table :data=\"templateList\" border highlight-current-row @row-click=\"selectTemplate\" style=\"cursor: pointer;\">\n        <el-table-column prop=\"name\" :label=\"t('template.name')\" min-width=\"150\">\n          <template #default=\"scope\">\n            {{ scope.row.name }}\n            <el-tag v-if=\"scope.row.is_builtin === 1\" size=\"small\" type=\"info\" style=\"margin-left: 4px;\">{{ t('template.builtin') }}</el-tag>\n          </template>\n        </el-table-column>\n        <el-table-column prop=\"description\" :label=\"t('template.description')\" min-width=\"200\"></el-table-column>\n        <el-table-column prop=\"category\" :label=\"t('template.category')\" width=\"100\" align=\"center\">\n          <template #default=\"scope\">\n            <el-tag size=\"small\">{{ getCategoryLabel(scope.row.category) }}</el-tag>\n          </template>\n        </el-table-column>\n        <el-table-column :label=\"t('template.protocol')\" width=\"80\" align=\"center\">\n          <template #default=\"scope\">{{ scope.row.protocol === 1 ? 'HTTP' : 'Shell' }}</template>\n        </el-table-column>\n      </el-table>\n    </el-dialog>\n\n    <!-- 模板变量填写对话框 -->\n    <el-dialog v-model=\"showVariableDialog\" :title=\"t('template.fillVariables')\" width=\"500px\" append-to-body>\n      <el-alert type=\"warning\" :closable=\"false\" style=\"margin-bottom: 16px;\">\n        {{ t('template.securityWarning') }}\n      </el-alert>\n      <el-form label-width=\"120px\">\n        <el-form-item v-for=\"v in templateVariables\" :key=\"v\" :label=\"v\">\n          <el-input v-model=\"templateVarValues[v]\"></el-input>\n        </el-form-item>\n      </el-form>\n      <template #footer>\n        <el-button @click=\"showVariableDialog = false\">{{ t('common.cancel') }}</el-button>\n        <el-button type=\"primary\" @click=\"applyTemplateWithVars\">{{ t('template.applyTemplate') }}</el-button>\n      </template>\n    </el-dialog>\n\n    <!-- 保存为模板对话框 -->\n    <el-dialog v-model=\"showSaveTemplateDialog\" :title=\"t('template.saveAsTemplate')\" width=\"500px\">\n      <el-alert type=\"warning\" :closable=\"false\" style=\"margin-bottom: 16px;\">\n        {{ t('template.saveAsTemplateWarning') }}\n      </el-alert>\n      <el-form label-width=\"100px\">\n        <el-form-item :label=\"t('template.saveAsTemplateName')\">\n          <el-input v-model=\"saveTemplateForm.name\" :placeholder=\"t('template.templateNamePlaceholder')\"></el-input>\n        </el-form-item>\n        <el-form-item :label=\"t('template.saveAsTemplateDesc')\">\n          <el-input v-model=\"saveTemplateForm.description\" :placeholder=\"t('template.templateDescPlaceholder')\"></el-input>\n        </el-form-item>\n        <el-form-item :label=\"t('template.saveAsTemplateCategory')\">\n          <el-select v-model=\"saveTemplateForm.category\" filterable allow-create style=\"width: 100%;\">\n            <el-option value=\"backup\" :label=\"t('template.category_backup')\"></el-option>\n            <el-option value=\"cleanup\" :label=\"t('template.category_cleanup')\"></el-option>\n            <el-option value=\"monitor\" :label=\"t('template.category_monitor')\"></el-option>\n            <el-option value=\"deploy\" :label=\"t('template.category_deploy')\"></el-option>\n            <el-option value=\"api\" :label=\"t('template.category_api')\"></el-option>\n            <el-option value=\"custom\" :label=\"t('template.category_custom')\"></el-option>\n          </el-select>\n        </el-form-item>\n      </el-form>\n      <template #footer>\n        <el-button @click=\"showSaveTemplateDialog = false\">{{ t('common.cancel') }}</el-button>\n        <el-button type=\"primary\" @click=\"saveAsTemplate\">{{ t('common.save') }}</el-button>\n      </template>\n    </el-dialog>\n    </el-main>\n</template>\n\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport taskService from '../../api/task'\nimport templateService from '../../api/template'\nimport notificationService from '../../api/notification'\nimport { validateCronSpec, getCronExamples, extractTimezone } from '../../utils/cronValidator'\nimport { ElMessageBox } from 'element-plus'\nimport MonacoEditor from '../../components/common/MonacoEditor.vue'\nimport CronInput from '../../components/common/CronInput.vue'\nimport CronPreview from '../../components/common/CronPreview.vue'\n\nconst createDefaultForm = () => ({\n  id: '',\n  name: '',\n  tag: '',\n  tags: [],\n  level: 1,\n  dependency_status: 1,\n  dependency_task_id: '',\n  spec: '',\n  timezone: '',\n  protocol: 2,\n  http_method: 1,\n  http_body: '',\n  http_headers: '',\n  success_pattern: '',\n  command: '',\n  host_id: '',\n  host_ids: [],\n  timeout: 3600,\n  multi: 0,\n  notify_status: 0,\n  notify_type: 0,\n  notify_receiver_id: '',\n  notify_keyword: '',\n  retry_times: 0,\n  retry_interval: 0,\n  log_retention_days: 0,\n  remark: ''\n})\n\nexport default {\n  name: 'task-edit',\n  components: { MonacoEditor, CronInput, CronPreview },\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale }\n  },\n  data () {\n    return {\n      form: createDefaultForm(),\n      formRules: {},\n      httpMethods: [\n        {\n          value: 1,\n          label: 'get'\n        },\n        {\n          value: 2,\n          label: 'post'\n        }\n      ],\n      protocolList: [\n        {\n          value: 1,\n          label: 'http'\n        },\n        {\n          value: 2,\n          label: 'shell'\n        }\n      ],\n      levelList: [],\n      dependencyStatusList: [],\n      runStatusList: [],\n      notifyStatusList: [],\n      notifyTypes: [],\n      hosts: [],\n      mailUsers: [],\n      slackChannels: [],\n      webhookUrls: [],\n      selectedMailNotifyIds: [],\n      selectedSlackNotifyIds: [],\n      selectedWebhookNotifyIds: [],\n      tagOptions: [],\n      showVersionDrawer: false,\n      showVersionCommand: false,\n      selectedVersionCommand: '',\n      versions: [],\n      versionTotal: 0,\n      versionPage: 1,\n      showTemplateDialog: false,\n      showVariableDialog: false,\n      showSaveTemplateDialog: false,\n      templateList: [],\n      templateCategories: [],\n      templateCategory: '',\n      selectedTemplate: null,\n      templateVariables: [],\n      templateVarValues: {},\n      saveTemplateForm: {\n        name: '',\n        description: '',\n        category: 'custom'\n      }\n    }\n  },\n  computed: {\n    timezoneGroups () {\n      try {\n        const zones = Intl.supportedValuesOf('timeZone')\n        const groups = { UTC: ['UTC'] }\n        for (const tz of zones) {\n          const region = tz.split('/')[0]\n          if (!groups[region]) {\n            groups[region] = []\n          }\n          groups[region].push(tz)\n        }\n        // Sort regions, put common ones first\n        const priority = ['UTC', 'Asia', 'America', 'Europe', 'Pacific', 'Australia', 'Africa']\n        const sorted = Object.keys(groups).sort((a, b) => {\n          const ai = priority.indexOf(a)\n          const bi = priority.indexOf(b)\n          if (ai !== -1 && bi !== -1) return ai - bi\n          if (ai !== -1) return -1\n          if (bi !== -1) return 1\n          return a.localeCompare(b)\n        })\n        return sorted.map(region => ({ label: region, zones: groups[region] }))\n      } catch {\n        // Fallback for browsers without Intl.supportedValuesOf\n        const fallback = [\n          'UTC',\n          'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore',\n          'Asia/Hong_Kong', 'Asia/Kolkata', 'Asia/Dubai',\n          'America/New_York', 'America/Chicago', 'America/Denver',\n          'America/Los_Angeles', 'America/Sao_Paulo',\n          'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Moscow',\n          'Australia/Sydney', 'Australia/Perth',\n          'Pacific/Auckland', 'Pacific/Honolulu'\n        ]\n        return [{ label: 'All', zones: fallback }]\n      }\n    },\n    editorLanguage () {\n      return this.form.protocol === 1 ? 'plaintext' : 'shell'\n    },\n    commandPlaceholder () {\n      if (this.form.protocol === 1) {\n        return this.t('message.pleaseEnterUrl')\n      }\n      return this.t('message.pleaseEnterShellCommand')\n    },\n    commandWarning () {\n      if (!this.form.command) return ''\n      if (this.form.command.includes('&quot;')) {\n        return this.t('message.htmlEntityDetected') || 'HTML 实体编码已检测到，将自动转换为正确的引号'\n      }\n      return ''\n    }\n  },\n  watch: {\n    $route () {\n      this.initializeForm()\n    },\n    showVersionDrawer (val) {\n      if (val) {\n        this.versionPage = 1\n        this.loadVersions()\n      }\n    },\n    'form.command' (newVal) {\n      if (newVal && newVal.includes('&quot;')) {\n        this.$nextTick(() => {\n          this.form.command = newVal\n            .replace(/&quot;/g, '\"')\n            .replace(/&apos;/g, \"'\")\n            .replace(/&lt;/g, '<')\n            .replace(/&gt;/g, '>')\n            .replace(/&amp;/g, '&')\n        })\n      }\n    },\n    showTemplateDialog (val) {\n      if (val) {\n        this.loadTemplateCategories()\n        this.loadTemplates()\n      }\n    },\n    'form.notify_status' () {\n      this.updateNotifyKeywordRule()\n      if (this.form.notify_status === 0) {\n        this.form.notify_type = 0\n      }\n    },\n    'form.level' () {\n      this.updateSpecRule()\n    }\n  },\n  created () {\n    this.initFormRules()\n    this.initSelectOptions()\n    this.loadNotificationOptions()\n    this.loadTagOptions()\n    this.initializeForm()\n  },\n  methods: {\n    initFormRules() {\n      this.formRules = {\n        name: [\n          {required: true, message: this.t('message.pleaseEnterTaskName'), trigger: 'blur'}\n        ],\n        spec: [\n          {required: true, message: this.t('message.pleaseEnterCronExpression'), trigger: 'blur'},\n          {validator: (rule, value, callback) => this.validateCronSpecField(rule, value, callback), trigger: 'blur'},\n          {validator: (rule, value, callback) => this.validateCronSpecField(rule, value, callback), trigger: 'change'}\n        ],\n        command: [\n          {required: true, message: this.t('message.pleaseEnterCommand'), trigger: 'blur'}\n        ],\n        timeout: [\n          {type: 'number', required: true, message: this.t('message.pleaseEnterValidTimeout'), trigger: 'blur'}\n        ],\n        retry_times: [\n          {type: 'number', required: true, message: this.t('message.pleaseEnterValidRetryTimes'), trigger: 'blur'}\n        ],\n        retry_interval: [\n          {type: 'number', required: true, message: this.t('message.pleaseEnterValidRetryInterval'), trigger: 'blur'}\n        ],\n        notify_keyword: [\n          {required: true, message: this.t('message.pleaseEnterNotifyKeyword'), trigger: 'blur'}\n        ],\n        host_ids: [\n          {required: true, message: this.t('message.selectTaskNode'), trigger: 'blur'},\n          {validator: (rule, value, callback) => this.validateHostIds(rule, value, callback), trigger: 'change'}\n        ]\n      }\n    },\n    initSelectOptions() {\n      this.levelList = [\n        { value: 1, label: this.t('task.mainTask') },\n        { value: 2, label: this.t('task.childTask') }\n      ]\n      this.dependencyStatusList = [\n        { value: 1, label: this.t('task.strongDependency') },\n        { value: 2, label: this.t('task.weakDependency') }\n      ]\n      this.runStatusList = [\n        { value: 0, label: this.t('common.yes') },\n        { value: 1, label: this.t('common.no') }\n      ]\n      this.notifyStatusList = [\n        { value: 0, label: this.t('task.notifyDisabled') },\n        { value: 1, label: this.t('task.notifyOnFailure') },\n        { value: 2, label: this.t('task.notifyAlways') },\n        { value: 3, label: this.t('task.notifyKeywordMatch') }\n      ]\n      this.notifyTypes = [\n        { value: 0, label: this.t('task.notifyEmail') },\n        { value: 1, label: this.t('task.notifySlack') },\n        { value: 2, label: this.t('task.notifyWebhook') }\n      ]\n    },\n    updateNotifyKeywordRule () {\n      const keywordRules = this.formRules.notify_keyword\n      const needKeyword = this.form.notify_status === 3\n      if (!keywordRules || !keywordRules.length) {\n        return\n      }\n      keywordRules[0].required = needKeyword\n      if (!needKeyword) {\n        this.form.notify_keyword = ''\n        if (this.$refs.form) {\n          this.$refs.form.clearValidate('notify_keyword')\n        }\n      }\n      // 移除主动验证，只在用户交互时才验证\n    },\n    updateSpecRule () {\n      const specRules = this.formRules.spec\n      if (!specRules || !specRules.length) {\n        return\n      }\n      const needSpec = this.form.level === 1\n      specRules[0].required = needSpec\n      if (!needSpec && this.$refs.form) {\n        this.$refs.form.clearValidate('spec')\n      }\n      // 移除主动验证，只在用户交互时才验证\n    },\n    validateHostIds (rule, value, callback) {\n      if (Number(this.form.protocol) === 2 && (!value || value.length === 0)) {\n        callback(new Error(this.t('message.selectTaskNode')))\n        return\n      }\n      callback()\n    },\n    handleProtocolChange (value, skipValidation = false) {\n      const protocolValue = Number(value)\n      if (Number.isNaN(protocolValue)) {\n        return\n      }\n      this.form.protocol = protocolValue\n      if (protocolValue === 2) {\n        if (!skipValidation) {\n          this.$nextTick(() => {\n            if (this.$refs.form) {\n              const p = this.$refs.form.validateField('host_ids')\n              if (p && p.catch) p.catch(() => {})\n            }\n          })\n        }\n        return\n      }\n      this.form.host_ids = []\n      this.form.host_id = ''\n      this.$nextTick(() => {\n        if (this.$refs.form) {\n          try { this.$refs.form.clearValidate('host_ids') } catch { /* ignore */ }\n        }\n      })\n    },\n    validateCronSpecField (rule, value, callback) {\n      if (this.form.level !== 1) {\n        callback()\n        return\n      }\n      const result = validateCronSpec(value)\n      if (!result.valid) {\n        callback(new Error(result.message))\n        return\n      }\n      callback()\n    },\n    validateCommand () {\n      if (this.form.command && this.form.command.includes('&quot;')) {\n        // 自动修复 HTML 实体编码\n        this.form.command = this.form.command\n          .replace(/&quot;/g, '\"')\n          .replace(/&apos;/g, \"'\")\n          .replace(/&lt;/g, '<')\n          .replace(/&gt;/g, '>')\n          .replace(/&amp;/g, '&')\n      }\n    },\n    resetForm () {\n      if (this.$refs.form) {\n        this.$refs.form.clearValidate()\n      }\n      const defaults = createDefaultForm()\n      Object.assign(this.form, defaults)\n      this.selectedMailNotifyIds = []\n      this.selectedSlackNotifyIds = []\n      this.selectedWebhookNotifyIds = []\n      this.handleProtocolChange(this.form.protocol, true)\n      this.updateNotifyKeywordRule()\n      this.updateSpecRule()\n    },\n    initializeForm () {\n      this.resetForm()\n      const id = this.$route.params.id\n      if (id) {\n        taskService.detail(id, (taskData, hosts) => {\n          this.hosts = hosts || []\n          if (!taskData) {\n            this.$message.error(this.t('message.dataNotFound'))\n            this.cancel()\n            return\n          }\n          this.populateForm(taskData)\n        })\n        return\n      }\n      taskService.detail(null, (...args) => {\n        const hosts = args.length > 1 ? args[1] : args[0]\n        this.hosts = hosts || []\n        this.handleProtocolChange(this.form.protocol, true)\n        this.updateSpecRule()\n\n        // 从模板列表页跳转过来时，加载模板并走变量检测流程\n        const templateId = this.$route.query.template_id\n        if (templateId) {\n          templateService.apply(templateId, (data) => {\n            if (data) {\n              this.selectTemplate(data)\n            }\n          })\n        }\n      })\n    },\n    populateForm (taskData) {\n      const { timezone, spec: cronSpec } = extractTimezone(taskData.spec)\n      Object.assign(this.form, {\n        id: taskData.id,\n        name: taskData.name,\n        tag: taskData.tag,\n        tags: taskData.tag ? taskData.tag.split(',').filter(Boolean) : [],\n        level: taskData.level,\n        dependency_status: taskData.dependency_status || 1,\n        dependency_task_id: taskData.dependency_task_id || '',\n        spec: cronSpec,\n        timezone: timezone,\n        protocol: taskData.protocol,\n        http_method: taskData.http_method || 1,\n        http_body: taskData.http_body || '',\n        http_headers: taskData.http_headers || '',\n        success_pattern: taskData.success_pattern || '',\n        command: taskData.command,\n        timeout: taskData.timeout,\n        multi: taskData.multi,\n        notify_keyword: taskData.notify_keyword,\n        notify_status: taskData.notify_status,\n        notify_type: taskData.notify_type,\n        notify_receiver_id: taskData.notify_receiver_id,\n        retry_times: taskData.retry_times,\n        retry_interval: taskData.retry_interval,\n        log_retention_days: taskData.log_retention_days || 0,\n        remark: taskData.remark || ''\n      })\n      const taskHosts = taskData.hosts || []\n      this.form.host_ids = Number(this.form.protocol) === 2 ? taskHosts.map(v => v.host_id) : []\n      this.handleProtocolChange(this.form.protocol, true)\n      this.updateNotifyKeywordRule()\n      this.updateSpecRule()\n\n\n      this.selectedMailNotifyIds = []\n      this.selectedSlackNotifyIds = []\n      this.selectedWebhookNotifyIds = []\n      if (this.form.notify_status > 0 && this.form.notify_receiver_id) {\n        const notifyReceiverIds = this.form.notify_receiver_id.split(',').filter(Boolean)\n        if (this.form.notify_type === 0) {\n          this.selectedMailNotifyIds = notifyReceiverIds.map(v => parseInt(v))\n        } else if (this.form.notify_type === 1) {\n          this.selectedSlackNotifyIds = notifyReceiverIds.map(v => parseInt(v))\n        } else if (this.form.notify_type === 2) {\n          this.selectedWebhookNotifyIds = notifyReceiverIds.map(v => parseInt(v))\n        }\n      }\n    },\n    loadTagOptions () {\n      taskService.allTags((tags) => {\n        this.tagOptions = tags || []\n      })\n    },\n    loadNotificationOptions () {\n      notificationService.mail((data) => {\n        this.mailUsers = data.mail_users || []\n      })\n      notificationService.slack((data) => {\n        this.slackChannels = data.channels || []\n      })\n      notificationService.webhook((data) => {\n        this.webhookUrls = data.webhook_urls || []\n      })\n    },\n    submit () {\n      this.$refs.form.validate().then((valid) => {\n        if (!valid) {\n          return false\n        }\n        if (this.form.notify_status > 0) {\n          if (this.form.notify_type === 0 && this.selectedMailNotifyIds.length === 0) {\n            this.$message.error(this.t('message.selectMailReceiver'))\n            return false\n          }\n          if (this.form.notify_type === 1 && this.selectedSlackNotifyIds.length === 0) {\n            this.$message.error(this.t('message.selectSlackChannel'))\n            return false\n          }\n          if (this.form.notify_type === 2 && this.selectedWebhookNotifyIds.length === 0) {\n            this.$message.error(this.t('message.selectWebhookUrl'))\n            return false\n          }\n        }\n\n        this.save()\n      }).catch(() => {})\n    },\n    save () {\n      // 构建提交用的 spec，不修改 form.spec 避免重试时双重前缀\n      let specToSave = this.form.spec\n      if (this.form.level === 1 && this.form.timezone && specToSave) {\n        specToSave = 'CRON_TZ=' + this.form.timezone + ' ' + specToSave\n      }\n\n      // 将标签数组转换为逗号分隔的字符串\n      this.form.tag = (this.form.tags || []).join(',')\n\n      // 清理命令中的 HTML 实体编码\n      let command = this.form.command || ''\n      if (command) {\n        command = command\n          .replace(/&quot;/g, '\"')\n          .replace(/&apos;/g, \"'\")\n          .replace(/&lt;/g, '<')\n          .replace(/&gt;/g, '>')\n          .replace(/&amp;/g, '&')\n      }\n\n      const payload = { ...this.form, spec: specToSave, command: command }\n\n      if (Number(payload.protocol) === 2) {\n        payload.host_id = this.form.host_ids.join(',')\n      } else {\n        payload.host_id = ''\n      }\n      if (payload.notify_status > 0) {\n        if (payload.notify_type === 0) {\n          payload.notify_receiver_id = this.selectedMailNotifyIds.join(',')\n        } else if (payload.notify_type === 1) {\n          payload.notify_receiver_id = this.selectedSlackNotifyIds.join(',')\n        } else if (payload.notify_type === 2) {\n          payload.notify_receiver_id = this.selectedWebhookNotifyIds.join(',')\n        }\n      } else {\n        payload.notify_receiver_id = ''\n      }\n      taskService.update(payload, () => {\n        this.$router.push('/task')\n      })\n    },\n    cancel () {\n      this.$router.push('/task')\n    },\n    loadVersions () {\n      if (!this.form.id) return\n      taskService.versions(this.form.id, { page: this.versionPage, page_size: 10 }, (data) => {\n        this.versions = data.data || []\n        this.versionTotal = data.total || 0\n      })\n    },\n    previewVersion (row) {\n      this.selectedVersionCommand = row.command\n      this.showVersionCommand = true\n    },\n    getCategoryLabel (cat) {\n      const key = `template.category_${cat}`\n      const label = this.t(key)\n      return label === key ? cat : label\n    },\n    loadTemplateCategories () {\n      templateService.categories((data) => {\n        this.templateCategories = data || []\n      })\n    },\n    loadTemplates () {\n      templateService.list({ category: this.templateCategory, page_size: 50 }, (data) => {\n        this.templateList = data.data || []\n      })\n    },\n    selectTemplate (row) {\n      this.selectedTemplate = row\n      // 提取模板变量\n      const regex = /\\{\\{(\\w+)\\}\\}/g\n      const vars = new Set()\n      let match\n      const fields = [row.command, row.http_body, row.http_headers]\n      for (const field of fields) {\n        if (!field) continue\n        while ((match = regex.exec(field)) !== null) {\n          vars.add(match[1])\n        }\n      }\n      this.templateVariables = Array.from(vars)\n      this.templateVarValues = {}\n      for (const v of this.templateVariables) {\n        this.templateVarValues[v] = ''\n      }\n\n      if (this.templateVariables.length > 0) {\n        this.showTemplateDialog = false\n        this.showVariableDialog = true\n      } else {\n        this.applyTemplate(row)\n      }\n    },\n    applyTemplateWithVars () {\n      if (!this.selectedTemplate) return\n      const tmpl = { ...this.selectedTemplate }\n      // 替换变量\n      for (const [key, val] of Object.entries(this.templateVarValues)) {\n        const pattern = new RegExp(`\\\\{\\\\{${key}\\\\}\\\\}`, 'g')\n        tmpl.command = (tmpl.command || '').replace(pattern, val)\n        tmpl.http_body = (tmpl.http_body || '').replace(pattern, val)\n        tmpl.http_headers = (tmpl.http_headers || '').replace(pattern, val)\n      }\n      this.applyTemplate(tmpl)\n      this.showVariableDialog = false\n    },\n    applyTemplate (tmpl) {\n      this.form.protocol = tmpl.protocol\n      this.form.command = tmpl.command\n      this.form.http_method = tmpl.http_method || 1\n      this.form.http_body = tmpl.http_body || ''\n      this.form.http_headers = tmpl.http_headers || ''\n      this.form.success_pattern = tmpl.success_pattern || ''\n      if (tmpl.tag) {\n        this.form.tags = tmpl.tag.split(',').filter(Boolean)\n        this.form.tag = tmpl.tag\n      }\n      if (tmpl.spec) {\n        this.form.spec = tmpl.spec\n      }\n      if (tmpl.timeout > 0) {\n        this.form.timeout = tmpl.timeout\n      }\n      if (tmpl.multi !== undefined) {\n        this.form.multi = tmpl.multi\n      }\n      if (tmpl.retry_times > 0) {\n        this.form.retry_times = tmpl.retry_times\n      }\n      if (tmpl.retry_interval > 0) {\n        this.form.retry_interval = tmpl.retry_interval\n      }\n      if (tmpl.timezone) {\n        this.form.timezone = tmpl.timezone\n      }\n      if (tmpl.notify_status > 0) {\n        this.form.notify_status = tmpl.notify_status\n        this.form.notify_type = tmpl.notify_type || 0\n        if (tmpl.notify_keyword) {\n          this.form.notify_keyword = tmpl.notify_keyword\n        }\n      }\n      if (tmpl.log_retention_days > 0) {\n        this.form.log_retention_days = tmpl.log_retention_days\n      }\n      if (tmpl.description) {\n        this.form.remark = tmpl.description\n      }\n      this.handleProtocolChange(tmpl.protocol, true)\n      this.showTemplateDialog = false\n      this.$message.success(this.t('template.applySuccess'))\n    },\n    saveAsTemplate () {\n      if (!this.saveTemplateForm.name) {\n        this.$message.warning(this.t('template.templateNamePlaceholder'))\n        return\n      }\n      templateService.saveFromTask({\n        task_id: this.form.id,\n        name: this.saveTemplateForm.name,\n        description: this.saveTemplateForm.description,\n        category: this.saveTemplateForm.category\n      }, () => {\n        this.$message.success(this.t('message.saveSuccess'))\n        this.showSaveTemplateDialog = false\n        this.saveTemplateForm = { name: '', description: '', category: 'custom' }\n      })\n    },\n    rollbackVersion (row) {\n      ElMessageBox.confirm(\n        this.t('task.versionRollbackConfirm', { version: row.version }),\n        this.t('common.tip'),\n        {\n          confirmButtonText: this.t('common.confirm'),\n          cancelButtonText: this.t('common.cancel'),\n          type: 'warning'\n        }\n      ).then(() => {\n        taskService.versionRollback(this.form.id, row.id, () => {\n          this.$message.success(this.t('task.versionRollbackSuccess'))\n          this.showVersionDrawer = false\n          this.initializeForm()\n        })\n      }).catch(() => {})\n    }\n  }\n}\n</script>\n\n<style scoped>\n:deep(.el-form-item__error) {\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/task/list.vue",
    "content": "<template>\n  <el-main>\n    <el-form\n      :inline=\"true\"\n      label-width=\"auto\"\n    >\n      <el-form-item :label=\"t('task.id')\">\n        <el-input\n          v-model.trim=\"searchParams.id\"\n          style=\"width: 180px;\"\n        />\n      </el-form-item>\n      <el-form-item :label=\"t('task.name')\">\n        <el-input\n          v-model.trim=\"searchParams.name\"\n          style=\"width: 180px;\"\n        />\n      </el-form-item>\n      <el-form-item :label=\"t('task.tag')\">\n        <el-select\n          v-model=\"searchParams.selectedTags\"\n          multiple\n          filterable\n          allow-create\n          default-first-option\n          collapse-tags\n          collapse-tags-tooltip\n          :placeholder=\"t('task.tagPlaceholder')\"\n          style=\"width: 180px;\"\n        >\n          <el-option\n            v-for=\"tag in tagOptions\"\n            :key=\"tag\"\n            :label=\"tag\"\n            :value=\"tag\"\n          />\n        </el-select>\n      </el-form-item>\n      <el-form-item>\n        <el-button\n          type=\"primary\"\n          @click=\"search()\"\n        >\n          {{ t('common.search') }}\n        </el-button>\n      </el-form-item>\n      <br>\n      <el-form-item :label=\"t('task.protocol')\">\n        <el-select\n          v-model.trim=\"searchParams.protocol\"\n          style=\"width: 180px;\"\n        >\n          <el-option\n            :label=\"t('select')\"\n            value=\"\"\n          />\n          <el-option\n            v-for=\"item in protocolList\"\n            :key=\"item.value\"\n            :label=\"item.label\"\n            :value=\"item.value\"\n          />\n        </el-select>\n      </el-form-item>\n      <el-form-item :label=\"t('task.taskNode')\">\n        <el-select\n          v-model.trim=\"searchParams.host_id\"\n          style=\"width: 180px;\"\n        >\n          <el-option\n            :label=\"t('select')\"\n            value=\"\"\n          />\n          <el-option\n            v-for=\"item in hosts\"\n            :key=\"item.id\"\n            :label=\"item.alias + ' - ' + item.name + ':' + item.port \"\n            :value=\"item.id\"\n          />\n        </el-select>\n      </el-form-item>\n      <el-form-item :label=\"t('common.status')\">\n        <el-select\n          v-model.trim=\"searchParams.status\"\n          style=\"width: 180px;\"\n        >\n          <el-option\n            :label=\"t('select')\"\n            value=\"\"\n          />\n          <el-option\n            v-for=\"item in statusList\"\n            :key=\"item.value\"\n            :label=\"item.label\"\n            :value=\"item.value\"\n          />\n        </el-select>\n      </el-form-item>\n    </el-form>\n    <el-row\n      type=\"flex\"\n      justify=\"end\"\n      style=\"margin-bottom: 10px;\"\n    >\n      <el-col\n        :span=\"24\"\n        style=\"text-align: right;\"\n      >\n        <span\n          v-if=\"isAdmin && selectedTasks.length > 0\"\n          style=\"margin-right: 10px; color: #909399;\"\n        >{{ t('message.selected') }} {{ selectedTasks.length }} {{ t('message.tasks') }}</span>\n        <el-button\n          v-if=\"isAdmin\"\n          type=\"success\"\n          size=\"default\"\n          :disabled=\"selectedTasks.length === 0\"\n          @click=\"batchEnable\"\n        >\n          {{ t('message.batchEnable') }}\n        </el-button>\n        <el-button\n          v-if=\"isAdmin\"\n          type=\"warning\"\n          size=\"default\"\n          :disabled=\"selectedTasks.length === 0\"\n          @click=\"batchDisable\"\n        >\n          {{ t('message.batchDisable') }}\n        </el-button>\n        <el-button\n          v-if=\"isAdmin\"\n          type=\"danger\"\n          size=\"default\"\n          :disabled=\"selectedTasks.length === 0\"\n          @click=\"batchRemove\"\n        >\n          {{ t('message.batchDelete') }}\n        </el-button>\n        <el-button\n          v-if=\"isAdmin\"\n          type=\"primary\"\n          @click=\"toEdit(null)\"\n        >\n          {{ t('common.add') }}\n        </el-button>\n        <el-button\n          type=\"info\"\n          @click=\"refresh\"\n        >\n          {{ t('common.refresh') }}\n        </el-button>\n      </el-col>\n    </el-row>\n    <el-pagination\n      v-model:current-page=\"searchParams.page\"\n      v-model:page-size=\"searchParams.page_size\"\n      background\n      layout=\"prev, pager, next, sizes, total\"\n      :total=\"taskTotal\"\n      @size-change=\"changePageSize\"\n      @current-change=\"changePage\"\n    />\n    <el-table\n      :data=\"tasks\"\n      tooltip-effect=\"dark\"\n      border\n      style=\"width: 100%\"\n      @selection-change=\"handleSelectionChange\"\n    >\n      <el-table-column\n        v-if=\"isAdmin\"\n        type=\"selection\"\n        width=\"55\"\n      />\n      <el-table-column type=\"expand\">\n        <template #default=\"scope\">\n          <el-form\n            label-position=\"left\"\n            inline\n            class=\"demo-table-expand\"\n            label-width=\"auto\"\n          >\n            <el-form-item :label=\"t('message.taskCreatedTime') + ':'\">\n              {{ $filters.formatTime(scope.row.created) }} <br>\n            </el-form-item>\n            <el-form-item :label=\"t('message.taskType') + ':'\">\n              {{ formatLevel(scope.row.level) }} <br>\n            </el-form-item>\n            <el-form-item :label=\"t('message.singleInstanceRun') + ':'\">\n              {{ formatMulti(scope.row.multi) }} <br>\n            </el-form-item>\n            <el-form-item :label=\"t('message.timeoutTime') + ':'\">\n              {{ formatTimeout(scope.row.timeout) }} <br>\n            </el-form-item>\n            <el-form-item :label=\"t('message.retryCount') + ':'\">\n              {{ scope.row.retry_times }} <br>\n            </el-form-item>\n            <el-form-item :label=\"t('message.retryIntervalTime') + ':'\">\n              {{ formatRetryTimesInterval(scope.row.retry_interval) }}\n            </el-form-item> <br>\n            <el-form-item :label=\"t('message.taskNodeLabel')\">\n              <div\n                v-for=\"item in scope.row.hosts\"\n                :key=\"item.host_id\"\n              >\n                {{ item.alias }} - {{ item.name }}:{{ item.port }} <br>\n              </div>\n            </el-form-item> <br>\n            <el-form-item\n              :label=\"t('message.commandLabel') + ':'\"\n              style=\"width: 100%\"\n            >\n              {{ scope.row.command }}\n            </el-form-item> <br>\n            <el-form-item\n              :label=\"t('message.remarkLabel')\"\n              style=\"width: 100%\"\n            >\n              {{ scope.row.remark }}\n            </el-form-item>\n          </el-form>\n        </template>\n      </el-table-column>\n      <el-table-column\n        prop=\"id\"\n        :label=\"t('task.id')\"\n      />\n      <el-table-column\n        prop=\"name\"\n        :label=\"t('task.name')\"\n        width=\"150\"\n      />\n      <el-table-column\n        :label=\"t('task.tag')\"\n      >\n        <template #default=\"scope\">\n          <template v-if=\"scope.row.tag\">\n            <el-tag\n              v-for=\"tag in scope.row.tag.split(',').filter(Boolean)\"\n              :key=\"tag\"\n              size=\"small\"\n              style=\"margin-right: 4px; margin-bottom: 2px;\"\n            >\n              {{ tag }}\n            </el-tag>\n          </template>\n        </template>\n      </el-table-column>\n      <el-table-column\n        :label=\"t('task.cronExpression')\"\n        min-width=\"150\"\n        class-name=\"no-wrap-header\"\n      >\n        <template #default=\"scope\">\n          <span>{{ parseCronSpec(scope.row.spec).expr }}</span>\n          <div\n            v-if=\"parseCronSpec(scope.row.spec).tz\"\n            style=\"color: #909399; font-size: 12px; line-height: 1.4;\"\n          >\n            {{ parseCronSpec(scope.row.spec).tz }}\n          </div>\n        </template>\n      </el-table-column>\n      <el-table-column\n        :label=\"t('task.nextRunTime')\"\n        width=\"180\"\n        class-name=\"no-wrap-header\"\n      >\n        <template #default=\"scope\">\n          {{ $filters.formatTime(scope.row.next_run_time) }}\n        </template>\n      </el-table-column>\n      <el-table-column\n        prop=\"protocol\"\n        :formatter=\"formatProtocol\"\n        :label=\"t('task.protocol')\"\n        width=\"140\"\n        class-name=\"no-wrap-header\"\n      />\n      <el-table-column\n        v-if=\"isAdmin\"\n        :label=\"t('common.status')\"\n      >\n        <template #default=\"scope\">\n          <el-switch\n            v-if=\"scope.row.level === 1\"\n            v-model=\"scope.row.status\"\n            :active-value=\"1\"\n            :inactive-value=\"0\"\n            active-color=\"#13ce66\"\n            inactive-color=\"#ff4949\"\n            @change=\"changeStatus(scope.row)\"\n          />\n        </template>\n      </el-table-column>\n      <el-table-column\n        v-else\n        :label=\"t('common.status')\"\n      >\n        <template #default=\"scope\">\n          <el-switch\n            v-if=\"scope.row.level === 1\"\n            v-model=\"scope.row.status\"\n            :active-value=\"1\"\n            :inactive-value=\"0\"\n            active-color=\"#13ce66\"\n            :disabled=\"true\"\n            inactive-color=\"#ff4949\"\n          />\n        </template>\n      </el-table-column>\n      <el-table-column\n        v-if=\"isAdmin\"\n        :label=\"t('common.operation')\"\n        :width=\"locale === 'zh-CN' ? 240 : 280\"\n      >\n        <template #default=\"scope\">\n          <div style=\"display: flex; flex-direction: column; gap: 4px;\">\n            <div style=\"display: flex; gap: 4px;\">\n              <el-button\n                type=\"primary\"\n                size=\"small\"\n                style=\"flex: 1;\"\n                @click=\"toEdit(scope.row)\"\n              >\n                {{ t('common.edit') }}\n              </el-button>\n              <el-button\n                type=\"success\"\n                size=\"small\"\n                style=\"flex: 1;\"\n                @click=\"runTask(scope.row)\"\n              >\n                {{ t('task.manualRun') }}\n              </el-button>\n            </div>\n            <div style=\"display: flex; gap: 4px;\">\n              <el-button\n                type=\"info\"\n                size=\"small\"\n                style=\"flex: 1;\"\n                @click=\"jumpToLog(scope.row)\"\n              >\n                {{ t('task.viewLog') }}\n              </el-button>\n              <el-button\n                type=\"danger\"\n                size=\"small\"\n                style=\"flex: 1;\"\n                @click=\"remove(scope.row)\"\n              >\n                {{ t('common.delete') }}\n              </el-button>\n            </div>\n          </div>\n        </template>\n      </el-table-column>\n    </el-table>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport taskService from '../../api/task'\nimport { useUserStore } from '../../stores/user'\nimport { ElMessageBox } from 'element-plus'\n\nexport default {\n  name: 'TaskList',\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale }\n  },\n  data () {\n    const userStore = useUserStore()\n    return {\n      tasks: [],\n      hosts: [],\n      taskTotal: 0,\n      isFirstActivate: true,\n      selectedTasks: [],\n      searchParams: {\n        page_size: 20,\n        page: 1,\n        id: '',\n        protocol: '',\n        name: '',\n        tag: '',\n        selectedTags: [],\n        host_id: '',\n        status: ''\n      },\n      tagOptions: [],\n      isAdmin: userStore.isAdmin,\n      protocolList: [\n        {\n          value: '1',\n          label: 'http'\n        },\n        {\n          value: '2',\n          label: 'shell'\n        }\n      ],\n      statusList: []\n    }\n  },\n  computed: {\n    computedStatusList() {\n      return [\n        {\n          value: '2',\n          label: this.t('message.activated')\n        },\n        {\n          value: '1',\n          label: this.t('message.stopped')\n        }\n      ]\n    }\n  },\n  watch: {\n    computedStatusList: {\n      handler(newVal) {\n        this.statusList = newVal\n      },\n      immediate: true\n    }\n  },\n  created () {\n    const hostId = this.$route.query.host_id\n    if (hostId) {\n      this.searchParams.host_id = hostId\n    }\n\n    this.loadTagOptions()\n    this.search()\n  },\n  activated () {\n    if (this.isFirstActivate) {\n      this.isFirstActivate = false\n      return\n    }\n    this.search()\n  },\n  methods: {\n    formatLevel (value) {\n      return value === 1 ? this.t('task.mainTask') : this.t('task.childTask')\n    },\n    formatTimeout (value) {\n      return value > 0 ? value + this.t('message.seconds') : this.t('message.noLimit')\n    },\n    formatRetryTimesInterval (value) {\n      return value > 0 ? value + this.t('message.seconds') : this.t('message.systemDefault')\n    },\n    formatMulti (value) {\n      return value > 0 ? this.t('common.no') : this.t('common.yes')\n    },\n    changeStatus (item) {\n      if (item.status) {\n        taskService.enable(item.id, () => {\n          this.search()\n        })\n      } else {\n        taskService.disable(item.id, () => {\n          this.search()\n        })\n      }\n    },\n    parseCronSpec (spec) {\n      if (!spec) return { expr: '', tz: '' }\n      const match = spec.match(/^(?:CRON_TZ|TZ)=(\\S+)\\s+(.+)$/)\n      if (match) return { tz: match[1], expr: match[2] }\n      return { expr: spec, tz: '' }\n    },\n    formatProtocol (row, col) {\n      if (row[col.property] === 2) {\n        return 'shell'\n      }\n      if (row.http_method === 1) {\n        return 'http-get'\n      }\n      return 'http-post'\n    },\n    changePage (page) {\n      this.searchParams.page = page\n      this.search()\n    },\n    changePageSize (pageSize) {\n      this.searchParams.page_size = pageSize\n      this.search()\n    },\n    loadTagOptions () {\n      taskService.allTags((tags) => {\n        this.tagOptions = tags || []\n      })\n    },\n    search (callback = null) {\n      this.searchParams.tag = (this.searchParams.selectedTags || []).join(',')\n      taskService.list(this.searchParams, (tasks, hosts) => {\n        this.tasks = tasks.data\n        this.taskTotal = tasks.total\n        this.hosts = hosts\n        if (callback) {\n          callback()\n        }\n      })\n    },\n    runTask (item) {\n      ElMessageBox.confirm(\n        this.t('message.confirmRunTask', { name: item.name }),\n        this.t('message.manualRunTask'),\n        {\n          confirmButtonText: this.t('message.confirmExecute'),\n          cancelButtonText: this.t('common.cancel'),\n          type: 'warning',\n          center: true\n        }\n      ).then(() => {\n        taskService.run(item.id, () => {\n          this.$message.success(this.t('message.taskStarted'))\n        })\n      }).catch(() => {})\n    },\n    remove (item) {\n      ElMessageBox.confirm(\n        this.t('message.confirmDeleteTask', { name: item.name }),\n        this.t('message.confirmDeleteTitle'),\n        {\n          confirmButtonText: this.t('common.confirm'),\n          cancelButtonText: this.t('common.cancel'),\n          type: 'warning'\n        }\n      ).then(() => {\n        taskService.remove(item.id, () => {\n          this.refresh()\n        })\n      }).catch(() => {})\n    },\n    jumpToLog (item) {\n      this.$router.push(`/task/log?task_id=${item.id}`)\n    },\n    refresh () {\n      this.search(() => {\n        this.$message.success(this.t('message.refreshSuccess'))\n      })\n    },\n    toEdit (item) {\n      let path = ''\n      if (item === null) {\n        path = '/task/create'\n      } else {\n        path = `/task/edit/${item.id}`\n      }\n      this.$router.push(path)\n    },\n    handleSelectionChange (selection) {\n      this.selectedTasks = selection.filter(task => task.level === 1)\n    },\n    batchEnable () {\n      if (this.selectedTasks.length === 0) {\n        this.$message.warning(this.t('message.pleaseSelectTask', { action: this.t('task.enable') }))\n        return\n      }\n      ElMessageBox.confirm(\n        this.t('message.confirmBatchEnable', { count: this.selectedTasks.length }),\n        this.t('message.batchEnable'),\n        {\n          confirmButtonText: this.t('common.confirm'),\n          cancelButtonText: this.t('common.cancel'),\n          type: 'warning'\n        }\n      ).then(() => {\n        const ids = this.selectedTasks.map(task => task.id)\n        taskService.batchEnable(ids, () => {\n          this.$message.success(this.t('message.batchEnableSuccess'))\n          this.selectedTasks = []\n          this.search()\n        })\n      }).catch(() => {})\n    },\n    batchDisable () {\n      if (this.selectedTasks.length === 0) {\n        this.$message.warning(this.t('message.pleaseSelectTask', { action: this.t('task.disable') }))\n        return\n      }\n      ElMessageBox.confirm(\n        this.t('message.confirmBatchDisable', { count: this.selectedTasks.length }),\n        this.t('message.batchDisable'),\n        {\n          confirmButtonText: this.t('common.confirm'),\n          cancelButtonText: this.t('common.cancel'),\n          type: 'warning'\n        }\n      ).then(() => {\n        const ids = this.selectedTasks.map(task => task.id)\n        taskService.batchDisable(ids, () => {\n          this.$message.success(this.t('message.batchDisableSuccess'))\n          this.selectedTasks = []\n          this.search()\n        })\n      }).catch(() => {})\n    },\n    batchRemove () {\n      if (this.selectedTasks.length === 0) {\n        this.$message.warning(this.t('message.pleaseSelectTask', { action: this.t('common.delete') }))\n        return\n      }\n      ElMessageBox.confirm(\n        this.t('message.confirmBatchDelete', { count: this.selectedTasks.length }),\n        this.t('message.batchDelete'),\n        {\n          confirmButtonText: this.t('message.confirmDeleteButton'),\n          cancelButtonText: this.t('common.cancel'),\n          type: 'error'\n        }\n      ).then(() => {\n        const ids = this.selectedTasks.map(task => task.id)\n        taskService.batchRemove(ids, () => {\n          this.$message.success(this.t('message.batchDeleteSuccess'))\n          this.selectedTasks = []\n          this.search()\n        })\n      }).catch(() => {})\n    }\n  }\n}\n</script>\n<style scoped>\n  .demo-table-expand {\n    font-size: 0;\n  }\n  .demo-table-expand label {\n    color: #99a9bf;\n  }\n  .demo-table-expand .el-form-item {\n    margin-right: 0;\n    margin-bottom: 0;\n    width: 50%;\n  }\n\n  /* 防止表头文字换行 */\n  :deep(.no-wrap-header .cell) {\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  /* 表头文字居中对齐 */\n  :deep(.el-table th .cell) {\n    text-align: center;\n  }\n\n  /* 表格内容居中对齐 */\n  :deep(.el-table td .cell) {\n    text-align: center;\n  }\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/task/sidebar.vue",
    "content": "<template>\n  <el-aside\n    width=\"150px\"\n    class=\"sidebar-container\"\n  >\n    <el-menu\n      :default-active=\"currentRoute\"\n      mode=\"vertical\"\n      background-color=\"#545c64\"\n      text-color=\"#fff\"\n      active-text-color=\"#ffd04b\"\n      router\n    >\n      <el-menu-item index=\"/task\">\n        {{ t('task.list') }}\n      </el-menu-item>\n      <el-menu-item index=\"/template\">\n        {{ t('template.list') }}\n      </el-menu-item>\n      <el-menu-item index=\"/task/log\">\n        {{ t('task.log') }}\n      </el-menu-item>\n      <el-menu-item index=\"/statistics\">\n        {{ t('nav.statistics') }}\n      </el-menu-item>\n    </el-menu>\n    <div class=\"sidebar-language-switcher\">\n      <LanguageSwitcher />\n    </div>\n  </el-aside>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport LanguageSwitcher from '../../components/common/LanguageSwitcher.vue'\n\nexport default {\n  name: 'TaskSidebar',\n  components: { LanguageSwitcher },\n  setup() {\n    const { t } = useI18n()\n    return { t }\n  },\n  data () {\n    return {}\n  },\n  computed: {\n    currentRoute () {\n      if (this.$route.path === '/task/log') {\n        return '/task/log'\n      }\n      if (this.$route.path === '/statistics') {\n        return '/statistics'\n      }\n      if (this.$route.path.startsWith('/template')) {\n        return '/template'\n      }\n      return '/task'\n    }\n  }\n}\n</script>\n\n<style scoped>\n.sidebar-container {\n  position: relative;\n  display: flex;\n  flex-direction: column;\n}\n\n.sidebar-language-switcher {\n  position: absolute;\n  bottom: 20px;\n  left: 0;\n  right: 0;\n  padding: 10px;\n  background-color: #545c64;\n  border-top: 1px solid rgba(255, 255, 255, 0.1);\n}\n\n.sidebar-language-switcher :deep(.language-switcher) {\n  color: #67c23a;\n  font-weight: 500;\n  justify-content: center;\n  padding: 8px 12px;\n  border-radius: 4px;\n  transition: all 0.3s;\n}\n\n.sidebar-language-switcher :deep(.language-switcher:hover) {\n  background-color: rgba(103, 194, 58, 0.1);\n  color: #85ce61;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/taskLog/list.vue",
    "content": "<template>\n  <el-main>\n    <el-form :inline=\"true\">\n      <el-form-item :label=\"t('task.id')\">\n        <el-input v-model.trim=\"searchParams.task_id\" />\n      </el-form-item>\n      <el-form-item :label=\"t('task.protocol')\">\n        <el-select\n          v-model.trim=\"searchParams.protocol\"\n          :placeholder=\"t('task.protocol')\"\n          style=\"width: 180px\"\n        >\n          <el-option\n            :label=\"t('message.all')\"\n            value=\"\"\n          />\n          <el-option\n            v-for=\"item in protocolList\"\n            :key=\"item.value\"\n            :label=\"item.label\"\n            :value=\"item.value\"\n          />\n        </el-select>\n      </el-form-item>\n      <el-form-item :label=\"t('common.status')\">\n        <el-select\n          v-model.trim=\"searchParams.status\"\n          style=\"width: 180px\"\n        >\n          <el-option\n            :label=\"t('message.all')\"\n            value=\"\"\n          />\n          <el-option\n            v-for=\"item in statusList\"\n            :key=\"item.value\"\n            :label=\"item.label\"\n            :value=\"item.value\"\n          />\n        </el-select>\n      </el-form-item>\n      <el-form-item>\n        <el-button\n          type=\"primary\"\n          @click=\"search()\"\n        >\n          {{ t('common.search') }}\n        </el-button>\n      </el-form-item>\n    </el-form>\n    <el-row\n      type=\"flex\"\n      justify=\"end\"\n    >\n      <el-col\n        v-if=\"isAdmin && searchParams.task_id\"\n        :span=\"4\"\n      >\n        <el-button\n          type=\"warning\"\n          @click=\"clearTaskLog\"\n        >\n          {{ t('task.clearTaskLog') }}\n        </el-button>\n      </el-col>\n      <el-col :span=\"3\">\n        <el-button\n          v-if=\"isAdmin\"\n          type=\"danger\"\n          @click=\"clearLog\"\n        >\n          {{\n            t('message.clearLog')\n          }}\n        </el-button>\n      </el-col>\n      <el-col :span=\"2\">\n        <el-button\n          type=\"info\"\n          @click=\"refresh\"\n        >\n          {{ t('common.refresh') }}\n        </el-button>\n      </el-col>\n    </el-row>\n    <el-pagination\n      v-model:current-page=\"searchParams.page\"\n      v-model:page-size=\"searchParams.page_size\"\n      background\n      layout=\"prev, pager, next, sizes, total\"\n      :total=\"logTotal\"\n      @size-change=\"changePageSize\"\n      @current-change=\"changePage\"\n    />\n    <el-table\n      ref=\"table\"\n      :data=\"logs\"\n      border\n      style=\"width: 100%\"\n    >\n      <el-table-column type=\"expand\">\n        <template #default=\"scope\">\n          <el-form label-position=\"left\">\n            <el-form-item>\n              {{ t('message.retryCount') }}: {{ scope.row.retry_times }} <br>\n              {{ t('task.cronExpression') }}: {{ scope.row.spec }} <br>\n              {{ t('task.command') }}: {{ scope.row.command }}\n            </el-form-item>\n          </el-form>\n        </template>\n      </el-table-column>\n      <el-table-column\n        prop=\"id\"\n        label=\"ID\"\n      />\n      <el-table-column\n        prop=\"task_id\"\n        :label=\"t('task.id')\"\n      />\n      <el-table-column\n        prop=\"name\"\n        :label=\"t('task.name')\"\n        width=\"180\"\n      />\n      <el-table-column\n        prop=\"protocol\"\n        :label=\"t('task.protocol')\"\n        :formatter=\"formatProtocol\"\n      />\n      <el-table-column\n        :label=\"t('task.taskNode')\"\n        width=\"150\"\n      >\n        <template #default=\"scope\">\n          <div v-html=\"scope.row.hostname\" />\n        </template>\n      </el-table-column>\n      <el-table-column\n        :label=\"t('taskLog.duration')\"\n        width=\"250\"\n      >\n        <template #default=\"scope\">\n          {{ t('taskLog.duration') }}: {{ scope.row.total_time > 0 ? scope.row.total_time : 1\n          }}{{ t('message.seconds') }}<br>\n          {{ t('taskLog.startTime') }}: {{ $filters.formatTime(scope.row.start_time) }}<br>\n          <span v-if=\"scope.row.status !== 1\">{{ t('taskLog.endTime') }}: {{ $filters.formatTime(scope.row.end_time) }}</span>\n        </template>\n      </el-table-column>\n      <el-table-column :label=\"t('common.status')\">\n        <template #default=\"scope\">\n          <span\n            v-if=\"scope.row.status === 0\"\n            style=\"color: red\"\n          >{{ t('taskLog.failed') }}</span>\n          <span\n            v-else-if=\"scope.row.status === 1\"\n            style=\"color: green\"\n          >{{\n            t('message.running')\n          }}</span>\n          <span v-else-if=\"scope.row.status === 2\">{{ t('taskLog.success') }}</span>\n          <span\n            v-else-if=\"scope.row.status === 3\"\n            style=\"color: #4499ee\"\n          >{{\n            t('message.cancelled')\n          }}</span>\n        </template>\n      </el-table-column>\n      <el-table-column\n        v-if=\"isAdmin\"\n        :label=\"t('taskLog.result')\"\n        :width=\"locale === availableLanguages.zhCN.value ? 120 : 140\"\n      >\n        <template #default=\"scope\">\n          <el-button\n            v-if=\"scope.row.status === 2\"\n            type=\"success\"\n            size=\"small\"\n            @click=\"showTaskResult(scope.row)\"\n          >\n            {{ t('taskLog.viewOutput') }}\n          </el-button>\n          <el-button\n            v-if=\"scope.row.status === 0\"\n            type=\"warning\"\n            size=\"small\"\n            @click=\"showTaskResult(scope.row)\"\n          >\n            {{ t('taskLog.viewOutput') }}\n          </el-button>\n          <el-button\n            v-if=\"scope.row.status === 3\"\n            type=\"info\"\n            size=\"small\"\n            @click=\"showTaskResult(scope.row)\"\n          >\n            {{ t('taskLog.viewOutput') }}\n          </el-button>\n          <el-button\n            v-if=\"scope.row.status === 1 && scope.row.protocol === 2\"\n            type=\"danger\"\n            size=\"small\"\n            @click=\"stopTask(scope.row)\"\n          >\n            {{ t('message.stopTask') }}\n          </el-button>\n        </template>\n      </el-table-column>\n      <el-table-column\n        v-else\n        :label=\"t('taskLog.result')\"\n        :width=\"locale === availableLanguages.zhCN.value ? 120 : 140\"\n      >\n        <template #default=\"scope\">\n          <el-button\n            v-if=\"scope.row.status === 2\"\n            type=\"success\"\n            size=\"small\"\n            @click=\"showTaskResult(scope.row)\"\n          >\n            {{ t('taskLog.viewOutput') }}\n          </el-button>\n          <el-button\n            v-if=\"scope.row.status === 0\"\n            type=\"warning\"\n            size=\"small\"\n            @click=\"showTaskResult(scope.row)\"\n          >\n            {{ t('taskLog.viewOutput') }}\n          </el-button>\n          <el-button\n            v-if=\"scope.row.status === 3\"\n            type=\"info\"\n            size=\"small\"\n            @click=\"showTaskResult(scope.row)\"\n          >\n            {{ t('taskLog.viewOutput') }}\n          </el-button>\n        </template>\n      </el-table-column>\n    </el-table>\n    <el-dialog\n      v-model=\"dialogVisible\"\n      :title=\"t('message.taskExecutionResult')\"\n      width=\"60%\"\n    >\n      <div v-if=\"currentTaskResult.hostname\">\n        <strong>{{ t('taskLog.host') }}:</strong>\n        <pre v-html=\"currentTaskResult.hostname\" />\n      </div>\n      <div>\n        <strong>{{ t('task.command') }}:</strong>\n        <pre>{{ currentTaskResult.command }}</pre>\n      </div>\n      <div>\n        <strong>{{ t('taskLog.output') }}:</strong>\n        <pre>{{ currentTaskResult.result }}</pre>\n      </div>\n    </el-dialog>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport { ElMessageBox } from 'element-plus'\nimport taskLogService from '../../api/taskLog'\nimport { useUserStore } from '../../stores/user'\nimport { availableLanguages } from '@/const/index'\n\nexport default {\n  name: 'TaskLog',\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale, availableLanguages }\n  },\n  data() {\n    const userStore = useUserStore()\n    return {\n      logs: [],\n      logTotal: 0,\n      searchParams: {\n        page_size: 20,\n        page: 1,\n        task_id: '',\n        protocol: '',\n        status: ''\n      },\n      isAdmin: userStore.isAdmin,\n      dialogVisible: false,\n      currentTaskResult: {\n        hostname: '',\n        command: '',\n        result: ''\n      },\n      protocolList: [\n        {\n          value: '1',\n          label: 'http'\n        },\n        {\n          value: '2',\n          label: 'shell'\n        }\n      ],\n      statusList: []\n    }\n  },\n  computed: {\n    computedStatusList() {\n      return [\n        { value: '3', label: this.t('taskLog.success') },\n        { value: '1', label: this.t('taskLog.failed') },\n        { value: '4', label: this.t('message.cancelled') }\n      ]\n    }\n  },\n  watch: {\n    computedStatusList: {\n      handler(newVal) {\n        this.statusList = newVal\n      },\n      immediate: true\n    },\n    '$route.query.task_id': {\n      handler(newTaskId) {\n        if (newTaskId !== undefined) {\n          this.searchParams.task_id = newTaskId\n          this.searchParams.page = 1\n          this.search()\n        }\n      }\n    }\n  },\n  created() {\n    this.updateTaskIdFromRoute()\n    this.search()\n  },\n  activated() {\n    this.updateTaskIdFromRoute()\n    this.search()\n  },\n  methods: {\n    formatProtocol(row, col) {\n      if (row[col.property] === 1) {\n        return 'http'\n      }\n      return 'shell'\n    },\n    changePage(page) {\n      this.searchParams.page = page\n      this.search()\n    },\n    changePageSize(pageSize) {\n      this.searchParams.page_size = pageSize\n      this.search()\n    },\n    search(callback = null) {\n      taskLogService.list(this.searchParams, data => {\n        this.logs = data.data\n        this.logTotal = data.total\n\n        if (callback) {\n          callback()\n        }\n      })\n    },\n    clearTaskLog() {\n      const taskId = this.searchParams.task_id\n      ElMessageBox.confirm(\n        this.t('task.confirmClearTaskLog', { taskId }),\n        this.t('common.tip'),\n        {\n          confirmButtonText: this.t('common.confirm'),\n          cancelButtonText: this.t('common.cancel'),\n          type: 'warning',\n          center: true\n        }\n      )\n        .then(() => {\n          taskLogService.clearByTaskId(taskId, () => {\n            this.searchParams.page = 1\n            this.search()\n          })\n        })\n        .catch(() => {})\n    },\n    clearLog() {\n      ElMessageBox.confirm(this.t('message.confirmClearLog'), this.t('common.tip'), {\n        confirmButtonText: this.t('common.confirm'),\n        cancelButtonText: this.t('common.cancel'),\n        type: 'warning',\n        center: true\n      })\n        .then(() => {\n          taskLogService.clear(() => {\n            this.searchParams.page = 1\n            this.search()\n          })\n        })\n        .catch(() => {})\n    },\n    stopTask(item) {\n      taskLogService.stop(item.id, item.task_id, () => {\n        this.search()\n      })\n    },\n    showTaskResult(item) {\n      this.dialogVisible = true\n      // 清理命令中的 HTML 实体编码\n      let cleanedCommand = item.command\n      if (cleanedCommand) {\n        cleanedCommand = cleanedCommand\n          .replace(/&quot;/g, '\"')\n          .replace(/&apos;/g, \"'\")\n          .replace(/&#39;/g, \"'\")\n          .replace(/&lt;/g, '<')\n          .replace(/&gt;/g, '>')\n          .replace(/&amp;/g, '&')\n      }\n      this.currentTaskResult.hostname = item.hostname || ''\n      this.currentTaskResult.command = cleanedCommand\n      this.currentTaskResult.result = item.result\n    },\n    refresh() {\n      this.search(() => {\n        this.$message.success(this.t('message.refreshSuccess'))\n      })\n    },\n    updateTaskIdFromRoute() {\n      if (this.$route.query.task_id) {\n        this.searchParams.task_id = this.$route.query.task_id\n        this.searchParams.page = 1\n      }\n    }\n  }\n}\n</script>\n<style scoped>\npre {\n  white-space: pre-wrap;\n  word-wrap: break-word;\n  padding: 10px;\n  background-color: #4c4c4c;\n  color: white;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/template/edit.vue",
    "content": "<template>\n  <el-main>\n    <el-form ref=\"form\" :model=\"form\" :rules=\"formRules\" label-width=\"auto\">\n      <!-- 基本信息 -->\n      <el-row>\n        <el-col :span=\"8\">\n          <el-form-item :label=\"t('template.name')\" prop=\"name\">\n            <el-input v-model.trim=\"form.name\" :placeholder=\"t('template.templateNamePlaceholder')\"></el-input>\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"8\">\n          <el-form-item :label=\"t('template.category')\" prop=\"category\">\n            <el-select v-model=\"form.category\" filterable allow-create default-first-option\n              :placeholder=\"t('template.selectCategory')\" style=\"width: 100%\">\n              <el-option value=\"backup\" :label=\"t('template.category_backup')\"></el-option>\n              <el-option value=\"cleanup\" :label=\"t('template.category_cleanup')\"></el-option>\n              <el-option value=\"monitor\" :label=\"t('template.category_monitor')\"></el-option>\n              <el-option value=\"deploy\" :label=\"t('template.category_deploy')\"></el-option>\n              <el-option value=\"api\" :label=\"t('template.category_api')\"></el-option>\n              <el-option value=\"custom\" :label=\"t('template.category_custom')\"></el-option>\n            </el-select>\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"8\">\n          <el-form-item :label=\"t('task.tag')\">\n            <el-input v-model=\"form.tag\" :placeholder=\"t('task.tagPlaceholder')\"></el-input>\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-row>\n        <el-col :span=\"16\">\n          <el-form-item :label=\"t('template.description')\">\n            <el-input v-model=\"form.description\" :placeholder=\"t('template.templateDescPlaceholder')\"></el-input>\n          </el-form-item>\n        </el-col>\n      </el-row>\n\n      <!-- 调度配置 -->\n      <el-row>\n        <el-col :span=\"12\">\n          <el-form-item :label=\"t('task.cronExpression')\">\n            <CronInput v-model=\"form.spec\" />\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"8\">\n          <el-form-item :label=\"t('task.timezone')\">\n            <el-select v-model=\"form.timezone\" filterable clearable\n              :placeholder=\"t('task.timezoneServer')\" style=\"width: 100%;\">\n              <el-option-group v-for=\"group in timezoneGroups\" :key=\"group.label\" :label=\"group.label\">\n                <el-option v-for=\"tz in group.zones\" :key=\"tz\" :label=\"tz\" :value=\"tz\"></el-option>\n              </el-option-group>\n            </el-select>\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-row>\n        <el-col :span=\"24\">\n          <el-form-item label=\" \">\n            <CronPreview :spec=\"form.spec\" :timezone=\"form.timezone\" />\n          </el-form-item>\n        </el-col>\n      </el-row>\n\n      <!-- 执行配置 -->\n      <el-row>\n        <el-col :span=\"8\">\n          <el-form-item :label=\"t('template.protocol')\">\n            <el-select v-model.trim=\"form.protocol\">\n              <el-option :value=\"1\" label=\"HTTP\"></el-option>\n              <el-option :value=\"2\" label=\"Shell\"></el-option>\n            </el-select>\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"8\" v-if=\"form.protocol === 1\">\n          <el-form-item :label=\"t('task.httpMethod')\">\n            <el-select v-model.trim=\"form.http_method\">\n              <el-option :value=\"1\" label=\"GET\"></el-option>\n              <el-option :value=\"2\" label=\"POST\"></el-option>\n            </el-select>\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-row>\n        <el-col :span=\"20\">\n          <el-form-item :label=\"t('template.command')\" prop=\"command\">\n            <div style=\"width: 100%;\">\n              <MonacoEditor v-model=\"form.command\" :language=\"editorLanguage\" height=\"250px\" />\n              <div style=\"color: #909399; font-size: 12px; margin-top: 4px;\">\n                {{ t('template.templateVarTip') }}\n              </div>\n            </div>\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-row v-if=\"form.protocol === 1 && form.http_method === 2\">\n        <el-col :span=\"16\">\n          <el-form-item :label=\"t('task.httpBody')\">\n            <el-input type=\"textarea\" :rows=\"4\" v-model=\"form.http_body\"></el-input>\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-row v-if=\"form.protocol === 1\">\n        <el-col :span=\"16\">\n          <el-form-item :label=\"t('task.httpHeaders')\">\n            <el-input type=\"textarea\" :rows=\"3\" v-model=\"form.http_headers\"></el-input>\n          </el-form-item>\n        </el-col>\n      </el-row>\n      <el-row v-if=\"form.protocol === 1\">\n        <el-col :span=\"12\">\n          <el-form-item :label=\"t('task.successPattern')\">\n            <el-input v-model.trim=\"form.success_pattern\" :placeholder=\"t('task.successPatternPlaceholder')\"></el-input>\n          </el-form-item>\n        </el-col>\n      </el-row>\n\n      <!-- 超时与重试 -->\n      <el-row>\n        <el-col :span=\"6\">\n          <el-form-item :label=\"t('template.timeout')\">\n            <el-input-number v-model=\"form.timeout\" :min=\"0\" :max=\"86400\" style=\"width: 100%;\"></el-input-number>\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"6\">\n          <el-form-item :label=\"t('task.singleInstance')\">\n            <el-select v-model.trim=\"form.multi\" style=\"width: 100%;\">\n              <el-option :value=\"0\" :label=\"t('common.yes')\"></el-option>\n              <el-option :value=\"1\" :label=\"t('common.no')\"></el-option>\n            </el-select>\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"6\">\n          <el-form-item :label=\"t('task.retryTimes')\">\n            <el-input-number v-model=\"form.retry_times\" :min=\"0\" :max=\"10\" style=\"width: 100%;\"></el-input-number>\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"6\">\n          <el-form-item :label=\"t('task.retryInterval')\">\n            <el-input-number v-model=\"form.retry_interval\" :min=\"0\" :max=\"3600\" style=\"width: 100%;\"></el-input-number>\n          </el-form-item>\n        </el-col>\n      </el-row>\n\n      <!-- 通知策略 -->\n      <el-row>\n        <el-col :span=\"8\">\n          <el-form-item :label=\"t('task.notification')\">\n            <el-select v-model.trim=\"form.notify_status\" style=\"width: 100%;\">\n              <el-option :value=\"0\" :label=\"t('task.notifyDisabled')\"></el-option>\n              <el-option :value=\"1\" :label=\"t('task.notifyOnFailure')\"></el-option>\n              <el-option :value=\"2\" :label=\"t('task.notifyAlways')\"></el-option>\n              <el-option :value=\"3\" :label=\"t('task.notifyKeywordMatch')\"></el-option>\n            </el-select>\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"8\" v-if=\"form.notify_status !== 0\">\n          <el-form-item :label=\"t('task.notifyType')\">\n            <el-select v-model.trim=\"form.notify_type\" style=\"width: 100%;\">\n              <el-option :value=\"0\" :label=\"t('task.notifyEmail')\"></el-option>\n              <el-option :value=\"1\" :label=\"t('task.notifySlack')\"></el-option>\n              <el-option :value=\"2\" :label=\"t('task.notifyWebhook')\"></el-option>\n            </el-select>\n          </el-form-item>\n        </el-col>\n        <el-col :span=\"8\" v-if=\"form.notify_status === 3\">\n          <el-form-item :label=\"t('task.notifyKeyword')\">\n            <el-input v-model.trim=\"form.notify_keyword\" :placeholder=\"t('task.notifyKeywordPlaceholder')\"></el-input>\n          </el-form-item>\n        </el-col>\n      </el-row>\n\n      <!-- 日志保留 -->\n      <el-row>\n        <el-col :span=\"12\">\n          <el-form-item :label=\"t('task.logRetentionDays')\">\n            <el-input-number v-model=\"form.log_retention_days\" :min=\"0\" :max=\"3650\"></el-input-number>\n            <span style=\"margin-left: 8px; color: #909399; font-size: 12px;\">{{ t('task.logRetentionDaysTip') }}</span>\n          </el-form-item>\n        </el-col>\n      </el-row>\n\n      <el-form-item>\n        <el-button type=\"primary\" @click=\"submit\">{{ t('common.save') }}</el-button>\n        <el-button @click=\"cancel\">{{ t('common.cancel') }}</el-button>\n      </el-form-item>\n    </el-form>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport templateService from '../../api/template'\nimport MonacoEditor from '../../components/common/MonacoEditor.vue'\nimport CronInput from '../../components/common/CronInput.vue'\nimport CronPreview from '../../components/common/CronPreview.vue'\n\nexport default {\n  name: 'template-edit',\n  components: { MonacoEditor, CronInput, CronPreview },\n  setup() {\n    const { t } = useI18n()\n    return { t }\n  },\n  computed: {\n    editorLanguage() {\n      return this.form.protocol === 1 ? 'plaintext' : 'shell'\n    },\n    timezoneGroups() {\n      try {\n        const zones = Intl.supportedValuesOf('timeZone')\n        const groups = { UTC: ['UTC'] }\n        for (const tz of zones) {\n          const region = tz.split('/')[0]\n          if (!groups[region]) groups[region] = []\n          groups[region].push(tz)\n        }\n        const priority = ['UTC', 'Asia', 'America', 'Europe', 'Pacific', 'Australia', 'Africa']\n        const sorted = Object.keys(groups).sort((a, b) => {\n          const ai = priority.indexOf(a)\n          const bi = priority.indexOf(b)\n          if (ai !== -1 && bi !== -1) return ai - bi\n          if (ai !== -1) return -1\n          if (bi !== -1) return 1\n          return a.localeCompare(b)\n        })\n        return sorted.map(region => ({ label: region, zones: groups[region] }))\n      } catch {\n        return [{ label: 'All', zones: ['UTC', 'Asia/Shanghai', 'America/New_York', 'Europe/London'] }]\n      }\n    }\n  },\n  data() {\n    return {\n      form: {\n        id: '',\n        name: '',\n        description: '',\n        category: 'custom',\n        protocol: 2,\n        command: '',\n        http_method: 1,\n        http_body: '',\n        http_headers: '',\n        success_pattern: '',\n        tag: '',\n        spec: '',\n        timeout: 300,\n        multi: 1,\n        retry_times: 0,\n        retry_interval: 0,\n        timezone: '',\n        notify_status: 0,\n        notify_type: 0,\n        notify_keyword: '',\n        log_retention_days: 0\n      },\n      formRules: {\n        name: [\n          { required: true, message: '', trigger: 'blur' }\n        ],\n        category: [\n          { required: true, message: '', trigger: 'blur' }\n        ],\n        command: [\n          { required: true, message: '', trigger: 'blur' }\n        ]\n      }\n    }\n  },\n  watch: {\n    $route() {\n      this.loadForm()\n    }\n  },\n  created() {\n    this.formRules.name[0].message = this.t('template.templateNamePlaceholder')\n    this.formRules.category[0].message = this.t('template.selectCategory')\n    this.formRules.command[0].message = this.t('message.pleaseEnterCommand')\n    this.loadForm()\n  },\n  methods: {\n    loadForm() {\n      // 重置表单\n      this.form = {\n        id: '',\n        name: '',\n        description: '',\n        category: 'custom',\n        protocol: 2,\n        command: '',\n        http_method: 1,\n        http_body: '',\n        http_headers: '',\n        success_pattern: '',\n        tag: '',\n        spec: '',\n        timeout: 300,\n        multi: 1,\n        retry_times: 0,\n        retry_interval: 0,\n        timezone: '',\n        notify_status: 0,\n        notify_type: 0,\n        notify_keyword: '',\n        log_retention_days: 0\n      }\n      if (this.$refs.form) {\n        this.$refs.form.clearValidate()\n      }\n\n      const id = this.$route.params.id\n      if (id) {\n        templateService.detail(id, (data) => {\n          if (data) {\n            this.form = {\n              id: data.id,\n              name: data.name,\n              description: data.description || '',\n              category: data.category,\n              protocol: data.protocol,\n              command: data.command,\n              http_method: data.http_method || 1,\n              http_body: data.http_body || '',\n              http_headers: data.http_headers || '',\n              success_pattern: data.success_pattern || '',\n              tag: data.tag || '',\n              spec: data.spec || '',\n              timeout: data.timeout || 300,\n              multi: data.multi ?? 1,\n              retry_times: data.retry_times || 0,\n              retry_interval: data.retry_interval || 0,\n              timezone: data.timezone || '',\n              notify_status: data.notify_status || 0,\n              notify_type: data.notify_type || 0,\n              notify_keyword: data.notify_keyword || '',\n              log_retention_days: data.log_retention_days || 0\n            }\n          }\n        })\n      }\n    },\n    submit() {\n      this.$refs.form.validate().then((valid) => {\n        if (!valid) return false\n        templateService.store(this.form, () => {\n          this.$router.push('/template')\n        })\n      }).catch(() => {})\n    },\n    cancel() {\n      this.$router.push('/template')\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "web/vue/src/pages/template/list.vue",
    "content": "<template>\n  <el-main>\n    <el-form :inline=\"true\" label-width=\"auto\">\n      <el-form-item :label=\"t('template.category')\">\n        <el-select v-model=\"searchParams.category\" style=\"width: 150px;\" @change=\"search\">\n          <el-option :label=\"t('template.category_all')\" value=\"\"></el-option>\n          <el-option\n            v-for=\"cat in categoryList\"\n            :key=\"cat\"\n            :label=\"getCategoryLabel(cat)\"\n            :value=\"cat\">\n          </el-option>\n        </el-select>\n      </el-form-item>\n      <el-form-item :label=\"t('template.name')\">\n        <el-input v-model.trim=\"searchParams.name\" style=\"width: 180px;\" @keyup.enter=\"search\"></el-input>\n      </el-form-item>\n      <el-form-item>\n        <el-button type=\"primary\" @click=\"search\">{{ t('common.search') }}</el-button>\n      </el-form-item>\n    </el-form>\n    <el-row type=\"flex\" justify=\"end\" style=\"margin-bottom: 10px;\">\n      <el-col :span=\"24\" style=\"text-align: right;\">\n        <el-button type=\"primary\" @click=\"toEdit(null)\" v-if=\"isAdmin\">{{ t('template.createNew') }}</el-button>\n        <el-button type=\"info\" @click=\"refresh\">{{ t('common.refresh') }}</el-button>\n      </el-col>\n    </el-row>\n    <el-pagination\n      background\n      layout=\"prev, pager, next, sizes, total\"\n      :total=\"total\"\n      v-model:current-page=\"searchParams.page\"\n      v-model:page-size=\"searchParams.page_size\"\n      @size-change=\"changePageSize\"\n      @current-change=\"changePage\">\n    </el-pagination>\n    <el-table :data=\"templates\" border style=\"width: 100%\">\n      <el-table-column type=\"expand\">\n        <template #default=\"scope\">\n          <div style=\"padding: 12px;\">\n            <strong>{{ t('template.command') }}:</strong>\n            <pre style=\"white-space: pre-wrap; word-break: break-all; background: #f5f7fa; padding: 12px; border-radius: 4px; margin-top: 8px;\">{{ scope.row.command }}</pre>\n          </div>\n        </template>\n      </el-table-column>\n      <el-table-column prop=\"id\" label=\"ID\" width=\"60\" align=\"center\"></el-table-column>\n      <el-table-column prop=\"name\" :label=\"t('template.name')\" min-width=\"150\" align=\"center\">\n        <template #default=\"scope\">\n          {{ scope.row.name }}\n          <el-tag v-if=\"scope.row.is_builtin === 1\" size=\"small\" type=\"info\" style=\"margin-left: 4px;\">{{ t('template.builtin') }}</el-tag>\n        </template>\n      </el-table-column>\n      <el-table-column prop=\"description\" :label=\"t('template.description')\" min-width=\"200\" align=\"center\"></el-table-column>\n      <el-table-column prop=\"category\" :label=\"t('template.category')\" width=\"100\" align=\"center\">\n        <template #default=\"scope\">\n          <el-tag size=\"small\">{{ getCategoryLabel(scope.row.category) }}</el-tag>\n        </template>\n      </el-table-column>\n      <el-table-column :label=\"t('template.protocol')\" width=\"80\" align=\"center\">\n        <template #default=\"scope\">\n          {{ scope.row.protocol === 1 ? 'HTTP' : 'Shell' }}\n        </template>\n      </el-table-column>\n      <el-table-column :label=\"t('common.operation')\" width=\"180\" align=\"center\" v-if=\"isAdmin\">\n        <template #default=\"scope\">\n          <div style=\"display: flex; flex-direction: column; gap: 4px;\">\n            <el-button type=\"primary\" size=\"small\" style=\"width: 100%;\" @click=\"useTemplate(scope.row)\">{{ t('template.useTemplate') }}</el-button>\n            <div v-if=\"scope.row.is_builtin !== 1\" style=\"display: flex; gap: 4px;\">\n              <el-button size=\"small\" style=\"flex: 1;\" @click=\"toEdit(scope.row)\">{{ t('common.edit') }}</el-button>\n              <el-button type=\"danger\" size=\"small\" style=\"flex: 1;\" @click=\"remove(scope.row)\">{{ t('common.delete') }}</el-button>\n            </div>\n          </div>\n        </template>\n      </el-table-column>\n    </el-table>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport templateService from '../../api/template'\nimport { useUserStore } from '../../stores/user'\nimport { ElMessageBox } from 'element-plus'\n\nexport default {\n  name: 'template-list',\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale }\n  },\n  data() {\n    const userStore = useUserStore()\n    return {\n      templates: [],\n      total: 0,\n      categoryList: [],\n      isAdmin: userStore.isAdmin,\n      searchParams: {\n        page: 1,\n        page_size: 20,\n        category: '',\n        name: ''\n      },\n      isFirstActivate: true\n    }\n  },\n  created() {\n    this.loadCategories()\n    this.search()\n  },\n  activated() {\n    if (this.isFirstActivate) {\n      this.isFirstActivate = false\n      return\n    }\n    this.loadCategories()\n    this.search()\n  },\n  methods: {\n    getCategoryLabel(cat) {\n      const key = `template.category_${cat}`\n      const label = this.t(key)\n      return label === key ? cat : label\n    },\n    loadCategories() {\n      templateService.categories((data) => {\n        this.categoryList = data || []\n      })\n    },\n    search() {\n      templateService.list(this.searchParams, (data) => {\n        this.templates = data.data || []\n        this.total = data.total || 0\n      })\n    },\n    changePage(page) {\n      this.searchParams.page = page\n      this.search()\n    },\n    changePageSize(size) {\n      this.searchParams.page_size = size\n      this.search()\n    },\n    refresh() {\n      this.search()\n      this.$message.success(this.t('message.refreshSuccess'))\n    },\n    toEdit(item) {\n      if (item === null) {\n        this.$router.push('/template/create')\n      } else {\n        this.$router.push(`/template/edit/${item.id}`)\n      }\n    },\n    useTemplate(row) {\n      this.$router.push({\n        path: '/task/create',\n        query: { template_id: row.id }\n      })\n    },\n    remove(row) {\n      ElMessageBox.confirm(\n        this.t('template.confirmDelete', { name: row.name }),\n        this.t('common.tip'),\n        {\n          confirmButtonText: this.t('common.confirm'),\n          cancelButtonText: this.t('common.cancel'),\n          type: 'warning'\n        }\n      ).then(() => {\n        templateService.remove(row.id, () => {\n          this.$message.success(this.t('message.deleteSuccess'))\n          this.search()\n        })\n      }).catch(() => {})\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "web/vue/src/pages/user/edit.vue",
    "content": "<template>\n  <el-main>\n    <div class=\"form-container\">\n      <el-form\n        ref=\"form\"\n        :model=\"form\"\n        :rules=\"formRules\"\n        label-width=\"140px\"\n        label-position=\"left\"\n        class=\"user-form\"\n      >\n        <el-form-item>\n          <el-input\n            v-model=\"form.id\"\n            type=\"hidden\"\n          />\n        </el-form-item>\n        <el-form-item\n          :label=\"t('user.username')\"\n          prop=\"name\"\n        >\n          <el-input v-model=\"form.name\" />\n        </el-form-item>\n        <el-form-item\n          :label=\"t('user.email')\"\n          prop=\"email\"\n        >\n          <el-input v-model=\"form.email\" />\n        </el-form-item>\n        <template v-if=\"!form.id\">\n          <el-form-item\n            :label=\"t('user.password')\"\n            prop=\"password\"\n          >\n            <el-input\n              v-model=\"form.password\"\n              type=\"password\"\n              :placeholder=\"t('user.passwordPlaceholder')\"\n            />\n          </el-form-item>\n          <el-form-item\n            :label=\"t('user.confirmPassword')\"\n            prop=\"confirm_password\"\n          >\n            <el-input\n              v-model=\"form.confirm_password\"\n              type=\"password\"\n              :placeholder=\"t('user.passwordPlaceholder')\"\n            />\n          </el-form-item>\n        </template>\n        <el-form-item\n          :label=\"t('user.role')\"\n          prop=\"is_admin\"\n          required\n        >\n          <el-radio-group v-model=\"form.is_admin\">\n            <el-radio :label=\"0\">\n              {{ t('user.normalUser') }}\n            </el-radio>\n            <el-radio :label=\"1\">\n              {{ t('user.admin') }}\n            </el-radio>\n          </el-radio-group>\n        </el-form-item>\n        <el-form-item\n          :label=\"t('common.status')\"\n          prop=\"status\"\n          required\n        >\n          <el-radio-group v-model=\"form.status\">\n            <el-radio :label=\"1\">\n              {{ t('common.enabled') }}\n            </el-radio>\n            <el-radio :label=\"0\">\n              {{ t('common.disabled') }}\n            </el-radio>\n          </el-radio-group>\n        </el-form-item>\n        <el-form-item>\n          <div class=\"button-group\">\n            <el-button\n              type=\"primary\"\n              @click=\"submit()\"\n            >\n              {{ t('common.save') }}\n            </el-button>\n            <el-button @click=\"cancel\">\n              {{ t('common.cancel') }}\n            </el-button>\n          </div>\n        </el-form-item>\n      </el-form>\n    </div>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport userService from '../../api/user'\nexport default {\n  name: 'UserEdit',\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale }\n  },\n  data: function () {\n    return {\n      form: {\n        id: '',\n        name: '',\n        email: '',\n        is_admin: 0,\n        password: '',\n        confirm_password: '',\n        status: 1\n      },\n      formRules: {}\n    }\n  },\n  computed: {\n    computedFormRules() {\n      return {\n        name: [\n          {required: true, message: this.t('user.usernameRequired'), trigger: 'blur'}\n        ],\n        email: [\n          {type: 'email', required: true, message: this.t('user.emailRequired'), trigger: 'blur'}\n        ],\n        password: [\n          {required: true, message: this.t('user.passwordRequired'), trigger: 'blur'}\n        ],\n        confirm_password: [\n          {required: true, message: this.t('user.confirmPasswordRequired'), trigger: 'blur'}\n        ]\n      }\n    }\n  },\n  watch: {\n    computedFormRules: {\n      handler(newVal) {\n        this.formRules = newVal\n      },\n      immediate: true\n    }\n  },\n  created () {\n    const id = this.$route.params.id\n    if (!id) {\n      return\n    }\n    userService.detail(id, (data) => {\n      if (!data) {\n        this.$message.error(this.t('message.dataNotFound'))\n        return\n      }\n      this.form.id = data.id\n      this.form.name = data.name\n      this.form.email = data.email\n      this.form.is_admin = data.is_admin\n      this.form.status = data.status\n    })\n  },\n  methods: {\n    submit () {\n      this.$refs['form'].validate((valid) => {\n        if (!valid) {\n          return false\n        }\n        this.save()\n      })\n    },\n    save () {\n      userService.update(this.form, () => {\n        this.$router.push('/user')\n      })\n    },\n    cancel () {\n      this.$router.push('/user')\n    }\n  }\n}\n</script>\n\n<style scoped>\n.form-container {\n  max-width: 600px;\n  margin: 0 auto;\n  padding: 20px;\n}\n\n.user-form {\n  background: white;\n  padding: 30px;\n  border-radius: 8px;\n  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);\n}\n\n.user-form :deep(.el-form-item:last-child) {\n  margin-bottom: 0;\n}\n\n.user-form :deep(.el-form-item:last-child .el-form-item__content) {\n  margin-left: 0 !important;\n}\n\n.button-group {\n  display: flex;\n  justify-content: center;\n  gap: 12px;\n  margin-top: 20px;\n  width: 100%;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/user/editMyPassword.vue",
    "content": "<template>\n  <el-main>\n    <div class=\"form-container\">\n      <el-form\n        ref=\"form\"\n        :model=\"form\"\n        :rules=\"formRules\"\n        label-width=\"180px\"\n        label-position=\"left\"\n        class=\"password-form\"\n      >\n        <el-form-item\n          :label=\"t('user.oldPassword')\"\n          prop=\"old_password\"\n        >\n          <el-input\n            v-model=\"form.old_password\"\n            type=\"password\"\n          />\n        </el-form-item>\n        <el-form-item\n          :label=\"t('user.newPassword')\"\n          prop=\"new_password\"\n        >\n          <el-input\n            v-model=\"form.new_password\"\n            type=\"password\"\n            :placeholder=\"t('user.passwordPlaceholder')\"\n          />\n        </el-form-item>\n        <el-form-item\n          :label=\"t('user.confirmNewPassword')\"\n          prop=\"confirm_new_password\"\n        >\n          <el-input\n            v-model=\"form.confirm_new_password\"\n            type=\"password\"\n            :placeholder=\"t('user.passwordPlaceholder')\"\n          />\n        </el-form-item>\n        <el-form-item>\n          <div class=\"button-group\">\n            <el-button\n              type=\"primary\"\n              @click=\"submit()\"\n            >\n              {{ t('common.save') }}\n            </el-button>\n            <el-button @click=\"cancel\">\n              {{ t('common.cancel') }}\n            </el-button>\n          </div>\n        </el-form-item>\n      </el-form>\n    </div>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport userService from '../../api/user'\nexport default {\n  name: 'UserEditMyPassword',\n  setup() {\n    const { t } = useI18n()\n    return { t }\n  },\n  data: function () {\n    return {\n      form: {\n        old_password: '',\n        new_password: '',\n        confirm_new_password: ''\n      },\n      formRules: {}\n    }\n  },\n  computed: {\n    computedFormRules() {\n      return {\n        old_password: [\n          {required: true, message: this.t('user.oldPasswordRequired'), trigger: 'blur'}\n        ],\n        new_password: [\n          {required: true, message: this.t('user.newPasswordRequired'), trigger: 'blur'}\n        ],\n        confirm_new_password: [\n          {required: true, message: this.t('user.confirmPasswordRequired'), trigger: 'blur'}\n        ]\n      }\n    }\n  },\n  watch: {\n    computedFormRules: {\n      handler(newVal) {\n        this.formRules = newVal\n      },\n      immediate: true\n    }\n  },\n  methods: {\n    submit () {\n      this.$refs['form'].validate((valid) => {\n        if (!valid) {\n          return false\n        }\n        this.save()\n      })\n    },\n    save () {\n      userService.editMyPassword(this.form, () => {\n        this.$router.back()\n      })\n    },\n    cancel () {\n      this.$router.back()\n    }\n  }\n}\n</script>\n\n<style scoped>\n.form-container {\n  max-width: 600px;\n  margin: 0 auto;\n  padding: 20px;\n}\n\n.password-form {\n  background: white;\n  padding: 30px;\n  border-radius: 8px;\n  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);\n}\n\n.password-form :deep(.el-form-item:last-child) {\n  margin-bottom: 0;\n}\n\n.password-form :deep(.el-form-item:last-child .el-form-item__content) {\n  margin-left: 0 !important;\n}\n\n.button-group {\n  display: flex;\n  justify-content: center;\n  gap: 12px;\n  margin-top: 20px;\n  width: 100%;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/user/editPassword.vue",
    "content": "<template>\n  <el-main>\n    <div class=\"form-container\">\n      <el-form\n        ref=\"form\"\n        :model=\"form\"\n        :rules=\"formRules\"\n        label-width=\"180px\"\n        label-position=\"left\"\n        class=\"password-form\"\n      >\n        <el-form-item\n          :label=\"t('user.newPassword')\"\n          prop=\"new_password\"\n        >\n          <el-input\n            v-model=\"form.new_password\"\n            type=\"password\"\n            :placeholder=\"t('user.passwordPlaceholder')\"\n          />\n        </el-form-item>\n        <el-form-item\n          :label=\"t('user.confirmNewPassword')\"\n          prop=\"confirm_new_password\"\n        >\n          <el-input\n            v-model=\"form.confirm_new_password\"\n            type=\"password\"\n            :placeholder=\"t('user.passwordPlaceholder')\"\n          />\n        </el-form-item>\n        <el-form-item>\n          <div class=\"button-group\">\n            <el-button\n              type=\"primary\"\n              @click=\"submit()\"\n            >\n              {{ t('common.save') }}\n            </el-button>\n            <el-button @click=\"cancel\">\n              {{ t('common.cancel') }}\n            </el-button>\n          </div>\n        </el-form-item>\n      </el-form>\n    </div>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport userService from '../../api/user'\nexport default {\n  name: 'UserEditPassword',\n  setup() {\n    const { t } = useI18n()\n    return { t }\n  },\n  data: function () {\n    return {\n      form: {\n        id: '',\n        new_password: '',\n        confirm_new_password: ''\n      },\n      formRules: {}\n    }\n  },\n  computed: {\n    computedFormRules() {\n      return {\n        new_password: [\n          {required: true, message: this.t('user.newPasswordRequired'), trigger: 'blur'}\n        ],\n        confirm_new_password: [\n          {required: true, message: this.t('user.confirmPasswordRequired'), trigger: 'blur'}\n        ]\n      }\n    }\n  },\n  watch: {\n    computedFormRules: {\n      handler(newVal) {\n        this.formRules = newVal\n      },\n      immediate: true\n    }\n  },\n  created () {\n    const id = this.$route.params.id\n    if (!id) {\n      return\n    }\n    this.form.id = id\n  },\n  methods: {\n    submit () {\n      this.$refs['form'].validate((valid) => {\n        if (!valid) {\n          return false\n        }\n        this.save()\n      })\n    },\n    save () {\n      userService.editPassword(this.form, () => {\n        this.$router.push('/user')\n      })\n    },\n    cancel () {\n      this.$router.push('/user')\n    }\n  }\n}\n</script>\n\n<style scoped>\n.form-container {\n  max-width: 600px;\n  margin: 0 auto;\n  padding: 20px;\n}\n\n.password-form {\n  background: white;\n  padding: 30px;\n  border-radius: 8px;\n  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);\n}\n\n.password-form :deep(.el-form-item:last-child) {\n  margin-bottom: 0;\n}\n\n.password-form :deep(.el-form-item:last-child .el-form-item__content) {\n  margin-left: 0 !important;\n}\n\n.button-group {\n  display: flex;\n  justify-content: center;\n  gap: 12px;\n  margin-top: 20px;\n  width: 100%;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/user/list.vue",
    "content": "<template>\n  <el-main>\n    <el-row\n      type=\"flex\"\n      justify=\"end\"\n    >\n      <el-col :span=\"2\">\n        <el-button\n          type=\"primary\"\n          @click=\"toEdit(null)\"\n        >\n          {{ t('common.add') }}\n        </el-button>\n      </el-col>\n      <el-col :span=\"2\">\n        <el-button\n          type=\"info\"\n          @click=\"refresh\"\n        >\n          {{ t('common.refresh') }}\n        </el-button>\n      </el-col>\n    </el-row>\n    <el-pagination\n      v-model:current-page=\"searchParams.page\"\n      v-model:page-size=\"searchParams.page_size\"\n      background\n      layout=\"prev, pager, next, sizes, total\"\n      :total=\"userTotal\"\n      @size-change=\"changePageSize\"\n      @current-change=\"changePage\"\n    />\n    <el-table\n      :data=\"users\"\n      tooltip-effect=\"dark\"\n      border\n      style=\"width: 100%\"\n    >\n      <el-table-column\n        prop=\"id\"\n        label=\"ID\"\n      />\n      <el-table-column\n        prop=\"name\"\n        :label=\"t('user.username')\"\n      />\n      <el-table-column\n        prop=\"email\"\n        :label=\"t('user.email')\"\n      />\n      <el-table-column\n        prop=\"is_admin\"\n        :formatter=\"formatRole\"\n        :label=\"t('user.role')\"\n      />\n      <el-table-column :label=\"t('common.status')\">\n        <template #default=\"scope\">\n          <el-switch\n            v-model=\"scope.row.status\"\n            :active-value=\"1\"\n            :inactive-value=\"0\"\n            active-color=\"#13ce66\"\n            inactive-color=\"#ff4949\"\n            @change=\"changeStatus(scope.row)\"\n          />\n        </template>\n      </el-table-column>\n      <el-table-column\n        v-if=\"isAdmin\"\n        :label=\"t('common.operation')\"\n        :width=\"locale === availableLanguages.zhCN.value ? 280 : 340\"\n      >\n        <template #default=\"scope\">\n          <el-button\n            type=\"primary\"\n            size=\"small\"\n            @click=\"toEdit(scope.row)\"\n          >\n            {{\n              t('common.edit')\n            }}\n          </el-button>\n          <el-button\n            type=\"success\"\n            size=\"small\"\n            @click=\"editPassword(scope.row)\"\n          >\n            {{\n              t('user.changePassword')\n            }}\n          </el-button>\n          <el-button\n            type=\"danger\"\n            size=\"small\"\n            @click=\"remove(scope.row)\"\n          >\n            {{\n              t('common.delete')\n            }}\n          </el-button>\n        </template>\n      </el-table-column>\n    </el-table>\n  </el-main>\n</template>\n\n<script>\nimport { useI18n } from 'vue-i18n'\nimport { ElMessageBox } from 'element-plus'\nimport userService from '../../api/user'\nimport { useUserStore } from '../../stores/user'\nimport { availableLanguages } from '@/const/lang'\n\nexport default {\n  name: 'UserList',\n  setup() {\n    const { t, locale } = useI18n()\n    return { t, locale, availableLanguages }\n  },\n  data() {\n    const userStore = useUserStore()\n    return {\n      users: [],\n      userTotal: 0,\n      searchParams: {\n        page_size: 20,\n        page: 1\n      },\n      isAdmin: userStore.isAdmin\n    }\n  },\n  mounted() {\n    this.search()\n  },\n  methods: {\n    changeStatus(item) {\n      if (item.status) {\n        userService.enable(item.id)\n      } else {\n        userService.disable(item.id)\n      }\n    },\n    formatRole(row, col) {\n      if (row[col.property] === 1) {\n        return this.t('user.admin')\n      }\n      return this.t('user.normalUser')\n    },\n    changePage(page) {\n      this.searchParams.page = page\n      this.search()\n    },\n    changePageSize(pageSize) {\n      this.searchParams.page_size = pageSize\n      this.search()\n    },\n    search(callback = null) {\n      userService.list(this.searchParams, data => {\n        this.users = data.data\n        this.userTotal = data.total\n        if (callback) {\n          callback()\n        }\n      })\n    },\n    remove(item) {\n      ElMessageBox.confirm(this.t('message.confirmDeleteUser'), this.t('common.tip'), {\n        confirmButtonText: this.t('common.confirm'),\n        cancelButtonText: this.t('common.cancel'),\n        type: 'warning',\n        center: true\n      })\n        .then(() => {\n          userService.remove(item.id, () => {\n            this.refresh()\n          })\n        })\n        .catch(() => {})\n    },\n    toEdit(item) {\n      let path = ''\n      if (item === null) {\n        path = '/user/create'\n      } else {\n        path = `/user/edit/${item.id}`\n      }\n      this.$router.push(path)\n    },\n    refresh() {\n      this.search(() => {\n        this.$message.success(this.t('message.refreshSuccess'))\n      })\n    },\n    editPassword(item) {\n      this.$router.push(`/user/edit-password/${item.id}`)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "web/vue/src/pages/user/login.vue",
    "content": "<template>\n  <div class=\"login-container\">\n    <div class=\"login-box\">\n      <div class=\"language-switcher\">\n        <LanguageSwitcher />\n      </div>\n      <h2 class=\"login-title\">\n        {{ t('login.title') }}\n      </h2>\n      <el-alert\n        v-if=\"errorMessage\"\n        :title=\"errorMessage\"\n        type=\"error\"\n        :closable=\"false\"\n        style=\"margin-bottom: 20px;\"\n      />\n      <el-form\n        ref=\"formRef\"\n        :model=\"form\"\n        label-width=\"100px\"\n        :rules=\"formRules\"\n      >\n        <el-form-item\n          :label=\"t('login.username')\"\n          prop=\"username\"\n        >\n          <el-input\n            v-model.trim=\"form.username\"\n            :placeholder=\"t('login.usernamePlaceholder')\"\n            size=\"large\"\n          />\n        </el-form-item>\n        <el-form-item\n          :label=\"t('login.password')\"\n          prop=\"password\"\n        >\n          <el-input\n            v-model.trim=\"form.password\"\n            type=\"password\"\n            :placeholder=\"t('login.passwordPlaceholder')\"\n            size=\"large\"\n            @keyup.enter=\"submit\"\n          />\n        </el-form-item>\n        <el-form-item\n          v-if=\"require2FA\"\n          :label=\"t('login.verifyCode')\"\n          prop=\"twoFactorCode\"\n        >\n          <el-input\n            v-model.trim=\"form.twoFactorCode\"\n            :placeholder=\"t('login.verifyCodePlaceholder')\"\n            maxlength=\"6\"\n            size=\"large\"\n            @keyup.enter=\"submit\"\n          />\n        </el-form-item>\n        <el-form-item>\n          <el-button\n            type=\"primary\"\n            :loading=\"loading\"\n            class=\"login-button\"\n            size=\"large\"\n            @click=\"submit\"\n          >\n            {{ t('login.login') }}\n          </el-button>\n        </el-form-item>\n      </el-form>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, reactive, computed } from 'vue'\nimport { useRouter, useRoute } from 'vue-router'\nimport { useI18n } from 'vue-i18n'\nimport { useUserStore } from '../../stores/user'\nimport { useLoading } from '../../composables/useLoading'\nimport userService from '../../api/user'\nimport LanguageSwitcher from '../../components/common/LanguageSwitcher.vue'\n\nconst { t, locale } = useI18n()\n\nconst router = useRouter()\nconst route = useRoute()\nconst userStore = useUserStore()\nconst { loading, withLoading } = useLoading()\n\nconst require2FA = ref(false)\nconst formRef = ref()\nconst errorMessage = ref('')\n\nconst form = reactive({\n  username: '',\n  password: '',\n  twoFactorCode: ''\n})\n\nconst formRules = computed(() => ({\n  username: [{ required: true, message: t('login.usernameRequired'), trigger: 'blur' }],\n  password: [{ required: true, message: t('login.passwordRequired'), trigger: 'blur' }],\n  twoFactorCode: [{ required: true, message: t('login.verifyCodeRequired'), trigger: 'blur' }]\n}))\n\nconst submit = async () => {\n  if (!formRef.value) return\n  \n  errorMessage.value = ''\n  \n  await formRef.value.validate(async (valid) => {\n    if (!valid) return\n    \n    if (require2FA.value && !form.twoFactorCode) {\n      errorMessage.value = t('login.verifyCodeRequired')\n      return\n    }\n    \n    await withLoading(async () => {\n      const params = {\n        username: form.username,\n        password: form.password,\n        two_factor_code: form.twoFactorCode || undefined\n      }\n      \n      userService.login(\n        params.username, \n        params.password, \n        params.two_factor_code, \n        (data) => {\n          if (data.require_2fa) {\n            require2FA.value = true\n            errorMessage.value = ''\n            return\n          }\n          \n          userStore.setUser({\n            token: data.token,\n            uid: data.uid,\n            username: data.username,\n            isAdmin: data.is_admin\n          })\n          \n          router.push(route.query.redirect || '/')\n        },\n        (code, message) => {\n          errorMessage.value = message || '登录失败'\n        }\n      )\n    })\n  })\n}\n</script>\n\n\n<style scoped>\n.login-container {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  min-height: 100vh;\n  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);\n  position: relative;\n  overflow: hidden;\n}\n\n.login-container::before {\n  content: '';\n  position: absolute;\n  top: -50%;\n  right: -10%;\n  width: 600px;\n  height: 600px;\n  background: rgba(99, 102, 241, 0.1);\n  border-radius: 50%;\n  filter: blur(80px);\n}\n\n.login-container::after {\n  content: '';\n  position: absolute;\n  bottom: -30%;\n  left: -10%;\n  width: 500px;\n  height: 500px;\n  background: rgba(168, 85, 247, 0.08);\n  border-radius: 50%;\n  filter: blur(80px);\n}\n\n.login-box {\n  background: rgba(255, 255, 255, 0.95);\n  backdrop-filter: blur(10px);\n  padding: 48px 40px;\n  border-radius: 16px;\n  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);\n  width: 100%;\n  max-width: 420px;\n  position: relative;\n  z-index: 1;\n  border: 1px solid rgba(255, 255, 255, 0.8);\n}\n\n.language-switcher {\n  position: absolute;\n  top: 16px;\n  left: 16px;\n}\n\n.login-title {\n  text-align: center;\n  margin: 0 0 32px 0;\n  font-size: 26px;\n  color: #1f2937;\n  font-weight: 600;\n  letter-spacing: -0.5px;\n}\n\n.el-button--large {\n  height: 40px;\n  line-height: 40px;\n  padding: 0 15px;\n}\n\n.login-button {\n  width: calc(100% + 60px);\n  margin-left: -60px;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/pages/user/twoFactor.vue",
    "content": "<template>\n  <div class=\"two-factor-container\">\n    <el-card class=\"box-card\">\n      <template #header>\n        <div class=\"clearfix\">\n          <span>{{ t('twoFactor.title') }}</span>\n        </div>\n      </template>\n      \n      <div v-if=\"!twoFactorEnabled\">\n        <el-alert\n          :title=\"t('twoFactor.alertTitle')\"\n          type=\"info\"\n          :description=\"t('twoFactor.alertDescription')\"\n          :closable=\"false\"\n          show-icon\n        />\n        \n        <el-button \n          type=\"primary\" \n          style=\"margin-top: 20px;\" \n          :loading=\"loading\"\n          @click=\"setup2FA\"\n        >\n          {{ t('twoFactor.enable') }}\n        </el-button>\n      </div>\n\n      <div v-else>\n        <el-alert\n          :title=\"t('twoFactor.enabledAlertTitle')\"\n          type=\"success\"\n          :description=\"t('twoFactor.enabledAlertDescription')\"\n          :closable=\"false\"\n          show-icon\n        />\n        \n        <el-button \n          type=\"danger\" \n          style=\"margin-top: 20px;\" \n          @click=\"showDisableDialog\"\n        >\n          {{ t('twoFactor.disable') }}\n        </el-button>\n      </div>\n    </el-card>\n\n    <el-dialog\n      v-model=\"setupDialogVisible\"\n      :title=\"t('twoFactor.setup')\"\n      width=\"500px\"\n      :close-on-click-modal=\"false\"\n    >\n      <div v-if=\"qrCode\">\n        <p>{{ t('twoFactor.scanQR') }}</p>\n        <div style=\"text-align: center; margin: 20px 0;\">\n          <img\n            :src=\"qrCode\"\n            alt=\"QR Code\"\n            style=\"width: 200px; height: 200px;\"\n          >\n        </div>\n        \n        <p>{{ t('twoFactor.manualEntry') }}</p>\n        <el-input\n          v-model=\"secret\"\n          readonly\n        >\n          <template #append>\n            <el-button @click=\"copySecret\">\n              {{ t('twoFactor.copySecret') }}\n            </el-button>\n          </template>\n        </el-input>\n        \n        <p style=\"margin-top: 20px;\">\n          {{ t('twoFactor.verifyCodeStep') }}\n        </p>\n        <el-input \n          v-model=\"verifyCode\" \n          :placeholder=\"t('twoFactor.verifyCodePlaceholder')\"\n          maxlength=\"6\"\n          @keyup.enter=\"enable2FA\"\n        />\n      </div>\n\n      <template #footer>\n        <span class=\"dialog-footer\">\n          <el-button @click=\"setupDialogVisible = false\">{{ t('common.cancel') }}</el-button>\n          <el-button\n            type=\"primary\"\n            :loading=\"loading\"\n            @click=\"enable2FA\"\n          >{{ t('twoFactor.confirm') }}</el-button>\n        </span>\n      </template>\n    </el-dialog>\n\n    <el-dialog\n      v-model=\"disableDialogVisible\"\n      :title=\"t('twoFactor.disableDialogTitle')\"\n      width=\"400px\"\n      :close-on-click-modal=\"false\"\n    >\n      <p>{{ t('twoFactor.disableDialogDescription') }}</p>\n      <el-input \n        v-model=\"disableCode\" \n        :placeholder=\"t('twoFactor.verifyCodePlaceholder')\"\n        maxlength=\"6\"\n        @keyup.enter=\"disable2FA\"\n      />\n\n      <template #footer>\n        <span class=\"dialog-footer\">\n          <el-button @click=\"disableDialogVisible = false\">{{ t('common.cancel') }}</el-button>\n          <el-button\n            type=\"danger\"\n            :loading=\"loading\"\n            @click=\"disable2FA\"\n          >{{ t('twoFactor.confirmDisable') }}</el-button>\n        </span>\n      </template>\n    </el-dialog>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { ElMessage } from 'element-plus'\nimport userApi from '@/api/user'\n\nconst { t } = useI18n()\n\nconst twoFactorEnabled = ref(false)\nconst loading = ref(false)\nconst setupDialogVisible = ref(false)\nconst disableDialogVisible = ref(false)\nconst qrCode = ref('')\nconst secret = ref('')\nconst verifyCode = ref('')\nconst disableCode = ref('')\n\nonMounted(() => {\n  check2FAStatus()\n})\n\nconst check2FAStatus = () => {\n  userApi.get2FAStatus((data) => {\n    twoFactorEnabled.value = data.enabled\n  })\n}\n\nconst setup2FA = () => {\n  loading.value = true\n  userApi.setup2FA((data) => {\n    qrCode.value = data.qr_code\n    secret.value = data.secret\n    setupDialogVisible.value = true\n    loading.value = false\n  })\n}\n\nconst enable2FA = () => {\n  if (!verifyCode.value || verifyCode.value.length !== 6) {\n    ElMessage.warning(t('twoFactor.verifyCodeLength'))\n    return\n  }\n\n  loading.value = true\n  userApi.enable2FA(secret.value, verifyCode.value, () => {\n    ElMessage.success(t('twoFactor.enableSuccess'))\n    setupDialogVisible.value = false\n    twoFactorEnabled.value = true\n    verifyCode.value = ''\n    loading.value = false\n  })\n}\n\nconst showDisableDialog = () => {\n  disableCode.value = ''\n  disableDialogVisible.value = true\n}\n\nconst disable2FA = () => {\n  if (!disableCode.value || disableCode.value.length !== 6) {\n    ElMessage.warning(t('twoFactor.verifyCodeLength'))\n    return\n  }\n\n  loading.value = true\n  userApi.disable2FA(disableCode.value, () => {\n    ElMessage.success(t('twoFactor.disableSuccess'))\n    disableDialogVisible.value = false\n    twoFactorEnabled.value = false\n    disableCode.value = ''\n    loading.value = false\n  }, (code, msg) => {\n    ElMessage.error(msg || t('twoFactor.disableFailed'))\n    loading.value = false\n  })\n}\n\nconst copySecret = () => {\n  const input = document.createElement('input')\n  input.value = secret.value\n  document.body.appendChild(input)\n  input.select()\n  document.execCommand('copy')\n  document.body.removeChild(input)\n  ElMessage.success(t('twoFactor.secretCopied'))\n}\n</script>\n\n<style scoped>\n.two-factor-container {\n  padding: 20px;\n}\n\n.box-card {\n  max-width: 600px;\n}\n</style>\n"
  },
  {
    "path": "web/vue/src/router/index.js",
    "content": "import { createRouter, createWebHashHistory } from 'vue-router'\nimport { useUserStore } from '../stores/user'\n\nconst routes = [\n  {\n    path: '/',\n    redirect: '/task'\n  },\n  {\n    path: '/install',\n    name: 'install',\n    component: () => import('../pages/install/index.vue'),\n    meta: { noLogin: true, noNeedAdmin: true }\n  },\n  {\n    path: '/task',\n    name: 'task-list',\n    component: () => import('../pages/task/list.vue'),\n    meta: { noNeedAdmin: true }\n  },\n  {\n    path: '/task/create',\n    name: 'task-create',\n    component: () => import('../pages/task/edit.vue')\n  },\n  {\n    path: '/task/edit/:id',\n    name: 'task-edit',\n    component: () => import('../pages/task/edit.vue')\n  },\n  {\n    path: '/task/log',\n    name: 'task-log',\n    component: () => import('../pages/taskLog/list.vue'),\n    meta: { noNeedAdmin: true }\n  },\n  {\n    path: '/template',\n    name: 'template-list',\n    component: () => import('../pages/template/list.vue'),\n    meta: { noNeedAdmin: true }\n  },\n  {\n    path: '/template/create',\n    name: 'template-create',\n    component: () => import('../pages/template/edit.vue')\n  },\n  {\n    path: '/template/edit/:id',\n    name: 'template-edit',\n    component: () => import('../pages/template/edit.vue')\n  },\n  {\n    path: '/host',\n    name: 'host-list',\n    component: () => import('../pages/host/list.vue'),\n    meta: { noNeedAdmin: true }\n  },\n  {\n    path: '/host/create',\n    name: 'host-create',\n    component: () => import('../pages/host/edit.vue')\n  },\n  {\n    path: '/host/edit/:id',\n    name: 'host-edit',\n    component: () => import('../pages/host/edit.vue')\n  },\n  {\n    path: '/user',\n    name: 'user-list',\n    component: () => import('../pages/user/list.vue')\n  },\n  {\n    path: '/user/create',\n    name: 'user-create',\n    component: () => import('../pages/user/edit.vue')\n  },\n  {\n    path: '/user/edit/:id',\n    name: 'user-edit',\n    component: () => import('../pages/user/edit.vue')\n  },\n  {\n    path: '/user/login',\n    name: 'user-login',\n    component: () => import('../pages/user/login.vue'),\n    meta: { noLogin: true }\n  },\n  {\n    path: '/user/edit-password/:id',\n    name: 'user-edit-password',\n    component: () => import('../pages/user/editPassword.vue')\n  },\n  {\n    path: '/user/edit-my-password',\n    name: 'user-edit-my-password',\n    component: () => import('../pages/user/editMyPassword.vue'),\n    meta: { noNeedAdmin: true }\n  },\n  {\n    path: '/user/two-factor',\n    name: 'user-two-factor',\n    component: () => import('../pages/user/twoFactor.vue'),\n    meta: { noNeedAdmin: true }\n  },\n  {\n    path: '/system',\n    redirect: '/system/notification/email'\n  },\n  {\n    path: '/system/notification/email',\n    name: 'system-notification-email',\n    component: () => import('../pages/system/notification/email.vue')\n  },\n  {\n    path: '/system/notification/slack',\n    name: 'system-notification-slack',\n    component: () => import('../pages/system/notification/slack.vue')\n  },\n  {\n    path: '/system/notification/webhook',\n    name: 'system-notification-webhook',\n    component: () => import('../pages/system/notification/webhook.vue')\n  },\n  {\n    path: '/system/login-log',\n    name: 'login-log',\n    component: () => import('../pages/system/loginLog.vue')\n  },\n  {\n    path: '/system/log-retention',\n    name: 'log-retention',\n    component: () => import('../pages/system/logRetention.vue')\n  },\n  {\n    path: '/system/audit-log',\n    name: 'system-audit-log',\n    component: () => import('../pages/system/auditLog.vue')\n  },\n  {\n    path: '/statistics',\n    name: 'statistics',\n    component: () => import('../pages/statistics/index.vue'),\n    meta: { noNeedAdmin: true }\n  },\n  {\n    path: '/:pathMatch(.*)*',\n    component: () => import('../components/common/notFound.vue'),\n    meta: { noLogin: true, noNeedAdmin: true }\n  }\n]\n\nconst router = createRouter({\n  history: createWebHashHistory(),\n  routes\n})\n\nrouter.beforeEach((to, from, next) => {\n  // 防止登录后访问登录页\n  if (to.path === '/user/login') {\n    const userStore = useUserStore()\n    if (userStore.token) {\n      next({ path: '/' })\n      return\n    }\n  }\n\n  if (to.meta.noLogin) {\n    next()\n    return\n  }\n\n  const userStore = useUserStore()\n\n  if (userStore.token) {\n    if (userStore.isAdmin || to.meta.noNeedAdmin) {\n      next()\n      return\n    }\n    next({ path: '/404.html' })\n    return\n  }\n\n  next({\n    path: '/user/login',\n    query: { redirect: to.fullPath }\n  })\n})\n\nexport default router\n"
  },
  {
    "path": "web/vue/src/storage/user.js",
    "content": "class User {\n  get () {\n    return {\n      'token': this.getToken(),\n      'uid': this.getUid(),\n      'username': this.getUsername(),\n      'isAdmin': this.getIsAdmin()\n    }\n  }\n\n  getToken () {\n    return localStorage.getItem('token') || ''\n  }\n\n  setToken (token) {\n    localStorage.setItem('token', token)\n    return this\n  }\n\n  clear () {\n    localStorage.clear()\n  }\n\n  getUid () {\n    return localStorage.getItem('uid') || ''\n  }\n\n  setUid (uid) {\n    localStorage.setItem('uid', uid)\n    return this\n  }\n\n  getUsername () {\n    return localStorage.getItem('username') || ''\n  }\n\n  setUsername (username) {\n    localStorage.setItem('username', username)\n    return this\n  }\n\n  getIsAdmin () {\n    let isAdmin = localStorage.getItem('is_admin')\n    return isAdmin === '1'\n  }\n\n  setIsAdmin (isAdmin) {\n    localStorage.setItem('is_admin', isAdmin)\n    return this\n  }\n}\n\nexport default new User()\n"
  },
  {
    "path": "web/vue/src/stores/user.js",
    "content": "import { defineStore } from 'pinia'\n\nexport const useUserStore = defineStore('user', {\n  state: () => ({\n    token: '',\n    uid: '',\n    username: '',\n    isAdmin: false\n  }),\n  \n  getters: {\n    isLogin: (state) => state.token !== ''\n  },\n  \n  actions: {\n    setUser(user) {\n      this.token = user.token || ''\n      this.uid = user.uid || ''\n      this.username = user.username || ''\n      this.isAdmin = user.isAdmin || false\n    },\n    \n    logout() {\n      this.token = ''\n      this.uid = ''\n      this.username = ''\n      this.isAdmin = false\n    }\n  },\n  \n  persist: {\n    key: 'gocron-user',\n    storage: localStorage,\n    paths: ['token', 'uid', 'username', 'isAdmin']\n  }\n})\n"
  },
  {
    "path": "web/vue/src/utils/__tests__/cronValidator.spec.js",
    "content": "import { describe, it, expect } from 'vitest'\nimport { extractTimezone, validateCronSpec } from '../cronValidator'\n\ndescribe('extractTimezone', () => {\n  it('extracts CRON_TZ= prefix', () => {\n    const result = extractTimezone('CRON_TZ=Asia/Shanghai 0 30 8 * * *')\n    expect(result.timezone).toBe('Asia/Shanghai')\n    expect(result.spec).toBe('0 30 8 * * *')\n  })\n\n  it('extracts TZ= prefix', () => {\n    const result = extractTimezone('TZ=America/New_York 0 0 9 * * *')\n    expect(result.timezone).toBe('America/New_York')\n    expect(result.spec).toBe('0 0 9 * * *')\n  })\n\n  it('extracts TZ= prefix with descriptor', () => {\n    const result = extractTimezone('CRON_TZ=UTC @daily')\n    expect(result.timezone).toBe('UTC')\n    expect(result.spec).toBe('@daily')\n  })\n\n  it('returns empty timezone when no prefix', () => {\n    const result = extractTimezone('0 30 8 * * *')\n    expect(result.timezone).toBe('')\n    expect(result.spec).toBe('0 30 8 * * *')\n  })\n\n  it('returns empty timezone for descriptors without prefix', () => {\n    const result = extractTimezone('@daily')\n    expect(result.timezone).toBe('')\n    expect(result.spec).toBe('@daily')\n  })\n\n  it('handles empty string', () => {\n    const result = extractTimezone('')\n    expect(result.timezone).toBe('')\n    expect(result.spec).toBe('')\n  })\n\n  it('handles null/undefined', () => {\n    expect(extractTimezone(null).timezone).toBe('')\n    expect(extractTimezone(undefined).timezone).toBe('')\n  })\n\n  it('preserves spec with extra spaces', () => {\n    const result = extractTimezone('CRON_TZ=Asia/Tokyo @every 30s')\n    expect(result.timezone).toBe('Asia/Tokyo')\n    expect(result.spec).toBe('@every 30s')\n  })\n})\n\ndescribe('validateCronSpec with timezone prefix', () => {\n  it('validates spec with CRON_TZ= prefix', () => {\n    const result = validateCronSpec('CRON_TZ=Asia/Shanghai 0 30 8 * * *')\n    expect(result.valid).toBe(true)\n  })\n\n  it('validates spec with TZ= prefix', () => {\n    const result = validateCronSpec('TZ=UTC @daily')\n    expect(result.valid).toBe(true)\n  })\n\n  it('rejects invalid cron after stripping timezone', () => {\n    const result = validateCronSpec('CRON_TZ=Asia/Shanghai invalid')\n    expect(result.valid).toBe(false)\n  })\n\n  it('validates spec without prefix (backward compatible)', () => {\n    expect(validateCronSpec('0 30 8 * * *').valid).toBe(true)\n    expect(validateCronSpec('@daily').valid).toBe(true)\n    expect(validateCronSpec('@every 30s').valid).toBe(true)\n  })\n\n  it('rejects empty spec', () => {\n    expect(validateCronSpec('').valid).toBe(false)\n    expect(validateCronSpec(null).valid).toBe(false)\n  })\n})\n"
  },
  {
    "path": "web/vue/src/utils/__tests__/env.spec.js",
    "content": "import { describe, it, expect, vi } from 'vitest'\nimport { env, devLog } from '../env'\n\ndescribe('env utilities', () => {\n  it('should have correct env properties', () => {\n    expect(env).toHaveProperty('isDev')\n    expect(env).toHaveProperty('isProd')\n    expect(env).toHaveProperty('apiBaseUrl')\n  })\n\n  it('should have default apiBaseUrl', () => {\n    expect(env.apiBaseUrl).toBe('/api')\n  })\n\n  it('devLog should only log in dev mode', () => {\n    const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})\n    \n    devLog('test message')\n    \n    if (env.isDev) {\n      expect(consoleSpy).toHaveBeenCalledWith('[Dev]', 'test message')\n    } else {\n      expect(consoleSpy).not.toHaveBeenCalled()\n    }\n    \n    consoleSpy.mockRestore()\n  })\n})\n"
  },
  {
    "path": "web/vue/src/utils/cronValidator.js",
    "content": "/**\n * Cron表达式验证器\n * 支持格式：秒 分 时 天 月 周\n * 支持快捷语法：@yearly, @monthly, @weekly, @daily, @midnight, @hourly, @every\n */\n\nimport i18n from '@/locales'\n\nconst t = (key, params) => i18n.global.t(key, params)\n\n// 快捷语法列表\nconst SHORTCUTS = [\n  '@reboot',\n  '@yearly',\n  '@annually',\n  '@monthly',\n  '@weekly',\n  '@daily',\n  '@midnight',\n  '@hourly'\n]\n\n// @every 语法正则\nconst EVERY_PATTERN = /^@every\\s+(\\d+[smh])+$/\n\n/**\n * 从 spec 中提取 CRON_TZ=/TZ= 前缀，返回 { timezone, spec }\n * 无前缀时 timezone 为空字符串\n */\nexport function extractTimezone(spec) {\n  if (!spec || typeof spec !== 'string') {\n    return { timezone: '', spec: spec || '' }\n  }\n  const trimmed = spec.trim()\n  const match = trimmed.match(/^(?:CRON_TZ|TZ)=(\\S+)\\s+(.+)$/)\n  if (match) {\n    return { timezone: match[1], spec: match[2] }\n  }\n  return { timezone: '', spec: trimmed }\n}\n\n/**\n * 验证cron表达式\n * @param {string} spec - cron表达式（可带 CRON_TZ= 前缀）\n * @returns {{valid: boolean, message: string}}\n */\nexport function validateCronSpec(spec) {\n  if (!spec || typeof spec !== 'string') {\n    return { valid: false, message: t('cronValidator.required') }\n  }\n\n  // 剥离 CRON_TZ=/TZ= 前缀后再验证\n  const { spec: cronExpr } = extractTimezone(spec)\n  const trimmed = cronExpr.trim()\n\n  if (!trimmed) {\n    return { valid: false, message: t('cronValidator.required') }\n  }\n\n  // 检查快捷语法\n  if (trimmed.startsWith('@')) {\n    return validateShortcut(trimmed)\n  }\n\n  // 检查标准cron表达式\n  return validateStandardCron(trimmed)\n}\n\n/**\n * 验证快捷语法\n */\nfunction validateShortcut(spec) {\n  const lower = spec.toLowerCase()\n\n  // 检查固定快捷语法\n  if (SHORTCUTS.includes(lower)) {\n    return { valid: true, message: '' }\n  }\n\n  // 检查 @every 语法\n  if (lower.startsWith('@every')) {\n    if (!EVERY_PATTERN.test(lower)) {\n      return {\n        valid: false,\n        message: t('cronValidator.everyFormatError')\n      }\n    }\n    return { valid: true, message: '' }\n  }\n\n  return {\n    valid: false,\n    message: t('cronValidator.shortcutError')\n  }\n}\n\n/**\n * 验证标准cron表达式（6段式）\n */\nfunction validateStandardCron(spec) {\n  const segments = spec.split(/\\s+/)\n\n  // 必须是6段\n  if (segments.length !== 6) {\n    return {\n      valid: false,\n      message: t('cronValidator.sixFieldsRequired')\n    }\n  }\n\n  // 字段范围定义\n  const ranges = [\n    { name: t('cronValidator.fieldSecond'), min: 0, max: 59 },\n    { name: t('cronValidator.fieldMinute'), min: 0, max: 59 },\n    { name: t('cronValidator.fieldHour'), min: 0, max: 23 },\n    { name: t('cronValidator.fieldDay'), min: 1, max: 31 },\n    { name: t('cronValidator.fieldMonth'), min: 1, max: 12 },\n    { name: t('cronValidator.fieldWeek'), min: 0, max: 7 }\n  ]\n\n  // 验证每一段\n  for (let i = 0; i < segments.length; i++) {\n    const result = validateSegment(segments[i], ranges[i])\n    if (!result.valid) {\n      return result\n    }\n  }\n\n  return { valid: true, message: '' }\n}\n\n/**\n * 验证单个字段\n */\nfunction validateSegment(segment, range) {\n  // 允许的字符\n  if (!/^[0-9*/,\\-?LW#]+$/.test(segment)) {\n    return {\n      valid: false,\n      message: t('cronValidator.illegalChar', { field: range.name })\n    }\n  }\n\n  // * 通配符\n  if (segment === '*') {\n    return { valid: true }\n  }\n\n  // ? 占位符（用于天和周）\n  if (segment === '?') {\n    return { valid: true }\n  }\n\n  // 范围：1-5\n  if (segment.includes('-')) {\n    return validateRange(segment, range)\n  }\n\n  // 步长：*/5 或 1-10/2\n  if (segment.includes('/')) {\n    return validateStep(segment, range)\n  }\n\n  // 列表：1,2,3\n  if (segment.includes(',')) {\n    return validateList(segment, range)\n  }\n\n  // 单个数字\n  if (/^\\d+$/.test(segment)) {\n    const num = parseInt(segment, 10)\n    if (num < range.min || num > range.max) {\n      return {\n        valid: false,\n        message: t('cronValidator.valueOutOfRange', {\n          field: range.name,\n          value: num,\n          min: range.min,\n          max: range.max\n        })\n      }\n    }\n    return { valid: true }\n  }\n\n  // L, W, # 等特殊字符（简单验证）\n  if (/^[LW#]/.test(segment)) {\n    return { valid: true }\n  }\n\n  return {\n    valid: false,\n    message: t('cronValidator.formatError', { field: range.name })\n  }\n}\n\n/**\n * 验证范围表达式：1-5\n */\nfunction validateRange(segment, range) {\n  const parts = segment.split('-')\n  if (parts.length !== 2) {\n    return {\n      valid: false,\n      message: t('cronValidator.rangeFormatError', { field: range.name })\n    }\n  }\n\n  const start = parseInt(parts[0], 10)\n  const end = parseInt(parts[1], 10)\n\n  if (isNaN(start) || isNaN(end)) {\n    return {\n      valid: false,\n      message: t('cronValidator.rangeNotNumber', { field: range.name })\n    }\n  }\n\n  if (start < range.min || end > range.max || start > end) {\n    return {\n      valid: false,\n      message: t('cronValidator.rangeInvalid', {\n        field: range.name,\n        start,\n        end\n      })\n    }\n  }\n\n  return { valid: true }\n}\n\n/**\n * 验证步长表达式：星号/5 或 1-10/2\n */\nfunction validateStep(segment, range) {\n  const parts = segment.split('/')\n  if (parts.length !== 2) {\n    return {\n      valid: false,\n      message: t('cronValidator.stepFormatError', { field: range.name })\n    }\n  }\n\n  const step = parseInt(parts[1], 10)\n  if (isNaN(step) || step <= 0) {\n    return {\n      valid: false,\n      message: t('cronValidator.stepNotPositive', { field: range.name })\n    }\n  }\n\n  // 验证基础部分\n  if (parts[0] !== '*') {\n    return validateSegment(parts[0], range)\n  }\n\n  return { valid: true }\n}\n\n/**\n * 验证列表表达式：1,2,3\n */\nfunction validateList(segment, range) {\n  const parts = segment.split(',')\n\n  for (const part of parts) {\n    const result = validateSegment(part.trim(), range)\n    if (!result.valid) {\n      return result\n    }\n  }\n\n  return { valid: true }\n}\n\n/**\n * 获取cron表达式示例\n */\nexport function getCronExamples() {\n  return [\n    { expr: '0 * * * * *', desc: '每分钟第0秒运行' },\n    { expr: '*/20 * * * * *', desc: '每隔20秒运行一次' },\n    { expr: '0 30 21 * * *', desc: '每天晚上21:30:00运行' },\n    { expr: '0 0 23 * * 6', desc: '每周六晚上23:00:00运行' },\n    { expr: '0 0 1 1 * *', desc: '每月1号凌晨1点运行' },\n    { expr: '@hourly', desc: '每小时运行一次' },\n    { expr: '@daily', desc: '每天运行一次' },\n    { expr: '@every 30s', desc: '每隔30秒运行一次' },\n    { expr: '@every 1m20s', desc: '每隔1分钟20秒运行一次' }\n  ]\n}\n"
  },
  {
    "path": "web/vue/src/utils/env.js",
    "content": "// 环境变量验证和获取\nexport const env = {\n  isDev: import.meta.env.DEV,\n  isProd: import.meta.env.PROD,\n  apiBaseUrl: import.meta.env.VITE_API_BASE_URL || '/api'\n}\n\n// 开发环境日志\nexport const devLog = (...args) => {\n  if (env.isDev) {\n    console.log('[Dev]', ...args)\n  }\n}\n\nexport const devWarn = (...args) => {\n  if (env.isDev) {\n    console.warn('[Dev]', ...args)\n  }\n}\n\nexport const devError = (...args) => {\n  if (env.isDev) {\n    console.error('[Dev]', ...args)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/utils/httpClient.js",
    "content": "import axios from 'axios'\nimport { ElMessage } from 'element-plus'\nimport router from '../router/index'\nimport { useUserStore } from '../stores/user'\nimport qs from 'qs'\nimport NProgress from '@/utils/progress'\n\nconst getLocale = () => localStorage.getItem('locale') || 'zh-CN'\n\nconst messages = {\n  'zh-CN': {\n    loadFailed: '加载失败, 请稍后再试',\n    requestTimeout: '请求超时，请稍后重试',\n    authExpired: '登录已过期，请重新登录',\n    requestFailed: '请求失败'\n  },\n  'en-US': {\n    loadFailed: 'Load failed, please try again later',\n    requestTimeout: 'Request timeout, please try again later',\n    authExpired: 'Login expired, please login again',\n    requestFailed: 'Request failed'\n  }\n}\n\nconst t = key => {\n  const locale = getLocale()\n  return messages[locale]?.[key] || messages['zh-CN'][key]\n}\n// 成功状态码\nconst SUCCESS_CODE = 0\n// 认证失败\nconst AUTH_ERROR_CODE = 401\n// 应用未安装\nconst APP_NOT_INSTALL_CODE = 801\n\naxios.defaults.baseURL = '/api'\naxios.defaults.timeout = 30000\naxios.defaults.responseType = 'json'\naxios.interceptors.request.use(\n  config => {\n    NProgress.start()\n    const userStore = useUserStore()\n    config.headers['Auth-Token'] = userStore.token\n    config.headers['Accept-Language'] = localStorage.getItem('locale') || 'zh-CN'\n    return config\n  },\n  error => {\n    NProgress.done()\n    ElMessage.error({\n      message: t('loadFailed')\n    })\n\n    return Promise.reject(error)\n  }\n)\n\naxios.interceptors.response.use(\n  data => {\n    NProgress.done()\n    // 检查是否有新的 token\n    const newToken = data.headers['new-auth-token']\n    if (newToken) {\n      const userStore = useUserStore()\n      userStore.token = newToken\n    }\n    return data\n  },\n  error => {\n    NProgress.done()\n    // 处理超时\n    if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {\n      ElMessage.error({\n        message: t('requestTimeout')\n      })\n      return Promise.reject(error)\n    }\n\n    // 处理认证失败\n    if (error.response && error.response.status === 401) {\n      const userStore = useUserStore()\n      userStore.token = ''\n      ElMessage.warning({\n        message: t('authExpired')\n      })\n      setTimeout(() => {\n        window.location.href = '/'\n      }, 500)\n      return Promise.reject(error)\n    }\n\n    ElMessage.error({\n      message: t('loadFailed')\n    })\n\n    return Promise.reject(error)\n  }\n)\n\nfunction handle(promise, next, errorCallback) {\n  promise\n    .then(res => successCallback(res, next, errorCallback))\n    .catch(error => failureCallback(error))\n}\n\nfunction checkResponseCode(code, msg) {\n  switch (code) {\n    // 应用未安装\n    case APP_NOT_INSTALL_CODE:\n      router.push('/install')\n      return false\n    // 认证失败\n    case AUTH_ERROR_CODE: {\n      const userStore = useUserStore()\n      userStore.token = ''\n      ElMessage.warning({\n        message: t('authExpired')\n      })\n      setTimeout(() => {\n        window.location.href = '/'\n      }, 500)\n      return false\n    }\n  }\n  if (code !== SUCCESS_CODE) {\n    ElMessage.error({\n      message: msg\n    })\n    return false\n  }\n\n  return true\n}\n\nfunction successCallback(res, next, errorCallback) {\n  if (res.data.code !== SUCCESS_CODE) {\n    if (errorCallback) {\n      errorCallback(res.data.code, res.data.message)\n      return\n    }\n    if (!checkResponseCode(res.data.code, res.data.message)) {\n      return\n    }\n  }\n  if (!next) {\n    return\n  }\n  next(res.data.data, res.data.code, res.data.message)\n}\n\nfunction failureCallback(error) {\n  // 避免重复提示（已在 interceptor 中处理）\n  if (error.response && error.response.status === 401) {\n    return\n  }\n  if (error.code === 'ECONNABORTED') {\n    return\n  }\n  ElMessage.error({\n    message: t('requestFailed') + ' - ' + error.message\n  })\n}\n\nexport default {\n  get(uri, params, next) {\n    const promise = axios.get(uri, { params })\n    handle(promise, next)\n  },\n\n  batchGet(uriGroup, next) {\n    const requests = []\n    for (let item of uriGroup) {\n      let params = {}\n      if (item.params !== undefined) {\n        params = item.params\n      }\n      requests.push(axios.get(item.uri, { params }))\n    }\n\n    Promise.all(requests)\n      .then(function (res) {\n        const result = []\n        for (let item of res) {\n          if (!checkResponseCode(item.data.code, item.data.message)) {\n            return\n          }\n          result.push(item.data.data)\n        }\n        next(...result)\n      })\n      .catch(error => failureCallback(error))\n  },\n\n  post(uri, data, next, errorCallback) {\n    const promise = axios.post(uri, qs.stringify(data), {\n      headers: {\n        'Content-Type': 'application/x-www-form-urlencoded'\n      }\n    })\n    handle(promise, next, errorCallback)\n  },\n\n  postJson(uri, data, next, errorCallback) {\n    const promise = axios.post(uri, data, {\n      headers: {\n        'Content-Type': 'application/json'\n      }\n    })\n    handle(promise, next, errorCallback)\n  }\n}\n"
  },
  {
    "path": "web/vue/src/utils/performance.js",
    "content": "// 性能监控工具\nexport const measurePerformance = (name, fn) => {\n  if (import.meta.env.DEV) {\n    const start = performance.now()\n    const result = fn()\n    const end = performance.now()\n    console.log(`[Performance] ${name}: ${(end - start).toFixed(2)}ms`)\n    return result\n  }\n  return fn()\n}\n\n// 监控路由切换性能\nexport const measureRouteChange = (to, from) => {\n  if (import.meta.env.DEV && performance.mark) {\n    performance.mark(`route-${to.path}-start`)\n  }\n}\n\nexport const measureRouteChangeEnd = (to) => {\n  if (import.meta.env.DEV && performance.mark && performance.measure) {\n    performance.mark(`route-${to.path}-end`)\n    try {\n      performance.measure(\n        `route-${to.path}`,\n        `route-${to.path}-start`,\n        `route-${to.path}-end`\n      )\n      const measure = performance.getEntriesByName(`route-${to.path}`)[0]\n      console.log(`[Route Performance] ${to.path}: ${measure.duration.toFixed(2)}ms`)\n    } catch (e) {\n      // ignore\n    }\n  }\n}\n"
  },
  {
    "path": "web/vue/src/utils/progress/index.js",
    "content": "//@ts-ignore\nimport NProgress from 'nprogress'\n\nNProgress.configure({\n  // 动画方式\n  easing: 'ease',\n  // 递增进度条的速度\n  speed: 500,\n  // 是否显示加载ico\n  showSpinner: false,\n  // 自动递增间隔\n  trickleSpeed: 200,\n  // 初始化时的最小百分比\n  minimum: 0.3\n})\n\nlet activeRequests = 0\n\nexport function start() {\n  if (activeRequests === 0) {\n    NProgress.start()\n  }\n  activeRequests++\n}\n\nexport function done() {\n  activeRequests = Math.max(0, activeRequests - 1)\n  if (activeRequests === 0) {\n    NProgress.done()\n  }\n}\n\nexport default { start, done }\n"
  },
  {
    "path": "web/vue/src/utils/request.js",
    "content": "import axios from 'axios'\nimport { ElMessage } from 'element-plus'\nimport router from '../router'\nimport { useUserStore } from '../stores/user'\n\nconst SUCCESS_CODE = 0\nconst AUTH_ERROR_CODE = 401\nconst APP_NOT_INSTALL_CODE = 801\n\n// 请求取消管理\nconst pendingRequests = new Map()\n\nconst request = axios.create({\n  baseURL: '/api',\n  timeout: 10000,\n  withCredentials: false,\n  headers: {\n    'X-Requested-With': 'XMLHttpRequest'\n  }\n})\n\nrequest.interceptors.request.use(\n  config => {\n    const userStore = useUserStore()\n    if (userStore.token) {\n      config.headers['Auth-Token'] = userStore.token\n    }\n    \n    // 取消重复请求\n    const requestKey = `${config.method}_${config.url}`\n    if (pendingRequests.has(requestKey)) {\n      const controller = pendingRequests.get(requestKey)\n      controller.abort()\n    }\n    const controller = new AbortController()\n    config.signal = controller.signal\n    pendingRequests.set(requestKey, controller)\n    \n    return config\n  },\n  error => {\n    ElMessage.error('请求失败')\n    return Promise.reject(error)\n  }\n)\n\nrequest.interceptors.response.use(\n  response => {\n    // 清除已完成的请求\n    const requestKey = `${response.config.method}_${response.config.url}`\n    pendingRequests.delete(requestKey)\n    \n    const { code, message, data } = response.data\n    \n    if (code === APP_NOT_INSTALL_CODE) {\n      router.push('/install')\n      return Promise.reject(new Error(message))\n    }\n    \n    if (code === AUTH_ERROR_CODE) {\n      const userStore = useUserStore()\n      userStore.logout()\n      router.push('/user/login')\n      return Promise.reject(new Error(message))\n    }\n    \n    if (code !== SUCCESS_CODE) {\n      ElMessage.error(message || '请求失败')\n      return Promise.reject(new Error(message))\n    }\n    \n    return data\n  },\n  error => {\n    // 清除失败的请求\n    if (error.config) {\n      const requestKey = `${error.config.method}_${error.config.url}`\n      pendingRequests.delete(requestKey)\n    }\n    \n    // 忽略取消的请求\n    if (axios.isCancel(error)) {\n      return Promise.reject(error)\n    }\n    \n    // 网络错误或超时\n    if (error.code === 'ECONNABORTED') {\n      ElMessage.error('请求超时，请稍后重试')\n    } else if (!error.response) {\n      ElMessage.error('网络连接失败，请检查网络')\n    } else {\n      ElMessage.error(error.message || '请求失败')\n    }\n    return Promise.reject(error)\n  }\n)\n\nexport default request\n"
  },
  {
    "path": "web/vue/static/.gitkeep",
    "content": ""
  },
  {
    "path": "web/vue/static/robots.txt",
    "content": "User-agent: *\nDisallow: /\n"
  },
  {
    "path": "web/vue/verify.sh",
    "content": "#!/bin/bash\n\nset -e\n\necho \"==========================================\"\necho \"前端改动验证脚本\"\necho \"==========================================\"\necho \"\"\n\n# 1. 依赖检查\necho \"1. 检查依赖...\"\nyarn install --frozen-lockfile\necho \"✅ 依赖检查完成\"\necho \"\"\n\n# 2. 运行测试\necho \"2. 运行单元测试...\"\nyarn test --run\necho \"✅ 测试通过\"\necho \"\"\n\n# 3. Lint 检查\necho \"3. 运行 Lint 检查...\"\nyarn lint || echo \"⚠️  Lint 有警告（非阻塞）\"\necho \"\"\n\n# 4. 构建验证\necho \"4. 构建生产版本...\"\nyarn build\necho \"✅ 构建成功\"\necho \"\"\n\n# 5. 检查构建产物\necho \"5. 检查构建产物大小...\"\necho \"\"\necho \"主要 JS 文件:\"\nls -lh dist/static/*.js 2>/dev/null | head -5 || echo \"无 JS 文件\"\necho \"\"\necho \"主要 CSS 文件:\"\nls -lh dist/static/*.css 2>/dev/null | head -5 || echo \"无 CSS 文件\"\necho \"\"\n\n# 6. 总结\necho \"==========================================\"\necho \"✅ 所有验证通过！\"\necho \"==========================================\"\necho \"\"\necho \"下一步:\"\necho \"  1. 启动开发服务器: yarn dev\"\necho \"  2. 手动测试功能\"\necho \"\"\n"
  },
  {
    "path": "web/vue/vite.config.js",
    "content": "import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport { resolve } from 'path'\nimport AutoImport from 'unplugin-auto-import/vite'\nimport Components from 'unplugin-vue-components/vite'\nimport { ElementPlusResolver } from 'unplugin-vue-components/resolvers'\nimport viteCompression from 'vite-plugin-compression'\n\nexport default defineConfig({\n  plugins: [\n    vue(),\n    AutoImport({\n      resolvers: [ElementPlusResolver()],\n      imports: ['vue', 'vue-router', 'pinia', '@vueuse/core']\n    }),\n    Components({\n      resolvers: [ElementPlusResolver()]\n    }),\n    viteCompression({\n      algorithm: 'gzip',\n      ext: '.gz'\n    })\n  ],\n  resolve: {\n    alias: {\n      '@': resolve(__dirname, 'src')\n    }\n  },\n  server: {\n    port: 8080,\n    proxy: {\n      '/api': {\n        target: 'http://localhost:5920',\n        changeOrigin: true\n      }\n    }\n  },\n  build: {\n    outDir: 'dist',\n    assetsDir: 'static',\n    sourcemap: false,\n    minify: 'esbuild',\n    cssCodeSplit: true,\n    reportCompressedSize: false,\n    rollupOptions: {\n      output: {\n        manualChunks: {\n          'vue-vendor': ['vue', 'vue-router', 'pinia'],\n          'element-plus': ['element-plus', '@element-plus/icons-vue'],\n          'utils': ['axios', 'dayjs', 'qs']\n        },\n        chunkFileNames: 'static/js/[name]-[hash].js',\n        entryFileNames: 'static/js/[name]-[hash].js',\n        assetFileNames: 'static/[ext]/[name]-[hash].[ext]'\n      }\n    },\n    chunkSizeWarningLimit: 1000\n  }\n})\n"
  },
  {
    "path": "web/vue/vitest.config.js",
    "content": "import { defineConfig } from 'vitest/config'\nimport vue from '@vitejs/plugin-vue'\nimport { resolve } from 'path'\n\nexport default defineConfig({\n  plugins: [vue()],\n  test: {\n    environment: 'jsdom',\n    globals: true\n  },\n  resolve: {\n    alias: {\n      '@': resolve(__dirname, 'src')\n    }\n  }\n})\n"
  },
  {
    "path": "webhook-test/go.mod",
    "content": "module webhook-test\n\ngo 1.21\n"
  },
  {
    "path": "webhook-test/go.sum",
    "content": ""
  },
  {
    "path": "webhook-test/start-webhook-server.sh",
    "content": "#!/bin/bash\n\necho \"🚀 启动Webhook测试服务...\"\n\n# 检查Go是否安装\nif ! command -v go &> /dev/null; then\n    echo \"❌ 未找到Go，请先安装Go语言环境\"\n    exit 1\nfi\n\n# 进入webhook-test目录\ncd \"$(dirname \"$0\")\"\n\n# 启动服务\necho \"📡 启动服务在端口8080...\"\ngo run webhook-test-server.go"
  },
  {
    "path": "webhook-test/test-webhook.sh",
    "content": "#!/bin/bash\n\necho \"🧪 测试Webhook服务...\"\n\n# 测试数据\ntest_data='{\n  \"task_id\": 123,\n  \"task_name\": \"测试任务\",\n  \"status\": \"成功\",\n  \"output\": \"任务执行完成\",\n  \"remark\": \"这是一个测试webhook\"\n}'\n\necho \"📤 发送测试数据到webhook服务...\"\necho \"数据: $test_data\"\n\n# 发送POST请求\ncurl -X POST \\\n  -H \"Content-Type: application/json\" \\\n  -d \"$test_data\" \\\n  http://localhost:8080/webhook\n\necho -e \"\\n\\n✅ 测试完成\""
  },
  {
    "path": "webhook-test/webhook-test-server.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"time\"\n)\n\n// WebhookPayload webhook接收的数据结构\ntype WebhookPayload struct {\n\tTaskID   int    `json:\"task_id\"`\n\tTaskName string `json:\"task_name\"`\n\tStatus   string `json:\"status\"`\n\tOutput   string `json:\"output\"`\n\tRemark   string `json:\"remark\"`\n}\n\nfunc main() {\n\thttp.HandleFunc(\"/webhook\", handleWebhook)\n\thttp.HandleFunc(\"/health\", handleHealth)\n\n\tfmt.Println(\"🚀 Webhook测试服务启动\")\n\tfmt.Println(\"📡 监听地址: http://localhost:8080/webhook\")\n\tfmt.Println(\"💚 健康检查: http://localhost:8080/health\")\n\n\tlog.Fatal(http.ListenAndServe(\":8080\", nil))\n}\n\nfunc handleWebhook(w http.ResponseWriter, r *http.Request) {\n\t// 记录请求时间\n\ttimestamp := time.Now().Format(\"2006-01-02 15:04:05\")\n\n\tfmt.Printf(\"\\n=== [%s] 收到Webhook请求 ===\\n\", timestamp)\n\tfmt.Printf(\"方法: %s\\n\", r.Method)\n\tfmt.Printf(\"路径: %s\\n\", r.URL.Path)\n\n\t// 打印请求头\n\tfmt.Println(\"请求头:\")\n\tfor name, values := range r.Header {\n\t\tfor _, value := range values {\n\t\t\tfmt.Printf(\"  %s: %s\\n\", name, value)\n\t\t}\n\t}\n\n\t// 读取请求体\n\tbody, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\tfmt.Printf(\"❌ 读取请求体失败: %v\\n\", err)\n\t\thttp.Error(w, \"读取请求体失败\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tfmt.Printf(\"请求体: %s\\n\", string(body))\n\n\t// 尝试解析JSON\n\tvar payload WebhookPayload\n\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\tfmt.Printf(\"⚠️  JSON解析失败: %v\\n\", err)\n\t\tfmt.Println(\"将作为纯文本处理\")\n\t} else {\n\t\tfmt.Println(\"✅ JSON解析成功:\")\n\t\tfmt.Printf(\"  任务ID: %d\\n\", payload.TaskID)\n\t\tfmt.Printf(\"  任务名称: %s\\n\", payload.TaskName)\n\t\tfmt.Printf(\"  状态: %s\\n\", payload.Status)\n\t\tfmt.Printf(\"  输出: %s\\n\", payload.Output)\n\t\tfmt.Printf(\"  备注: %s\\n\", payload.Remark)\n\t}\n\n\t// 返回成功响应\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(http.StatusOK)\n\n\tresponse := map[string]interface{}{\n\t\t\"success\":   true,\n\t\t\"message\":   \"webhook接收成功\",\n\t\t\"timestamp\": timestamp,\n\t\t\"received\":  len(body) > 0,\n\t}\n\n\tjson.NewEncoder(w).Encode(response)\n\tfmt.Println(\"✅ 响应已发送\")\n}\n\nfunc handleHealth(w http.ResponseWriter, r *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tw.WriteHeader(http.StatusOK)\n\n\tresponse := map[string]string{\n\t\t\"status\": \"ok\",\n\t\t\"time\":   time.Now().Format(\"2006-01-02 15:04:05\"),\n\t}\n\n\tjson.NewEncoder(w).Encode(response)\n}\n"
  }
]