[
  {
    "path": ".air.toml",
    "content": "root = \".\"\ntestdata_dir = \"testdata\"\ntmp_dir = \"tmp\"\n\n[build]\n  args_bin = []\n  # 使用相对路径，或者不指定配置文件让程序自动查找\n  bin = \"./tmp/main service\"\n  cmd = \"go build -o ./tmp/main\"\n  # 设置环境变量\n  env = [\"GO_ENV=development\"]\n  delay = 0\n  exclude_dir = [\"assets\", \"tmp\", \"vendor\", \"testdata\", \"logs\", \"tests\"]\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 = \"0s\"\n  log = \"./logs/build-errors.log\"\n  poll = false\n  poll_interval = 0\n  rerun = false\n  rerun_delay = 500\n  send_interrupt = false\n  stop_on_error = false\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": ".github/pull_request_template.md",
    "content": "## 变更说明\n\n- 主要目标：\n- 影响模块：\n- 风险等级：低 / 中 / 高\n\n## 自查清单\n\n- [ ] 已确认本次改动解决的是主要问题，而不是无关重构\n- [ ] 已调研相关代码并复用现有实现（未重复造轮子）\n- [ ] 已评估影响范围与兼容性（必要时给出回滚方案）\n- [ ] 已补充或更新测试（至少覆盖一个新增/修改分支）\n\n## 可读性专项检查（必填）\n\n- [ ] 未引入“仅一层转发且无语义增量”的新方法\n- [ ] 如果新增拆分，已说明拆分理由（事务边界 / 复用 / 测试隔离）\n- [ ] 方法职责保持单一，命名表达清晰\n- [ ] 跨文件跳转成本可接受（阅读主流程不需要反复来回定位）\n\n## 验证记录\n\n- 本地执行命令：\n  - `go test ./...`\n- 关键结果：\n\n## 关联信息\n\n- 关联 Issue / 任务：\n- 额外说明：\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL\"\n\non:\n  push:\n    branches: [ master, x_l_admin ]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ master, x_l_admin ]\n  schedule:\n    - cron: '27 0 * * 6'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'go' ]\n        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]\n        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v3\n\n      # Initializes the CodeQL tools for scanning.\n      - name: Initialize CodeQL\n        uses: github/codeql-action/init@v2\n        with:\n          languages: ${{ matrix.language }}\n          # If you wish to specify custom queries, you can do so here or in a config file.\n          # By default, queries listed here will override any specified in a config file.\n          # Prefix the list here with \"+\" to use these queries and those in the config file.\n\n          # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs\n          # queries: security-extended,security-and-quality\n\n\n      # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).\n      # If this step fails, then you should remove it and run the build manually (see below)\n      - name: Autobuild\n        uses: github/codeql-action/autobuild@v2\n\n      # ℹ️ Command-line programs to run using the OS shell.\n      # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun\n\n      #   If the Autobuild fails above, remove it and uncomment the following three lines.\n      #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.\n\n      # - run: |\n      #   echo \"Run, Build Application using script\"\n      #   ./location_of_script_within_repo/buildscript.sh\n\n      - name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@v2"
  },
  {
    "path": ".github/workflows/go.yml",
    "content": "name: Go\n\non:\n  push:\n    branches: [ master, x_l_admin ]\n  pull_request:\n    branches: [ master, x_l_admin ]\n\njobs:\n\n  build:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        go-version: [ '1.26.x' ]\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Setup Go ${{ matrix.go-version }}\n        uses: actions/setup-go@v4\n        with:\n          go-version: ${{ matrix.go-version }}\n\n      - name: Display Go version\n        run: go version\n\n      - name: Build\n        run: go build -v ./...\n\n      - name: Test\n        run: go test $(go list ./... | grep -v /tests/)"
  },
  {
    "path": ".gitignore",
    "content": "# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with `go test -c`\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# IDE and editor files\n.idea\n.vscode\n*.DS_Store\n\n# Runtime directories\nstorage\nlogs/\ntmp\nbuild/\n\n# Local config and environment files\n*.yaml\n*.ini\n.env\n.env.local\n.env.*.local\n\n# Claude Code local settings (keep memory but ignore local configs)\n.claude\n\n# Application binaries\ngin-layout\ngin-layout/\ngo-layout\n/migrate\n\n# Documentation (project-specific, not for version control)\nPROJECT_ARCHITECTURE.md\nCODE_OPTIMIZATION_REPORT.md\n\n# Go cache\n.gocache\n.go-test-cache\n\n# Memory (if you want to keep it local only)\n# memory/\n.aitasks\n"
  },
  {
    "path": ".golangci.yml",
    "content": "# golangci-lint 配置文件\n# 参考: https://golangci-lint.run/usage/configuration/\n\nrun:\n  # 超时时间\n  timeout: 5m\n  # 并发数\n  concurrency: 4\n  # 包含的测试文件\n  tests: true\n  # 跳过目录\n  skip-dirs:\n    - vendor\n    - build\n    - dist\n    - logs\n    - storage\n    - tmp\n  # 跳过文件\n  skip-files:\n    - \".*\\\\.pb\\\\.go$\"\n    - \".*\\\\.gen\\\\.go$\"\n\n# 输出配置\noutput:\n  # 输出格式: colored-line-number, line-number, json, tab, checkstyle, code-climate, html, junit-xml, github-actions\n  format: colored-line-number\n  # 打印问题数量\n  print-issued-lines: true\n  # 打印 linter 名称\n  print-linter-name: true\n  # 不打印欢迎信息\n  uniq-by-line: false\n  # 打印源代码行\n  print-welcome: false\n\n# 启用的 linter\nlinters:\n  enable:\n    # 代码质量\n    - errcheck          # 检查错误处理\n    - gosimple          # 简化代码建议\n    - govet             # go vet 检查\n    - ineffassign       # 检查未使用的赋值\n    - staticcheck       # 静态分析\n    - unused            # 检查未使用的代码\n    \n    # 代码风格\n    - gofmt             # 代码格式检查\n    - goimports         # import 语句检查\n    - misspell          # 拼写检查\n    - revive            # Go 代码检查工具（替代 golint）\n    - stylecheck        # 风格检查\n    \n    # 性能\n    - prealloc          # 预分配切片检查\n    \n    # 复杂度\n    - gocyclo           # 圈复杂度检查\n    - gocognit          # 认知复杂度检查\n    \n    # 其他\n    - exportloopref     # 循环变量引用检查\n    - gocritic          # 代码审查建议\n    - gosec             # 安全检查\n    - nakedret          # 检查裸返回\n    - noctx             # 检查 context 传递\n    - rowserrcheck      # 检查 rows.Err()\n    - sqlclosecheck     # 检查 SQL 连接关闭\n\nlinters-settings:\n  # errcheck 配置\n  errcheck:\n    check-type-assertions: true\n    check-blank: true\n  \n  # gocyclo 配置 - 圈复杂度阈值\n  gocyclo:\n    min-complexity: 15\n  \n  # gocognit 配置 - 认知复杂度阈值\n  gocognit:\n    min-complexity: 15\n  \n  # revive 配置\n  revive:\n    rules:\n      - name: exported\n        severity: warning\n      - name: var-naming\n        severity: warning\n      - name: package-comments\n        severity: warning\n      - name: range\n        severity: warning\n      - name: increment-decrement\n        severity: warning\n      - name: error-return\n        severity: warning\n      - name: error-strings\n        severity: warning\n      - name: error-naming\n        severity: warning\n      - name: receiver-naming\n        severity: warning\n      - name: unexported-return\n        severity: warning\n      - name: time-equal\n        severity: warning\n      - name: banned-characters\n        severity: warning\n      - name: context-keys-type\n        severity: warning\n      - name: context-as-argument\n        severity: warning\n      - name: if-return\n        severity: warning\n      - name: increment-decrement\n        severity: warning\n      - name: var-declaration\n        severity: warning\n      - name: range-val-in-closure\n        severity: warning\n      - name: range-val-address\n        severity: warning\n      - name: waitgroup-by-value\n        severity: warning\n      - name: atomic\n        severity: warning\n      - name: empty-lines\n        severity: warning\n      - name: line-length-limit\n        severity: warning\n        arguments:\n          - 120\n  \n  # gocritic 配置\n  gocritic:\n    enabled-tags:\n      - diagnostic\n      - experimental\n      - opinionated\n      - performance\n      - style\n    disabled-checks:\n      - dupImport # 允许重复导入（某些情况下需要）\n      - ifElseChain # 允许 if-else 链\n      - octalLiteral # 允许八进制字面量\n  \n  # gosec 配置\n  gosec:\n    # 严重程度: low, medium, high\n    severity: medium\n    # 置信度: low, medium, high\n    confidence: medium\n    # 排除的规则\n    excludes:\n      - G104 # 审计错误未检查（某些情况下可以接受）\n      - G401 # 弱随机数生成（某些场景可以接受）\n      - G501 # 导入黑名单（某些依赖是必需的）\n\nissues:\n  # 排除的问题\n  exclude-rules:\n    # 排除测试文件中的某些检查\n    - path: _test\\.go\n      linters:\n        - errcheck\n        - gosec\n        - gocritic\n        - gocyclo\n        - gocognit\n  \n  # 最大问题数（0 表示不限制）\n  max-issues-per-linter: 0\n  max-same-issues: 0\n  \n  # 排除的问题模式\n  exclude:\n    # 排除 \"Error return value of .((os\\.)?std(out|err)\\..*|.*Close|.*Flush|os\\.Remove(All)?|.*printf?|os\\.(Un)?Setenv). is not checked\"\n    - 'Error return value of .((os\\.)?std(out|err)\\..*|.*Close|.*Flush|os\\.Remove(All)?|.*printf?|os\\.(Un)?Setenv). is not checked'\n    # 排除 \"exported function .* should have comment\"\n    - 'exported function .* should have comment'\n    # 排除 \"comment on exported .* should be of the form\"\n    - 'comment on exported .* should be of the form'\n\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022 wannanbigpig\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": "MEMORY.md",
    "content": "# MEMORY\n\n这份文档写给接手 `gin-layout` 的 AI Agent。它不是项目营销介绍，而是为了让下一位 AI 尽快知道：\n\n- 项目当前真实状态是什么\n- 主要入口、核心链路和高风险区域在哪\n- 关键约束和禁止事项是什么\n- 写代码时如何保持当前项目风格\n\n## 0. 快速结论\n\n先记住这些，不确定时再往下查细节：\n\n- 代码是真相，文档只是辅助；文档和代码冲突时，以代码为准并顺手修正文档。\n- 新功能优先沿用现有 `service / model / resources / validator` 分层。\n- 保持现有工程范式，不引入重框架化、函数式大重写、新 DI 容器或 repository 全量替换。\n- Casbin 使用 `github.com/casbin/casbin/v3`。\n- 鉴权链路采用 claims-first：请求上下文保存 `AuthPrincipal`，不是完整数据库 `AdminUser`。\n- 错误文案按请求语言国际化返回；需要自定义错误文案时优先使用错误码或 message key，而不是在 controller 里硬编码中文。\n- 请求审计是“审计快照 + 队列优先”：队列开启时异步落库，队列关闭时才同步落库审计日志。\n- CORS 使用常规 `[\"*\"]` 语义。\n- 菜单列表/树只返回当前语言 `title`；菜单详情返回 `title_i18n`，不返回 `title`。\n- 继续改代码时保持小步修改、局部重组、命名稳定。\n- 改完代码必须自己验证；新增或修改 HTTP 行为默认要补 `tests/` 接口级测试。\n\n## 1. 禁止事项\n\n当前代码库禁止这样做：\n\n- 引入 `casbin/v2` 或混用不同主版本的 Casbin adapter。\n- 在认证中间件里每个请求都回表查询完整 `AdminUser`；账号状态变更应通过服务层撤销 token 或更新会话状态生效。\n- 把 `AuthPrincipal.AdminUser()` 当成数据库实时状态。\n- 在队列开启时同步落库请求审计日志。\n- 把请求日志设计成“每个请求同步写文件 + 异步审计”的双写链路。\n- 把 CORS 空数组当成全放开；显式全放开使用 `[\"*\"]`。\n- 让菜单列表或用户菜单树返回 `title_i18n`。\n- 让菜单详情返回 `title`。\n- 让菜单写接口接收 `title`；写接口使用 `title_i18n`。\n- 把业务逻辑、事务或批量数据修复塞进 controller。\n- 把所有逻辑塞回单个超大 service 文件。\n- 绕过 `PermissionSyncCoordinator` 分散写入 Casbin 权限。\n- 只改代码不跑测试，或把验证责任留给用户或下一个 AI。\n\n## 2. 当前定位\n\n项目名：`gin-layout`\n\n这是一个偏后台管理场景的 Go 后端骨架，内置：\n\n- JWT 登录、校验、刷新、登出\n- Casbin RBAC 接口权限控制\n- 管理员、角色、部门、菜单、API 权限体系\n- 请求日志、登录日志、审计日志\n- 文件上传与本地文件访问\n- CLI 命令、定时任务、异步队列 worker\n\n运行形态包含三类进程：\n\n1. `service`\n   - 提供 HTTP API\n   - 构建请求审计日志快照\n   - 队列开启时把审计快照投递到异步队列\n   - 队列关闭时在当前请求链路同步落库审计日志\n\n2. `worker`\n   - 消费异步任务\n   - 主要消费请求审计日志异步落库\n\n3. `cron`\n   - 负责周期任务\n\n## 3. 当前事实\n\n这些是当前实现与开发规范。\n\n### 3.1 Casbin v3\n\n权限引擎是：\n\n- `github.com/casbin/casbin/v3`\n- `github.com/casbin/gorm-adapter/v3`\n\n相关入口：\n\n- [internal/access/casbin/casbin.go](/Users/liuml/data/go/src/go-layout/internal/access/casbin/casbin.go:1)\n- [internal/access/casbin/adapter.go](/Users/liuml/data/go/src/go-layout/internal/access/casbin/adapter.go:1)\n\n### 3.2 Claims-First 鉴权\n\n请求上下文保存 claims 快照，不保存完整数据库 `AdminUser` 模型：\n\n- [internal/service/auth/principal.go](/Users/liuml/data/go/src/go-layout/internal/service/auth/principal.go:1)\n- [internal/middleware/parse_token.go](/Users/liuml/data/go/src/go-layout/internal/middleware/parse_token.go:1)\n- [internal/middleware/admin_auth.go](/Users/liuml/data/go/src/go-layout/internal/middleware/admin_auth.go:1)\n\n这意味着：\n\n- 中间件不应为每个请求回表查用户。\n- 需要用户实时数据库状态的地方，由具体业务接口显式查库。\n- `AuthPrincipal.AdminUser()` 是轻量投影，不代表数据库最新状态。\n\n### 3.3 请求日志\n\n请求日志链路以审计日志为核心：\n\n- 请求链路：缓存请求体、录制响应体，构建审计快照。\n- 队列开启：把审计快照投给队列，由 worker 异步落库。\n- 队列关闭：在当前请求链路同步落库审计日志。\n\n全局 zap 文件日志用于系统日志、错误日志和 panic 日志；每个请求日志按审计快照链路处理。\n\n关键文件：\n\n- [internal/middleware/logger.go](/Users/liuml/data/go/src/go-layout/internal/middleware/logger.go:1)\n- [internal/middleware/audit_queue.go](/Users/liuml/data/go/src/go-layout/internal/middleware/audit_queue.go:1)\n- [internal/jobs/audit_log.go](/Users/liuml/data/go/src/go-layout/internal/jobs/audit_log.go:1)\n- [cmd/worker/worker.go](/Users/liuml/data/go/src/go-layout/cmd/worker/worker.go:1)\n\n### 3.4 CORS\n\n支持常规 `*` 语义：\n\n- `cors_origins: [\"*\"]`\n- `cors_methods: [\"*\"]`\n- `cors_headers: [\"*\"]`\n- `cors_expose_headers: [\"*\"]`\n\n实现位置：\n\n- [internal/middleware/cors.go](/Users/liuml/data/go/src/go-layout/internal/middleware/cors.go:1)\n- [config/config.yaml.example](/Users/liuml/data/go/src/go-layout/config/config.yaml.example:17)\n\n### 3.5 错误国际化\n\n错误文案由请求语言驱动：\n\n- `RequestLocaleHandler` 从 `Accept-Language` 解析语言并写入请求上下文。\n- 响应层根据上下文语言选择错误文案语言。\n- 通用业务错误默认走错误码文案表。\n- 需要细粒度错误文案时，优先使用 `message key`，由响应层统一国际化。\n- 参数校验错误由 validator translator 输出多语言文案，字段名优先使用 `label/json/form` 标签。\n\n关键文件：\n\n- [internal/middleware/request_locale.go](/Users/liuml/data/go/src/go-layout/internal/middleware/request_locale.go:1)\n- [internal/pkg/i18n/locale.go](/Users/liuml/data/go/src/go-layout/internal/pkg/i18n/locale.go:1)\n- [internal/pkg/errors/error.go](/Users/liuml/data/go/src/go-layout/internal/pkg/errors/error.go:1)\n- [internal/pkg/errors/zh-cn.go](/Users/liuml/data/go/src/go-layout/internal/pkg/errors/zh-cn.go:1)\n- [internal/pkg/errors/en-us.go](/Users/liuml/data/go/src/go-layout/internal/pkg/errors/en-us.go:1)\n- [internal/pkg/response/response.go](/Users/liuml/data/go/src/go-layout/internal/pkg/response/response.go:1)\n- [internal/validator/runtime.go](/Users/liuml/data/go/src/go-layout/internal/validator/runtime.go:1)\n- [internal/validator/translation.go](/Users/liuml/data/go/src/go-layout/internal/validator/translation.go:1)\n- [internal/validator/binding.go](/Users/liuml/data/go/src/go-layout/internal/validator/binding.go:1)\n\n### 3.6 菜单 I18n\n\n菜单标题由请求语言驱动：\n\n- 列表/树：只返回当前语言 `title`，不返回 `title_i18n`。\n- 详情：返回 `title_i18n` 供编辑回填，不返回 `title`。\n- 写接口：使用 `title_i18n`，支持 `zh-CN` 与 `en-US`，至少一种非空。\n\n关键文件：\n\n- [internal/controller/admin_v1/auth_menu.go](/Users/liuml/data/go/src/go-layout/internal/controller/admin_v1/auth_menu.go:1)\n- [internal/service/menu/menu_query.go](/Users/liuml/data/go/src/go-layout/internal/service/menu/menu_query.go:1)\n- [internal/service/menu/menu_edit.go](/Users/liuml/data/go/src/go-layout/internal/service/menu/menu_edit.go:1)\n- [internal/resources/menu.go](/Users/liuml/data/go/src/go-layout/internal/resources/menu.go:1)\n- [tests/admin_test/menu_test.go](/Users/liuml/data/go/src/go-layout/tests/admin_test/menu_test.go:1)\n\n### 3.7 文件组织\n\n典型职责包：\n\n- `internal/service/admin/`\n- `internal/service/role/`\n- `internal/service/dept/`\n- `internal/service/access/`\n- `internal/validator/`\n- `internal/model/`\n\n继续改时，优先往对应职责文件里放；可以同包拆文件，保持文件职责清晰。\n\n## 4. 任务阅读索引\n\n新会话建议先看：\n\n1. [README.md](/Users/liuml/data/go/src/go-layout/README.md:1)\n2. [docs/COMMANDS_AND_TASKS.md](/Users/liuml/data/go/src/go-layout/docs/COMMANDS_AND_TASKS.md:1)\n3. [cmd/root.go](/Users/liuml/data/go/src/go-layout/cmd/root.go:1)\n4. [cmd/service/service.go](/Users/liuml/data/go/src/go-layout/cmd/service/service.go:1)\n5. [internal/routers](/Users/liuml/data/go/src/go-layout/internal/routers)\n\n按任务追加阅读：\n\n- 新增或修改后台接口：\n  - [internal/routers](/Users/liuml/data/go/src/go-layout/internal/routers)\n  - [internal/controller/admin_v1](/Users/liuml/data/go/src/go-layout/internal/controller/admin_v1)\n  - [internal/validator/form](/Users/liuml/data/go/src/go-layout/internal/validator/form)\n  - [internal/service](/Users/liuml/data/go/src/go-layout/internal/service)\n  - [internal/resources](/Users/liuml/data/go/src/go-layout/internal/resources)\n  - [tests/admin_test](/Users/liuml/data/go/src/go-layout/tests/admin_test)\n\n- 权限 / Casbin / 菜单可见性：\n  - [internal/access/casbin](/Users/liuml/data/go/src/go-layout/internal/access/casbin)\n  - [internal/service/access](/Users/liuml/data/go/src/go-layout/internal/service/access)\n  - [rbac_model.conf](/Users/liuml/data/go/src/go-layout/rbac_model.conf:1)\n\n- 鉴权 / token / 当前用户：\n  - [internal/middleware/parse_token.go](/Users/liuml/data/go/src/go-layout/internal/middleware/parse_token.go:1)\n  - [internal/middleware/admin_auth.go](/Users/liuml/data/go/src/go-layout/internal/middleware/admin_auth.go:1)\n  - [internal/service/auth](/Users/liuml/data/go/src/go-layout/internal/service/auth)\n\n- 请求日志 / 审计 / 队列：\n  - [internal/middleware/logger.go](/Users/liuml/data/go/src/go-layout/internal/middleware/logger.go:1)\n  - [internal/middleware/audit_queue.go](/Users/liuml/data/go/src/go-layout/internal/middleware/audit_queue.go:1)\n  - [internal/jobs/audit_log.go](/Users/liuml/data/go/src/go-layout/internal/jobs/audit_log.go:1)\n  - [internal/service/audit](/Users/liuml/data/go/src/go-layout/internal/service/audit)\n  - [cmd/worker/worker.go](/Users/liuml/data/go/src/go-layout/cmd/worker/worker.go:1)\n\n- 菜单标题国际化：\n  - [internal/service/menu](/Users/liuml/data/go/src/go-layout/internal/service/menu)\n  - [internal/model/menu_i18n.go](/Users/liuml/data/go/src/go-layout/internal/model/menu_i18n.go:1)\n  - [internal/resources/menu.go](/Users/liuml/data/go/src/go-layout/internal/resources/menu.go:1)\n  - [tests/admin_test/menu_test.go](/Users/liuml/data/go/src/go-layout/tests/admin_test/menu_test.go:1)\n\n- 模型基础能力 / 列表分页 / 删除：\n  - [internal/model/base.go](/Users/liuml/data/go/src/go-layout/internal/model/base.go:1)\n  - [internal/model/base_crud.go](/Users/liuml/data/go/src/go-layout/internal/model/base_crud.go:1)\n  - [internal/model/base_list.go](/Users/liuml/data/go/src/go-layout/internal/model/base_list.go:1)\n  - [internal/model/base_tree.go](/Users/liuml/data/go/src/go-layout/internal/model/base_tree.go:1)\n\n## 5. 目录边界\n\n目录需要按职责边界理解，而不只是物理位置。\n\n### 5.1 `cmd/`\n\n负责程序入口和启动编排，不负责业务逻辑。\n\n关键命令：\n\n- `service`\n- `worker`\n- `cron`\n- `command`\n\n### 5.2 `internal/controller/`\n\nHTTP 控制器层。\n\n职责：\n\n- 收参\n- 调 validator/form\n- 调 service\n- 返回 response\n\n不应该在 controller 里写复杂业务判断、事务或批量数据修复逻辑。\n\n### 5.3 `internal/service/`\n\n业务层核心。\n\n职责：\n\n- 业务规则\n- 事务编排\n- 模型组合查询\n- 触发权限同步\n- 触发 token 撤销\n\n这个项目的大部分改动都应该优先落在 service 层。\n\n### 5.4 `internal/model/`\n\nGORM 模型层和基础数据访问层。\n\n职责：\n\n- 表结构\n- 通用 CRUD\n- 列表分页\n- 树形节点辅助\n\n复杂业务判断放在 service 层，model 保持数据模型与通用数据访问职责。\n\n### 5.5 `internal/resources/`\n\n响应整形层。\n\n职责：\n\n- 把 model / service 结果转换成前端要的结构。\n\n响应整形采用显式 transformer，不直接把 model 原样 JSON 输出。\n\n### 5.6 `internal/validator/` 与 `internal/validator/form/`\n\n参数对象和校验规则层。\n\n职责：\n\n- 定义请求参数 struct。\n- 定义 tag 校验规则。\n- 统一错误翻译和绑定错误处理。\n\n### 5.7 `internal/service/access/`\n\n权限同步协调层。\n\n职责拆分为：\n\n- `api_cache`\n- `scope_resolver`\n- `graph_loader`\n- `coordinator`\n- `menu_api_defaults`\n- `system_defaults`\n- `user_permission_sync`\n\n外层统一从 `PermissionSyncCoordinator` 进入，业务层通过协调器触发 Casbin 权限同步。\n\n## 6. 开发风格\n\n### 6.1 命名\n\n- service 名称以业务对象命名，如 `AdminUserService`、`RoleService`。\n- constructor 用 `NewXxxService()`。\n- 业务入口方法名直接用动词：`List`、`Create`、`Update`、`Delete`、`BindRole`。\n- 内部辅助方法用小写，尽量语义直接：`buildListCondition`、`updateDeptRole`。\n\n避免引入抽象但缺少项目语境的命名，例如：\n\n- `Processor`\n- `Manager`\n- `Facade`\n- `RepositoryFactory`\n\n只有代码里存在明确同类模式时，才沿用同类命名。\n\n### 6.2 结构\n\n优先做“同包拆文件”，新增 package 前先确认职责边界确实独立。\n\n结构整理方式：\n\n- 保留 `package` 不变。\n- 保留原 service 名称不变。\n- 把一个大文件拆成多个职责文件。\n\n### 6.3 错误处理\n\n业务错误优先使用：\n\n- `internal/pkg/errors`\n\n典型方式：\n\n- `e.NewBusinessError(...)`\n\n业务错误码优先复用，保持现有错误码风格。\n\n### 6.4 事务与权限刷新\n\n事务与权限刷新规范：\n\n- service 层显式事务。\n- 事务工具统一复用。\n- 权限刷新或缓存刷新在事务提交后处理。\n\n“事务 + 后置刷新”保持在 service 层编排。\n\n### 6.5 响应\n\n接口响应走统一响应封装，不直接返回裸对象。\n\n所以：\n\n- controller 保持现有 response 风格。\n- 列表接口尽量返回 collection。\n- detail 接口尽量走 transformer。\n\n### 6.6 注释\n\n注释使用少量中文，说明职责和意图；除非必要复杂需求才写长注释。\n\n可以写：\n\n- `// BindRole 绑定角色。`\n- `// ReloadPolicyCache 在事务提交后刷新共享 Casbin Enforcer 的内存策略。`\n\n注释保持高信息密度，只解释职责、意图和复杂逻辑。\n\n## 7. 写代码硬规则\n\n### 7.1 先复用，再新增\n\n做任何功能前，先查：\n\n- service 是否存在类似逻辑。\n- model 是否存在通用方法。\n- validator 是否存在相同校验模式。\n- resources 是否存在类似 transformer。\n\n优先复用现有能力。\n\n### 7.2 小步修改\n\n这个项目是工程型仓库，不是实验仓库。\n\n可以：\n\n- 局部重构。\n- 同包拆文件。\n- 补测试。\n\n保持现有范式，尤其避免突然引入：\n\n- repository 模式全量替换。\n- 泛型 service 框架大改。\n- 新 DI 容器。\n- 新 HTTP 框架。\n\n### 7.3 保持接口签名稳定\n\n如果只是做结构优化或内部修复：\n\n- controller 签名不改。\n- service 对外方法名尽量不改。\n- HTTP API 不改。\n- CLI 命令名不改。\n\n### 7.4 新增行为要配测试\n\n尤其是下面几类：\n\n- 鉴权\n- 权限同步\n- CORS\n- 日志截断\n- token 撤销\n- 默认值生成\n- 事务提交后刷新\n\n如果是新增接口，不仅要补直接相关的测试，还要默认在 `tests/` 目录下补接口级测试用例。\n\n原因：\n\n- 项目有明确的 `tests/admin_test` 集成测试入口。\n- 只补 service 或 middleware 级测试，不足以证明接口链路真实可用。\n- 新接口至少要验证路由、鉴权、请求参数、响应结构中的关键路径。\n\n默认要求：\n\n- 新增 HTTP 接口：补 `tests/` 目录下的接口测试。\n- 修改现有 HTTP 接口行为：补或更新 `tests/` 目录下对应测试。\n- 只改内部实现且对外行为不变：可以只补包内测试，但要能说明为什么不需要接口测试。\n\n### 7.5 改完先验证\n\n改完代码后，先自己跑测试，再决定是否结束本轮工作。\n\n最小要求：\n\n- 小改动：跑直接受影响包测试。\n- 中等改动：跑相关子系统测试。\n- 公共层或高风险改动：优先跑 `go test ./...`。\n\n以下结束方式不符合项目要求：\n\n- 只改代码，不跑测试。\n- 只说“理论上没问题”。\n- 只让用户自己去验证。\n\n如果因为环境限制无法完成某项测试，结果里必须明确：\n\n- 哪些测试跑了。\n- 哪些测试没跑。\n- 没跑的原因是什么。\n\n推荐基线：\n\n```bash\ngo test ./internal/...\ngo test ./...\ngo test ./internal/middleware ./internal/service/access ./internal/service/auth\n```\n\n## 8. 新增后台接口路径\n\n建议按这个顺序：\n\n1. 在 `internal/validator/form/` 定义参数 struct。\n2. 在 `internal/model/` 复用或补充数据访问方法。\n3. 在 `internal/service/` 写业务逻辑。\n4. 在 `internal/resources/` 补返回结构。\n5. 在 `internal/controller/admin_v1/` 加控制器方法。\n6. 在 `internal/routers/` 的声明式路由树里注册。\n7. 如涉及权限元数据，同步执行 `command api-route`。\n8. 如涉及角色/菜单/API 关系变化，确认权限重建链路是否需要触发。\n9. 在 `tests/` 目录下补接口级测试。\n\n## 9. 高风险区域\n\n### 9.1 `internal/service/access`\n\n会影响：\n\n- 菜单可见性\n- API 权限\n- Casbin 最终策略\n\n### 9.2 `internal/service/auth`\n\n风险点：\n\n- 鉴权链路采用 claims-first。\n- token 黑名单 / 撤销逻辑集中在认证服务内。\n- 改不好会影响所有登录态请求。\n\n### 9.3 `internal/middleware/logger*`\n\n风险点：\n\n- 请求体截断。\n- 响应体捕获。\n- 队列异步投递。\n- 队列关闭时的同步审计落库。\n- 性能与日志正确性平衡。\n\n### 9.4 `internal/model/base_*`\n\n风险点：\n\n- 很多 model 共用。\n- 一旦改坏，会影响列表、分页、删除、防误删逻辑。\n\n## 10. 接手检查命令\n\n先看工作树状态：\n\n```bash\ngit status --short\n```\n\n再看测试基线：\n\n```bash\ngo test ./...\n```\n\n如果只改中间件 / 权限 / 鉴权，优先跑对应子集：\n\n```bash\ngo test ./internal/middleware ./internal/service/access ./internal/service/auth\n```\n\n## 11. 文档关系\n\n根目录文档建议这样理解：\n\n- [README.md](/Users/liuml/data/go/src/go-layout/README.md:1)\n  - 对外项目介绍、基础使用说明。\n\n- [docs/COMMANDS_AND_TASKS.md](/Users/liuml/data/go/src/go-layout/docs/COMMANDS_AND_TASKS.md:1)\n  - 命令说明、定时任务与操作约束。\n\n- 本文档\n  - 给下一位 AI 的“当前状态 + 接手约束 + 写码风格”说明。\n\n如果文档和代码冲突：\n\n- 以代码为准。\n- 再顺手修正文档。\n\n## 12. 一句话接手策略\n\n先读入口和当前链路，再读与你任务相关的 service；优先复用现有结构，保持命名和接口稳定，用小步修改把需求做完，维持项目既有风格。\n"
  },
  {
    "path": "README.en.md",
    "content": "# <div align=\"center\">gin-layout</div>\n\n<div align=\"center\">\n  <a href=\"./README.md\">中文</a> | <strong>English</strong>\n</div>\n\n<br />\n\n<div align=\"center\">\n  <strong>A Gin-based admin backend scaffold</strong>\n</div>\n\n<div align=\"center\">\n  Built with JWT auth, RBAC, request/login logging, file upload, readiness probes, validation, request-locale i18n, declarative routing, and CLI initialization commands.\n</div>\n\n<br />\n\n<div align=\"center\">\n  <img src=\"https://github.com/wannanbigpig/gin-layout/actions/workflows/go.yml/badge.svg\" alt=\"go\" />\n  <img src=\"https://github.com/wannanbigpig/gin-layout/actions/workflows/codeql.yml/badge.svg\" alt=\"codeql\" />\n  <img src=\"https://goreportcard.com/badge/github.com/wannanbigpig/gin-layout\" alt=\"go report card\" />\n  <img src=\"https://img.shields.io/github/license/wannanbigpig/gin-layout\" alt=\"license\" />\n  <img src=\"https://img.shields.io/badge/Go-%3E%3D1.23-blue.svg\" alt=\"go version\" />\n</div>\n\n<br />\n\n## Why This Exists\n\nMost admin projects start with the same goal: get login, permissions, menus, uploads, and logs working quickly. In practice, the same engineering problems show up again and again:\n\n- Auth, permissions, logging, and file handling are split across too many places\n- Route declarations, menus, and API permissions drift apart over time\n- The same admin infrastructure is rewritten repeatedly across projects\n- Config, command, migration, and deployment workflows lack a clear baseline\n\n`gin-layout` is built to turn these repeated backend concerns into a reusable, production-oriented foundation for admin systems.\n\n## Highlights\n\n| Capability | Description |\n| --- | --- |\n| Auth | JWT login, token verification, auto refresh, and blacklist support |\n| RBAC | Admin, role, department, menu, and API permission management |\n| Route Metadata | Declarative route tree generates both Gin routes and API metadata |\n| Logs | Built-in login logs, request logs, and unified response structure |\n| File Access | File upload and public / private file access |\n| I18n | Error messages and menu titles are resolved by `Accept-Language` (`zh-CN` / `en-US`) |\n| Health Probes | Built-in `/ping` and `/health/readiness` for liveness and dependency-readiness checks |\n| Tooling | CLI commands for initialization, route sync, permission rebuild, and migrations |\n| Hot Reload | Partial config hot reload with fallback to previous live instances on failure |\n\n## Related Resources\n\n- Frontend project: [go-admin-ui](https://github.com/wannanbigpig/go-admin-ui)\n- Online docs: [Apifox](https://wannanbigpig.apifox.cn/)\n- Demo: [Live Demo](https://x-l-admin.wannanbigpig.com/)\n- Commands and jobs guide: [docs/COMMANDS_AND_TASKS.en.md](./docs/COMMANDS_AND_TASKS.en.md)\n- Migration command guide: [docs/MIGRATE_COMMANDS.en.md](./docs/MIGRATE_COMMANDS.en.md)\n\n## Quick Start\n\n### 1. Requirements\n\n- `Go >= 1.23`\n- `MySQL >= 5.7`\n- `Redis >= 5.0` (optional)\n\n### 2. Install\n\n```bash\ngit clone https://github.com/wannanbigpig/gin-layout.git\ncd gin-layout\ngo mod download\n```\n\n### 3. Run Migrations\n\nRecommended project commands:\n\n```bash\ngo run main.go command migrate        # defaults to migrate up\ngo run main.go command migrate check\n```\n\nAfter migrations finish, a default baseline dataset is inserted, including the super admin account `super_admin / 123456`. It is only recommended for local initialization. Change the password immediately after the first login.\n\nFor migration file creation, timestamp naming rules, and full `down/goto/force/version` usage, see [docs/MIGRATE_COMMANDS.en.md](./docs/MIGRATE_COMMANDS.en.md).\n\n### 4. Configure\n\nFor source runs, `GO_ENV=development` is recommended. Without `-c`:\n\n- development mode uses `config.yaml` in the current working directory\n- if missing, it copies from `config/config.yaml.example` in the current working directory\n- non-development mode resolves `config.yaml` next to the executable and copies from sibling `config.yaml.example` when needed\n\nYou can also copy and edit the config file manually.\n\nMinimal example:\n\n```yaml\napp:\n  app_env: local\n  debug: true\n  language: zh_CN\n  trusted_proxies:\n    - 127.0.0.1\n  watch_config: true\n  # allow_degraded_startup: false\n\njwt:\n  ttl: 7200\n  refresh_ttl: 3600\n  secret_key: change-me-to-a-random-secret\n\nmysql:\n  enable: true\n  host: 127.0.0.1\n  port: 3306\n  database: go_layout\n  username: root\n  password: your_password\n\nredis:\n  enable: true\n  host: 127.0.0.1\n  port: 6379\n  password: \"\"\n  database: 0\n\nqueue:\n  enable: true\n  use_default_redis: true\n  namespace: go_layout\n  concurrency: 8\n  strict_priority: false\n  queues:\n    critical: 4\n    default: 2\n    audit: 2\n    low: 1\n  audit_max_retry: 3\n  audit_timeout_seconds: 10\n```\n\nNotes:\n\n- `jwt.secret_key` is required and cannot be empty\n- If you only run the API and do not need async jobs, set `queue.enable` to `false`\n- If `queue.enable=true` but you do not want to reuse `redis.*`, set `queue.use_default_redis` to `false` and fill in `queue.redis.*`\n\n### 5. Start Service\n\n```bash\nGO_ENV=development go run main.go service\n```\n\nTo explicitly set the listen host or port:\n\n```bash\nGO_ENV=development go run main.go service -H 127.0.0.1 -P 9001\n```\n\nIf `queue.enable=true`, start the worker in a separate process as well:\n\n```bash\nGO_ENV=development go run main.go worker\n```\n\n### 6. Verify\n\n```bash\ncurl http://127.0.0.1:9001/ping\ncurl http://127.0.0.1:9001/health/readiness\n```\n\n- `/ping` returns `pong` when the HTTP process is alive.\n- `/health/readiness` returns `ready=true` when required dependencies are ready for the current runtime mode.\n\n## Core Ideas\n\n### Prefer Declarative Routing\n\nAdmin routes are maintained in a single declarative route tree. The current entry is `AdminRouteTree()` in `internal/routers/admin_router.go`. Gin route registration and API metadata initialization are both generated from that tree so route code and permission metadata do not drift apart.\n\n### Database Relations Are The Source Of Truth\n\nThe current permission model treats database relations as the source of truth, while Casbin performs final API authorization checks. The `rebuild-user-permissions` command rebuilds each user's final API permissions from user, department, role, menu, and API relationships stored in the database.\n\n### Hot Reload Is Tiered\n\nThe project supports config hot reload, but not every setting can be applied live. Supported resources are rebuilt when possible. If a rebuild fails, the previous live instance is kept so the service can continue running.\n\n### Request-Locale Driven Texts\n\nThe request pipeline parses `Accept-Language` (currently `zh-CN` / `en-US`) and applies normalized fallback behavior:\n\n- Error messages are resolved by request locale, with fallback to default locale (`zh-CN`).\n- Menu list / user menu tree returns localized `title` only.\n- Menu detail returns `title_i18n` for edit backfill, not `title`.\n- Menu create/update writes `title_i18n`; at least one of `zh-CN` / `en-US` must be non-empty.\n\n## Commands\n\nHelp:\n\n```bash\ngo run main.go -h\ngo run main.go command -h\ngo run main.go service --help\n```\n\nCommon commands:\n\n| Command | Description |\n| --- | --- |\n| `go run main.go version` | Print the current version |\n| `go run main.go service` | Start the API service |\n| `go run main.go service -H 0.0.0.0 -P 9001` | Explicitly set the listen host and port |\n| `go run main.go worker` | Start the Asynq async worker |\n| `go run main.go cron` | Start scheduled jobs |\n| `go run main.go command demo` | Run the demo command |\n| `go run main.go command api-route` | Scan the declarative route tree and rebuild the `api` route table |\n| `go run main.go command rebuild-user-permissions` | Rebuild final user API permissions from database relationships |\n| `go run main.go command init-system` | Roll back and rerun migrations, rebuild API routes, and rebuild user permissions |\n| `go run main.go -c ./config.yaml command task scan-async` | Scan async task registration against the task-definition mirror |\n| `go run main.go -c ./config.yaml command task scan-cron` | Scan cron task definitions against the task-definition mirror |\n| `go run main.go -c ./config.yaml command migrate check` | Validate migration naming and up/down pairing |\n| `go run main.go -c ./config.yaml command migrate up` | Apply all pending migrations |\n| `go run main.go -c ./config.yaml command migrate down 1` | Roll back one migration version |\n\nIf the config file is not in the default location:\n\n```bash\ngo run main.go -c /path/to/config.yaml service\ngo run main.go -c /path/to/config.yaml command init-system\n```\n\nSee [docs/MIGRATE_COMMANDS.en.md](./docs/MIGRATE_COMMANDS.en.md) for full migration command details.\n\n## Configuration\n\n### Config Resolution\n\nConfig lookup order:\n\n1. Explicit `-c` / `--config`\n2. `config.yaml` in the current working directory for development mode\n3. if missing, copy from `config/config.yaml.example` in the current working directory\n4. `config.yaml` next to the executable for non-development mode\n5. if missing, copy from sibling `config.yaml.example`\n\n### Key Settings\n\n| Key | Description |\n| --- | --- |\n| `app.base_path` | Base directory for logs, uploaded files, and other local paths; when not set it follows `GO_ENV` (development=current working directory, otherwise executable directory) |\n| `app.allow_degraded_startup` | Only applies to the `service` command; allows the HTTP service to start when dependency initialization fails, exposing the not-ready state through readiness and route guards |\n| `app.base_url` | URL prefix used to generate public file access URLs |\n| `app.trusted_proxies` | Trusted proxy addresses or CIDRs that affect `ClientIP()` and log IPs |\n| `jwt.secret_key` | Required; do not use weak placeholder values in production |\n| `jwt.ttl` / `jwt.refresh_ttl` | Token expiration and auto-refresh threshold |\n| `mysql` | Database enable flag and connection settings |\n| `redis` | Cache, blacklist, and distributed lock settings |\n| `queue.use_default_redis` | `true` reuses `redis.*`; `false` uses the independent `queue.redis.*` connection |\n| `queue` | Asynq enable flag, queue concurrency, priorities, and audit-log retry settings |\n| `logger` | Log output, rotation, and retention strategy |\n\nIf requests pass through Nginx, Ingress, or a load balancer, keep `app.trusted_proxies` aligned or client IP logging may be inaccurate.\n\n### Worker And Cron\n\n- `service` serves the HTTP API.\n- `worker` consumes Asynq jobs. The current first phase only moves request audit-log persistence to Asynq.\n- When `queue.enable=false`, you do not need to start `worker`; request audit logs are persisted synchronously in the current request flow.\n- `cron` owns scheduled jobs. The demo cron task is controlled by the system config `task.cron_demo_enabled` and is disabled by default after initialization.\n- `reset-system-data` is only registered when `app.enable_reset_system_cron=true` is explicitly configured, and startup logs a high-risk warning.\n- Do not register the same recurring business task in both `cron` and the async worker flow, or it will run twice.\n\nNote: `reset-system-data` currently calls `system.ReinitializeSystemData()`, which rolls back migrations and rebuilds system data. Keep `app.enable_reset_system_cron=false` in production, and only enable it temporarily when you explicitly need to rebuild system data.\n\n### Hot Reload\n\nEnable it with:\n\n```yaml\napp:\n  watch_config: true\n```\n\nHot-reload supported:\n\n- `logger.*`\n- `mysql.*`\n- `redis.*`\n- `app.base_url`\n- `app.cors_*`\n- `jwt.ttl`\n- `jwt.refresh_ttl`\n\nDetected but requires restart:\n\n- `app.trusted_proxies`\n- `app.language`\n- `app.allow_degraded_startup`\n- `jwt.secret_key`\n- service listen address and port\n- route structure\n\nNotes:\n\n- `watch_config=true` only enables file watching; it does not mean every setting is safely swappable\n- MySQL, Redis, and Casbin instances are rebuilt from the new config and the old instance is kept on failure\n- JWT secret hot reload is not currently supported; changes are logged and only take effect after restart\n\n## Development\n\n### Add A New Endpoint\n\n1. Write the controller in `internal/controller/`\n2. Write the business logic in `internal/service/`\n3. Define request params in `internal/validator/form/`\n4. Declare the route in `AdminRouteTree()`\n5. Run `go run main.go command api-route` if the API route table needs to be refreshed\n\n### Validation Conventions\n\n- For enum-like fields (for example `status`, `is_auth`, `method`), prefer explicit `oneof` constraints.\n- For ID arrays (for example `role_ids`, `menu_list`, `api_list`), prefer `dive,gt=0` so invalid IDs such as `0` are rejected at validation stage.\n- When adding/changing validation rules, add both positive and negative tests to prevent regressions.\n\n### Test\n\n```bash\ngo test ./...\ngo test ./tests/admin_test\n```\n\nTests prefer the root `config.yaml`. If MySQL or Redis is unavailable in the current environment, the test setup falls back to example config paths for cases that can run without those external services.\n\n## Deployment\n\n### Build\n\n```bash\ngo build -o go-layout main.go\n./go-layout service\n```\n\nIf `-c` is not provided, use `GO_ENV=development` in development and ensure `config/config.yaml.example` exists in the current working directory or `config.yaml` has already been generated. For binary deployment, keep the config file next to the executable.\n\n### Supervisor\n\n```ini\n[program:go-layout]\ncommand=/path/to/go-layout -c /path/to/config.yaml service\ndirectory=/path/to/go-layout\nautostart=true\nautorestart=true\nstartsecs=5\nuser=www-data\nredirect_stderr=true\nstdout_logfile=/path/to/go-layout/supervisord.log\n```\n\n### Nginx\n\n```nginx\nserver {\n    listen 80;\n    server_name api.example.com;\n\n    location / {\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_pass http://127.0.0.1:9001;\n    }\n}\n```\n\nIf you are behind a reverse proxy, add the proxy address or CIDR to `app.trusted_proxies`.\n\n## Project Layout\n\n```text\ngin-layout/\n├── cmd/                    # CLI entrypoints\n├── config/                 # Config structs and example config\n├── data/                   # MySQL / Redis and migrations\n├── docs/                   # Supplementary docs and resources\n├── internal/\n│   ├── access/             # Access and permission infrastructure\n│   ├── controller/         # Controllers\n│   ├── middleware/         # Middlewares\n│   ├── model/              # Data models\n│   ├── resources/          # Resource transformers\n│   ├── routers/            # Declarative routing\n│   ├── service/            # Business services\n│   └── validator/          # Request validation\n├── pkg/                    # Shared utilities\n├── storage/                # File storage\n├── tests/                  # Route and integration tests\n└── README.md\n```\n\n## 💝 Support This Project\n\nThanks for using `gin-layout`.\n\nIf this project helps you, you can support its ongoing development and maintenance.\n\n<a href=\"./docs/DONATE.en.md\">\n  <img src=\"https://img.shields.io/badge/BUY_ME_A_COFFEE-SUPPORT_AUTHOR-f08a24?style=for-the-badge&logo=buymeacoffee&logoColor=ffdd00&labelColor=4a4a4a\" alt=\"Support the author\" />\n</a>\n\n## License\n\nThis project is released under the MIT License. See [LICENSE](LICENSE).\n\n## Contributing\n\nIssues and pull requests are welcome.\n\n## Disclaimer\n\nThis project is provided **“as is”**, without any express or implied warranty. It may contain defects, security vulnerabilities, or implementations that do not fit a specific business scenario. Before using it in production, you should perform your own code review, security hardening, configuration review, permission validation, and data backup. Any issues caused by using, relying on, deploying, modifying, or operating this project are the responsibility of the user.\n"
  },
  {
    "path": "README.md",
    "content": "# <div align=\"center\">gin-layout</div>\n\n<div align=\"center\">\n  <strong>中文</strong> | <a href=\"./README.en.md\">English</a>\n</div>\n\n<br />\n\n<div align=\"center\">\n  <strong>基于 Gin 的后台管理系统脚手架</strong>\n</div>\n\n<div align=\"center\">\n  内置 JWT 认证、RBAC 权限、请求/登录日志、文件上传、readiness 探针、参数校验、请求语言国际化、声明式路由和 CLI 初始化命令。\n</div>\n\n<br />\n\n<div align=\"center\">\n  <img src=\"https://github.com/wannanbigpig/gin-layout/actions/workflows/go.yml/badge.svg\" alt=\"go\" />\n  <img src=\"https://github.com/wannanbigpig/gin-layout/actions/workflows/codeql.yml/badge.svg\" alt=\"codeql\" />\n  <img src=\"https://goreportcard.com/badge/github.com/wannanbigpig/gin-layout\" alt=\"go report card\" />\n  <img src=\"https://img.shields.io/github/license/wannanbigpig/gin-layout\" alt=\"license\" />\n  <img src=\"https://img.shields.io/badge/Go-%3E%3D1.23-blue.svg\" alt=\"go version\" />\n</div>\n\n<br />\n\n## 项目定位\n\n很多后台项目一开始都只是想“先把登录、权限、菜单、上传和日志跑起来”，但真正进入开发后，通常会很快碰到这些重复问题：\n\n- 认证、权限、日志和文件能力分散，初始化成本高\n- 路由、菜单、API 权限关系容易逐步失控\n- 不同项目里同一套后台基础设施被反复重写\n- 配置、命令、迁移和部署流程缺少统一约定\n\n`gin-layout` 的目标很明确：把后台管理场景里高频、重复、工程化要求高的基础能力沉淀成一套可以直接落地的后端骨架。\n\n## 核心特性\n\n| 能力 | 说明 |\n| --- | --- |\n| Auth | 内置 JWT 登录、Token 校验、自动刷新、黑名单 |\n| RBAC | 管理员、角色、部门、菜单、API 权限管理 |\n| Route Metadata | 声明式路由树统一生成 Gin 路由和 API 元数据 |\n| Logs | 内置登录日志、请求日志、统一响应结构 |\n| File Access | 文件上传与公开 / 私有文件访问 |\n| I18n | 基于 `Accept-Language` 返回错误文案与菜单标题（支持 `zh-CN` / `en-US`） |\n| Health Probes | 提供 `/ping` 与 `/health/readiness`，便于存活检测与依赖就绪检查 |\n| Tooling | 提供 CLI 初始化、路由同步、权限重建、迁移配套能力 |\n| Hot Reload | 支持部分配置热更新，失败时保留旧实例继续运行 |\n\n## 相关资源\n\n- 前端项目：[go-admin-ui](https://github.com/wannanbigpig/go-admin-ui)\n- 在线文档：[Apifox](https://wannanbigpig.apifox.cn/)\n- 演示地址：[在线演示](https://x-l-admin.wannanbigpig.com/)\n- 命令与任务文档：[docs/COMMANDS_AND_TASKS.md](./docs/COMMANDS_AND_TASKS.md)\n- 迁移命令详解：[docs/MIGRATE_COMMANDS.md](./docs/MIGRATE_COMMANDS.md)\n\n## 快速开始\n\n### 1. 环境要求\n\n- `Go >= 1.23`\n- `MySQL >= 5.7`\n- `Redis >= 5.0`（可选）\n\n### 2. 安装项目\n\n```bash\ngit clone https://github.com/wannanbigpig/gin-layout.git\ncd gin-layout\ngo mod download\n```\n\n### 3. 执行迁移\n\n推荐使用项目命令：\n\n```bash\ngo run main.go command migrate        # 默认等价于 migrate up\ngo run main.go command migrate check\n```\n\n迁移执行完成后会写入一套默认基础数据，其中包含超级管理员账号 `super_admin / 123456`。仅建议用于本地初始化，首次登录后请立即修改密码。\n\n迁移文件创建、时间戳命名规范、`down/goto/force/version` 等详细说明见：[docs/MIGRATE_COMMANDS.md](./docs/MIGRATE_COMMANDS.md)。\n\n### 4. 配置项目\n\n源码运行时建议带上 `GO_ENV=development`。未显式传入 `-c` 时：\n\n- 开发模式会把当前工作目录下的 `config/config.yaml.example` 自动复制为项目根目录 `config.yaml`\n- 非开发模式会在可执行文件同级查找 `config.yaml`，若不存在则尝试从同目录 `config.yaml.example` 复制\n\n也可以手动复制配置文件后再修改。\n\n最小配置示例：\n\n```yaml\napp:\n  app_env: local\n  debug: true\n  language: zh_CN\n  trusted_proxies:\n    - 127.0.0.1\n  watch_config: true\n  # allow_degraded_startup: false\n\njwt:\n  ttl: 7200\n  refresh_ttl: 3600\n  secret_key: change-me-to-a-random-secret\n\nmysql:\n  enable: true\n  host: 127.0.0.1\n  port: 3306\n  database: go_layout\n  username: root\n  password: your_password\n\nredis:\n  enable: true\n  host: 127.0.0.1\n  port: 6379\n  password: \"\"\n  database: 0\n\nqueue:\n  enable: true\n  use_default_redis: true\n  namespace: go_layout\n  concurrency: 8\n  strict_priority: false\n  queues:\n    critical: 4\n    default: 2\n    audit: 2\n    low: 1\n  audit_max_retry: 3\n  audit_timeout_seconds: 10\n```\n\n注意：\n\n- `jwt.secret_key` 为必填项，不能为空\n- 如果只启动 API、不启用异步任务，可以把 `queue.enable` 设为 `false`\n- 如果 `queue.enable=true` 但不想复用 `redis.*`，请把 `queue.use_default_redis` 设为 `false`，并补齐 `queue.redis.*`\n\n### 5. 启动服务\n\n```bash\nGO_ENV=development go run main.go service\n```\n\n需要显式指定监听地址或端口时：\n\n```bash\nGO_ENV=development go run main.go service -H 127.0.0.1 -P 9001\n```\n\n如果启用了 `queue.enable=true`，还需要单独启动 worker：\n\n```bash\nGO_ENV=development go run main.go worker\n```\n\n### 6. 验证服务\n\n```bash\ncurl http://127.0.0.1:9001/ping\ncurl http://127.0.0.1:9001/health/readiness\n```\n\n- `/ping` 返回 `pong`，说明 HTTP 进程已正常启动\n- `/health/readiness` 返回 `ready=true`，说明当前配置下需要的依赖已经就绪\n\n## 设计思路\n\n### 声明式路由优先\n\n后台路由维护在一棵声明式路由树中，目前入口位于 `internal/routers/admin_router.go` 的 `AdminRouteTree()`。Gin 路由注册和 API 元数据初始化都从这棵树生成，避免“代码路由”和“权限路由”长期分叉。\n\n### 数据库关系是权限真相\n\n当前权限模型采用“数据库关系为真相，Casbin 负责最终接口判定”的方式。角色、部门、菜单和 API 的业务关系以数据库为准，`rebuild-user-permissions` 命令会按这些关系重建用户最终 API 权限。\n\n### 配置热更新是分级的\n\n项目支持配置热更新，但不是所有配置都会在运行中立即生效。支持热更新的资源会尝试重建；如果重建失败，会继续保留旧实例运行，避免把服务直接打挂。\n\n### 请求语言驱动文案\n\n项目会在请求链路读取 `Accept-Language`（当前支持 `zh-CN` / `en-US`），并做归一化与降级处理：\n\n- 错误码文案：按请求语言返回；无法识别时降级到默认语言（`zh-CN`）。\n- 菜单列表/用户菜单树：仅返回 `title`，由后端按请求语言解析后返回。\n- 菜单详情：返回 `title_i18n`（用于前端编辑回填），不返回 `title`。\n- 菜单新增/编辑：写接口使用 `title_i18n`，目前支持 `zh-CN` 与 `en-US`，两者至少一种非空。\n\n## 常用命令\n\n查看帮助：\n\n```bash\ngo run main.go -h\ngo run main.go command -h\ngo run main.go service --help\n```\n\n常用命令：\n\n| 命令 | 说明 |\n| --- | --- |\n| `go run main.go version` | 输出当前版本号 |\n| `go run main.go service` | 启动 API 服务 |\n| `go run main.go service -H 0.0.0.0 -P 9001` | 显式指定监听地址与端口 |\n| `go run main.go worker` | 启动 Asynq 异步任务消费进程 |\n| `go run main.go cron` | 启动定时任务 |\n| `go run main.go command demo` | 运行示例命令 |\n| `go run main.go command api-route -y` | 扫描声明式路由树并重建 `api` 路由表 |\n| `go run main.go command rebuild-user-permissions -y` | 按数据库关系重建用户最终 API 权限 |\n| `go run main.go command init-system -y` | 回滚并重新执行迁移、重建 API 路由、重建用户权限 |\n| `go run main.go -c ./config.yaml command task scan-async` | 扫描异步任务注册与任务定义镜像 |\n| `go run main.go -c ./config.yaml command task scan-cron` | 扫描定时任务定义与任务定义镜像 |\n| `go run main.go -c ./config.yaml command migrate check` | 校验迁移文件命名与 up/down 配对 |\n| `go run main.go -c ./config.yaml command migrate up` | 执行全部未应用迁移 |\n| `go run main.go -c ./config.yaml command migrate down 1` | 回滚 1 个迁移版本 |\n\n如果配置文件不在默认位置，可以显式指定：\n\n```bash\ngo run main.go -c /path/to/config.yaml service\ngo run main.go -c /path/to/config.yaml command init-system\n```\n\n迁移命令完整参数见：[docs/MIGRATE_COMMANDS.md](./docs/MIGRATE_COMMANDS.md)。\n\n补充说明：\n\n- `api-route`、`rebuild-user-permissions`、`init-system` 默认会二次确认；自动化场景建议显式加 `-y`\n- `init-system` 会清空并重建系统数据，只适合本地初始化或明确允许重置的环境\n\n## 配置说明\n\n### 配置查找顺序\n\n配置文件查找顺序：\n\n1. 显式传入 `-c` / `--config`\n2. `GO_ENV=development` 时，使用当前工作目录的 `config.yaml`\n3. 若第 2 步缺失，则尝试从当前工作目录的 `config/config.yaml.example` 复制生成\n4. 非开发模式下，使用可执行文件所在目录的 `config.yaml`\n5. 若第 4 步缺失，则尝试从可执行文件同级的 `config.yaml.example` 复制生成\n\n### 主要配置项\n\n| 配置项 | 说明 |\n| --- | --- |\n| `app.base_path` | 日志、上传文件等本地路径的基础目录；未配置时默认按 `GO_ENV` 选择（development=当前工作目录，其他=可执行文件目录） |\n| `app.allow_degraded_startup` | 仅 `service` 命令生效；依赖初始化失败时允许 HTTP 服务先启动，并通过 readiness / 路由守卫暴露未就绪状态 |\n| `app.base_url` | 文件访问 URL 前缀，用于生成公开文件地址 |\n| `app.trusted_proxies` | 受信任代理地址或网段，影响 `ClientIP()` 与日志 IP |\n| `jwt.secret_key` | 必填；生产环境不能使用弱占位值 |\n| `jwt.ttl` / `jwt.refresh_ttl` | Token 过期时间与自动刷新阈值 |\n| `mysql` | 数据库开关与连接信息 |\n| `redis` | 缓存、黑名单和分布式锁配置 |\n| `queue.use_default_redis` | `true` 复用 `redis.*`；`false` 时改用 `queue.redis.*` 独立连接 |\n| `queue` | Asynq 异步任务开关、队列命名空间、并发度、优先级和审计日志重试策略 |\n| `logger` | 日志输出、切割和保留策略 |\n\n如果通过 Nginx、Ingress 或负载均衡转发请求，需要同步配置 `app.trusted_proxies`，否则客户端 IP 可能记录不准确。\n\n### Worker 与 Cron\n\n- `service` 负责提供 HTTP API。\n- `worker` 负责消费 Asynq 异步任务。当前首版只接入请求审计日志异步落库。\n- `queue.enable=false` 时，不需要启动 `worker`，请求审计日志会在当前请求链路同步落库。\n- `cron` 负责定时任务调度；演示定时任务由系统配置 `task.cron_demo_enabled` 控制，初始化默认关闭。\n- `reset-system-data` 需要显式配置 `app.enable_reset_system_cron=true` 才会注册，并在启动时输出高风险告警。\n- 不要把同一个周期任务同时注册到 `cron` 和 `worker` 体系里，否则会重复执行。\n\n注意：`reset-system-data` 当前调用的是 `system.ReinitializeSystemData()`，会回滚迁移并重建系统数据。生产环境建议保持 `app.enable_reset_system_cron=false`，只有在明确需要重建系统数据时才临时开启。\n\n### 热更新\n\n启用方式：\n\n```yaml\napp:\n  watch_config: true\n```\n\n支持热更新：\n\n- `logger.*`\n- `mysql.*`\n- `redis.*`\n- `app.base_url`\n- `app.cors_*`\n- `jwt.ttl`\n- `jwt.refresh_ttl`\n\n仅检测并提示“需要重启”：\n\n- `app.trusted_proxies`\n- `app.language`\n- `app.allow_degraded_startup`\n- `jwt.secret_key`\n- 服务监听地址与端口\n- 路由结构\n\n说明：\n\n- `watch_config=true` 只表示启用监听，不代表所有配置都能无损切换\n- MySQL、Redis、Casbin 会按新配置重建实例，失败时保留旧实例\n- JWT 密钥当前不支持热更新，修改后会记录告警并继续使用旧密钥，直到进程重启\n\n## 开发指南\n\n### 新增接口流程\n\n1. 在 `internal/controller/` 编写控制器\n2. 在 `internal/service/` 编写业务逻辑\n3. 在 `internal/validator/form/` 定义请求参数\n4. 在 `AdminRouteTree()` 中声明路由\n5. 需要更新 API 路由表时执行 `go run main.go command api-route`\n\n### 参数校验约定\n\n- 枚举字段（如 `status`、`is_auth`、`method`）建议使用 `oneof` 显式约束。\n- ID 数组字段（如 `role_ids`、`menu_list`、`api_list`）建议统一使用 `dive,gt=0`，避免无效 ID（如 `0`）进入业务层。\n- 新增/修改校验规则时，建议同时补充正反例单测，避免后续回归。\n\n### 测试\n\n```bash\ngo test ./...\ngo test ./tests/admin_test\n```\n\n测试会优先使用项目根目录 `config.yaml`。如果当前环境的 MySQL 或 Redis 不可用，会自动回退到示例配置运行可脱离外部依赖的测试。\n\n## 部署说明\n\n### 构建\n\n```bash\ngo build -o go-layout main.go\n./go-layout service\n```\n\n如果没有显式传 `-c`，请在开发环境使用 `GO_ENV=development`，并确保当前工作目录存在 `config/config.yaml.example`（或已生成 `config.yaml`）；部署二进制时请保证可执行文件同级存在配置文件。\n\n### Supervisor\n\n```ini\n[program:go-layout]\ncommand=/path/to/go-layout -c /path/to/config.yaml service\ndirectory=/path/to/go-layout\nautostart=true\nautorestart=true\nstartsecs=5\nuser=www-data\nredirect_stderr=true\nstdout_logfile=/path/to/go-layout/supervisord.log\n```\n\n### Nginx\n\n```nginx\nserver {\n    listen 80;\n    server_name api.example.com;\n\n    location / {\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_pass http://127.0.0.1:9001;\n    }\n}\n```\n\n如果前面有反向代理，请把代理地址或网段加入 `app.trusted_proxies`。\n\n## 目录结构\n\n```text\ngin-layout/\n├── cmd/                    # 命令行入口\n├── config/                 # 配置结构与示例配置\n├── data/                   # MySQL / Redis 与迁移\n├── docs/                   # 补充文档与资源\n├── internal/\n│   ├── access/             # 权限基础设施\n│   ├── controller/         # 控制器\n│   ├── middleware/         # 中间件\n│   ├── model/              # 数据模型\n│   ├── resources/          # 资源转换\n│   ├── routers/            # 声明式路由\n│   ├── service/            # 业务逻辑\n│   └── validator/          # 参数验证\n├── pkg/                    # 通用工具\n├── storage/                # 文件存储\n├── tests/                  # 路由与集成测试\n└── README.md\n```\n\n## 💝 赞助项目\n\n感谢你使用 `gin-layout`。\n\n如果这个项目对你有帮助，欢迎支持项目的持续开发与维护。\n\n<a href=\"./docs/DONATE.md\">\n  <img src=\"https://img.shields.io/badge/BUY_ME_A_COFFEE-%E6%94%AF%E6%8C%81%E4%BD%9C%E8%80%85-f08a24?style=for-the-badge&logo=buymeacoffee&logoColor=ffdd00&labelColor=4a4a4a\" alt=\"支持作者\" />\n</a>\n\n## 许可证\n\n本项目采用 MIT 许可证，详见 [LICENSE](LICENSE)。\n\n## 贡献\n\n欢迎提交 Issue 和 Pull Request。\n\n## 免责声明\n\n本项目按 **“现状”提供**，不附带任何明示或默示担保。项目可能存在缺陷、安全漏洞或与特定业务场景不匹配的实现；上线前请自行完成代码审查、安全加固、配置审查、权限验收和数据备份。因使用、依赖、部署、改造或运维本项目导致的问题，由使用者自行承担。\n"
  },
  {
    "path": "build.sh",
    "content": "#!/bin/bash\n\nset -e  # 遇到错误立即退出\n\n# ==================== 配置区域 ====================\nPROJECT_NAME=\"go-layout\"\nBUILD_DIR=\"build\"\nDIST_DIR=\"${BUILD_DIR}/dist\"\nKEEP_VERSIONS=3\n\n# 默认平台（单平台构建时使用）\nDEFAULT_OS=\"linux\"\nDEFAULT_ARCH=\"amd64\"\n\n# 多平台构建列表\nPLATFORMS=(\n    \"linux/amd64\"\n    \"linux/arm64\"\n    \"darwin/amd64\"\n    \"darwin/arm64\"\n    \"windows/amd64\"\n)\n\n# 颜色输出\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m'\n\n# 生成版本号\nVERSION=$(date +\"%Y%m%d_%H%M%S\")\n\n# ==================== 工具函数 ====================\n\nusage() {\n    cat << EOF\n用法：$0 [选项]\n\n选项:\n  -o, --os <os>       目标操作系统 (linux, darwin, windows)\n  -a, --arch <arch>   目标架构 (amd64, arm64, 386)\n  -p, --platform      指定平台 (格式：os/arch，如 linux/amd64)\n  -a, --all           构建所有支持的平台\n  -n, --no-compress   不使用 UPX 压缩\n  -k, --keep <num>    保留的历史版本数量 (默认：3，0=全部清理)\n  -h, --help          显示帮助信息\n\n示例:\n  $0                              # 使用默认平台 linux/amd64\n  $0 -o linux -a arm64            # 构建 linux/arm64\n  $0 -p darwin/amd64              # 构建 darwin/amd64\n  $0 --all                        # 构建所有平台\n  $0 --all -n                     # 构建所有平台，不压缩\n  $0 --all -k 5                   # 构建所有平台，保留 5 个历史版本\n  $0 --clean-only -k 0            # 仅清理，删除所有历史版本\nEOF\n    exit 0\n}\n\n# 清理 macOS 资源分叉文件\nclean_macos_artifacts() {\n    local target_dir=\"$1\"\n    if command -v dot_clean &> /dev/null; then\n        dot_clean \"${target_dir}\" 2>/dev/null || true\n    fi\n    find \"${target_dir}\" -type f \\( -name \"._*\" -o -name \".DS_Store\" \\) -delete 2>/dev/null || true\n}\n\n# 获取文件大小（字节）\nget_file_size() {\n    stat -f%z \"$1\" 2>/dev/null || stat -c%s \"$1\" 2>/dev/null\n}\n\n# 获取人类可读的文件大小\nget_human_size() {\n    local bytes=$1\n    if [ \"$bytes\" -lt 1024 ]; then\n        echo \"${bytes}B\"\n    elif [ \"$bytes\" -lt 1048576 ]; then\n        awk \"BEGIN {printf \\\"%.1fK\\\", $bytes/1024}\"\n    else\n        awk \"BEGIN {printf \\\"%.1fM\\\", $bytes/1048576}\"\n    fi\n}\n\n# ==================== 构建流程 ====================\n\nprint_header() {\n    local os=\"$1\"\n    local arch=\"$2\"\n    echo -e \"${BLUE}==========================================\"\n    echo \"构建：${os}/${arch}\"\n    echo \"版本：${VERSION}\"\n    echo \"==========================================${NC}\"\n}\n\n# 编译 Go 二进制文件\nbuild_binary() {\n    local os=\"$1\"\n    local arch=\"$2\"\n    local dist_dir=\"$3\"\n    local binary_name=\"$4\"\n\n    echo \"正在编译...\"\n\n    local binary_file=\"${dist_dir}/${binary_name}\"\n    # Windows 平台添加.exe 后缀\n    if [ \"$os\" = \"windows\" ]; then\n        binary_file=\"${dist_dir}/${binary_name}.exe\"\n    fi\n\n    if ! CGO_ENABLED=0 GOOS=\"${os}\" GOARCH=\"${arch}\" go build \\\n        -ldflags=\"-w -s\" -trimpath -o \"${binary_file}\" .; then\n        echo -e \"${RED}编译失败：${os}/${arch}${NC}\"\n        return 1\n    fi\n\n    local size=$(get_file_size \"${binary_file}\")\n    echo \"编译完成：$(get_human_size ${size})\"\n    return 0\n}\n\n# 使用 UPX 压缩二进制文件\ncompress_with_upx() {\n    local os=\"$1\"\n    local dist_dir=\"$2\"\n    local binary_name=\"$3\"\n    local use_compress=\"$4\"\n\n    if [ \"$use_compress\" = \"false\" ]; then\n        echo \"跳过 UPX 压缩\"\n        return 0\n    fi\n\n    if ! command -v upx &> /dev/null; then\n        echo \"提示：安装 UPX 可减小二进制大小 (brew install upx)\"\n        return 0\n    fi\n\n    local binary_file=\"${dist_dir}/${binary_name}\"\n    [ \"$os\" = \"windows\" ] && binary_file=\"${dist_dir}/${binary_name}.exe\"\n\n    echo \"正在使用 UPX 压缩...\"\n    local original_size=$(get_file_size \"${binary_file}\")\n\n    if upx --best --lzma \"${binary_file}\" > /dev/null 2>&1; then\n        local compressed_size=$(get_file_size \"${binary_file}\")\n        local ratio=$(awk \"BEGIN {printf \\\"%.1f\\\", (1 - ${compressed_size}/${original_size}) * 100}\")\n        echo -e \"${GREEN}压缩完成：$(get_human_size ${compressed_size}) (压缩率：${ratio}%)${NC}\"\n    else\n        echo -e \"${YELLOW}UPX 压缩失败，使用原始二进制${NC}\"\n    fi\n}\n\n# 复制必要的资源文件\ncopy_resources() {\n    local dist_dir=\"$1\"\n\n    echo \"复制资源文件...\"\n\n    # 配置文件示例\n    [ -f \"config/config.yaml.example\" ] && cp \"config/config.yaml.example\" \"${dist_dir}/\"\n    [ -f \"AI_DEPLOYMENT.md\" ] && cp \"AI_DEPLOYMENT.md\" \"${dist_dir}/\"\n\n    # 数据库迁移文件\n    if [ -d \"data/migrations\" ]; then\n        mkdir -p \"${dist_dir}/data/migrations\"\n        rsync -av --exclude='._*' --exclude='.DS_Store' \\\n            data/migrations/ \"${dist_dir}/data/migrations/\" 2>/dev/null \\\n            || cp -r data/migrations/* \"${dist_dir}/data/migrations/\" 2>/dev/null || true\n    fi\n\n    # 权限相关文件\n    [ -f \"policy.csv\" ] && cp \"policy.csv\" \"${dist_dir}/\"\n    [ -f \"rbac_model.conf\" ] && cp \"rbac_model.conf\" \"${dist_dir}/\"\n}\n\n# 创建压缩包\ncreate_tarball() {\n    local os=\"$1\"\n    local arch=\"$2\"\n    local dist_dir=\"$3\"\n\n    local tarball_name=\"${PROJECT_NAME}_${os}_${arch}_${VERSION}.tar.gz\"\n\n    # 输出到 stderr（不污染返回值）\n    echo \"创建压缩包...\" >&2\n    cd \"${BUILD_DIR}\"\n\n    # Windows 平台使用 zip 格式\n    if [ \"$os\" = \"windows\" ]; then\n        tarball_name=\"${PROJECT_NAME}_${os}_${arch}_${VERSION}.zip\"\n        if command -v zip &> /dev/null; then\n            zip -rq \"${tarball_name}\" dist\n        else\n            echo -e \"${YELLOW}警告：zip 命令不可用，使用 tar.gz 格式${NC}\" >&2\n            tar -czf \"${tarball_name%.zip}.tar.gz\" -C dist .\n            tarball_name=\"${tarball_name%.zip}.tar.gz\"\n        fi\n    else\n        tar -czf \"${tarball_name}\" -C dist .\n    fi\n\n    cd ..\n    # 只输出文件名（用于返回值）\n    echo \"TARBALL:${tarball_name}\"\n}\n\n# 验证压缩包\nverify_tarball() {\n    local tarball=\"$1\"\n\n    echo \"验证压缩包...\"\n\n    # Windows zip 文件验证\n    if [[ \"$tarball\" == *.zip ]]; then\n        if command -v unzip &> /dev/null; then\n            unzip -l \"${tarball}\" | grep -q \"\\._\" && {\n                echo -e \"${RED}错误：压缩包中包含 macOS 资源分叉文件！${NC}\"\n                return 1\n            }\n        fi\n    else\n        if tar -tzf \"${tarball}\" | grep -q \"\\._\"; then\n            echo -e \"${RED}错误：压缩包中包含 macOS 资源分叉文件！${NC}\"\n            return 1\n        fi\n    fi\n\n    local file_count\n    if [[ \"$tarball\" == *.zip ]]; then\n        file_count=$(unzip -l \"${tarball}\" | tail -1 | awk '{print $2}')\n    else\n        file_count=$(tar -tzf \"${tarball}\" | wc -l | tr -d ' ')\n    fi\n    echo \"验证通过：共 ${file_count} 个文件\"\n    return 0\n}\n\n# 构建单个平台\nbuild_platform() {\n    local os=\"$1\"\n    local arch=\"$2\"\n    local use_compress=\"$3\"\n\n    local platform_dist=\"${DIST_DIR}/${os}_${arch}\"\n    mkdir -p \"${platform_dist}\"\n\n    print_header \"${os}\" \"${arch}\"\n\n    if ! build_binary \"${os}\" \"${arch}\" \"${platform_dist}\" \"${PROJECT_NAME}\"; then\n        return 1\n    fi\n\n    compress_with_upx \"${os}\" \"${platform_dist}\" \"${PROJECT_NAME}\" \"${use_compress}\"\n    copy_resources \"${platform_dist}\"\n\n    echo \"清理 macOS 资源分叉文件...\"\n    clean_macos_artifacts \"${platform_dist}\"\n\n    local tarball\n    tarball=$(create_tarball \"${os}\" \"${arch}\" \"${platform_dist}\")\n    tarball=\"${tarball#TARBALL:}\"\n\n    # 压缩包已在 BUILD_DIR 目录下，无需移动\n    # 清理平台临时目录\n    rm -rf \"${platform_dist}\"\n\n    if ! verify_tarball \"${BUILD_DIR}/${tarball}\"; then\n        return 1\n    fi\n\n    echo -e \"${GREEN}构建完成：${tarball}${NC}\"\n    echo \"\"\n    return 0\n}\n\n# 清理旧版本\ncleanup_old_versions() {\n    local keep_count=\"${1:-$KEEP_VERSIONS}\"\n\n    if [ \"$keep_count\" -eq 0 ]; then\n        echo \"清理所有历史版本...\"\n        rm -f \"${BUILD_DIR}\"/${PROJECT_NAME}_*_*.tar.gz \"${BUILD_DIR}\"/${PROJECT_NAME}_*_*.zip 2>/dev/null || true\n        echo \"已删除所有历史版本\"\n        return\n    fi\n\n    echo \"清理旧版本（保留最新${keep_count}个）...\"\n\n    for platform in \"${PLATFORMS[@]}\"; do\n        local os=\"${platform%/*}\"\n        local arch=\"${platform#*/}\"\n        local pattern=\"${PROJECT_NAME}_${os}_${arch}_*\"\n\n        local all_versions=($(ls -t \"${BUILD_DIR}\"/${pattern}.tar.gz \"${BUILD_DIR}\"/${pattern}.zip 2>/dev/null || true))\n        local count=${#all_versions[@]}\n\n        if [ \"${count}\" -le \"${keep_count}\" ]; then\n            continue\n        fi\n\n        local delete_count=$((count - keep_count))\n        for ((i=keep_count; i<count; i++)); do\n            echo \"  删除：$(basename \"${all_versions[$i]}\")\"\n            rm -f \"${all_versions[$i]}\"\n        done\n    done\n    echo \"旧版本清理完成\"\n}\n\n# ==================== 参数解析 ====================\n\nBUILD_ALL=false\nUSE_COMPRESS=true\nTARGET_OS=\"\"\nTARGET_ARCH=\"\"\nPLATFORM=\"\"\nCLEAN_ONLY=false\nKEEP_COUNT=3\n\nwhile [[ $# -gt 0 ]]; do\n    case $1 in\n        -o|--os)\n            TARGET_OS=\"$2\"\n            shift 2\n            ;;\n        -a|--arch)\n            TARGET_ARCH=\"$2\"\n            shift 2\n            ;;\n        -p|--platform)\n            PLATFORM=\"$2\"\n            shift 2\n            ;;\n        --all|-all)\n            BUILD_ALL=true\n            shift\n            ;;\n        -n|--no-compress)\n            USE_COMPRESS=false\n            shift\n            ;;\n        -k|--keep)\n            KEEP_COUNT=\"$2\"\n            shift 2\n            ;;\n        --clean-only)\n            CLEAN_ONLY=true\n            shift\n            ;;\n        -h|--help)\n            usage\n            ;;\n        *)\n            echo -e \"${RED}未知选项：$1${NC}\"\n            usage\n            ;;\n    esac\ndone\n\n# ==================== 主流程 ====================\n\nmain() {\n    # 仅清理模式\n    if [ \"$CLEAN_ONLY\" = true ]; then\n        echo \"==========================================\"\n        echo \"清理历史版本\"\n        echo \"==========================================\"\n        cleanup_old_versions \"${KEEP_COUNT}\"\n        if [ \"$KEEP_COUNT\" -eq 0 ]; then\n            echo \"所有历史版本已清理完成\"\n        else\n            echo \"旧版本清理完成（保留最新${KEEP_COUNT}个）\"\n        fi\n        ls -lh \"${BUILD_DIR}\"/*.tar.gz \"${BUILD_DIR}\"/*.zip 2>/dev/null || echo \"无构建文件\"\n        return\n    fi\n\n    echo \"==========================================\"\n    echo \"Go 项目构建脚本\"\n    echo \"版本：${VERSION}\"\n    echo \"==========================================\"\n\n    # 准备构建目录\n    echo \"清理旧的构建文件...\"\n    rm -rf \"${DIST_DIR}\"\n    mkdir -p \"${DIST_DIR}\"\n\n    if [ \"$BUILD_ALL\" = true ]; then\n        # 多平台构建\n        echo -e \"${BLUE}开始多平台构建...${NC}\"\n\n        for platform in \"${PLATFORMS[@]}\"; do\n            local os=\"${platform%/*}\"\n            local arch=\"${platform#*/}\"\n\n            if ! build_platform \"${os}\" \"${arch}\" \"${USE_COMPRESS}\"; then\n                echo -e \"${YELLOW}跳过失败的平台：${platform}${NC}\"\n            fi\n        done\n\n        cleanup_old_versions \"${KEEP_COUNT}\"\n\n        echo \"==========================================\"\n        echo -e \"${GREEN}所有平台构建完成！${NC}\"\n        echo \"输出目录：${BUILD_DIR}/\"\n        ls -lh \"${BUILD_DIR}\"/*.tar.gz \"${BUILD_DIR}\"/*.zip 2>/dev/null || true\n        echo \"==========================================\"\n\n    elif [ -n \"$PLATFORM\" ]; then\n        # 指定平台构建\n        if [[ ! \"$PLATFORM\" =~ ^[a-z0-9]+/[a-z0-9]+$ ]]; then\n            echo -e \"${RED}错误：平台格式不正确，应为 os/arch 格式${NC}\"\n            exit 1\n        fi\n\n        local os=\"${PLATFORM%/*}\"\n        local arch=\"${PLATFORM#*/}\"\n        build_platform \"${os}\" \"${arch}\" \"${USE_COMPRESS}\"\n\n        echo \"==========================================\"\n        echo -e \"${GREEN}构建完成！${NC}\"\n        ls -lh \"${BUILD_DIR}\"/*.tar.gz \"${BUILD_DIR}\"/*.zip 2>/dev/null || true\n        echo \"==========================================\"\n\n    else\n        # 单平台构建（使用默认或命令行指定）\n        local os=\"${TARGET_OS:-$DEFAULT_OS}\"\n        local arch=\"${TARGET_ARCH:-$DEFAULT_ARCH}\"\n        build_platform \"${os}\" \"${arch}\" \"${USE_COMPRESS}\"\n\n        echo \"==========================================\"\n        echo -e \"${GREEN}构建完成！${NC}\"\n        ls -lh \"${BUILD_DIR}\"/*.tar.gz \"${BUILD_DIR}\"/*.zip 2>/dev/null || true\n        echo \"==========================================\"\n    fi\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "cmd/.gitignore",
    "content": "!.gitignore\ngo-layout"
  },
  {
    "path": "cmd/bootstrapx/bootstrap.go",
    "content": "package bootstrapx\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\ttaskcron \"github.com/wannanbigpig/gin-layout/internal/cron\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/sys_config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/system\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"go.uber.org/zap\"\n)\n\nconst errorLoadingLocation = \"Error loading location: %v\"\n\n// Requirements 描述命令运行前需要初始化的依赖。\ntype Requirements struct {\n\tData                 bool\n\tValidator            bool\n\tQueue                bool\n\tAllowDegradedStartup bool\n}\n\nvar (\n\tinitializeDataFunc      = InitializeData\n\tinitializeValidatorFunc = InitializeValidator\n\tinitializeQueueFunc     = InitializeQueue\n)\n\n// InitializeConfig 初始化配置。\nfunc InitializeConfig(configPath string) error {\n\treturn config.InitConfig(configPath)\n}\n\n// InitializeTimezone 根据配置设置进程时区。\nfunc InitializeTimezone() {\n\tcfg := config.GetConfig()\n\tif cfg.Timezone == nil {\n\t\treturn\n\t}\n\n\tlocation, err := time.LoadLocation(*cfg.Timezone)\n\tif err != nil {\n\t\tif log.Logger != nil {\n\t\t\tlog.Logger.Error(fmt.Sprintf(errorLoadingLocation, err), zap.Error(err))\n\t\t}\n\t\tfmt.Printf(errorLoadingLocation+\"\\n\", err)\n\t\treturn\n\t}\n\ttime.Local = location\n}\n\n// InitializeLogger 初始化全局日志组件。\nfunc InitializeLogger() error {\n\treturn log.InitLogger()\n}\n\n// InitializeData 初始化数据源依赖。\nfunc InitializeData() error {\n\tif err := data.InitData(); err != nil {\n\t\treturn err\n\t}\n\ttaskcron.RegisterHandler(taskcron.HandlerCronResetSystemData, func(ctx context.Context, payload map[string]any) error {\n\t\t_ = ctx\n\t\t_ = payload\n\t\treturn system.ReinitializeSystemData()\n\t})\n\tif err := sys_config.NewSysConfigService().WarmupRuntimeConfigIfAvailable(); err != nil {\n\t\treturn err\n\t}\n\treturn taskcron.SyncBuiltinDefinitionsIfAvailable(config.GetConfig())\n}\n\n// InitializeValidator 初始化参数校验器。\nfunc InitializeValidator() error {\n\treturn validator.InitValidatorTrans(\"zh\")\n}\n\n// WrapCommand 为命令注入统一的初始化逻辑，并保留原有 PreRunE/RunE。\nfunc WrapCommand(cmd *cobra.Command, req Requirements) *cobra.Command {\n\tif cmd == nil {\n\t\treturn cmd\n\t}\n\n\toriginalPreRunE := cmd.PreRunE\n\tcmd.PreRunE = func(c *cobra.Command, args []string) error {\n\t\tif req.Data {\n\t\t\tif err := initializeDataFunc(); err != nil {\n\t\t\t\tif shouldAllowDegradedStartup(req) {\n\t\t\t\t\tlogDependencyInitWarning(c, \"data\", err)\n\t\t\t\t} else {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif req.Validator {\n\t\t\tif err := initializeValidatorFunc(); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif req.Queue {\n\t\t\tif err := initializeQueueFunc(); err != nil {\n\t\t\t\tif shouldAllowDegradedStartup(req) {\n\t\t\t\t\tlogDependencyInitWarning(c, \"queue\", err)\n\t\t\t\t} else {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif originalPreRunE != nil {\n\t\t\treturn originalPreRunE(c, args)\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn cmd\n}\n\n// InitializeQueue 初始化队列发布者。\nfunc InitializeQueue() error {\n\tcfg := config.GetConfig()\n\tif !cfg.Queue.Enable {\n\t\treturn nil\n\t}\n\tif err := queue.InitPublisher(cfg); err != nil {\n\t\treturn err\n\t}\n\treturn queue.InitInspector(cfg)\n}\n\nfunc shouldAllowDegradedStartup(req Requirements) bool {\n\tif !req.AllowDegradedStartup {\n\t\treturn false\n\t}\n\tcfg := config.GetConfig()\n\treturn cfg != nil && cfg.AllowDegradedStartup\n}\n\nfunc logDependencyInitWarning(cmd *cobra.Command, dependency string, err error) {\n\tif err == nil {\n\t\treturn\n\t}\n\tcommandPath := \"\"\n\tif cmd != nil {\n\t\tcommandPath = cmd.CommandPath()\n\t}\n\tif log.Logger != nil {\n\t\tlog.Logger.Warn(\"Dependency initialization failed, continue with degraded startup\",\n\t\t\tzap.String(\"command\", commandPath),\n\t\t\tzap.String(\"dependency\", dependency),\n\t\t\tzap.Error(err))\n\t\treturn\n\t}\n\tfmt.Printf(\"warning: dependency initialization failed, continue with degraded startup; command=%s dependency=%s err=%v\\n\", commandPath, dependency, err)\n}\n"
  },
  {
    "path": "cmd/bootstrapx/bootstrap_test.go",
    "content": "package bootstrapx\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/spf13/cobra\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n)\n\nfunc TestWrapCommandReturnsDataErrorWhenDegradedStartupDisabled(t *testing.T) {\n\trestoreInit := stubBootstrapInitializers(\n\t\tfunc() error { return errTestDataInit },\n\t\tfunc() error { return nil },\n\t\tfunc() error { return nil },\n\t)\n\tdefer restoreInit()\n\n\trestoreConfig := setAllowDegradedStartup(t, false)\n\tdefer restoreConfig()\n\n\tcmd := WrapCommand(&cobra.Command{Use: \"service\"}, Requirements{\n\t\tData:                 true,\n\t\tAllowDegradedStartup: true,\n\t})\n\n\terr := cmd.PreRunE(cmd, nil)\n\tif !errors.Is(err, errTestDataInit) {\n\t\tt.Fatalf(\"expected data init error, got %v\", err)\n\t}\n}\n\nfunc TestWrapCommandAllowsDataAndQueueErrorsWhenEnabled(t *testing.T) {\n\tdataCalled := false\n\tvalidatorCalled := false\n\tqueueCalled := false\n\toriginalCalled := false\n\n\trestoreInit := stubBootstrapInitializers(\n\t\tfunc() error {\n\t\t\tdataCalled = true\n\t\t\treturn errTestDataInit\n\t\t},\n\t\tfunc() error {\n\t\t\tvalidatorCalled = true\n\t\t\treturn nil\n\t\t},\n\t\tfunc() error {\n\t\t\tqueueCalled = true\n\t\t\treturn errTestQueueInit\n\t\t},\n\t)\n\tdefer restoreInit()\n\n\trestoreConfig := setAllowDegradedStartup(t, true)\n\tdefer restoreConfig()\n\n\tcmd := WrapCommand(&cobra.Command{\n\t\tUse: \"service\",\n\t\tPreRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\toriginalCalled = true\n\t\t\treturn nil\n\t\t},\n\t}, Requirements{\n\t\tData:                 true,\n\t\tValidator:            true,\n\t\tQueue:                true,\n\t\tAllowDegradedStartup: true,\n\t})\n\n\tif err := cmd.PreRunE(cmd, nil); err != nil {\n\t\tt.Fatalf(\"expected degraded startup to continue, got %v\", err)\n\t}\n\tif !dataCalled || !validatorCalled || !queueCalled {\n\t\tt.Fatalf(\"expected all initializers to run, got data=%v validator=%v queue=%v\", dataCalled, validatorCalled, queueCalled)\n\t}\n\tif !originalCalled {\n\t\tt.Fatal(\"expected original PreRunE to be called\")\n\t}\n}\n\nfunc TestWrapCommandKeepsStrictModeForCommandsWithoutOptIn(t *testing.T) {\n\trestoreInit := stubBootstrapInitializers(\n\t\tfunc() error { return errTestDataInit },\n\t\tfunc() error { return nil },\n\t\tfunc() error { return nil },\n\t)\n\tdefer restoreInit()\n\n\trestoreConfig := setAllowDegradedStartup(t, true)\n\tdefer restoreConfig()\n\n\tcmd := WrapCommand(&cobra.Command{Use: \"cron\"}, Requirements{\n\t\tData: true,\n\t})\n\n\terr := cmd.PreRunE(cmd, nil)\n\tif !errors.Is(err, errTestDataInit) {\n\t\tt.Fatalf(\"expected strict command to return data init error, got %v\", err)\n\t}\n}\n\nfunc TestWrapCommandKeepsValidatorStrictEvenWhenDegradedStartupEnabled(t *testing.T) {\n\trestoreInit := stubBootstrapInitializers(\n\t\tfunc() error { return errTestDataInit },\n\t\tfunc() error { return errTestValidatorInit },\n\t\tfunc() error { return nil },\n\t)\n\tdefer restoreInit()\n\n\trestoreConfig := setAllowDegradedStartup(t, true)\n\tdefer restoreConfig()\n\n\tcmd := WrapCommand(&cobra.Command{Use: \"service\"}, Requirements{\n\t\tData:                 true,\n\t\tValidator:            true,\n\t\tAllowDegradedStartup: true,\n\t})\n\n\terr := cmd.PreRunE(cmd, nil)\n\tif !errors.Is(err, errTestValidatorInit) {\n\t\tt.Fatalf(\"expected validator init error, got %v\", err)\n\t}\n}\n\nvar (\n\terrTestDataInit      = errors.New(\"data init failed\")\n\terrTestQueueInit     = errors.New(\"queue init failed\")\n\terrTestValidatorInit = errors.New(\"validator init failed\")\n)\n\nfunc stubBootstrapInitializers(dataFn, validatorFn, queueFn func() error) func() {\n\tpreviousData := initializeDataFunc\n\tpreviousValidator := initializeValidatorFunc\n\tpreviousQueue := initializeQueueFunc\n\tinitializeDataFunc = dataFn\n\tinitializeValidatorFunc = validatorFn\n\tinitializeQueueFunc = queueFn\n\n\treturn func() {\n\t\tinitializeDataFunc = previousData\n\t\tinitializeValidatorFunc = previousValidator\n\t\tinitializeQueueFunc = previousQueue\n\t}\n}\n\nfunc setAllowDegradedStartup(t *testing.T, enabled bool) func() {\n\tt.Helper()\n\n\toriginalLogger := log.Logger\n\trestoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {\n\t\tcfg.AllowDegradedStartup = enabled\n\t})\n\tlog.Logger = zap.NewNop()\n\n\treturn func() {\n\t\trestoreConfig()\n\t\tlog.Logger = originalLogger\n\t}\n}\n"
  },
  {
    "path": "cmd/command/command.go",
    "content": "package command\n\nimport (\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/wannanbigpig/gin-layout/cmd/bootstrapx\"\n\t\"github.com/wannanbigpig/gin-layout/internal/console/demo\"\n\tinitconsole \"github.com/wannanbigpig/gin-layout/internal/console/init\"\n\tmigrateconsole \"github.com/wannanbigpig/gin-layout/internal/console/migrate\"\n\t\"github.com/wannanbigpig/gin-layout/internal/console/system_init\"\n\ttaskconsole \"github.com/wannanbigpig/gin-layout/internal/console/task\"\n)\n\nvar (\n\tCmd = &cobra.Command{\n\t\tUse:     \"command\",\n\t\tShort:   \"The control head runs the command\",\n\t\tExample: \"go-layout command demo\",\n\t}\n)\n\nfunc init() {\n\tregisterSubCommands()\n}\n\n// registerSubCommands 注册子命令\nfunc registerSubCommands() {\n\t// 一次性运行脚本\n\tCmd.AddCommand(demo.Cmd)\n\tCmd.AddCommand(bootstrapx.WrapCommand(initconsole.ApiRouteCmd, bootstrapx.Requirements{Data: true}))               // 初始化API路由表: go-layout command api-route\n\tCmd.AddCommand(bootstrapx.WrapCommand(initconsole.RebuildUserPermissionsCmd, bootstrapx.Requirements{Data: true})) // 重建用户最终 API 权限: go-layout command rebuild-user-permissions\n\tCmd.AddCommand(bootstrapx.WrapCommand(system_init.InitSystemCmd, bootstrapx.Requirements{Data: true}))             // 初始化系统: go-layout command init-system\n\tCmd.AddCommand(migrateconsole.Cmd)                                                                                 // 迁移管理: go-layout command migrate up / down / create / check\n\tCmd.AddCommand(bootstrapx.WrapCommand(taskconsole.Cmd, bootstrapx.Requirements{Data: true}))                       // 任务扫描: go-layout command task scan-async\n}\n"
  },
  {
    "path": "cmd/completion.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n)\n\nvar completionNoDesc bool\n\nvar completionCmd = &cobra.Command{\n\tUse:   \"completion [bash|zsh|fish|powershell]\",\n\tShort: \"Generate shell completion scripts\",\n\tLong: `Generate shell completion scripts for go-layout.\n\nLoad examples:\n  bash:       source <(go-layout completion bash)\n  zsh:        source <(go-layout completion zsh)\n  fish:       go-layout completion fish | source\n  powershell: go-layout completion powershell | Out-String | Invoke-Expression`,\n\tArgs: cobra.ExactArgs(1),\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\tswitch args[0] {\n\t\tcase \"bash\":\n\t\t\treturn rootCmd.GenBashCompletionV2(os.Stdout, !completionNoDesc)\n\t\tcase \"zsh\":\n\t\t\treturn rootCmd.GenZshCompletion(os.Stdout)\n\t\tcase \"fish\":\n\t\t\treturn rootCmd.GenFishCompletion(os.Stdout, !completionNoDesc)\n\t\tcase \"powershell\":\n\t\t\treturn rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"unsupported shell type %q\", args[0])\n\t\t}\n\t},\n}\n\nfunc init() {\n\tcompletionCmd.Flags().BoolVar(&completionNoDesc, \"no-descriptions\", false, \"Disable completion descriptions where supported\")\n}\n"
  },
  {
    "path": "cmd/cron/cron.go",
    "content": "package cron\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\n\t\"github.com/robfig/cron/v3\"\n\t\"github.com/spf13/cobra\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/cmd/bootstrapx\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n)\n\nvar (\n\tCmd = bootstrapx.WrapCommand(&cobra.Command{\n\t\tUse:     \"cron\",\n\t\tShort:   \"Starting a scheduled task\",\n\t\tExample: \"go-layout cron\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn Start()\n\t\t},\n\t}, bootstrapx.Requirements{Data: true})\n)\n\n// Start 启动定时任务服务\nfunc Start() error {\n\tcrontab, err := newScheduler()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := registerTasks(crontab); err != nil {\n\t\tlog.Logger.Error(\"定时任务启动失败\", zap.Error(err))\n\t\treturn fmt.Errorf(\"定时任务启动失败: %w\", err)\n\t}\n\n\t// 启动定时器\n\tcrontab.Start()\n\n\tlog.Logger.Info(\"Cron service started successfully\")\n\n\t// 优雅关闭\n\twaitForShutdown()\n\tstopCtx := crontab.Stop()\n\t<-stopCtx.Done()\n\tif err := data.Shutdown(); err != nil {\n\t\treturn fmt.Errorf(\"shutdown data resources failed: %w\", err)\n\t}\n\tlog.Logger.Info(\"Cron service stopped gracefully\")\n\treturn nil\n}\n\nfunc newScheduler() (*cron.Cron, error) {\n\tlogger := &cronLogger{}\n\tscheduler := cron.New(\n\t\tcron.WithSeconds(),\n\t\tcron.WithChain(cron.Recover(logger)),\n\t)\n\tif scheduler == nil {\n\t\treturn nil, fmt.Errorf(\"创建定时任务调度器失败\")\n\t}\n\treturn scheduler, nil\n}\n\n// waitForShutdown 等待关闭信号，实现优雅关闭\nfunc waitForShutdown() {\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\tgo handleSignals(cancel)\n\t<-ctx.Done()\n}\n\n// handleSignals 处理系统信号（SIGINT、SIGTERM）\nfunc handleSignals(cancel context.CancelFunc) {\n\tsigChan := make(chan os.Signal, 1)\n\tsignal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)\n\n\tsig := <-sigChan\n\tlog.Logger.Warn(\"Received shutdown signal\", zap.String(\"signal\", sig.String()))\n\tcancel()\n}\n\n// cronLogger 定时任务日志记录器\ntype cronLogger struct{}\n\n// Info 记录信息日志\nfunc (cl *cronLogger) Info(msg string, keysAndValues ...interface{}) {\n\tif len(keysAndValues) > 0 {\n\t\tlog.Logger.Info(fmt.Sprintf(msg, keysAndValues...))\n\t} else {\n\t\tlog.Logger.Info(msg)\n\t}\n}\n\n// Error 记录错误日志\nfunc (cl *cronLogger) Error(err error, msg string, keysAndValues ...interface{}) {\n\terrorMsg := err.Error()\n\tif len(keysAndValues) > 0 {\n\t\terrorMsg += \" \" + fmt.Sprintf(msg, keysAndValues...)\n\t} else if msg != \"\" {\n\t\terrorMsg += \" \" + msg\n\t}\n\tlog.Logger.Error(errorMsg, zap.Error(err))\n}\n"
  },
  {
    "path": "cmd/cron/schedule.go",
    "content": "package cron\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/robfig/cron/v3\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/taskcenter\"\n)\n\n// Scheduler 提供链式的任务注册方式。\ntype Scheduler struct {\n\tlogger *cronLogger\n\ttasks  []*scheduledTask\n}\n\ntype scheduledTask struct {\n\tname      string\n\tspec      string\n\tspecErr   error\n\trun       func() error\n\tskipIfRun bool\n}\n\n// TaskBuilder 用于链式声明任务调度规则。\ntype TaskBuilder struct {\n\ttask *scheduledTask\n}\n\nvar cronSpecParser = cron.NewParser(\n\tcron.Second |\n\t\tcron.Minute |\n\t\tcron.Hour |\n\t\tcron.Dom |\n\t\tcron.Month |\n\t\tcron.Dow |\n\t\tcron.Descriptor,\n)\n\nfunc registerTasks(crontab *cron.Cron) error {\n\tlogger := &cronLogger{}\n\tschedule := NewSchedule(logger)\n\tdefineSchedule(schedule)\n\treturn schedule.Register(crontab)\n}\n\n// NewSchedule 创建任务声明器。\nfunc NewSchedule(logger *cronLogger) *Scheduler {\n\treturn &Scheduler{\n\t\tlogger: logger,\n\t\ttasks:  make([]*scheduledTask, 0, 4),\n\t}\n}\n\n// Call 注册一个函数任务，默认启用防重入。\nfunc (s *Scheduler) Call(name string, fn func()) *TaskBuilder {\n\ttask := &scheduledTask{\n\t\tname:      name,\n\t\trun:       func() error { fn(); return nil },\n\t\tskipIfRun: true,\n\t}\n\ts.tasks = append(s.tasks, task)\n\treturn &TaskBuilder{task: task}\n}\n\n// CallE 注册一个返回 error 的函数任务。\nfunc (s *Scheduler) CallE(name string, fn func() error) *TaskBuilder {\n\ttask := &scheduledTask{\n\t\tname:      name,\n\t\trun:       fn,\n\t\tskipIfRun: true,\n\t}\n\ts.tasks = append(s.tasks, task)\n\treturn &TaskBuilder{task: task}\n}\n\n// Cron 直接使用 cron 表达式。\nfunc (b *TaskBuilder) Cron(spec string) *TaskBuilder {\n\tb.task.spec = spec\n\treturn b\n}\n\n// EveryFiveSeconds 每 5 秒执行一次，适合本地测试任务。\nfunc (b *TaskBuilder) EveryFiveSeconds() *TaskBuilder {\n\treturn b.Cron(\"0/5 * * * * *\")\n}\n\n// DailyAt 每天固定时间执行，支持 HH:MM 或 HH:MM:SS。\nfunc (b *TaskBuilder) DailyAt(value string) *TaskBuilder {\n\tspec, err := dailyAtSpec(value)\n\tif err != nil {\n\t\tb.task.specErr = err\n\t\treturn b\n\t}\n\tb.task.spec = spec\n\treturn b\n}\n\n// WithoutOverlapping 表示任务执行期间跳过重入。\nfunc (b *TaskBuilder) WithoutOverlapping() *TaskBuilder {\n\tb.task.skipIfRun = true\n\treturn b\n}\n\n// AllowOverlap 允许任务重入。\nfunc (b *TaskBuilder) AllowOverlap() *TaskBuilder {\n\tb.task.skipIfRun = false\n\treturn b\n}\n\n// Register 把声明过的任务统一注册到 cron 实例中。\nfunc (s *Scheduler) Register(crontab *cron.Cron) error {\n\tfor _, task := range s.tasks {\n\t\tif err := s.registerTask(crontab, task); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *Scheduler) registerTask(crontab *cron.Cron, task *scheduledTask) error {\n\tif task.specErr != nil {\n\t\treturn fmt.Errorf(\"定时任务 %s 调度表达式无效: %w\", task.name, task.specErr)\n\t}\n\tif task.spec == \"\" {\n\t\treturn fmt.Errorf(\"定时任务 %s 缺少调度表达式\", task.name)\n\t}\n\n\tchain := cron.NewChain(cron.Recover(s.logger))\n\tif task.skipIfRun {\n\t\tchain = cron.NewChain(\n\t\t\tcron.SkipIfStillRunning(s.logger),\n\t\t\tcron.Recover(s.logger),\n\t\t)\n\t}\n\n\tif _, err := crontab.AddJob(task.spec, chain.Then(s.recordedJob(task))); err != nil {\n\t\treturn fmt.Errorf(\"添加定时任务失败 [%s] (schedule: %s): %w\", task.name, task.spec, err)\n\t}\n\n\tlog.Logger.Info(\"定时任务添加成功\",\n\t\tzap.String(\"name\", task.name),\n\t\tzap.String(\"schedule\", task.spec),\n\t\tzap.Bool(\"skip_if_still_running\", task.skipIfRun),\n\t)\n\treturn nil\n}\n\nfunc (s *Scheduler) recordedJob(task *scheduledTask) cron.Job {\n\treturn cron.FuncJob(func() {\n\t\tif task == nil || task.run == nil {\n\t\t\treturn\n\t\t}\n\n\t\tctx := context.Background()\n\t\ttaskCode := \"cron:\" + task.name\n\t\trun, recordErr := taskcenter.NewRunRecorder().Start(ctx, taskcenter.RunStart{\n\t\t\tTaskCode: taskCode,\n\t\t\tKind:     model.TaskKindCron,\n\t\t\tSource:   model.TaskSourceCron,\n\t\t\tSourceID: task.name,\n\t\t\tCronSpec: task.spec,\n\t\t})\n\t\tif recordErr != nil {\n\t\t\tlog.Logger.Warn(\"记录定时任务开始失败\",\n\t\t\t\tzap.String(\"name\", task.name),\n\t\t\t\tzap.Error(recordErr))\n\t\t}\n\n\t\terr := task.run()\n\t\tif err != nil {\n\t\t\tlog.Logger.Error(\"定时任务执行失败\",\n\t\t\t\tzap.String(\"name\", task.name),\n\t\t\t\tzap.Error(err))\n\t\t}\n\n\t\tif run == nil {\n\t\t\treturn\n\t\t}\n\t\tfinishInput := taskcenter.RunFinish{Error: err}\n\t\tnextRunAt, nextRunErr := calculateNextRunAt(task.spec, time.Now())\n\t\tif nextRunErr != nil {\n\t\t\tlog.Logger.Warn(\"计算定时任务下次执行时间失败\",\n\t\t\t\tzap.String(\"name\", task.name),\n\t\t\t\tzap.String(\"schedule\", task.spec),\n\t\t\t\tzap.Error(nextRunErr))\n\t\t}\n\t\tfinishInput.NextRunAt = nextRunAt\n\t\tif recordErr := taskcenter.NewRunRecorder().Finish(ctx, run, finishInput); recordErr != nil {\n\t\t\tlog.Logger.Warn(\"记录定时任务结束失败\",\n\t\t\t\tzap.String(\"name\", task.name),\n\t\t\t\tzap.Error(recordErr))\n\t\t}\n\t})\n}\n\nfunc calculateNextRunAt(spec string, base time.Time) (*time.Time, error) {\n\tspec = strings.TrimSpace(spec)\n\tif spec == \"\" {\n\t\treturn nil, nil\n\t}\n\n\tschedule, err := cronSpecParser.Parse(spec)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tnextRunAt := schedule.Next(base)\n\tif nextRunAt.IsZero() {\n\t\treturn nil, nil\n\t}\n\treturn &nextRunAt, nil\n}\n\nfunc dailyAtSpec(value string) (string, error) {\n\tparts := strings.Split(value, \":\")\n\tswitch len(parts) {\n\tcase 2:\n\t\thour, err := parseTimePart(\"hour\", parts[0], 0, 23)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tminute, err := parseTimePart(\"minute\", parts[1], 0, 59)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\treturn fmt.Sprintf(\"0 %d %d * * *\", minute, hour), nil\n\tcase 3:\n\t\thour, err := parseTimePart(\"hour\", parts[0], 0, 23)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tminute, err := parseTimePart(\"minute\", parts[1], 0, 59)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tsecond, err := parseTimePart(\"second\", parts[2], 0, 59)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\treturn fmt.Sprintf(\"%d %d %d * * *\", second, minute, hour), nil\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"invalid daily time format: %s\", value)\n\t}\n}\n\nfunc parseTimePart(name, raw string, min, max int) (int, error) {\n\traw = strings.TrimSpace(raw)\n\tvalue, err := strconv.Atoi(raw)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"invalid %s value %q\", name, raw)\n\t}\n\tif value < min || value > max {\n\t\treturn 0, fmt.Errorf(\"%s value out of range [%d,%d]: %d\", name, min, max, value)\n\t}\n\treturn value, nil\n}\n"
  },
  {
    "path": "cmd/cron/schedule_test.go",
    "content": "package cron\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/robfig/cron/v3\"\n)\n\nfunc TestDailyAtSpecWithHHMM(t *testing.T) {\n\tspec, err := dailyAtSpec(\"02:30\")\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif spec != \"0 30 2 * * *\" {\n\t\tt.Fatalf(\"unexpected cron spec: %s\", spec)\n\t}\n}\n\nfunc TestDailyAtSpecWithHHMMSS(t *testing.T) {\n\tspec, err := dailyAtSpec(\"03:04:05\")\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif spec != \"5 4 3 * * *\" {\n\t\tt.Fatalf(\"unexpected cron spec: %s\", spec)\n\t}\n}\n\nfunc TestDailyAtSpecRejectsInvalidInput(t *testing.T) {\n\t_, err := dailyAtSpec(\"invalid\")\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid input to return error\")\n\t}\n}\n\nfunc TestDailyAtSpecRejectsOutOfRange(t *testing.T) {\n\t_, err := dailyAtSpec(\"25:00\")\n\tif err == nil {\n\t\tt.Fatal(\"expected out of range input to return error\")\n\t}\n}\n\nfunc TestSchedulerRegisterReturnsDailyAtError(t *testing.T) {\n\tschedule := NewSchedule(&cronLogger{})\n\tschedule.Call(\"bad-task\", func() {}).DailyAt(\"bad-time\")\n\n\tc := cron.New(cron.WithSeconds())\n\terr := schedule.Register(c)\n\tif err == nil {\n\t\tt.Fatal(\"expected register to return error for invalid daily time\")\n\t}\n\tif !strings.Contains(err.Error(), \"调度表达式无效\") {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "cmd/cron/task_record_test.go",
    "content": "package cron\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/taskcenter\"\n)\n\nfunc TestRecordedJobRecordsCallEFailure(t *testing.T) {\n\tfake := &fakeCronRunRecorder{}\n\trestore := taskcenter.SetRecorderForTesting(fake)\n\tdefer restore()\n\n\texpectedErr := errors.New(\"cron failed\")\n\tschedule := NewSchedule(&cronLogger{})\n\tbuilder := schedule.CallE(\"demo\", func() error {\n\t\treturn expectedErr\n\t}).EveryFiveSeconds()\n\n\tstartAt := time.Now()\n\tschedule.recordedJob(builder.task).Run()\n\n\tif len(fake.starts) != 1 {\n\t\tt.Fatalf(\"expected 1 start call, got %d\", len(fake.starts))\n\t}\n\tstart := fake.starts[0]\n\tif start.TaskCode != \"cron:demo\" || start.Kind != model.TaskKindCron || start.Source != model.TaskSourceCron {\n\t\tt.Fatalf(\"unexpected start input: %#v\", start)\n\t}\n\tif start.CronSpec != \"0/5 * * * * *\" {\n\t\tt.Fatalf(\"unexpected cron spec: %s\", start.CronSpec)\n\t}\n\tif len(fake.finishes) != 1 {\n\t\tt.Fatalf(\"expected 1 finish call, got %d\", len(fake.finishes))\n\t}\n\tif !errors.Is(fake.finishes[0].Error, expectedErr) {\n\t\tt.Fatalf(\"unexpected finish error: %v\", fake.finishes[0].Error)\n\t}\n\tif fake.finishes[0].NextRunAt == nil {\n\t\tt.Fatal(\"expected finish next run at to be set\")\n\t}\n\tif !fake.finishes[0].NextRunAt.After(startAt) {\n\t\tt.Fatalf(\"expected next run at after start time, got %s\", fake.finishes[0].NextRunAt.Format(time.DateTime))\n\t}\n}\n\nfunc TestCalculateNextRunAtInvalidSpec(t *testing.T) {\n\tnextRunAt, err := calculateNextRunAt(\"bad spec\", time.Now())\n\tif err == nil {\n\t\tt.Fatal(\"expected parse error for invalid cron spec\")\n\t}\n\tif nextRunAt != nil {\n\t\tt.Fatalf(\"expected nil next run at on parse error, got %v\", nextRunAt)\n\t}\n}\n\ntype fakeCronRunRecorder struct {\n\tstarts   []taskcenter.RunStart\n\tfinishes []taskcenter.RunFinish\n}\n\nfunc (f *fakeCronRunRecorder) Enqueue(ctx context.Context, input taskcenter.RunStart) (*model.TaskRun, error) {\n\t_ = ctx\n\tf.starts = append(f.starts, input)\n\treturn &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.starts))}, TaskCode: input.TaskCode, Source: input.Source}, nil\n}\n\nfunc (f *fakeCronRunRecorder) Start(ctx context.Context, input taskcenter.RunStart) (*model.TaskRun, error) {\n\t_ = ctx\n\tf.starts = append(f.starts, input)\n\treturn &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.starts))}, TaskCode: input.TaskCode, Source: input.Source}, nil\n}\n\nfunc (f *fakeCronRunRecorder) Finish(ctx context.Context, run *model.TaskRun, input taskcenter.RunFinish) error {\n\t_ = ctx\n\t_ = run\n\tf.finishes = append(f.finishes, input)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/cron/tasks.go",
    "content": "package cron\n\nimport (\n\t\"context\"\n\t\"strings\"\n\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\ttaskcron \"github.com/wannanbigpig/gin-layout/internal/cron\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n)\n\nfunc defineSchedule(schedule *Scheduler) {\n\tcfg := config.GetConfig()\n\tfor _, definition := range taskcron.BuiltinTaskDefinitions(cfg) {\n\t\tif definition.Kind != model.TaskKindCron || definition.Status != model.TaskStatusEnabled {\n\t\t\tcontinue\n\t\t}\n\t\tif strings.TrimSpace(definition.Code) == \"\" || strings.TrimSpace(definition.CronSpec) == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\ttaskName := taskNameFromCode(definition.Code)\n\t\thandler := definition.Handler\n\t\tif definition.IsHighRisk == model.TaskHighRisk {\n\t\t\tlog.Logger.Warn(\"高风险定时任务已启用\",\n\t\t\t\tzap.String(\"name\", taskName),\n\t\t\t\tzap.String(\"schedule\", definition.CronSpec),\n\t\t\t)\n\t\t}\n\t\tschedule.CallE(taskName, func() error {\n\t\t\treturn taskcron.ExecuteHandler(context.Background(), handler, nil)\n\t\t}).\n\t\t\tCron(definition.CronSpec).\n\t\t\tWithoutOverlapping()\n\t}\n}\n\nfunc taskNameFromCode(code string) string {\n\tcode = strings.TrimSpace(code)\n\tif strings.HasPrefix(code, \"cron:\") {\n\t\ttrimmed := strings.TrimPrefix(code, \"cron:\")\n\t\tif trimmed != \"\" {\n\t\t\treturn trimmed\n\t\t}\n\t}\n\treturn code\n}\n"
  },
  {
    "path": "cmd/cron/tasks_test.go",
    "content": "package cron\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n)\n\nfunc TestDefineScheduleSkipsResetTaskByDefault(t *testing.T) {\n\trestoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {\n\t\tcfg.EnableResetSystemCron = false\n\t})\n\tdefer restoreConfig()\n\n\tschedule := NewSchedule(&cronLogger{})\n\tdefineSchedule(schedule)\n\n\tif hasScheduledTask(schedule, \"reset-system-data\") {\n\t\tt.Fatal(\"expected reset-system-data task to be skipped by default\")\n\t}\n\tif hasScheduledTask(schedule, \"demo\") {\n\t\tt.Fatal(\"expected demo task to be skipped by default\")\n\t}\n}\n\nfunc TestDefineScheduleRegistersResetTaskWhenEnabled(t *testing.T) {\n\trestoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {\n\t\tcfg.EnableResetSystemCron = true\n\t})\n\tdefer restoreConfig()\n\n\tschedule := NewSchedule(&cronLogger{})\n\tdefineSchedule(schedule)\n\n\tif !hasScheduledTask(schedule, \"reset-system-data\") {\n\t\tt.Fatal(\"expected reset-system-data task to be registered when enabled\")\n\t}\n}\n\nfunc hasScheduledTask(schedule *Scheduler, name string) bool {\n\tfor _, task := range schedule.tasks {\n\t\tif task.name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "cmd/root.go",
    "content": "package cmd\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/cmd/bootstrapx\"\n\t\"github.com/wannanbigpig/gin-layout/cmd/command\"\n\t\"github.com/wannanbigpig/gin-layout/cmd/cron\"\n\t\"github.com/wannanbigpig/gin-layout/cmd/service\"\n\t\"github.com/wannanbigpig/gin-layout/cmd/version\"\n\t\"github.com/wannanbigpig/gin-layout/cmd/worker\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/runtime\"\n)\n\nconst (\n\twelcomeMessage = \"Welcome to go-layout. Use -h to see more commands\"\n)\n\nvar (\n\trootCmd = &cobra.Command{\n\t\tUse:           \"go-layout\",\n\t\tShort:         \"go-layout\",\n\t\tSilenceUsage:  true,\n\t\tSilenceErrors: true,\n\t\tLong: `Gin framework is used as the core of this project to build a scaffold,\nbased on the project can be quickly completed business development, out of the box 📦`,\n\t\tPersistentPreRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tif shouldSkipBootstrap(cmd) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif err := bootstrapx.InitializeConfig(configPath); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tbootstrapx.InitializeTimezone()\n\t\t\tif err := bootstrapx.InitializeLogger(); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t\tRun: func(cmd *cobra.Command, args []string) {\n\t\t\tfmt.Printf(\"%s\\n\", welcomeMessage)\n\t\t},\n\t}\n\tconfigPath string\n)\n\nfunc init() {\n\truntime.RegisterConfigReloadHandlers()\n\tregisterFlags()\n\tregisterCommands()\n}\n\n// registerFlags 注册命令行标志\nfunc registerFlags() {\n\trootCmd.PersistentFlags().StringVarP(&configPath, \"config\", \"c\", \"\", \"The absolute path of the configuration file\")\n}\n\nfunc shouldSkipBootstrap(cmd *cobra.Command) bool {\n\tif cmd == nil {\n\t\treturn false\n\t}\n\tif cmd.Name() == \"help\" {\n\t\treturn true\n\t}\n\tcommandPath := cmd.CommandPath()\n\tswitch commandPath {\n\tcase \"go-layout\", \"go-layout version\", \"go-layout help\":\n\t\treturn true\n\tdefault:\n\t\treturn strings.HasPrefix(commandPath, \"go-layout completion\") ||\n\t\t\tstrings.HasPrefix(commandPath, \"go-layout __complete\")\n\t}\n}\n\n// registerCommands 注册子命令\nfunc registerCommands() {\n\trootCmd.AddCommand(version.Cmd) // 查看版本: go-layout version\n\trootCmd.AddCommand(completionCmd)\n\trootCmd.AddCommand(service.Cmd) // 启动服务: go-layout service\n\trootCmd.AddCommand(command.Cmd) // 运行命令: go-layout command demo / go-layout command init api-route\n\trootCmd.AddCommand(cron.Cmd)    // 启动计划任务: go-layout cron\n\trootCmd.AddCommand(worker.Cmd)  // 启动异步任务 worker: go-layout worker\n}\n\n// Execute 执行命令\nfunc Execute() {\n\tif err := rootCmd.Execute(); err != nil {\n\t\tif log.Logger != nil {\n\t\t\tlog.Logger.Error(\"Command execution failed\", zap.Error(err))\n\t\t}\n\t\tfmt.Fprintf(os.Stderr, \"Error: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "cmd/service/service.go",
    "content": "package service\n\nimport (\n\t\"context\"\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/spf13/cobra\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/cmd/bootstrapx\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t_ \"github.com/wannanbigpig/gin-layout/internal/queue/asynqx\"\n\t\"github.com/wannanbigpig/gin-layout/internal/routers\"\n)\n\nconst (\n\tdefaultHost            = \"0.0.0.0\"\n\tdefaultPort            = 9001\n\tgracefulShutdownTimout = 10 * time.Second\n)\n\nvar (\n\tCmd = bootstrapx.WrapCommand(&cobra.Command{\n\t\tUse:     \"service\",\n\t\tShort:   \"Start API service\",\n\t\tExample: \"go-layout service -c config.yml\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn run()\n\t\t},\n\t}, bootstrapx.Requirements{Data: true, Validator: true, Queue: true, AllowDegradedStartup: true})\n\thost string\n\tport int\n)\n\nfunc init() {\n\tregisterFlags()\n}\n\n// registerFlags 注册命令行标志\nfunc registerFlags() {\n\tCmd.Flags().StringVarP(&host, \"host\", \"H\", defaultHost, \"监听服务器地址\")\n\tCmd.Flags().IntVarP(&port, \"port\", \"P\", defaultPort, \"监听服务器端口\")\n}\n\n// run 运行服务器\nfunc run() error {\n\tengine, err := routers.SetRouters()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"build router failed: %w\", err)\n\t}\n\taddress := fmt.Sprintf(\"%s:%d\", host, port)\n\tserver := &http.Server{\n\t\tAddr:    address,\n\t\tHandler: engine,\n\t}\n\n\terrChan := make(chan error, 1)\n\tgo func() {\n\t\tlog.Logger.Info(\"API service starting\", zap.String(\"address\", address))\n\t\tif err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\t\terrChan <- err\n\t\t}\n\t\tclose(errChan)\n\t}()\n\n\treturn waitForShutdown(server, errChan)\n}\n\nfunc waitForShutdown(server *http.Server, errChan <-chan error) error {\n\tsigChan := make(chan os.Signal, 1)\n\tsignal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)\n\tdefer signal.Stop(sigChan)\n\n\tselect {\n\tcase err, ok := <-errChan:\n\t\tif ok && err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\tcase sig := <-sigChan:\n\t\tlog.Logger.Warn(\"Received API shutdown signal\", zap.String(\"signal\", sig.String()))\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), gracefulShutdownTimout)\n\tdefer cancel()\n\n\tif err := server.Shutdown(ctx); err != nil {\n\t\treturn fmt.Errorf(\"shutdown http server failed: %w\", err)\n\t}\n\tif err := data.Shutdown(); err != nil {\n\t\treturn fmt.Errorf(\"shutdown data resources failed: %w\", err)\n\t}\n\n\tlog.Logger.Info(\"API service stopped gracefully\")\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/version/version.go",
    "content": "package version\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n)\n\nvar (\n\t// Cmd 版本信息命令\n\tCmd = &cobra.Command{\n\t\tUse:     \"version\",\n\t\tShort:   \"Get version info\",\n\t\tExample: \"go-layout version\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tfmt.Println(global.Version)\n\t\t\treturn nil\n\t\t},\n\t}\n)\n"
  },
  {
    "path": "cmd/worker/worker.go",
    "content": "package worker\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/cmd/bootstrapx\"\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\ttaskcron \"github.com/wannanbigpig/gin-layout/internal/cron\"\n\t\"github.com/wannanbigpig/gin-layout/internal/jobs\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue/asynqx\"\n)\n\nvar Cmd = bootstrapx.WrapCommand(&cobra.Command{\n\tUse:     \"worker\",\n\tShort:   \"Start async worker\",\n\tExample: \"go-layout worker -c config.yml\",\n\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\treturn run()\n\t},\n}, bootstrapx.Requirements{Data: true})\n\nfunc run() error {\n\tcfg := config.GetConfig()\n\tif cfg == nil {\n\t\treturn fmt.Errorf(\"queue config is not initialized\")\n\t}\n\tif !cfg.Queue.Enable {\n\t\treturn fmt.Errorf(\"queue.enable is false\")\n\t}\n\tif cfg.Queue.UseDefaultRedis {\n\t\tif !cfg.Redis.Enable {\n\t\t\treturn fmt.Errorf(\"queue uses default redis, but redis.enable is false\")\n\t\t}\n\t\tif err := data.GetRedisInitError(); err != nil {\n\t\t\treturn fmt.Errorf(\"redis initialization failed: %w\", err)\n\t\t}\n\t\tif data.RedisClient() == nil {\n\t\t\treturn fmt.Errorf(\"redis client is unavailable\")\n\t\t}\n\t}\n\n\tregistry := jobs.NewRegistry()\n\tcronFallbackHandlers := 0\n\tif cfg.Queue.ConsumeCronFallback {\n\t\tcronFallbackHandlers = taskcron.RegisterQueueFallbackHandlers(registry, cfg)\n\t}\n\n\tserver, mux, err := asynqx.NewServer(cfg, registry)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Logger.Info(\"Async worker starting\",\n\t\tzap.Int(\"concurrency\", cfg.Queue.Concurrency),\n\t\tzap.Bool(\"strict_priority\", cfg.Queue.StrictPriority),\n\t\tzap.Bool(\"consume_cron_fallback\", cfg.Queue.ConsumeCronFallback),\n\t\tzap.Int(\"cron_fallback_handlers\", cronFallbackHandlers),\n\t\tzap.Any(\"queues\", cfg.Queue.Queues))\n\n\tif err := server.Run(mux); err != nil {\n\t\treturn err\n\t}\n\tif err := data.Shutdown(); err != nil {\n\t\treturn fmt.Errorf(\"shutdown data resources failed: %w\", err)\n\t}\n\n\tlog.Logger.Info(\"Async worker stopped gracefully\")\n\treturn nil\n}\n"
  },
  {
    "path": "config/.gitignore",
    "content": "*.yaml\n*.ini"
  },
  {
    "path": "config/autoload/app.go",
    "content": "package autoload\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\n// AppConfig 定义应用运行时基础配置。\ntype AppConfig struct {\n\t// AppEnv 应用环境标识，如：local（本地）、dev（开发）、prod（生产）\n\tAppEnv string `mapstructure:\"app_env\"`\n\t// Debug 是否开启调试模式，true 时输出详细调试信息\n\tDebug bool `mapstructure:\"debug\"`\n\t// Language 国际化语言，如：zh_CN（中文）、en_US（英文）\n\tLanguage string `mapstructure:\"language\"`\n\t// WatchConfig 是否开启配置热更新，true 时配置变更自动重载\n\tWatchConfig bool `mapstructure:\"watch_config\"`\n\t// BasePath 应用基础路径，用于拼接文件存储路径。\n\t// 未在配置文件显式设置时，会在配置加载阶段优先回填为当前工作目录。\n\tBasePath string `mapstructure:\"base_path\"`\n\t// BaseURL 文件访问的基础 URL（如：https://example.com），用于拼接文件访问地址\n\tBaseURL string `mapstructure:\"base_url\"`\n\t// Timezone 时区设置，nil 时使用系统默认时区\n\tTimezone *string `mapstructure:\"timezone\"`\n\t// TrustedProxies 受信任代理列表，仅这些代理转发的 X-Forwarded-For/X-Real-IP 会被信任\n\t// 生产环境应配置为负载均衡或反向代理的 IP/网段\n\tTrustedProxies []string `mapstructure:\"trusted_proxies\"`\n\t// CorsOrigins CORS 允许的源列表，如：[\"http://localhost:3000\", \"https://example.com\"]\n\t// 使用 [\"*\"] 表示允许所有源（生产环境慎用）\n\tCorsOrigins []string `mapstructure:\"cors_origins\"`\n\t// CorsMethods 允许的 HTTP 方法，如：[\"GET\", \"POST\", \"PUT\", \"DELETE\"]\n\t// 使用 [\"*\"] 表示允许全部已支持方法，空数组使用默认值\n\tCorsMethods []string `mapstructure:\"cors_methods\"`\n\t// CorsHeaders 允许的请求头，如：[\"Content-Type\", \"Authorization\"]\n\t// 使用 [\"*\"] 表示允许全部请求头\n\tCorsHeaders []string `mapstructure:\"cors_headers\"`\n\t// CorsExposeHeaders 暴露的响应头，如：[\"Content-Length\", \"X-Request-Id\"]\n\t// 使用 [\"*\"] 表示暴露全部响应头\n\tCorsExposeHeaders []string `mapstructure:\"cors_expose_headers\"`\n\t// CorsMaxAge 预检请求（OPTIONS）缓存时间（秒），默认 43200（12 小时）\n\tCorsMaxAge int `mapstructure:\"cors_max_age\"`\n\t// CorsCredentials 是否允许携带凭证（cookies、Authorization 头等），默认 false\n\tCorsCredentials bool `mapstructure:\"cors_credentials\"`\n\t// AllowDegradedStartup 是否允许 service 在依赖初始化失败时降级启动。\n\t// true 时仅 HTTP 服务会继续启动，由 readiness 与路由守卫体现未就绪状态。\n\tAllowDegradedStartup bool `mapstructure:\"allow_degraded_startup\"`\n\t// EnableResetSystemCron 是否启用高风险的系统重建定时任务。\n\t// 默认 false，避免在非预期环境触发系统数据重建。\n\tEnableResetSystemCron bool `mapstructure:\"enable_reset_system_cron\"`\n}\n\nvar App = AppConfig{\n\tAppEnv:                \"local\", // 默认本地环境\n\tDebug:                 true,    // 默认开启调试模式\n\tLanguage:              \"zh_CN\", // 默认中文\n\tWatchConfig:           false,   // 默认关闭配置热更新\n\tBasePath:              getDefaultPath(),\n\tBaseURL:               \"\",                    // 默认空，需要配置\n\tTimezone:              nil,                   // 默认使用系统时区\n\tTrustedProxies:        []string{\"127.0.0.1\"}, // 默认只信任本地\n\tCorsOrigins:           []string{},            // 默认空数组，不放行跨域来源；使用 [\"*\"] 表示允许所有源\n\tCorsMethods:           []string{},            // 默认空数组，使用默认方法列表；使用 [\"*\"] 表示允许全部已支持方法\n\tCorsHeaders:           []string{},            // 默认空数组，按请求头自动放行预检头；使用 [\"*\"] 表示允许全部请求头\n\tCorsExposeHeaders:     []string{},            // 默认空数组，默认暴露全部响应头；使用 [\"*\"] 明确表示暴露全部响应头\n\tCorsMaxAge:            43200,                 // 默认 12 小时（43200 秒）\n\tCorsCredentials:       false,                 // 默认不允许携带凭证\n\tAllowDegradedStartup:  false,                 // 默认关闭降级启动，依赖初始化失败时直接退出\n\tEnableResetSystemCron: false,                 // 默认关闭高风险系统重建定时任务\n}\n\nfunc getDefaultPath() (path string) {\n\t// 初始化时优先按 GO_ENV 处理：\n\t// - development: 当前工作目录\n\t// - 其他环境: 可执行文件所在目录\n\t// 配置加载阶段会按 app.base_path 是否显式配置进一步修正。\n\tpath, err := utils.GetDefaultPath()\n\tif err != nil || path == \"\" {\n\t\tpath = \".\"\n\t}\n\treturn\n}\n"
  },
  {
    "path": "config/autoload/jwt.go",
    "content": "package autoload\n\nimport \"time\"\n\n// JwtConfig 定义 JWT 相关配置。\ntype JwtConfig struct {\n\t// TTL Token 有效期（秒），默认 7200 秒（2 小时）\n\tTTL time.Duration `mapstructure:\"ttl\"`\n\t// RefreshTTL Token 刷新阈值（秒）。\n\t// 默认 0：不主动刷新 Token\n\t// 大于 0 时：当 Token 剩余有效期小于该值时，自动刷新 Token 并在 Response Header 中返回新 Token\n\t// 推荐设置为 TTL/2，例如 TTL=7200 时，RefreshTTL=3600\n\tRefreshTTL time.Duration `mapstructure:\"refresh_ttl\"`\n\t// SecretKey JWT 签名密钥，用于生成和验证 Token\n\t// 启动时会校验非空；生产环境还会拒绝弱占位值和长度不足的密钥\n\t// 建议使用随机密钥，例如：openssl rand -hex 32\n\tSecretKey string `mapstructure:\"secret_key\"`\n}\n\nvar Jwt = JwtConfig{\n\tTTL:        7200, // Token 有效期 2 小时\n\tRefreshTTL: 0,    // 0 表示不主动刷新 Token\n\tSecretKey:  \"\",   // 默认空，启动时必须由配置提供有效密钥\n}\n"
  },
  {
    "path": "config/autoload/logger.go",
    "content": "package autoload\n\n// DivisionTime 定义按时间切割日志时的参数。\ntype DivisionTime struct {\n\t// MaxAge 日志文件保留的最大天数，过期会被删除\n\tMaxAge int `mapstructure:\"max_age\"`\n\t// RotationTime 多长时间切割一次日志文件，单位小时（24 表示每天切割）\n\tRotationTime int `mapstructure:\"rotation_time\"`\n}\n\n// DivisionSize 定义按大小切割日志时的参数。\ntype DivisionSize struct {\n\t// MaxSize 日志文件的最大大小（以 MB 为单位），超过该值会触发切割\n\tMaxSize int `mapstructure:\"max_size\"`\n\t// MaxBackups 保留的旧日志文件最大个数，超过会被删除\n\tMaxBackups int `mapstructure:\"max_backups\"`\n\t// MaxAge 旧日志文件保留的最大天数，过期会被删除\n\tMaxAge int `mapstructure:\"max_age\"`\n\t// Compress 是否压缩/归档旧日志文件（gzip 格式）\n\tCompress bool `mapstructure:\"compress\"`\n}\n\n// LoggerConfig 定义日志输出与切割策略。\ntype LoggerConfig struct {\n\t// Output 日志输出方式：file（输出到文件）、stderr（输出到标准错误）\n\tOutput string `mapstructure:\"output\"`\n\t// DefaultDivision 默认切割方式：time（按时间）、size（按大小）\n\tDefaultDivision string `mapstructure:\"default_division\"`\n\t// Filename 日志文件名\n\tFilename string `mapstructure:\"file_name\"`\n\t// DivisionTime 按时间切割的参数配置\n\tDivisionTime DivisionTime `mapstructure:\"division_time\"`\n\t// DivisionSize 按大小切割的参数配置\n\tDivisionSize DivisionSize `mapstructure:\"division_size\"`\n}\n\nvar Logger = LoggerConfig{\n\tOutput:          \"file\", // 默认输出到文件\n\tDefaultDivision: \"time\", // 默认按时间切割\n\tFilename:        \"gin-layout.sys.log\",\n\tDivisionTime: DivisionTime{\n\t\tMaxAge:       15,  // 日志保留 15 天\n\t\tRotationTime: 24,  // 每 24 小时切割一次\n\t},\n\tDivisionSize: DivisionSize{\n\t\tMaxSize:    20,    // 日志文件最大 20MB\n\t\tMaxBackups: 15,    // 最多保留 15 个备份\n\t\tMaxAge:     15,    // 日志保留 15 天\n\t\tCompress:   false, // 默认不压缩\n\t},\n}\n"
  },
  {
    "path": "config/autoload/mysql.go",
    "content": "package autoload\n\nimport \"time\"\n\n// MysqlConfig 定义 MySQL 连接与连接池配置。\ntype MysqlConfig struct {\n\t// Enable 是否启用 MySQL 连接\n\tEnable bool `mapstructure:\"enable\"`\n\t// Host 数据库服务器地址\n\tHost string `mapstructure:\"host\"`\n\t// Username 数据库用户名\n\tUsername string `mapstructure:\"username\"`\n\t// Password 数据库密码\n\tPassword string `mapstructure:\"password\"`\n\t// Port 数据库端口\n\tPort uint16 `mapstructure:\"port\"`\n\t// Database 数据库名称\n\tDatabase string `mapstructure:\"database\"`\n\t// Charset 字符集，推荐 utf8mb4\n\tCharset string `mapstructure:\"charset\"`\n\t// TablePrefix 表名前缀，用于区分不同应用的表\n\tTablePrefix string `mapstructure:\"table_prefix\"`\n\t// MaxIdleConns 最大空闲连接数\n\tMaxIdleConns int `mapstructure:\"max_idle_conns\"`\n\t// MaxOpenConns 最大打开连接数（并发连接数上限）\n\tMaxOpenConns int `mapstructure:\"max_open_conns\"`\n\t// MaxLifetime 连接最大存活时间，超时会被复用前重新创建\n\tMaxLifetime time.Duration `mapstructure:\"max_lifetime\"`\n\t// LogLevel GORM 日志级别：1=silent, 2=error, 3=warn, 4=info\n\tLogLevel int `mapstructure:\"log_level\"`\n\t// PrintSql 是否打印 SQL 到控制台，调试时使用\n\tPrintSql bool `mapstructure:\"print_sql\"`\n}\n\n// Mysql 数据库默认配置。\nvar Mysql = MysqlConfig{\n\tEnable:       false, // 默认关闭，需要时开启\n\tHost:         \"127.0.0.1\",\n\tUsername:     \"root\",\n\tPassword:     \"root1234\",\n\tPort:         3306,\n\tDatabase:     \"test\",\n\tCharset:      \"utf8mb4\",\n\tTablePrefix:  \"\",\n\tMaxIdleConns: 10,\n\tMaxOpenConns: 100,\n\tMaxLifetime:  time.Hour, // 连接存活 1 小时\n\tLogLevel:     4,         // info 级别\n\tPrintSql:     false,     // 默认不打印 SQL\n}\n"
  },
  {
    "path": "config/autoload/queue.go",
    "content": "package autoload\n\n// QueueRedisConfig 队列使用的 Redis 连接配置。\ntype QueueRedisConfig struct {\n\t// Host Redis 服务器地址\n\tHost string `mapstructure:\"host\" yaml:\"host\"`\n\t// Port Redis 服务器端口\n\tPort string `mapstructure:\"port\" yaml:\"port\"`\n\t// Password Redis 密码，空字符串表示无密码\n\tPassword string `mapstructure:\"password\" yaml:\"password\"`\n\t// Database 数据库编号\n\tDatabase int `mapstructure:\"database\" yaml:\"database\"`\n}\n\n// QueueConfig 异步任务队列配置。\ntype QueueConfig struct {\n\t// Enable 是否启用异步队列。false 时同步执行任务（如审计日志直接写库）\n\tEnable bool `mapstructure:\"enable\" yaml:\"enable\"`\n\t// UseDefaultRedis 是否复用全局 redis 配置。\n\t// true: 使用 redis.* 作为队列连接（默认）\n\t// false: 使用 queue.redis.* 作为队列独立连接\n\tUseDefaultRedis bool `mapstructure:\"use_default_redis\" yaml:\"use_default_redis\"`\n\t// Redis 队列独立 Redis 配置，仅当 UseDefaultRedis=false 时生效\n\tRedis QueueRedisConfig `mapstructure:\"redis\" yaml:\"redis\"`\n\t// Namespace 队列命名空间前缀，用于隔离不同应用的队列\n\tNamespace string `mapstructure:\"namespace\" yaml:\"namespace\"`\n\t// Concurrency Worker Server 的最大并发协程数（全局上限）\n\t// 建议值：开发环境 2-4，小流量生产 8-16，中等流量 16-32\n\t// 注意：并发过高会增加数据库压力，审计日志类任务建议 8-16\n\tConcurrency int `mapstructure:\"concurrency\" yaml:\"concurrency\"`\n\t// StrictPriority 是否严格优先级模式。\n\t// true: 必须处理完高优先级队列的所有任务后，才处理低优先级队列\n\t// false: 按权重比例调度，高优先级队列的任务被调度的概率更大（推荐）\n\tStrictPriority bool `mapstructure:\"strict_priority\" yaml:\"strict_priority\"`\n\t// ConsumeCronFallback 是否允许 worker 消费历史误入 Asynq 的非高风险 cron 任务。\n\t// 仅用于清理旧队列残留，不影响 cron 调度开关。\n\tConsumeCronFallback bool `mapstructure:\"consume_cron_fallback\" yaml:\"consume_cron_fallback\"`\n\t// Queues 各队列的权重配置，key 为队列名，value 为权重值\n\t// 新增队列必须在此配置，否则 Worker 不会消费该队列的任务！\n\t// 权重决定任务被调度的概率，不是分配的协程数量！\n\t// 所有队列共享 Concurrency 个协程，权重越高越容易被优先调度\n\t// 调度概率 = 该队列权重 / 所有队列权重之和\n\t// 示例（总权重=4+2+2+1=9）：\n\t//   critical: 权重 4 → 4/9≈44% 概率被调度（支付回调、短信发送）\n\t//   default:  权重 2 → 2/9≈22% 概率被调度（普通异步任务）\n\t//   audit:    权重 2 → 2/9≈22% 概率被调度（请求日志、登录日志）\n\t//   low:      权重 1 → 1/9≈11% 概率被调度（批量通知、数据导出）\n\tQueues map[string]int `mapstructure:\"queues\" yaml:\"queues\"`\n\t// AuditMaxRetry 审计日志队列的最大重试次数\n\tAuditMaxRetry int `mapstructure:\"audit_max_retry\" yaml:\"audit_max_retry\"`\n\t// AuditTimeoutSeconds 审计日志任务的超时时间（秒）\n\tAuditTimeoutSeconds int `mapstructure:\"audit_timeout_seconds\" yaml:\"audit_timeout_seconds\"`\n}\n\n// Queue 队列默认配置。\nvar Queue = QueueConfig{\n\tEnable:          false, // 默认关闭队列，同步执行\n\tUseDefaultRedis: true,  // 默认复用全局 redis 配置\n\tRedis: QueueRedisConfig{\n\t\tHost:     \"127.0.0.1\",\n\t\tPort:     \"6379\",\n\t\tPassword: \"\",\n\t\tDatabase: 0,\n\t},\n\tNamespace:      \"go_layout\",\n\tConcurrency:    8,     // 整个 worker 最多同时处理 8 个任务（全局上限）\n\tStrictPriority: false, // 按权重比例调度，非严格优先级\n\t// 默认不让 worker 消费 cron 类型任务；需要清理历史残留时再显式开启。\n\tConsumeCronFallback: false,\n\tQueues: map[string]int{\n\t\t// 权重值表示调度概率，不是协程数量！\n\t\t// 所有队列共享 8 个协程，权重越高越容易被优先调度\n\t\t// 总权重 = 4+2+2+1 = 9\n\t\t// critical 被选中的概率 ≈ 4/9 ≈ 44%\n\t\t\"critical\": 4, // 权重 4，约 44% 概率被调度（如支付回调）\n\t\t\"default\":  2, // 权重 2，约 22% 概率被调度（普通任务）\n\t\t\"audit\":    2, // 权重 2，约 22% 概率被调度（审计日志）\n\t\t\"low\":      1, // 权重 1，约 11% 概率被调度（批量通知）\n\t},\n\tAuditMaxRetry:       3,  // 审计日志失败最多重试 3 次\n\tAuditTimeoutSeconds: 10, // 审计日志任务超时 10 秒\n}\n"
  },
  {
    "path": "config/autoload/redis.go",
    "content": "package autoload\n\nimport \"time\"\n\n// RedisConfig 定义 Redis 连接配置。\ntype RedisConfig struct {\n\t// Enable 是否启用 Redis 连接\n\tEnable bool `mapstructure:\"enable\"`\n\t// Host Redis 服务器地址\n\tHost string `mapstructure:\"host\"`\n\t// Port Redis 服务器端口\n\tPort string `mapstructure:\"port\"`\n\t// Password Redis 密码，空字符串表示无密码\n\tPassword string `mapstructure:\"password\"`\n\t// Database 数据库编号，默认 0\n\tDatabase int `mapstructure:\"database\"`\n\t// PoolSize 连接池大小（最大连接数）\n\tPoolSize int `mapstructure:\"pool_size\"`\n\t// MinIdleConns 最小空闲连接数\n\tMinIdleConns int `mapstructure:\"min_idle_conns\"`\n\t// ConnMaxIdleTime 连接最大空闲时间，超时会被回收\n\tConnMaxIdle time.Duration `mapstructure:\"conn_max_idle_time\"`\n\t// ConnMaxLifetime 连接最大存活时间，超时会被重新创建\n\tConnMaxLifetime time.Duration `mapstructure:\"conn_max_lifetime\"`\n\t// ReadTimeout 读取超时时间\n\tReadTimeout time.Duration `mapstructure:\"read_timeout\"`\n\t// WriteTimeout 写入超时时间\n\tWriteTimeout time.Duration `mapstructure:\"write_timeout\"`\n}\n\n// Redis 默认配置。\nvar Redis = RedisConfig{\n\tEnable:          false, // 默认关闭，需要时开启\n\tHost:            \"127.0.0.1\",\n\tPassword:        \"\",\n\tPort:            \"6379\",\n\tDatabase:        0,\n\tPoolSize:        10,\n\tMinIdleConns:    5,\n\tConnMaxIdle:     5 * time.Minute,  // 空闲 5 分钟回收\n\tConnMaxLifetime: 30 * time.Minute, // 连接存活 30 分钟\n\tReadTimeout:     3 * time.Second,  // 读取超时 3 秒\n\tWriteTimeout:    3 * time.Second,  // 写入超时 3 秒\n}\n"
  },
  {
    "path": "config/config.go",
    "content": "package config\n\nimport (\n\t\"sort\"\n\t\"sync\"\n\t\"sync/atomic\"\n\n\t\"github.com/spf13/viper\"\n\t\"github.com/wannanbigpig/gin-layout/config/autoload\"\n)\n\n// Conf 配置项主结构体\ntype Conf struct {\n\tautoload.AppConfig `mapstructure:\"app\"`\n\tMysql              autoload.MysqlConfig  `mapstructure:\"mysql\"`\n\tRedis              autoload.RedisConfig  `mapstructure:\"redis\"`\n\tLogger             autoload.LoggerConfig `mapstructure:\"logger\"`\n\tJwt                autoload.JwtConfig    `mapstructure:\"jwt\"`\n\tQueue              autoload.QueueConfig  `mapstructure:\"queue\"`\n}\n\nvar (\n\tConfig = &Conf{\n\t\tAppConfig: cloneAppConfig(autoload.App),\n\t\tMysql:     autoload.Mysql,\n\t\tRedis:     autoload.Redis,\n\t\tLogger:    autoload.Logger,\n\t\tJwt:       autoload.Jwt,\n\t\tQueue:     cloneQueueConfig(autoload.Queue),\n\t}\n\tonce        sync.Once\n\tinitErr     error\n\tV           *viper.Viper\n\tconfigValue atomic.Value\n\n\treloadHandlersMu sync.RWMutex\n\treloadHandlers   []ConfigReloadHandler\n)\n\n// ConfigReloadHandler 在配置热更新时被调用。\ntype ConfigReloadHandler struct {\n\tName     string\n\tPriority int\n\tHandle   func(oldConfig, newConfig *Conf, diff ConfigDiff) error\n}\n\n// ConfigDiff 描述配置变更摘要。\ntype ConfigDiff struct {\n\tLoggerChanged         bool\n\tMysqlChanged          bool\n\tRedisChanged          bool\n\tJWTChanged            bool\n\tJWTSecretChanged      bool\n\tBaseURLChanged        bool\n\tCORSChanged           bool\n\tTrustedProxiesChanged bool\n\tLightAppChanged       bool\n\tRestartRequiredFields []string\n\tChangedFields         []string\n}\n\n// GetConfig 返回当前生效的配置快照。\nfunc GetConfig() *Conf {\n\tif cfg, ok := configValue.Load().(*Conf); ok && cfg != nil {\n\t\treturn cfg\n\t}\n\treturn Config\n}\n\n// RegisterConfigReloadHandler 注册配置热更新回调。\nfunc RegisterConfigReloadHandler(handler ConfigReloadHandler) {\n\tif handler.Name == \"\" {\n\t\treturn\n\t}\n\n\treloadHandlersMu.Lock()\n\tdefer reloadHandlersMu.Unlock()\n\n\tfor i := range reloadHandlers {\n\t\tif reloadHandlers[i].Name == handler.Name {\n\t\t\treloadHandlers[i] = handler\n\t\t\tsortConfigReloadHandlersLocked()\n\t\t\treturn\n\t\t}\n\t}\n\n\treloadHandlers = append(reloadHandlers, handler)\n\tsortConfigReloadHandlersLocked()\n}\n\nfunc sortConfigReloadHandlersLocked() {\n\tsort.SliceStable(reloadHandlers, func(i, j int) bool {\n\t\tif reloadHandlers[i].Priority == reloadHandlers[j].Priority {\n\t\t\treturn reloadHandlers[i].Name < reloadHandlers[j].Name\n\t\t}\n\t\treturn reloadHandlers[i].Priority < reloadHandlers[j].Priority\n\t})\n}\n"
  },
  {
    "path": "config/config.yaml.example",
    "content": "# 该文件为配置示例文件，请复制该文件改名为 config.yaml, 不要直接修改该文件，修改无意义\napp:\n  app_env: local\n  debug: true\n  language: zh_CN\n#  allow_degraded_startup: false   # service 启动时依赖初始化失败是否继续启动；仅建议在需要“先起服务再排障”场景开启\n#  enable_reset_system_cron: false # 高风险：是否启用 reset-system-data 定时任务；默认关闭，避免误重建系统数据\n#  watch_config: false\n#  base_path: \"\"\n#  base_url: \"https://example.com\"  # 文件访问的基础URL，用于拼接本地文件访问地址\n#  trusted_proxies:                # 受信任代理列表，仅这些代理转发的 X-Forwarded-For / X-Real-IP 会被信任\n#    - \"127.0.0.1\"\n#    # 生产环境可按实际代理网段配置，例如：\n#    # - \"10.0.0.0/8\"\n#    # - \"172.16.0.0/12\"\n#    # - \"192.168.0.0/16\"\n#  # CORS 跨域配置\n#  cors_origins:                    # CORS允许的源列表，使用 [\"*\"] 表示允许所有源\n#    - \"http://localhost:3000\"\n#    - \"http://localhost:8080\"\n#    - \"https://example.com\"\n#    # 支持通配符匹配，例如：\n#    - \"https://*.wannanbigpig.com\"  # 匹配所有 wannanbigpig.com 的子域名（如 https://x-l-admin.wannanbigpig.com）\n#    # 或者明确指定：\n#    - \"https://x-l-admin.wannanbigpig.com\"\n#  cors_methods:                    # 允许的HTTP方法，使用 [\"*\"] 表示允许全部已支持方法，空数组使用默认值\n#    - \"GET\"\n#    - \"POST\"\n#    - \"PUT\"\n#    - \"PATCH\"\n#    - \"DELETE\"\n#    - \"HEAD\"\n#    - \"OPTIONS\"\n#  cors_headers:                    # 允许的请求头，使用 [\"*\"] 表示允许所有请求头\n#    - \"Content-Type\"\n#    - \"Authorization\"\n#    - \"X-Requested-With\"\n#  cors_expose_headers:             # 暴露的响应头，使用 [\"*\"] 表示暴露所有响应头\n#    - \"Content-Length\"\n#    - \"X-Request-Id\"\n#  cors_max_age: 43200              # 预检请求缓存时间（秒），默认 43200（12小时）\n#  cors_credentials: false          # 是否允许携带凭证（cookies等），默认 false\njwt:\n  ttl: 7200\n  refresh_ttl: 3600\n  secret_key: <YOUR_SECRET_KEY>  # 请替换为随机密钥；启动时会校验非空，生产环境还会拒绝弱占位值和短密钥\nmysql:\n  enable: false\n  host: 127.0.0.1\n  port: 3306\n  database: test\n  username: root\n  password: root1234\n  charset: utf8mb4\n  table_prefix: \"\"\n  max_idle_conns: 10\n  max_open_conns: 100\n  max_lifetime: 3600s\nredis:\n  enable: false\n  host: 127.0.0.1\n  port: 6379\n  password:\n  database: 0\nqueue:\n  enable: false\n  use_default_redis: true  # true=复用 redis.*；false=使用 queue.redis.* 独立连接\n  redis:\n    host: 127.0.0.1\n    port: 6379\n    password:\n    database: 0\n  namespace: go_layout\n  concurrency: 8\n  strict_priority: false\n  consume_cron_fallback: false # true=worker 兜底消费历史误入 Asynq 的非高风险 cron 任务\n  queues:\n    critical: 4\n    default: 2\n    audit: 2\n    low: 1\n  audit_max_retry: 3\n  audit_timeout_seconds: 10\nlogger:\n  # 日志输出默认为文件，stderr 可选\n  output: file\n  default_division: time\n  file_name: gin-layout.sys.log\n  division_time:\n    max_age: 15\n    rotation_time: 24\n  division_size:\n    max_size: 20\n    max_backups: 15\n    max_age: 15\n    compress: false\n"
  },
  {
    "path": "config/config_clone.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\n\t\"github.com/wannanbigpig/gin-layout/config/autoload\"\n)\n\nfunc setActiveConfig(cfg *Conf) {\n\tConfig = cfg\n\tconfigValue.Store(cfg)\n}\n\nfunc cloneDefaultConfig() *Conf {\n\treturn &Conf{\n\t\tAppConfig: cloneAppConfig(autoload.App),\n\t\tMysql:     autoload.Mysql,\n\t\tRedis:     autoload.Redis,\n\t\tLogger:    autoload.Logger,\n\t\tJwt:       autoload.Jwt,\n\t\tQueue:     cloneQueueConfig(autoload.Queue),\n\t}\n}\n\nfunc cloneAppConfig(src autoload.AppConfig) autoload.AppConfig {\n\tcloned := src\n\tcloned.TrustedProxies = cloneStringSlice(src.TrustedProxies)\n\tcloned.CorsOrigins = cloneStringSlice(src.CorsOrigins)\n\tcloned.CorsMethods = cloneStringSlice(src.CorsMethods)\n\tcloned.CorsHeaders = cloneStringSlice(src.CorsHeaders)\n\tcloned.CorsExposeHeaders = cloneStringSlice(src.CorsExposeHeaders)\n\tif src.Timezone != nil {\n\t\ttz := *src.Timezone\n\t\tcloned.Timezone = &tz\n\t}\n\treturn cloned\n}\n\nfunc cloneQueueConfig(src autoload.QueueConfig) autoload.QueueConfig {\n\tcloned := src\n\tif src.Queues != nil {\n\t\tcloned.Queues = make(map[string]int, len(src.Queues))\n\t\tfor key, value := range src.Queues {\n\t\t\tcloned.Queues[key] = value\n\t\t}\n\t}\n\treturn cloned\n}\n\nfunc cloneStringSlice(src []string) []string {\n\tif src == nil {\n\t\treturn nil\n\t}\n\treturn append([]string(nil), src...)\n}\n\n// copyConf 复制配置示例文件\nfunc copyConf(exampleConfig, config string) error {\n\tfileInfo, err := os.Stat(config)\n\tif err == nil {\n\t\tif !fileInfo.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"配置文件目录存在同名的文件夹，无法创建配置文件\")\n\t}\n\tif !os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"初始化失败: %w\", err)\n\t}\n\n\tsource, err := os.Open(exampleConfig)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"创建配置文件失败，配置示例文件不存在: %w\", err)\n\t}\n\tdefer func(source *os.File) {\n\t\t_ = source.Close()\n\t}(source)\n\n\tdst, err := os.Create(config)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"生成配置文件失败: %w\", err)\n\t}\n\tdefer func(dst *os.File) {\n\t\t_ = dst.Close()\n\t}(dst)\n\n\tif _, err := io.Copy(dst, source); err != nil {\n\t\treturn fmt.Errorf(\"写入配置文件失败: %w\", err)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "config/config_load.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/fsnotify/fsnotify\"\n\t\"github.com/spf13/viper\"\n\n\t\"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\n// InitConfig 初始化配置系统并加载首个生效快照。\nfunc InitConfig(configPath string) error {\n\tonce.Do(func() {\n\t\tvar loaded *Conf\n\t\tloaded, initErr = load(configPath)\n\t\tif initErr != nil {\n\t\t\treturn\n\t\t}\n\t\tinitErr = validateJWTSecretKey(loaded)\n\t\tif initErr != nil {\n\t\t\treturn\n\t\t}\n\t\tsetActiveConfig(loaded)\n\t})\n\treturn initErr\n}\n\nfunc checkJwtSecretKey() error {\n\treturn validateJWTSecretKey(GetConfig())\n}\n\nfunc validateJWTSecretKey(cfg *Conf) error {\n\tif cfg == nil {\n\t\treturn fmt.Errorf(\"config is nil\")\n\t}\n\tsecret := strings.TrimSpace(cfg.Jwt.SecretKey)\n\tif secret == \"\" {\n\t\treturn fmt.Errorf(\"jwt.secret_key is empty, please set a non-empty secret key\")\n\t}\n\n\tisProd := strings.EqualFold(cfg.AppEnv, \"prod\") || strings.EqualFold(cfg.AppEnv, \"production\")\n\tif !isProd {\n\t\treturn nil\n\t}\n\n\tweakSecrets := map[string]struct{}{\n\t\t\"<your_secret_key>\":    {},\n\t\t\"your-secret-key-here\": {},\n\t\t\"default-secret-key\":   {},\n\t\t\"change-me\":            {},\n\t\t\"changeme\":             {},\n\t\t\"secret\":               {},\n\t\t\"123456\":               {},\n\t}\n\tif _, ok := weakSecrets[strings.ToLower(secret)]; ok {\n\t\treturn fmt.Errorf(\"jwt.secret_key uses a weak placeholder value in production\")\n\t}\n\tif len(secret) < 16 {\n\t\treturn fmt.Errorf(\"jwt.secret_key is too short in production, require at least 16 characters\")\n\t}\n\n\treturn nil\n}\n\nfunc load(configPath string) (*Conf, error) {\n\tfilePath, err := resolveConfigPath(configPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tV = viper.New()\n\tV.SetConfigFile(filePath)\n\tif err := V.ReadInConfig(); err != nil {\n\t\tif _, ok := err.(viper.ConfigFileNotFoundError); ok {\n\t\t\treturn nil, fmt.Errorf(\"未找到配置: %w\", err)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"读取配置出错: %w\", err)\n\t}\n\n\tloaded := cloneDefaultConfig()\n\tif err := V.Unmarshal(loaded); err != nil {\n\t\treturn nil, fmt.Errorf(\"映射配置出错: %w\", err)\n\t}\n\n\tresolveEnvVars(loaded)\n\tensureBasePathDefault(loaded, V.IsSet(\"app.base_path\"))\n\n\tensureCorsDefaults(loaded)\n\tregisterConfigWatcherIfNeeded(loaded)\n\treturn loaded, nil\n}\n\nfunc resolveConfigPath(configPath string) (string, error) {\n\tif configPath != \"\" {\n\t\treturn configPath, nil\n\t}\n\n\texampleConfig, targetConfig, err := resolveDefaultConfigFiles()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := copyConf(exampleConfig, targetConfig); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn targetConfig, nil\n}\n\nfunc resolveDefaultConfigFiles() (string, string, error) {\n\tif os.Getenv(\"GO_ENV\") == \"development\" {\n\t\treturn resolveDevelopmentConfigFiles()\n\t}\n\n\trunDirectory, err := utils.GetCurrentPath()\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"获取执行文件目录失败: %w\", err)\n\t}\n\treturn filepath.Join(runDirectory, \"config.yaml.example\"), filepath.Join(runDirectory, \"config.yaml\"), nil\n}\n\nfunc resolveDevelopmentConfigFiles() (string, string, error) {\n\tworkDir, err := os.Getwd()\n\tif err != nil {\n\t\treturn \"\", \"\", fmt.Errorf(\"获取工作目录失败: %w\", err)\n\t}\n\n\texampleConfig := filepath.Join(workDir, \"config\", \"config.yaml.example\")\n\tif !fileExists(exampleConfig) {\n\t\texampleConfig = filepath.Join(workDir, \"config.yaml.example\")\n\t}\n\treturn exampleConfig, filepath.Join(workDir, \"config.yaml\"), nil\n}\n\nfunc fileExists(path string) bool {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn false\n\t}\n\tinfo, err := os.Stat(path)\n\tif err != nil {\n\t\treturn false\n\t}\n\treturn !info.IsDir()\n}\n\nfunc ensureBasePathDefault(cfg *Conf, basePathConfigured bool) {\n\tif cfg == nil {\n\t\treturn\n\t}\n\tif basePathConfigured && strings.TrimSpace(cfg.BasePath) != \"\" {\n\t\treturn\n\t}\n\n\tif os.Getenv(\"GO_ENV\") == \"development\" {\n\t\tworkDir, err := os.Getwd()\n\t\tif err == nil && strings.TrimSpace(workDir) != \"\" {\n\t\t\tcfg.BasePath = workDir\n\t\t\treturn\n\t\t}\n\t}\n\n\trunDir, err := utils.GetCurrentPath()\n\tif err == nil && strings.TrimSpace(runDir) != \"\" {\n\t\tcfg.BasePath = runDir\n\t\treturn\n\t}\n\n\tworkDir, err := os.Getwd()\n\tif err == nil && strings.TrimSpace(workDir) != \"\" {\n\t\tcfg.BasePath = workDir\n\t\treturn\n\t}\n\tcfg.BasePath = strings.TrimSpace(cfg.BasePath)\n\tif cfg.BasePath == \"\" {\n\t\tcfg.BasePath = \".\"\n\t}\n}\n\nfunc registerConfigWatcherIfNeeded(cfg *Conf) {\n\tif !cfg.WatchConfig {\n\t\treturn\n\t}\n\n\tV.WatchConfig()\n\tV.OnConfigChange(func(in fsnotify.Event) {\n\t\tinitErr = reloadConfigFromWatcher()\n\t})\n}\n\nfunc ensureCorsDefaults(cfg *Conf) {\n\tif cfg.CorsOrigins == nil {\n\t\tcfg.CorsOrigins = []string{}\n\t}\n\tif cfg.CorsMethods == nil {\n\t\tcfg.CorsMethods = []string{}\n\t}\n\tif cfg.CorsHeaders == nil {\n\t\tcfg.CorsHeaders = []string{}\n\t}\n\tif cfg.CorsExposeHeaders == nil {\n\t\tcfg.CorsExposeHeaders = []string{}\n\t}\n\tif cfg.TrustedProxies == nil {\n\t\tcfg.TrustedProxies = []string{\"127.0.0.1\"}\n\t}\n\tif cfg.CorsMaxAge == 0 {\n\t\tcfg.CorsMaxAge = 43200\n\t}\n}\n\nfunc reloadConfigFromWatcher() error {\n\tif err := V.ReadInConfig(); err != nil {\n\t\treturn fmt.Errorf(\"重新读取配置出错: %w\", err)\n\t}\n\n\tnext := cloneDefaultConfig()\n\tif err := V.Unmarshal(next); err != nil {\n\t\treturn fmt.Errorf(\"重新映射配置出错: %w\", err)\n\t}\n\tresolveEnvVars(next)\n\tensureBasePathDefault(next, V.IsSet(\"app.base_path\"))\n\tensureCorsDefaults(next)\n\tif err := validateJWTSecretKey(next); err != nil {\n\t\treturn fmt.Errorf(\"JWT 配置校验失败: %w\", err)\n\t}\n\n\tcurrent := GetConfig()\n\tdiff := BuildConfigDiff(current, next)\n\tapplied := BuildAppliedConfig(current, next, diff)\n\n\treloadHandlersMu.RLock()\n\thandlers := append([]ConfigReloadHandler(nil), reloadHandlers...)\n\treloadHandlersMu.RUnlock()\n\n\tfor _, handler := range handlers {\n\t\tif handler.Handle == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif err := handler.Handle(current, applied, diff); err != nil {\n\t\t\treturn fmt.Errorf(\"配置热更新失败[%s]: %w\", handler.Name, err)\n\t\t}\n\t}\n\n\tsetActiveConfig(applied)\n\treturn nil\n}\n\nfunc resolveEnvVars(cfg *Conf) {\n\tcfg.Mysql.Username = resolveEnvVar(cfg.Mysql.Username)\n\tcfg.Mysql.Password = resolveEnvVar(cfg.Mysql.Password)\n\tcfg.Mysql.Host = resolveEnvVar(cfg.Mysql.Host)\n\tcfg.Redis.Password = resolveEnvVar(cfg.Redis.Password)\n\tcfg.Redis.Host = resolveEnvVar(cfg.Redis.Host)\n\tcfg.Queue.Redis.Password = resolveEnvVar(cfg.Queue.Redis.Password)\n\tcfg.Queue.Redis.Host = resolveEnvVar(cfg.Queue.Redis.Host)\n\tcfg.Jwt.SecretKey = resolveEnvVar(cfg.Jwt.SecretKey)\n}\n\nfunc resolveEnvVar(val string) string {\n\tif !strings.HasPrefix(val, \"${\") || !strings.HasSuffix(val, \"}\") {\n\t\treturn val\n\t}\n\tenvKey := val[2 : len(val)-1]\n\tif envVal := os.Getenv(envKey); envVal != \"\" {\n\t\treturn envVal\n\t}\n\treturn val\n}\n"
  },
  {
    "path": "config/config_load_test.go",
    "content": "package config\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc assertSamePath(t *testing.T, expected string, actual string) {\n\tt.Helper()\n\texpectedResolved, err := filepath.EvalSymlinks(expected)\n\tif err != nil {\n\t\texpectedResolved = filepath.Clean(expected)\n\t}\n\tactualResolved, err := filepath.EvalSymlinks(actual)\n\tif err != nil {\n\t\tactualResolved = filepath.Clean(actual)\n\t}\n\tif expectedResolved != actualResolved {\n\t\tt.Fatalf(\"expected %s, got %s\", expectedResolved, actualResolved)\n\t}\n}\n\nfunc TestResolveConfigPathPrefersWorkingDirectoryConfig(t *testing.T) {\n\tt.Setenv(\"GO_ENV\", \"development\")\n\n\tworkDir := t.TempDir()\n\tconfigPath := filepath.Join(workDir, \"config.yaml\")\n\tif err := os.WriteFile(configPath, []byte(\"app:\\n  port: 8080\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write config file failed: %v\", err)\n\t}\n\n\toriginWD, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatalf(\"getwd failed: %v\", err)\n\t}\n\tif err := os.Chdir(workDir); err != nil {\n\t\tt.Fatalf(\"chdir failed: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = os.Chdir(originWD)\n\t})\n\n\tresolved, err := resolveConfigPath(\"\")\n\tif err != nil {\n\t\tt.Fatalf(\"resolveConfigPath failed: %v\", err)\n\t}\n\tassertSamePath(t, configPath, resolved)\n}\n\nfunc TestResolveDefaultConfigFilesUsesWorkingDirectoryExample(t *testing.T) {\n\tt.Setenv(\"GO_ENV\", \"development\")\n\n\tworkDir := t.TempDir()\n\tconfigDir := filepath.Join(workDir, \"config\")\n\tif err := os.MkdirAll(configDir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir config dir failed: %v\", err)\n\t}\n\n\texamplePath := filepath.Join(configDir, \"config.yaml.example\")\n\tif err := os.WriteFile(examplePath, []byte(\"app:\\n  port: 8080\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write example file failed: %v\", err)\n\t}\n\n\toriginWD, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatalf(\"getwd failed: %v\", err)\n\t}\n\tif err := os.Chdir(workDir); err != nil {\n\t\tt.Fatalf(\"chdir failed: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = os.Chdir(originWD)\n\t})\n\n\tgotExample, gotTarget, err := resolveDefaultConfigFiles()\n\tif err != nil {\n\t\tt.Fatalf(\"resolveDefaultConfigFiles failed: %v\", err)\n\t}\n\tassertSamePath(t, examplePath, gotExample)\n\texpectedTarget := filepath.Join(filepath.Dir(filepath.Dir(gotExample)), \"config.yaml\")\n\tif gotTarget != expectedTarget {\n\t\tt.Fatalf(\"expected target %s, got %s\", expectedTarget, gotTarget)\n\t}\n}\n\nfunc TestEnsureBasePathDefaultUsesWorkingDirectoryWhenNotConfigured(t *testing.T) {\n\tt.Setenv(\"GO_ENV\", \"development\")\n\n\tworkDir := t.TempDir()\n\toriginWD, err := os.Getwd()\n\tif err != nil {\n\t\tt.Fatalf(\"getwd failed: %v\", err)\n\t}\n\tif err := os.Chdir(workDir); err != nil {\n\t\tt.Fatalf(\"chdir failed: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = os.Chdir(originWD)\n\t})\n\n\tcfg := cloneDefaultConfig()\n\tcfg.BasePath = \"/tmp/legacy-base-path\"\n\tensureBasePathDefault(cfg, false)\n\tassertSamePath(t, workDir, cfg.BasePath)\n}\n\nfunc TestEnsureBasePathDefaultKeepsExplicitConfiguredValue(t *testing.T) {\n\tcfg := cloneDefaultConfig()\n\tcfg.BasePath = \"/tmp/custom-base-path\"\n\tensureBasePathDefault(cfg, true)\n\tif cfg.BasePath != \"/tmp/custom-base-path\" {\n\t\tt.Fatalf(\"expected explicit base_path to be kept, got %s\", cfg.BasePath)\n\t}\n}\n\nfunc TestValidateJWTSecretKeyRejectsNilConfig(t *testing.T) {\n\tif err := validateJWTSecretKey(nil); err == nil {\n\t\tt.Fatal(\"expected nil config to return error\")\n\t}\n}\n\nfunc TestCheckJwtSecretKeyRejectsEmptySecret(t *testing.T) {\n\toriginal := GetConfig()\n\ttestCfg := cloneDefaultConfig()\n\ttestCfg.Jwt.SecretKey = \"\"\n\tsetActiveConfig(testCfg)\n\tt.Cleanup(func() { setActiveConfig(original) })\n\n\tif err := checkJwtSecretKey(); err == nil {\n\t\tt.Fatal(\"expected empty jwt secret key to return error\")\n\t}\n}\n\nfunc TestCheckJwtSecretKeyRejectsWeakProdSecret(t *testing.T) {\n\toriginal := GetConfig()\n\ttestCfg := cloneDefaultConfig()\n\ttestCfg.AppEnv = \"prod\"\n\ttestCfg.Jwt.SecretKey = \"default-secret-key\"\n\tsetActiveConfig(testCfg)\n\tt.Cleanup(func() { setActiveConfig(original) })\n\n\tif err := checkJwtSecretKey(); err == nil {\n\t\tt.Fatal(\"expected weak prod jwt secret key to return error\")\n\t}\n}\n\nfunc TestCheckJwtSecretKeyAllowsLocalWeakSecret(t *testing.T) {\n\toriginal := GetConfig()\n\ttestCfg := cloneDefaultConfig()\n\ttestCfg.AppEnv = \"local\"\n\ttestCfg.Jwt.SecretKey = \"default-secret-key\"\n\tsetActiveConfig(testCfg)\n\tt.Cleanup(func() { setActiveConfig(original) })\n\n\tif err := checkJwtSecretKey(); err != nil {\n\t\tt.Fatalf(\"expected local weak secret key to pass, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "config/config_test.go",
    "content": "package config\n\nimport \"testing\"\n\nfunc resetConfigReloadHandlersForTest(t *testing.T) {\n\tt.Helper()\n\treloadHandlersMu.Lock()\n\tdefer reloadHandlersMu.Unlock()\n\treloadHandlers = nil\n}\n\nfunc TestRegisterConfigReloadHandlerDeduplicatesByName(t *testing.T) {\n\tresetConfigReloadHandlersForTest(t)\n\n\tRegisterConfigReloadHandler(ConfigReloadHandler{Name: \"data\", Priority: 20})\n\tRegisterConfigReloadHandler(ConfigReloadHandler{Name: \"data\", Priority: 10})\n\n\treloadHandlersMu.RLock()\n\tdefer reloadHandlersMu.RUnlock()\n\n\tif len(reloadHandlers) != 1 {\n\t\tt.Fatalf(\"expected 1 handler, got %d\", len(reloadHandlers))\n\t}\n\tif reloadHandlers[0].Priority != 10 {\n\t\tt.Fatalf(\"expected overwritten priority 10, got %d\", reloadHandlers[0].Priority)\n\t}\n}\n\nfunc TestRegisterConfigReloadHandlerKeepsStablePriorityOrder(t *testing.T) {\n\tresetConfigReloadHandlersForTest(t)\n\n\tRegisterConfigReloadHandler(ConfigReloadHandler{Name: \"warnings\", Priority: 100})\n\tRegisterConfigReloadHandler(ConfigReloadHandler{Name: \"logger\", Priority: 10})\n\tRegisterConfigReloadHandler(ConfigReloadHandler{Name: \"data\", Priority: 20})\n\n\treloadHandlersMu.RLock()\n\tdefer reloadHandlersMu.RUnlock()\n\n\tif len(reloadHandlers) != 3 {\n\t\tt.Fatalf(\"expected 3 handlers, got %d\", len(reloadHandlers))\n\t}\n\n\tgot := []string{reloadHandlers[0].Name, reloadHandlers[1].Name, reloadHandlers[2].Name}\n\twant := []string{\"logger\", \"data\", \"warnings\"}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"unexpected order: got %v want %v\", got, want)\n\t\t}\n\t}\n}\n\nfunc TestRegisterConfigReloadHandlerIgnoresEmptyName(t *testing.T) {\n\tresetConfigReloadHandlersForTest(t)\n\n\tRegisterConfigReloadHandler(ConfigReloadHandler{Priority: 1})\n\n\treloadHandlersMu.RLock()\n\tdefer reloadHandlersMu.RUnlock()\n\n\tif len(reloadHandlers) != 0 {\n\t\tt.Fatalf(\"expected empty-name handler to be ignored, got %d handlers\", len(reloadHandlers))\n\t}\n}\n"
  },
  {
    "path": "config/provider.go",
    "content": "package config\n\n// GetConfigFrom 通过指定 provider 获取配置，并保证返回值非 nil。\nfunc GetConfigFrom(provider func() *Conf) *Conf {\n\tif provider == nil {\n\t\treturn &Conf{}\n\t}\n\n\tcfg := provider()\n\tif cfg == nil {\n\t\treturn &Conf{}\n\t}\n\treturn cfg\n}\n"
  },
  {
    "path": "config/runtime.go",
    "content": "package config\n\nimport \"reflect\"\n\n// BuildConfigDiff 生成配置差异摘要。\nfunc BuildConfigDiff(oldConfig, newConfig *Conf) ConfigDiff {\n\tdiff := ConfigDiff{}\n\tif oldConfig == nil || newConfig == nil {\n\t\treturn diff\n\t}\n\n\tdiff.LoggerChanged = !reflect.DeepEqual(oldConfig.Logger, newConfig.Logger)\n\tdiff.MysqlChanged = !reflect.DeepEqual(oldConfig.Mysql, newConfig.Mysql)\n\tdiff.RedisChanged = !reflect.DeepEqual(oldConfig.Redis, newConfig.Redis)\n\tdiff.JWTChanged = oldConfig.Jwt.TTL != newConfig.Jwt.TTL || oldConfig.Jwt.RefreshTTL != newConfig.Jwt.RefreshTTL\n\tdiff.JWTSecretChanged = oldConfig.Jwt.SecretKey != newConfig.Jwt.SecretKey\n\tdiff.BaseURLChanged = oldConfig.BaseURL != newConfig.BaseURL\n\tdiff.CORSChanged = !reflect.DeepEqual(oldConfig.CorsOrigins, newConfig.CorsOrigins) ||\n\t\t!reflect.DeepEqual(oldConfig.CorsMethods, newConfig.CorsMethods) ||\n\t\t!reflect.DeepEqual(oldConfig.CorsHeaders, newConfig.CorsHeaders) ||\n\t\t!reflect.DeepEqual(oldConfig.CorsExposeHeaders, newConfig.CorsExposeHeaders) ||\n\t\toldConfig.CorsMaxAge != newConfig.CorsMaxAge ||\n\t\toldConfig.CorsCredentials != newConfig.CorsCredentials\n\tdiff.TrustedProxiesChanged = !reflect.DeepEqual(oldConfig.TrustedProxies, newConfig.TrustedProxies)\n\tdiff.LightAppChanged = oldConfig.BasePath != newConfig.BasePath ||\n\t\toldConfig.AppEnv != newConfig.AppEnv ||\n\t\toldConfig.Debug != newConfig.Debug ||\n\t\toldConfig.WatchConfig != newConfig.WatchConfig ||\n\t\toldConfig.Language != newConfig.Language ||\n\t\toldConfig.AllowDegradedStartup != newConfig.AllowDegradedStartup\n\n\tif diff.LoggerChanged {\n\t\tdiff.ChangedFields = append(diff.ChangedFields, \"logger.*\")\n\t}\n\tif diff.MysqlChanged {\n\t\tdiff.ChangedFields = append(diff.ChangedFields, \"mysql.*\")\n\t}\n\tif diff.RedisChanged {\n\t\tdiff.ChangedFields = append(diff.ChangedFields, \"redis.*\")\n\t}\n\tif diff.JWTChanged {\n\t\tdiff.ChangedFields = append(diff.ChangedFields, \"jwt.ttl\", \"jwt.refresh_ttl\")\n\t}\n\tif diff.JWTSecretChanged {\n\t\tdiff.ChangedFields = append(diff.ChangedFields, \"jwt.secret_key\")\n\t\tdiff.RestartRequiredFields = append(diff.RestartRequiredFields, \"jwt.secret_key\")\n\t}\n\tif diff.BaseURLChanged {\n\t\tdiff.ChangedFields = append(diff.ChangedFields, \"app.base_url\")\n\t}\n\tif diff.CORSChanged {\n\t\tdiff.ChangedFields = append(diff.ChangedFields, \"app.cors_*\")\n\t}\n\tif diff.TrustedProxiesChanged {\n\t\tdiff.ChangedFields = append(diff.ChangedFields, \"app.trusted_proxies\")\n\t\tdiff.RestartRequiredFields = append(diff.RestartRequiredFields, \"app.trusted_proxies\")\n\t}\n\tif oldConfig.Language != newConfig.Language {\n\t\tdiff.RestartRequiredFields = append(diff.RestartRequiredFields, \"app.language\")\n\t}\n\tif oldConfig.AllowDegradedStartup != newConfig.AllowDegradedStartup {\n\t\tdiff.ChangedFields = append(diff.ChangedFields, \"app.allow_degraded_startup\")\n\t\tdiff.RestartRequiredFields = append(diff.RestartRequiredFields, \"app.allow_degraded_startup\")\n\t}\n\n\treturn diff\n}\n\n// BuildAppliedConfig 返回当前进程应采用的配置快照。\n// 对不支持热更新的字段保持旧值，避免配置快照与实际运行状态不一致。\nfunc BuildAppliedConfig(oldConfig, newConfig *Conf, diff ConfigDiff) *Conf {\n\tif oldConfig == nil {\n\t\treturn newConfig\n\t}\n\tapplied := *newConfig\n\tapplied.AppConfig = cloneAppConfig(newConfig.AppConfig)\n\tapplied.Queue = cloneQueueConfig(newConfig.Queue)\n\n\tif diff.JWTSecretChanged {\n\t\tapplied.Jwt.SecretKey = oldConfig.Jwt.SecretKey\n\t}\n\tif diff.TrustedProxiesChanged {\n\t\tapplied.TrustedProxies = cloneStringSlice(oldConfig.TrustedProxies)\n\t}\n\tif oldConfig.Language != newConfig.Language {\n\t\tapplied.Language = oldConfig.Language\n\t}\n\tif oldConfig.AllowDegradedStartup != newConfig.AllowDegradedStartup {\n\t\tapplied.AllowDegradedStartup = oldConfig.AllowDegradedStartup\n\t}\n\n\treturn &applied\n}\n"
  },
  {
    "path": "config/runtime_test.go",
    "content": "package config\n\nimport (\n\t\"testing\"\n\n\t. \"github.com/wannanbigpig/gin-layout/config/autoload\"\n)\n\nfunc TestBuildConfigDiff(t *testing.T) {\n\toldCfg := &Conf{\n\t\tAppConfig: App,\n\t\tMysql:     Mysql,\n\t\tRedis:     Redis,\n\t\tLogger:    Logger,\n\t\tJwt:       Jwt,\n\t}\n\tnewCfg := &Conf{\n\t\tAppConfig: App,\n\t\tMysql:     Mysql,\n\t\tRedis:     Redis,\n\t\tLogger:    Logger,\n\t\tJwt:       Jwt,\n\t}\n\n\tnewCfg.Logger.Output = \"stderr\"\n\tnewCfg.Redis.Enable = true\n\tnewCfg.BaseURL = \"https://example.com\"\n\tnewCfg.CorsOrigins = []string{\"https://ui.example.com\"}\n\tnewCfg.TrustedProxies = []string{\"10.0.0.0/8\"}\n\tnewCfg.Jwt.TTL = 999\n\tnewCfg.Jwt.SecretKey = \"new-secret\"\n\tnewCfg.Language = \"en\"\n\n\tdiff := BuildConfigDiff(oldCfg, newCfg)\n\tif !diff.LoggerChanged {\n\t\tt.Fatalf(\"expected logger change\")\n\t}\n\tif !diff.RedisChanged {\n\t\tt.Fatalf(\"expected redis change\")\n\t}\n\tif !diff.JWTChanged {\n\t\tt.Fatalf(\"expected jwt ttl change\")\n\t}\n\tif !diff.JWTSecretChanged {\n\t\tt.Fatalf(\"expected jwt secret change\")\n\t}\n\tif !diff.BaseURLChanged {\n\t\tt.Fatalf(\"expected base_url change\")\n\t}\n\tif !diff.CORSChanged {\n\t\tt.Fatalf(\"expected cors change\")\n\t}\n\tif !diff.TrustedProxiesChanged {\n\t\tt.Fatalf(\"expected trusted proxies change\")\n\t}\n\tif len(diff.RestartRequiredFields) == 0 {\n\t\tt.Fatalf(\"expected restart-required fields\")\n\t}\n}\n\nfunc TestBuildAppliedConfigKeepsUnsupportedFields(t *testing.T) {\n\toldCfg := &Conf{\n\t\tAppConfig: App,\n\t\tMysql:     Mysql,\n\t\tRedis:     Redis,\n\t\tLogger:    Logger,\n\t\tJwt: JwtConfig{\n\t\t\tTTL:        100,\n\t\t\tRefreshTTL: 10,\n\t\t\tSecretKey:  \"old-secret\",\n\t\t},\n\t}\n\toldCfg.TrustedProxies = []string{\"127.0.0.1\"}\n\toldCfg.Language = \"zh_CN\"\n\n\tnewCfg := &Conf{\n\t\tAppConfig: App,\n\t\tMysql:     Mysql,\n\t\tRedis:     Redis,\n\t\tLogger:    Logger,\n\t\tJwt: JwtConfig{\n\t\t\tTTL:        200,\n\t\t\tRefreshTTL: 20,\n\t\t\tSecretKey:  \"new-secret\",\n\t\t},\n\t}\n\tnewCfg.TrustedProxies = []string{\"10.0.0.0/8\"}\n\tnewCfg.Language = \"en\"\n\tnewCfg.BaseURL = \"https://cdn.example.com\"\n\n\tdiff := BuildConfigDiff(oldCfg, newCfg)\n\tapplied := BuildAppliedConfig(oldCfg, newCfg, diff)\n\n\tif applied.Jwt.SecretKey != \"old-secret\" {\n\t\tt.Fatalf(\"expected jwt secret to remain old, got %q\", applied.Jwt.SecretKey)\n\t}\n\tif applied.Language != \"zh_CN\" {\n\t\tt.Fatalf(\"expected language to remain old, got %q\", applied.Language)\n\t}\n\tif len(applied.TrustedProxies) != 1 || applied.TrustedProxies[0] != \"127.0.0.1\" {\n\t\tt.Fatalf(\"expected trusted proxies to remain old, got %#v\", applied.TrustedProxies)\n\t}\n\tif applied.BaseURL != \"https://cdn.example.com\" {\n\t\tt.Fatalf(\"expected supported field base_url to update, got %q\", applied.BaseURL)\n\t}\n\tif applied.Jwt.TTL != 200 {\n\t\tt.Fatalf(\"expected supported jwt ttl to update, got %v\", applied.Jwt.TTL)\n\t}\n}\n"
  },
  {
    "path": "config/testing_helper.go",
    "content": "package config\n\n// CloneConf 返回配置的深拷贝，避免测试场景下共享可变引用。\nfunc CloneConf(src *Conf) *Conf {\n\tif src == nil {\n\t\treturn &Conf{}\n\t}\n\n\tcloned := *src\n\tcloned.AppConfig = cloneAppConfig(src.AppConfig)\n\tcloned.Queue = cloneQueueConfig(src.Queue)\n\treturn &cloned\n}\n\n// ReplaceConfigForTesting 替换当前配置并返回恢复函数。\nfunc ReplaceConfigForTesting(cfg *Conf) func() {\n\tprevious := CloneConf(GetConfig())\n\n\tif cfg == nil {\n\t\tsetActiveConfig(&Conf{})\n\t} else {\n\t\tsetActiveConfig(CloneConf(cfg))\n\t}\n\n\treturn func() {\n\t\tsetActiveConfig(previous)\n\t}\n}\n\n// UpdateConfigForTesting 在当前配置副本上应用变更并返回恢复函数。\nfunc UpdateConfigForTesting(mutator func(cfg *Conf)) func() {\n\tnext := CloneConf(GetConfig())\n\tif mutator != nil {\n\t\tmutator(next)\n\t}\n\treturn ReplaceConfigForTesting(next)\n}\n"
  },
  {
    "path": "data/data.go",
    "content": "package data\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"sync\"\n\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n)\n\nvar once sync.Once\nvar initErr error\n\n// InitData 按配置初始化 MySQL 和 Redis 数据源。\nfunc InitData() error {\n\tonce.Do(func() {\n\t\tcfg := c.GetConfig()\n\t\tvar errs []error\n\n\t\tif cfg.Mysql.Enable {\n\t\t\tif err := initMysql(); err != nil {\n\t\t\t\terrs = append(errs, fmt.Errorf(\"mysql init error: %w\", err))\n\t\t\t}\n\t\t}\n\n\t\tif cfg.Redis.Enable {\n\t\t\tif err := initRedis(); err != nil {\n\t\t\t\terrs = append(errs, fmt.Errorf(\"redis init error: %w\", err))\n\t\t\t}\n\t\t}\n\n\t\tif len(errs) > 0 {\n\t\t\tinitErr = errors.Join(errs...)\n\t\t}\n\t})\n\n\treturn initErr\n}\n\n// Shutdown 关闭当前已初始化的数据源。\nfunc Shutdown() error {\n\tvar firstErr error\n\n\tif err := CloseRedis(); err != nil && firstErr == nil {\n\t\tfirstErr = err\n\t}\n\tif err := CloseMysql(); err != nil && firstErr == nil {\n\t\tfirstErr = err\n\t}\n\n\treturn firstErr\n}\n"
  },
  {
    "path": "data/data_test.go",
    "content": "package data\n\nimport \"testing\"\n\nfunc TestShutdownWithoutInitializedResources(t *testing.T) {\n\tif err := Shutdown(); err != nil {\n\t\tt.Fatalf(\"shutdown should be safe without initialized resources: %v\", err)\n\t}\n\tif err := Shutdown(); err != nil {\n\t\tt.Fatalf(\"shutdown should be idempotent: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "data/migrations/20260425000001_init_table.down.sql",
    "content": "BEGIN;\n\n-- 删除系统参数与字典相关表\nDROP TABLE IF EXISTS `sys_dict_item_i18n`;\nDROP TABLE IF EXISTS `sys_dict_type_i18n`;\nDROP TABLE IF EXISTS `sys_config_i18n`;\nDROP TABLE IF EXISTS `sys_dict_item`;\nDROP TABLE IF EXISTS `sys_dict_type`;\nDROP TABLE IF EXISTS `sys_config`;\n-- 删除任务中心相关表\nDROP TABLE IF EXISTS `cron_task_states`;\nDROP TABLE IF EXISTS `task_run_events`;\nDROP TABLE IF EXISTS `task_runs`;\nDROP TABLE IF EXISTS `task_definitions`;\n-- 删除管理员表\nDROP TABLE IF EXISTS `admin_user`;\n-- 删除路由表\nDROP TABLE IF EXISTS `api`;\n-- 删除路由分组表\nDROP TABLE IF EXISTS `api_group`;\n-- 删除菜单多语言标题表\nDROP TABLE IF EXISTS `menu_i18n`;\n-- 删除菜单表\nDROP TABLE IF EXISTS `menu`;\n-- 删除部门表\nDROP TABLE IF EXISTS `department`;\n-- 删除角色表\nDROP TABLE IF EXISTS `role`;\n-- 删除用户部门映射表\nDROP TABLE IF EXISTS `admin_user_department_map`;\n-- 删除部门角色映射表\nDROP TABLE IF EXISTS `department_role_map`;\n-- 删除用户菜单映射表\nDROP TABLE IF EXISTS `admin_user_menu_map`;\n-- 删除菜单权限映射表\nDROP TABLE IF EXISTS `menu_api_map`;\n-- 删除角色菜单映射表\nDROP TABLE IF EXISTS `role_menu_map`;\n-- 删除用户角色映射表\nDROP TABLE IF EXISTS `admin_user_role_map`;\n-- 删除请求日志表\nDROP TABLE IF EXISTS `request_logs`;\n-- 删除登录安全状态表\nDROP TABLE IF EXISTS `login_security_state`;\n-- 删除管理员登录日志表\nDROP TABLE IF EXISTS `admin_login_logs`;\n-- 删除casbin规则表\nDROP TABLE IF EXISTS `casbin_rule`;\n-- 删除文件上传表\nDROP TABLE IF EXISTS `upload_file_references`;\nDROP TABLE IF EXISTS `upload_file_folders`;\nDROP TABLE IF EXISTS `upload_files`;\n\nCOMMIT;\n"
  },
  {
    "path": "data/migrations/20260425000001_init_table.up.sql",
    "content": "BEGIN;\n\n-- 创建管理员表\nCREATE TABLE IF NOT EXISTS `admin_user`\n(\n    `id`                int unsigned                                                 NOT NULL AUTO_INCREMENT,\n    `nickname`          varchar(30)                                                  NOT NULL DEFAULT '' COMMENT '昵称',\n    `username`          varchar(30)                                                  NOT NULL DEFAULT '' COMMENT '用户名',\n    `password`          varchar(255)                                                 NOT NULL DEFAULT '' COMMENT '密码',\n    `phone_number`      varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '手机号',\n    `full_phone_number` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '带区号的手机号',\n    `country_code`      varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '国际区号',\n    `email`             varchar(120)                                                 NOT NULL DEFAULT '' COMMENT '邮箱',\n    `avatar`            varchar(255)                                                 NOT NULL DEFAULT '' COMMENT '头像',\n    `status`            tinyint(1)                                                   NOT NULL DEFAULT '1' COMMENT '状态 1启用 0禁用',\n    `is_super_admin`    tinyint(1)                                                   NOT NULL DEFAULT '1' COMMENT '是否超级管理员（拥有所有权限） 1是 0不是',\n    `last_login`        datetime                                                              DEFAULT NULL COMMENT '最后登录时间',\n    `last_ip`           varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '最后登录IP',\n    `created_at`        datetime                                                              DEFAULT NULL,\n    `updated_at`        datetime                                                              DEFAULT NULL,\n    `deleted_at`        int                                                          NOT NULL DEFAULT '0' COMMENT '删除时间',\n    PRIMARY KEY (`id`),\n    UNIQUE KEY `adu_u_d` (`username`, `deleted_at`),\n    KEY `idx_status_deleted_at` (`status`, `deleted_at`),\n    KEY `idx_full_phone_number_deleted_at` (`full_phone_number`, `deleted_at`),\n    KEY `idx_email_deleted_at` (`email`, `deleted_at`),\n    KEY `idx_created_at_deleted_at` (`created_at`, `deleted_at`)\n) ENGINE = InnoDB\n  AUTO_INCREMENT = 10000\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_0900_ai_ci COMMENT ='后台管理用户表';\n\n-- 创建权限分组表\nCREATE TABLE IF NOT EXISTS `api_group`\n(\n    `id`         int unsigned                                          NOT NULL AUTO_INCREMENT,\n    `pid`        int unsigned                                          NOT NULL DEFAULT '0' COMMENT '上级组织id',\n    `code`       varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT 'code',\n    `name`       varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '分组名称',\n    `created_at` datetime                                                       DEFAULT NULL,\n    `updated_at` datetime                                                       DEFAULT NULL,\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `ag_code` (`code`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='权限分组表';\n\n-- 创建权限表\nCREATE TABLE IF NOT EXISTS `api`\n(\n    `id`           int unsigned                                           NOT NULL AUTO_INCREMENT,\n    `code`         varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '权限唯一code码',\n    `group_code`   varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '分组唯一code码',\n    `name`         varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '权限名称',\n    `description`  varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '描述',\n    `method`       varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '接口请求方法',\n    `route`        varchar(160) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '接口路由',\n    `func`         varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '接口方法',\n    `func_path`    varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '接口方法路径',\n    `is_auth`      tinyint unsigned                                       NOT NULL DEFAULT '1' COMMENT '接口鉴权模式 0无需登录 1需要登录 2需要登录且需要API权限',\n    `is_effective` tinyint unsigned                                       NOT NULL DEFAULT '1' COMMENT '接口是否可用 1是 0否',\n    `sort`         int unsigned                                           NOT NULL DEFAULT '0' COMMENT '排序',\n    `created_at`   datetime                                                        DEFAULT NULL,\n    `updated_at`   datetime                                                        DEFAULT NULL,\n    `deleted_at`   int                                                    NOT NULL DEFAULT '0' COMMENT '删除时间戳',\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `api_uniq_code_del` (`code`, `deleted_at`) USING BTREE,\n    KEY `api_idx_route_method_deleted_at` (`route`, `method`, `deleted_at`) USING BTREE,\n    KEY `idx_group_code_deleted_at_sort` (`group_code`, `deleted_at`, `sort`) USING BTREE,\n    KEY `idx_is_auth_deleted_at` (`is_auth`, `deleted_at`) USING BTREE,\n    KEY `idx_updated_at` (`updated_at`) USING BTREE\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='权限表';\n\n-- 创建菜单表\nCREATE TABLE IF NOT EXISTS `menu`\n(\n    `id`                int                                                    NOT NULL AUTO_INCREMENT,\n    `icon`              varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '图标',\n    `code`              varchar(120) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '前端权限标识',\n    `path`              varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '前端路由路径',\n    `full_path`         varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '完整前端路由路径',\n    `redirect`          varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '重定向路由名称',\n    `name`              varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '前端路由名称',\n    `component`         varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '前端组件路径',\n    `animate_enter`     varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '进入动画，动画类参考https://animate.style/',\n    `animate_leave`     varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '离开动画，动画类参考https://animate.style/',\n    `animate_duration`  float(2, 2)                                            NOT NULL DEFAULT '0.00' COMMENT '动画持续时间',\n    `is_show`           tinyint                                                NOT NULL DEFAULT '0' COMMENT '是否显示，1是 0否',\n    `status`            tinyint                                                NOT NULL DEFAULT '0' COMMENT '状态，1正常 0禁用',\n    `is_auth`           tinyint                                                NOT NULL DEFAULT '0' COMMENT '是否需要授权，1是 0否 ',\n    `is_external_links` tinyint                                                NOT NULL DEFAULT '0' COMMENT '是否外链，1是 0否 ',\n    `is_new_window`     tinyint                                                NOT NULL DEFAULT '0' COMMENT '是否新窗口打开, 1是 0否',\n    `sort`              int                                                    NOT NULL DEFAULT '0' COMMENT '排序，数字越大，排名越靠前',\n    `type`              tinyint                                                NOT NULL DEFAULT '1' COMMENT '菜单类型，1目录，2菜单，3按钮',\n    `pid`               int                                                    NOT NULL DEFAULT '0' COMMENT '上级菜单id',\n    `level`             tinyint                                                NOT NULL DEFAULT '0' COMMENT '层级',\n    `pids`              varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '层级序列，多个用英文逗号隔开',\n    `children_num`      int                                                    NOT NULL DEFAULT '0' COMMENT '子集数量',\n    `description`       varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '描述',\n    `created_at`        datetime                                                        DEFAULT NULL COMMENT '创建时间',\n    `updated_at`        datetime                                                        DEFAULT NULL COMMENT '更新时间',\n    `deleted_at`        int                                                    NOT NULL DEFAULT '0' COMMENT '删除时间戳',\n    PRIMARY KEY (`id`) USING BTREE,\n    KEY `uniq_code_del` (`code`, `deleted_at`) USING BTREE,\n    KEY `idx_name_del` (`name`, `deleted_at`) USING BTREE,\n    KEY `idx_path_del` (`path`, `deleted_at`) USING BTREE,\n    KEY `idx_is_auth_del` (`is_auth`, `deleted_at`) USING BTREE,\n    KEY `idx_status_del` (`status`, `deleted_at`) USING BTREE,\n    KEY `idx_pid_deleted_at_sort_id` (`pid`, `deleted_at`, `sort`, `id`) USING BTREE,\n    KEY `idx_pids_deleted_at` (`pids`, `deleted_at`) USING BTREE\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='菜单表';\n\n-- 创建菜单多语言标题表\nCREATE TABLE IF NOT EXISTS `menu_i18n`\n(\n    `id`         int unsigned                                           NOT NULL AUTO_INCREMENT,\n    `menu_id`    int unsigned                                           NOT NULL DEFAULT '0' COMMENT '菜单ID',\n    `locale`     varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '语言代码，如 zh-CN、en-US',\n    `title`      varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '菜单标题',\n    `created_at` datetime                                                        DEFAULT NULL,\n    `updated_at` datetime                                                        DEFAULT NULL,\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `uniq_menu_id_locale` (`menu_id`, `locale`) USING BTREE,\n    KEY `idx_locale_menu_id` (`locale`, `menu_id`) USING BTREE\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='菜单多语言标题表';\n\n-- 创建组织表\nCREATE TABLE IF NOT EXISTS `department`\n(\n    `id`          int unsigned                                           NOT NULL AUTO_INCREMENT,\n    `code`        varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '部门业务编码',\n    `is_system`   tinyint                                                NOT NULL DEFAULT '0' COMMENT '是否系统保留对象,1是 0否',\n    `pid`         int unsigned                                           NOT NULL DEFAULT '0' COMMENT '上级部门id',\n    `pids`        varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '',\n    `name`        varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '部门名称',\n    `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '描述',\n    `level`       tinyint                                                NOT NULL DEFAULT '1' COMMENT '层级',\n    `sort`        int                                                    NOT NULL DEFAULT '0' COMMENT '排序',\n    `children_num` int                                                   NOT NULL DEFAULT '0' COMMENT '子集数量',\n    `user_number` int                                                    NOT NULL DEFAULT '0' COMMENT '部门用户数量',\n    `created_at`  datetime                                                        DEFAULT NULL,\n    `updated_at`  datetime                                                        DEFAULT NULL,\n    `deleted_at`  int                                                    NOT NULL DEFAULT '0' COMMENT '删除时间戳',\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `uniq_code_deleted_at` (`code`, `deleted_at`),\n    KEY `idx_name_deleted_at` (`name`, `deleted_at`),\n    KEY `idx_is_system_deleted_at` (`is_system`, `deleted_at`),\n    KEY `idx_pid_deleted_at_sort_id` (`pid`, `deleted_at`, `sort`, `id`),\n    KEY `idx_pids_deleted_at` (`pids`, `deleted_at`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='部门表';\n\n-- 创建角色表\nCREATE TABLE IF NOT EXISTS `role`\n(\n    `id`          int unsigned                                           NOT NULL AUTO_INCREMENT,\n    `code`        varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '角色业务编码',\n    `is_system`   tinyint                                                NOT NULL DEFAULT '0' COMMENT '是否系统保留对象,1是 0否',\n    `pid`         int unsigned                                           NOT NULL DEFAULT '0' COMMENT '上级id',\n    `pids`        varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '上级id路径链',\n    `name`        varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '角色名称',\n    `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '描述',\n    `level`       tinyint                                                NOT NULL DEFAULT '1' COMMENT '层级',\n    `sort`        mediumint                                              NOT NULL DEFAULT '0' COMMENT '排序',\n    `children_num` int unsigned                                          NOT NULL DEFAULT '0' COMMENT '子集数量',\n    `status`      tinyint                                                NOT NULL DEFAULT '0' COMMENT '是否启用状态,1是，0否',\n    `created_at`  datetime                                                        DEFAULT NULL,\n    `updated_at`  datetime                                                        DEFAULT NULL,\n    `deleted_at`  int                                                    NOT NULL DEFAULT '0' COMMENT '删除时间戳',\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `uniq_code_deleted_at` (`code`, `deleted_at`),\n    KEY `idx_name_deleted_at` (`name`, `deleted_at`),\n    KEY `idx_is_system_deleted_at` (`is_system`, `deleted_at`),\n    KEY `idx_pid_deleted_at_sort_id` (`pid`, `deleted_at`, `sort`, `id`),\n    KEY `idx_status_deleted_at` (`status`, `deleted_at`),\n    KEY `idx_pids_deleted_at` (`pids`, `deleted_at`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='角色表';\n\n-- 创建用户部门映射表\nCREATE TABLE IF NOT EXISTS `admin_user_department_map`\n(\n    `id`         int unsigned NOT NULL AUTO_INCREMENT,\n    `uid`        int unsigned NOT NULL DEFAULT '0' COMMENT 'admin_users表id',\n    `dept_id`    int unsigned NOT NULL DEFAULT '0' COMMENT '部门id，department表id',\n    `is_admin`   tinyint      NOT NULL DEFAULT '0' COMMENT '是否管理员，1是，0否',\n    `created_at` datetime              DEFAULT NULL,\n    `updated_at` datetime              DEFAULT NULL,\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `idx_uid_dept_id` (`uid`, `dept_id`),\n    KEY `idx_dept_id_uid` (`dept_id`, `uid`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='用户部门映射表';\n\n-- 创建菜单权限映射表\nCREATE TABLE IF NOT EXISTS `menu_api_map`\n(\n    `id`         int unsigned NOT NULL AUTO_INCREMENT,\n    `menu_id`    int unsigned NOT NULL DEFAULT '0' COMMENT '菜单id,对应menu表id',\n    `api_id`     int unsigned NOT NULL DEFAULT '0' COMMENT '接口id,对应api表id',\n    `created_at` datetime              DEFAULT NULL,\n    `updated_at` datetime              DEFAULT NULL,\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `idx_menu_id_api_id` (`menu_id`, `api_id`),\n    KEY `idx_api_id_menu_id` (`api_id`, `menu_id`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='菜单权限映射表';\n\n-- menu_api_map 维护菜单与 API 的静态关系。\n-- api 表由 `go-layout command api-route` 或 `init-system` 写入后，\n-- 默认菜单与 API 的初始绑定由 Go 初始化逻辑幂等补齐。\n\n-- 创建角色菜单映射表\nCREATE TABLE IF NOT EXISTS `role_menu_map`\n(\n    `id`         int unsigned NOT NULL AUTO_INCREMENT,\n    `role_id`    int unsigned NOT NULL DEFAULT '0' COMMENT '角色id,对应roles表id',\n    `menu_id`    int unsigned NOT NULL DEFAULT '0' COMMENT '菜单id,对应menu表id',\n    `created_at` datetime              DEFAULT NULL,\n    `updated_at` datetime              DEFAULT NULL,\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `idx_role_id_menu_id` (`role_id`, `menu_id`),\n    KEY `idx_menu_id_role_id` (`menu_id`, `role_id`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='角色菜单映射表';\n\n-- 创建用户菜单映射表\nCREATE TABLE IF NOT EXISTS `admin_user_menu_map`\n(\n    `id`         int unsigned NOT NULL AUTO_INCREMENT,\n    `uid`        int unsigned NOT NULL DEFAULT '0' COMMENT 'uid,admin_users表id',\n    `menu_id`    int unsigned NOT NULL DEFAULT '0' COMMENT '菜单id',\n    `created_at` datetime              DEFAULT NULL,\n    `updated_at` datetime              DEFAULT NULL,\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `idx_uid_menu_id` (`uid`, `menu_id`),\n    KEY `idx_menu_id_uid` (`menu_id`, `uid`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='用户菜单映射表';\n\n-- 创建部门角色映射表\nCREATE TABLE IF NOT EXISTS `department_role_map`\n(\n    `id`         int unsigned NOT NULL AUTO_INCREMENT,\n    `dept_id`    int unsigned NOT NULL DEFAULT '0' COMMENT '部门id,对应department表id',\n    `role_id`    int unsigned NOT NULL DEFAULT '0' COMMENT '角色id,对应roles表id',\n    `created_at` datetime              DEFAULT NULL,\n    `updated_at` datetime              DEFAULT NULL,\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `idx_dept_id_role_id` (`dept_id`, `role_id`),\n    KEY `idx_role_id_dept_id` (`role_id`, `dept_id`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='部门角色映射表';\n\n-- 创建用户角色映射表\nCREATE TABLE IF NOT EXISTS `admin_user_role_map`\n(\n    `id`         int unsigned NOT NULL AUTO_INCREMENT,\n    `uid`        int unsigned NOT NULL DEFAULT '0' COMMENT 'uid,admin_users表id',\n    `role_id`    int unsigned NOT NULL DEFAULT '0' COMMENT '角色id',\n    `created_at` datetime              DEFAULT NULL,\n    `updated_at` datetime              DEFAULT NULL,\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `idx_uid_role_id` (`uid`, `role_id`),\n    KEY `idx_role_id_uid` (`role_id`, `uid`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_bin\n  ROW_FORMAT = DYNAMIC COMMENT ='用户角色映射表';\n\n-- 创建管理员登录日志表\nCREATE TABLE IF NOT EXISTS `admin_login_logs`\n(\n    `id`                 bigint unsigned  NOT NULL AUTO_INCREMENT,\n    `uid`                int unsigned     NOT NULL DEFAULT '0' COMMENT '用户ID（登录失败时为0）',\n    `username`           varchar(50)      NOT NULL DEFAULT '' COMMENT '登录账号',\n    `jwt_id`             char(36)         NOT NULL DEFAULT '' COMMENT 'JWT唯一标识(jti claim)',\n    `access_token`       text                      DEFAULT NULL COMMENT '访问令牌（加密保存）',\n    `refresh_token`      text                      DEFAULT NULL COMMENT '刷新令牌（加密保存）',\n    `token_hash`         char(64)         NOT NULL DEFAULT '' COMMENT 'Token的SHA256哈希值',\n    `refresh_token_hash` char(64)         NOT NULL DEFAULT '' COMMENT 'Refresh Token的哈希值',\n    `ip`                 varchar(45)      NOT NULL DEFAULT '' COMMENT '登录IP(支持IPv6)',\n    `user_agent`         varchar(1024)    NOT NULL DEFAULT '' COMMENT '用户代理（浏览器/设备信息）',\n    `os`                 varchar(50)      NOT NULL DEFAULT '' COMMENT '操作系统',\n    `browser`            varchar(50)      NOT NULL DEFAULT '' COMMENT '浏览器',\n    `execution_time`     int(11)          NOT NULL DEFAULT '0' COMMENT '登录耗时（毫秒）',\n    `login_status`       tinyint(1)       NOT NULL DEFAULT '1' COMMENT '登录状态：1=成功, 0=失败',\n    `login_fail_reason`  varchar(255)     NOT NULL DEFAULT '' COMMENT '登录失败原因',\n    `type`               tinyint(1)       NOT NULL DEFAULT '1' COMMENT '操作类型：1=登录操作, 2=刷新token',\n    `is_revoked`         tinyint(1)       NOT NULL DEFAULT '0' COMMENT '是否被撤销：0=否, 1=是',\n    `revoked_code`       tinyint(1)       NOT NULL DEFAULT '0' COMMENT '撤销原因码：1=用户主动登出（退出登录）, 2=系统强制登出（账号被封）, 3=系统刷新token, 4=用户禁用（针对某个设备下线操作）, 5=其他原因, 6=用户自己修改密码, 7=管理员修改用户密码',\n    `revoked_reason`     varchar(255)     NOT NULL DEFAULT '' COMMENT '撤销原因',\n    `revoked_at`         datetime                  DEFAULT NULL COMMENT '撤销时间',\n    `token_expires`      datetime                  DEFAULT NULL COMMENT 'Token过期时间',\n    `refresh_expires`    datetime                  DEFAULT NULL COMMENT 'Refresh Token过期时间',\n    `created_at`         datetime                  DEFAULT NULL,\n    `updated_at`         datetime                  DEFAULT NULL,\n    `deleted_at`         int              NOT NULL DEFAULT '0' COMMENT '删除时间戳',\n    PRIMARY KEY (`id`),\n    KEY `aall_deleted_at_created_at` (`deleted_at`, `created_at`),\n    KEY `aall_login_status_deleted_at_created_at` (`login_status`, `deleted_at`, `created_at`),\n    KEY `aall_is_revoked_deleted_at_revoked_at` (`is_revoked`, `deleted_at`, `revoked_at`),\n    KEY `aall_uid_deleted_at_is_revoked_login_status_token_expires` (`uid`, `deleted_at`, `is_revoked`, `login_status`, `token_expires`),\n    KEY `aall_jwt_id_deleted_at_is_revoked` (`jwt_id`, `deleted_at`, `is_revoked`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='管理员登录日志表';\n\n-- 创建登录安全状态表\nCREATE TABLE IF NOT EXISTS `login_security_state`\n(\n    `id`             int unsigned NOT NULL AUTO_INCREMENT,\n    `username`       varchar(50)  NOT NULL DEFAULT '' COMMENT '登录账号',\n    `fail_count`     int unsigned NOT NULL DEFAULT '0' COMMENT '连续失败次数',\n    `lock_until`     datetime              DEFAULT NULL COMMENT '锁定截止时间',\n    `last_failed_at` datetime              DEFAULT NULL COMMENT '最近失败时间',\n    `created_at`     datetime              DEFAULT NULL,\n    `updated_at`     datetime              DEFAULT NULL,\n    PRIMARY KEY (`id`),\n    UNIQUE KEY `lss_username` (`username`),\n    KEY `lss_lock_until` (`lock_until`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='登录安全状态表';\n\n-- 创建请求日志表\nCREATE TABLE IF NOT EXISTS `request_logs`\n(\n    `id`              bigint(20)   NOT NULL AUTO_INCREMENT COMMENT '日志ID',\n    `request_id`      varchar(64)  NOT NULL DEFAULT '' COMMENT '请求唯一标识',\n    `jwt_id`          varchar(36)  NOT NULL DEFAULT '' COMMENT '请求授权的jwtId',\n    `operator_id`     bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '操作ID（用户ID）',\n    `ip`              varchar(45)  NOT NULL DEFAULT '' COMMENT '客户端IP地址',\n    `user_agent`      varchar(1024) NOT NULL DEFAULT '' COMMENT '用户代理（浏览器/设备信息）',\n    `os`             varchar(50)  NOT NULL DEFAULT '' COMMENT '操作系统',\n    `browser`        varchar(50)  NOT NULL DEFAULT '' COMMENT '浏览器',\n    `method`          varchar(10)  NOT NULL DEFAULT '' COMMENT 'HTTP请求方法（GET/POST等）',\n    `base_url`        varchar(160) NOT NULL DEFAULT '' COMMENT '请求基础URL',\n    `operation_name`  varchar(255) NOT NULL DEFAULT '' COMMENT '操作名称',\n    `operation_status` int(11) NOT NULL DEFAULT '0' COMMENT '操作状态码（响应返回的code，0=成功，其他=失败）',\n    `is_high_risk`    tinyint(1)    NOT NULL DEFAULT '0' COMMENT '是否高危操作 1是 0否',\n    `operator_account` varchar(50) NOT NULL DEFAULT '' COMMENT '操作账号',\n    `operator_name`   varchar(50)  NOT NULL DEFAULT '' COMMENT '操作人员',\n    `request_headers` text                  DEFAULT NULL COMMENT '请求头（JSON格式）',\n    `request_query`   text                  DEFAULT NULL COMMENT '请求参数',\n    `request_body`    text                  DEFAULT NULL COMMENT '请求体',\n    `change_diff`     longtext              DEFAULT NULL COMMENT '关键变更前后差异（JSON）',\n    `response_status` int(11)      NOT NULL DEFAULT '0' COMMENT '响应状态码',\n    `response_body`   text                  DEFAULT NULL COMMENT '响应体',\n    `response_header` text                  DEFAULT NULL COMMENT '响应头',\n    `execution_time`  DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '执行时间（毫秒，支持小数，最多4位）',\n    `created_at`      datetime              DEFAULT NULL COMMENT '创建时间',\n    `updated_at`      datetime              DEFAULT NULL COMMENT '更新时间',\n    PRIMARY KEY (`id`),\n    KEY `rl_request_id` (`request_id`),\n    KEY `rl_operator_id_created_at` (`operator_id`, `created_at`),\n    KEY `rl_base_url_method_created_at` (`base_url`, `method`, `created_at`),\n    KEY `rl_operation_status_created_at` (`operation_status`, `created_at`),\n    KEY `rl_response_status_operator_id_created_at` (`response_status`, `operator_id`, `created_at`),\n    KEY `rl_operator_account_created_at` (`operator_account`, `created_at`),\n    KEY `rl_created_at` (`created_at`),\n    KEY `rl_jwt_id` (`jwt_id`),\n    KEY `rl_is_high_risk_created_at` (`is_high_risk`, `created_at`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='请求日志表';\n\n\nCREATE TABLE IF NOT EXISTS `casbin_rule`\n(\n    `id`    bigint unsigned NOT NULL AUTO_INCREMENT,\n    `ptype` varchar(100) DEFAULT NULL,\n    `v0`    varchar(100) DEFAULT NULL,\n    `v1`    varchar(100) DEFAULT NULL,\n    `v2`    varchar(100) DEFAULT NULL,\n    `v3`    varchar(100) DEFAULT NULL,\n    `v4`    varchar(100) DEFAULT NULL,\n    `v5`    varchar(100) DEFAULT NULL,\n    PRIMARY KEY (`id`),\n    UNIQUE KEY `idx_casbin_rule` (`ptype`, `v0`, `v1`, `v2`, `v3`, `v4`, `v5`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_0900_ai_ci COMMENT ='casbin_rule表';\n\nCREATE TABLE IF NOT EXISTS `upload_files`\n(\n    `id`          int unsigned NOT NULL AUTO_INCREMENT,\n    `uid`         int unsigned NOT NULL DEFAULT '0' COMMENT '用户ID',\n    `folder_id`   int unsigned NOT NULL DEFAULT '0' COMMENT '逻辑目录ID',\n    `logical_path` varchar(1024) NOT NULL DEFAULT '/' COMMENT '逻辑路径快照',\n    `display_name` varchar(255) NOT NULL DEFAULT '' COMMENT '展示名称',\n    `origin_name` varchar(255) NOT NULL DEFAULT '' COMMENT '文件源名称',\n    `name`        varchar(255) NOT NULL DEFAULT '' COMMENT '文件名称（UUID+扩展名）',\n    `path`        varchar(255) NOT NULL DEFAULT '' COMMENT '文件相对路径（相对于storage/public或storage/private）',\n    `size`        int unsigned NOT NULL DEFAULT '0' COMMENT '文件大小（字节）',\n    `ext`         varchar(20)  NOT NULL DEFAULT '' COMMENT '文件扩展名',\n    `hash`        varchar(64)  NOT NULL DEFAULT '' COMMENT '文件SHA256哈希值（用于去重）',\n    `uuid`        varchar(32)  NOT NULL DEFAULT '' COMMENT '文件UUID（用于URL访问，32位十六进制字符串，不带连字符）',\n    `mime_type`   varchar(100) NOT NULL DEFAULT '' COMMENT 'MIME类型（如：image/jpeg, application/pdf）',\n    `file_type`   varchar(20)  NOT NULL DEFAULT '' COMMENT '文件类型:image,pdf,word,excel,ppt,archive,text,audio,video,other',\n    `is_public`   tinyint      NOT NULL DEFAULT '0' COMMENT '是否公开访问: 0否 1是',\n    `storage_driver` varchar(20)  NOT NULL DEFAULT 'local' COMMENT '存储驱动:local,aliyun_oss,s3',\n    `storage_base` varchar(512) NOT NULL DEFAULT '' COMMENT '存储基础位置',\n    `bucket`      varchar(128) NOT NULL DEFAULT '' COMMENT '存储桶',\n    `storage_path` varchar(512) NOT NULL DEFAULT '' COMMENT '实际存储路径',\n    `object_key`  varchar(512) NOT NULL DEFAULT '' COMMENT '对象key',\n    `etag`        varchar(128) NOT NULL DEFAULT '' COMMENT '对象ETag',\n    `storage_status` varchar(32) NOT NULL DEFAULT 'stored' COMMENT '存储状态:stored,delete_failed',\n    `upload_source` varchar(20) NOT NULL DEFAULT 'backend' COMMENT '上传来源:backend,direct,system',\n    `upload_scene` varchar(60) NOT NULL DEFAULT '' COMMENT '上传场景',\n    `upload_status` varchar(20) NOT NULL DEFAULT 'uploaded' COMMENT '上传状态:pending,uploaded,failed',\n    `last_accessed_at` datetime DEFAULT NULL COMMENT '最后访问时间',\n    `deleted_by`  int unsigned NOT NULL DEFAULT '0' COMMENT '删除人',\n    `deleted_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '删除原因',\n    `created_at`  datetime              DEFAULT NULL COMMENT '创建时间',\n    `updated_at`  datetime              DEFAULT NULL COMMENT '更新时间',\n    `deleted_at`  int unsigned NOT NULL DEFAULT '0' COMMENT '删除时间戳',\n    PRIMARY KEY (`id`),\n    KEY `idx_uid_created_at` (`uid`, `created_at`),\n    KEY `idx_folder_created_at` (`folder_id`, `created_at`),\n    KEY `idx_hash_is_public` (`hash`, `is_public`),\n    KEY `idx_file_type_created_at` (`file_type`, `created_at`),\n    KEY `idx_storage_driver_status` (`storage_driver`, `storage_status`),\n    KEY `idx_deleted_at_created_at` (`deleted_at`, `created_at`),\n    UNIQUE KEY `idx_uuid` (`uuid`),\n    KEY `idx_is_public_uuid` (`is_public`, `uuid`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='上传文件表';\n\nCREATE TABLE IF NOT EXISTS `upload_file_folders`\n(\n    `id`           int unsigned NOT NULL AUTO_INCREMENT,\n    `parent_id`    int unsigned NOT NULL DEFAULT '0' COMMENT '父目录ID',\n    `name`         varchar(120) NOT NULL DEFAULT '' COMMENT '目录名称',\n    `logical_path` varchar(1024) NOT NULL DEFAULT '/' COMMENT '逻辑路径',\n    `sort`         int NOT NULL DEFAULT '0' COMMENT '排序',\n    `created_by`   int unsigned NOT NULL DEFAULT '0' COMMENT '创建人',\n    `updated_by`   int unsigned NOT NULL DEFAULT '0' COMMENT '更新人',\n    `created_at`   datetime DEFAULT NULL COMMENT '创建时间',\n    `updated_at`   datetime DEFAULT NULL COMMENT '更新时间',\n    `deleted_at`   int unsigned NOT NULL DEFAULT '0' COMMENT '删除时间戳',\n    PRIMARY KEY (`id`),\n    UNIQUE KEY `uniq_parent_name_deleted` (`parent_id`, `name`, `deleted_at`),\n    KEY `idx_parent_sort` (`parent_id`, `sort`, `id`),\n    KEY `idx_logical_path` (`logical_path`(191))\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='上传文件逻辑目录表';\n\nCREATE TABLE IF NOT EXISTS `upload_file_references`\n(\n    `id`          int unsigned NOT NULL AUTO_INCREMENT,\n    `file_id`     int unsigned NOT NULL DEFAULT '0' COMMENT 'upload_files.id',\n    `uuid`        varchar(32)  NOT NULL DEFAULT '' COMMENT '文件UUID',\n    `owner_type`  varchar(60)  NOT NULL DEFAULT '' COMMENT '引用对象类型',\n    `owner_id`    int unsigned NOT NULL DEFAULT '0' COMMENT '引用对象ID',\n    `owner_field` varchar(60)  NOT NULL DEFAULT '' COMMENT '引用字段',\n    `created_at`  datetime DEFAULT NULL COMMENT '创建时间',\n    `updated_at`  datetime DEFAULT NULL COMMENT '更新时间',\n    PRIMARY KEY (`id`),\n    UNIQUE KEY `uniq_owner_file_field` (`owner_type`, `owner_id`, `owner_field`, `file_id`),\n    KEY `idx_file_id` (`file_id`),\n    KEY `idx_uuid` (`uuid`),\n    KEY `idx_owner` (`owner_type`, `owner_id`, `owner_field`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='上传文件引用关系表';\n\nCREATE TABLE IF NOT EXISTS `sys_config`\n(\n    `id`           int unsigned                                           NOT NULL AUTO_INCREMENT,\n    `config_key`   varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '参数键名',\n    `config_value` text                                                            DEFAULT NULL COMMENT '参数值',\n    `value_type`   varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT 'string' COMMENT '值类型:string,number,bool,json',\n    `group_code`   varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT 'default' COMMENT '参数分组',\n    `is_system`    tinyint unsigned                                       NOT NULL DEFAULT '0' COMMENT '是否系统内置:0否,1是',\n    `is_sensitive` tinyint unsigned                                       NOT NULL DEFAULT '0' COMMENT '是否敏感配置:0否,1是',\n    `is_visible`   tinyint unsigned                                       NOT NULL DEFAULT '1' COMMENT '是否在系统参数页展示:0否,1是',\n    `manage_tab`   varchar(60)                                            NOT NULL DEFAULT '' COMMENT '专属配置Tab',\n    `status`       tinyint unsigned                                       NOT NULL DEFAULT '1' COMMENT '状态:0禁用,1启用',\n    `sort`         int unsigned                                           NOT NULL DEFAULT '0' COMMENT '排序',\n    `remark`       varchar(255)                                           NOT NULL DEFAULT '' COMMENT '备注',\n    `created_at`   datetime                                                        DEFAULT NULL,\n    `updated_at`   datetime                                                        DEFAULT NULL,\n    `deleted_at`   int unsigned                                           NOT NULL DEFAULT '0' COMMENT '删除时间戳',\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `uniq_config_key_deleted_at` (`config_key`, `deleted_at`) USING BTREE,\n    KEY `idx_group_status_deleted_at_sort` (`group_code`, `status`, `deleted_at`, `sort`) USING BTREE,\n    KEY `idx_visible_deleted_at_sort` (`is_visible`, `deleted_at`, `sort`) USING BTREE,\n    KEY `idx_status_deleted_at` (`status`, `deleted_at`) USING BTREE\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='系统参数表';\n\nCREATE TABLE IF NOT EXISTS `sys_dict_type`\n(\n    `id`          int unsigned                                           NOT NULL AUTO_INCREMENT,\n    `type_code`   varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '字典类型编码',\n    `is_system`   tinyint unsigned                                       NOT NULL DEFAULT '0' COMMENT '是否系统内置:0否,1是',\n    `status`      tinyint unsigned                                       NOT NULL DEFAULT '1' COMMENT '状态:0禁用,1启用',\n    `sort`        int unsigned                                           NOT NULL DEFAULT '0' COMMENT '排序',\n    `remark`      varchar(255)                                           NOT NULL DEFAULT '' COMMENT '备注',\n    `created_at`  datetime                                                        DEFAULT NULL,\n    `updated_at`  datetime                                                        DEFAULT NULL,\n    `deleted_at`  int unsigned                                           NOT NULL DEFAULT '0' COMMENT '删除时间戳',\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `uniq_type_code_deleted_at` (`type_code`, `deleted_at`) USING BTREE,\n    KEY `idx_status_deleted_at_sort` (`status`, `deleted_at`, `sort`) USING BTREE\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='系统字典类型表';\n\nCREATE TABLE IF NOT EXISTS `sys_dict_item`\n(\n    `id`         int unsigned                                           NOT NULL AUTO_INCREMENT,\n    `type_code`  varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '字典类型编码',\n    `value`      varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '字典值',\n    `color`      varchar(30)                                            NOT NULL DEFAULT '' COMMENT '展示颜色',\n    `tag_type`   varchar(30)                                            NOT NULL DEFAULT '' COMMENT '前端标签类型',\n    `is_default` tinyint unsigned                                       NOT NULL DEFAULT '0' COMMENT '是否默认项:0否,1是',\n    `is_system`  tinyint unsigned                                       NOT NULL DEFAULT '0' COMMENT '是否系统内置:0否,1是',\n    `status`     tinyint unsigned                                       NOT NULL DEFAULT '1' COMMENT '状态:0禁用,1启用',\n    `sort`       int unsigned                                           NOT NULL DEFAULT '0' COMMENT '排序',\n    `remark`     varchar(255)                                           NOT NULL DEFAULT '' COMMENT '备注',\n    `created_at` datetime                                                        DEFAULT NULL,\n    `updated_at` datetime                                                        DEFAULT NULL,\n    `deleted_at` int unsigned                                           NOT NULL DEFAULT '0' COMMENT '删除时间戳',\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `uniq_type_value_deleted_at` (`type_code`, `value`, `deleted_at`) USING BTREE,\n    KEY `idx_type_status_deleted_at_sort` (`type_code`, `status`, `deleted_at`, `sort`) USING BTREE,\n    KEY `idx_status_deleted_at` (`status`, `deleted_at`) USING BTREE\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='系统字典项表';\n\nCREATE TABLE IF NOT EXISTS `sys_config_i18n`\n(\n    `id`          int unsigned                                           NOT NULL AUTO_INCREMENT,\n    `config_id`   int unsigned                                           NOT NULL DEFAULT '0' COMMENT '系统参数ID',\n    `locale`      varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '语言编码',\n    `config_name` varchar(100)                                           NOT NULL DEFAULT '' COMMENT '参数名称',\n    `created_at`  datetime                                                        DEFAULT NULL,\n    `updated_at`  datetime                                                        DEFAULT NULL,\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `uniq_config_id_locale` (`config_id`, `locale`) USING BTREE,\n    KEY `idx_locale_config_name` (`locale`, `config_name`) USING BTREE\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='系统参数多语言表';\n\nCREATE TABLE IF NOT EXISTS `sys_dict_type_i18n`\n(\n    `id`           int unsigned                                           NOT NULL AUTO_INCREMENT,\n    `dict_type_id` int unsigned                                           NOT NULL DEFAULT '0' COMMENT '字典类型ID',\n    `locale`       varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '语言编码',\n    `type_name`    varchar(100)                                           NOT NULL DEFAULT '' COMMENT '字典类型名称',\n    `created_at`   datetime                                                        DEFAULT NULL,\n    `updated_at`   datetime                                                        DEFAULT NULL,\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `uniq_dict_type_id_locale` (`dict_type_id`, `locale`) USING BTREE,\n    KEY `idx_locale_type_name` (`locale`, `type_name`) USING BTREE\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='系统字典类型多语言表';\n\nCREATE TABLE IF NOT EXISTS `sys_dict_item_i18n`\n(\n    `id`           int unsigned                                           NOT NULL AUTO_INCREMENT,\n    `dict_item_id` int unsigned                                           NOT NULL DEFAULT '0' COMMENT '字典项ID',\n    `locale`       varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin  NOT NULL DEFAULT '' COMMENT '语言编码',\n    `label`        varchar(100)                                           NOT NULL DEFAULT '' COMMENT '字典标签',\n    `created_at`   datetime                                                        DEFAULT NULL,\n    `updated_at`   datetime                                                        DEFAULT NULL,\n    PRIMARY KEY (`id`) USING BTREE,\n    UNIQUE KEY `uniq_dict_item_id_locale` (`dict_item_id`, `locale`) USING BTREE,\n    KEY `idx_locale_label` (`locale`, `label`) USING BTREE\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='系统字典项多语言表';\n\nCREATE TABLE IF NOT EXISTS `task_definitions`\n(\n    `id`             int unsigned NOT NULL AUTO_INCREMENT,\n    `code`           varchar(120) NOT NULL DEFAULT '' COMMENT '任务唯一编码',\n    `name`           varchar(120) NOT NULL DEFAULT '' COMMENT '任务名称',\n    `kind`           varchar(20)  NOT NULL DEFAULT '' COMMENT '任务类型 async/cron/manual',\n    `queue`          varchar(60)  NOT NULL DEFAULT '' COMMENT '队列名称',\n    `cron_spec`      varchar(120) NOT NULL DEFAULT '' COMMENT 'Cron 表达式',\n    `handler`        varchar(255) NOT NULL DEFAULT '' COMMENT '处理器标识',\n    `status`         tinyint(1)   NOT NULL DEFAULT '1' COMMENT '状态 1启用 0停用',\n    `allow_manual`   tinyint(1)   NOT NULL DEFAULT '0' COMMENT '是否允许手动触发 1是 0否',\n    `allow_retry`    tinyint(1)   NOT NULL DEFAULT '1' COMMENT '是否允许手动重试 1是 0否',\n    `is_high_risk`   tinyint(1)   NOT NULL DEFAULT '0' COMMENT '是否高危任务 1是 0否',\n    `remark`         varchar(255) NOT NULL DEFAULT '' COMMENT '备注',\n    `created_at`     datetime             DEFAULT NULL,\n    `updated_at`     datetime             DEFAULT NULL,\n    `deleted_at`     int          NOT NULL DEFAULT '0' COMMENT '删除时间戳',\n    PRIMARY KEY (`id`),\n    UNIQUE KEY `td_code_deleted_at` (`code`, `deleted_at`),\n    KEY `td_kind_status_deleted_at` (`kind`, `status`, `deleted_at`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='任务定义表';\n\nCREATE TABLE IF NOT EXISTS `task_runs`\n(\n    `id`              bigint unsigned NOT NULL AUTO_INCREMENT,\n    `task_code`       varchar(120)    NOT NULL DEFAULT '' COMMENT '任务唯一编码',\n    `kind`            varchar(20)     NOT NULL DEFAULT '' COMMENT '任务类型 async/cron/manual',\n    `source`          varchar(20)     NOT NULL DEFAULT '' COMMENT '来源 queue/cron/manual',\n    `source_id`       varchar(120)    NOT NULL DEFAULT '' COMMENT '来源任务ID，如 Asynq task id',\n    `queue`           varchar(60)     NOT NULL DEFAULT '' COMMENT '队列名称',\n    `trigger_user_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '触发人ID',\n    `trigger_account` varchar(60)     NOT NULL DEFAULT '' COMMENT '触发人账号',\n    `status`          varchar(20)     NOT NULL DEFAULT '' COMMENT '执行状态 pending/running/success/failed/canceled/retrying',\n    `attempt`         int             NOT NULL DEFAULT '0' COMMENT '当前尝试次数',\n    `max_retry`       int             NOT NULL DEFAULT '0' COMMENT '最大重试次数',\n    `payload`         mediumtext               DEFAULT NULL COMMENT '任务 payload',\n    `error_message`   text                     DEFAULT NULL COMMENT '失败原因',\n    `started_at`      datetime                 DEFAULT NULL COMMENT '开始时间',\n    `finished_at`     datetime                 DEFAULT NULL COMMENT '结束时间',\n    `duration_ms`     decimal(10, 4)  NOT NULL DEFAULT '0.0000' COMMENT '执行耗时毫秒',\n    `created_at`      datetime                 DEFAULT NULL,\n    `updated_at`      datetime                 DEFAULT NULL,\n    PRIMARY KEY (`id`),\n    KEY `tr_task_code_created_at` (`task_code`, `created_at`),\n    KEY `tr_status_created_at` (`status`, `created_at`),\n    KEY `tr_source_source_id` (`source`, `source_id`),\n    KEY `tr_kind_created_at` (`kind`, `created_at`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='任务执行记录表';\n\nCREATE TABLE IF NOT EXISTS `task_run_events`\n(\n    `id`         bigint unsigned NOT NULL AUTO_INCREMENT,\n    `run_id`     bigint unsigned NOT NULL DEFAULT '0' COMMENT '任务执行记录ID',\n    `event_type` varchar(30)     NOT NULL DEFAULT '' COMMENT '事件类型 enqueue/start/retry/fail/success/cancel',\n    `message`    text                    DEFAULT NULL COMMENT '事件说明',\n    `meta`       text                    DEFAULT NULL COMMENT '事件元数据 JSON',\n    `created_at` datetime                DEFAULT NULL,\n    `updated_at` datetime                DEFAULT NULL,\n    PRIMARY KEY (`id`),\n    KEY `tre_run_id_created_at` (`run_id`, `created_at`),\n    KEY `tre_event_type_created_at` (`event_type`, `created_at`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='任务执行事件表';\n\nCREATE TABLE IF NOT EXISTS `cron_task_states`\n(\n    `id`               bigint unsigned NOT NULL AUTO_INCREMENT,\n    `task_code`        varchar(120)    NOT NULL DEFAULT '' COMMENT '任务唯一编码',\n    `cron_spec`        varchar(120)    NOT NULL DEFAULT '' COMMENT 'Cron 表达式',\n    `last_run_id`      bigint unsigned NOT NULL DEFAULT '0' COMMENT '最近执行记录ID',\n    `last_status`      varchar(20)     NOT NULL DEFAULT '' COMMENT '最近执行状态',\n    `last_started_at`  datetime                 DEFAULT NULL COMMENT '最近开始时间',\n    `last_finished_at` datetime                 DEFAULT NULL COMMENT '最近结束时间',\n    `next_run_at`      datetime                 DEFAULT NULL COMMENT '下次执行时间',\n    `last_error`       text                     DEFAULT NULL COMMENT '最近失败原因',\n    `created_at`       datetime                 DEFAULT NULL,\n    `updated_at`       datetime                 DEFAULT NULL,\n    PRIMARY KEY (`id`),\n    UNIQUE KEY `cts_task_code` (`task_code`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='定时任务最近状态表';\n\nCOMMIT;\n"
  },
  {
    "path": "data/migrations/20260425000002_init_data.down.sql",
    "content": "BEGIN;\n\n-- 回滚系统数据\n\n-- 删除任务中心/系统管理新增菜单与映射\nDELETE FROM `role_menu_map` WHERE `menu_id` BETWEEN 42 AND 76;\nDELETE FROM `menu_i18n` WHERE `menu_id` BETWEEN 42 AND 76;\nDELETE FROM `menu` WHERE `id` BETWEEN 42 AND 76;\nDELETE FROM `api_group` WHERE `code` IN ('system', 'sysConfig', 'sysDict', 'task', 'file', 'session');\n\n-- 删除系统参数/字典初始化数据\nDELETE FROM `sys_dict_item_i18n`\nWHERE `dict_item_id` IN (\n    SELECT `id`\n    FROM `sys_dict_item`\n    WHERE `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')\n      AND `deleted_at` = 0\n);\nDELETE FROM `sys_dict_type_i18n`\nWHERE `dict_type_id` IN (\n    SELECT `id`\n    FROM `sys_dict_type`\n    WHERE `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')\n      AND `deleted_at` = 0\n);\nDELETE FROM `sys_config_i18n`\nWHERE `config_id` IN (\n    SELECT `id`\n    FROM `sys_config`\n    WHERE `config_key` IN ('auth.login_lock_enabled', 'auth.login_max_failures', 'auth.login_lock_minutes', 'task.cron_demo_enabled', 'audit.sensitive_fields', 'storage.active_driver', 'storage.config')\n      AND `deleted_at` = 0\n);\nDELETE FROM `sys_dict_item`\nWHERE `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')\n  AND `deleted_at` = 0;\nDELETE FROM `sys_dict_type`\nWHERE `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')\n  AND `deleted_at` = 0;\nDELETE FROM `sys_config`\nWHERE `config_key` IN ('auth.login_lock_enabled', 'auth.login_max_failures', 'auth.login_lock_minutes', 'task.cron_demo_enabled', 'audit.sensitive_fields', 'storage.active_driver', 'storage.config')\n  AND `deleted_at` = 0;\n\n-- 删除角色菜单映射\nDELETE FROM `role_menu_map` WHERE `role_id` = 1;\n\n-- 删除菜单翻译数据\nDELETE FROM `menu_i18n` WHERE `menu_id` BETWEEN 1 AND 41;\n\n-- 删除菜单数据\nDELETE FROM `menu` WHERE `id` BETWEEN 1 AND 41;\n\n-- 删除权限分组数据\nDELETE FROM `api_group` WHERE `id` BETWEEN 1 AND 9;\n\n-- 删除管理员用户部门/角色映射\nDELETE FROM `admin_user_role_map` WHERE `uid` = 1 AND `role_id` = 1;\nDELETE FROM `admin_user_department_map` WHERE `uid` = 1 AND `dept_id` = 1;\n\n-- 删除默认角色和部门\nDELETE FROM `role` WHERE `id` = 1;\nDELETE FROM `department` WHERE `id` = 1;\n\n-- 删除管理员用户数据\nDELETE FROM `admin_user` WHERE `id` = 1;\n\nCOMMIT;\n"
  },
  {
    "path": "data/migrations/20260425000002_init_data.up.sql",
    "content": "BEGIN;\n\n-- 初始化系统数据\n\n-- 初始密码 123456\nINSERT INTO `admin_user` (`id`, `nickname`, `username`, `password`, `phone_number`, `full_phone_number`,\n                            `country_code`, `email`, `avatar`, `status`,\n                            `is_super_admin`,\n                            `created_at`, `updated_at`, `deleted_at`)\nVALUES (1, '超级管理员', 'super_admin', '$2a$10$OuKQoJGH7xkCgwFISmDve.euBDbOCnYEJX6R22QMeLxCLwdoJ4iyi', '18888888888',\n        '8618888888888', '86', 'admin@go-layout.com', 'https://avatars.githubusercontent.com/u/48752601?v=4', 1, 1,\n        '2023-05-01 00:00:00', '2023-05-01 00:00:00', 0);\n\nINSERT INTO `department` (`id`, `code`, `is_system`, `pid`, `pids`, `name`, `description`, `level`, `sort`,\n                          `children_num`, `user_number`, `created_at`, `updated_at`, `deleted_at`)\nVALUES (1, 'default_department', 1, 0, '0', '默认部门', '系统默认部门', 1, 100, 0, 1,\n        '2023-05-01 00:00:00', '2023-05-01 00:00:00', 0);\n\nINSERT INTO `role` (`id`, `code`, `is_system`, `pid`, `pids`, `name`, `description`, `level`, `sort`, `children_num`,\n                    `status`, `created_at`, `updated_at`, `deleted_at`)\nVALUES (1, 'super_admin', 1, 0, '0', '超级管理员', '系统默认超级管理员角色', 1, 100, 0, 1,\n        '2023-05-01 00:00:00', '2023-05-01 00:00:00', 0);\n\nINSERT INTO `admin_user_department_map` (`uid`, `dept_id`, `is_admin`, `created_at`, `updated_at`)\nVALUES (1, 1, 1, '2023-05-01 00:00:00', '2023-05-01 00:00:00');\n\nINSERT INTO `admin_user_role_map` (`uid`, `role_id`, `created_at`, `updated_at`)\nVALUES (1, 1, '2023-05-01 00:00:00', '2023-05-01 00:00:00');\n\n-- 初始化权限分组数据\nINSERT INTO `api_group` (`id`, `pid`, `code`, `name`, `created_at`, `updated_at`)\nVALUES (1, 0, 'other', '其他', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),\n       (2, 0, 'login', '登录模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),\n       (3, 0, 'auth', '权限模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),\n       (4, 3, 'adminUser', '管理员模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),\n       (5, 3, 'api', 'API模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),\n       (6, 3, 'role', '角色模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),\n       (7, 3, 'menu', '菜单模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),\n       (8, 0, 'common', '公共模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00'),\n       (9, 0, 'log', '日志模块', '2025-04-26 18:00:00', '2025-04-26 18:00:00');\n\n-- 初始化菜单数据\nINSERT INTO `menu` (`id`, `icon`, `code`, `path`, `full_path`, `redirect`, `name`, `component`, `animate_enter`, `animate_leave`, `animate_duration`, `is_show`, `status`, `is_auth`, `is_external_links`, `is_new_window`, `sort`, `type`, `pid`, `level`, `pids`, `children_num`, `description`, `created_at`, `updated_at`, `deleted_at`) VALUES\n(1, 'ep:menu', '', '', '/', '', 'Home', 'home/index.vue', '', '', 0.00, 1, 1, 0, 0, 0, 100, 2, 0, 1, '0', 0, '', '2024-09-27 13:36:50', '2025-11-15 14:36:40', 0),\n(2, 'ant-design:lock-outlined', '', 'permission', '/permission', 'AdminUserList', 'Permission', '', '', '', 0.00, 1, 1, 1, 0, 0, 99, 1, 0, 1, '0', 0, '', '2025-04-16 15:36:33', '2025-04-22 18:16:25', 0),\n(3, 'ant-design:api-outlined', '', 'list', '/permission/list', '', 'PermissionList', 'permission/api.vue', '', '', 0.00, 1, 1, 1, 0, 0, 100, 2, 2, 2, '0,2', 2, '', '2025-04-16 15:41:54', '2025-11-25 17:23:53', 0),\n(4, 'ant-design:menu-outlined', '', 'menu-list', '/permission/menu-list', '', 'MenuList', 'permission/menuList.vue', '', '', 0.00, 1, 1, 1, 0, 0, 105, 2, 2, 2, '0,2', 5, '', '2025-04-16 15:45:31', '2025-11-25 17:23:44', 0),\n(5, 'lucide:info', '', '/about', '/about', '', 'About', 'about/index.vue', '', '', 0.00, 1, 1, 1, 0, 0, 78, 2, 0, 1, '0', 0, '', '2025-04-16 16:47:58', '2025-04-23 15:01:05', 0),\n(7, 'ep:user', '', 'admin-user-list', '/permission/admin-user-list', '', 'AdminUserList', 'permission/adminUser.vue', '', '', 0.00, 1, 1, 1, 0, 0, 120, 2, 2, 2, '0,2', 5, '', '2025-04-19 11:19:36', '2025-11-25 17:20:23', 0),\n(8, 'ant-design:usergroup-add-outlined', '', 'role-list', '/permission/role-list', '', 'RoleList', 'permission/role.vue', '', '', 0.00, 1, 1, 1, 0, 0, 115, 2, 2, 2, '0,2', 5, '', '2025-04-21 16:51:22', '2025-11-25 17:22:21', 0),\n(9, 'tdesign:tree-square-dot', '', 'department-list', '/permission/department-list', '', 'DepartmentList', 'permission/department.vue', '', '', 0.00, 1, 1, 1, 0, 0, 115, 2, 2, 2, '0,2', 6, '', '2025-04-21 16:51:22', '2025-11-25 17:21:30', 0),\n(10, 'ant-design:edit-filled', 'adminUser:update', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 7, 3, '0,2,7', 0, '', '2025-11-13 16:45:19', '2025-11-18 17:14:25', 0),\n(11, 'ant-design:plus-outlined', 'adminUser:add', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 7, 3, '0,2,7', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:22:41', 0),\n(12, 'ant-design:user-switch-outlined', 'adminUser:bindRole', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 7, 3, '0,2,7', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:20:53', 0),\n(13, 'ant-design:delete-outlined', 'adminUser:delete', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 7, 3, '0,2,7', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:24:34', 0),\n(14, 'ant-design:plus-outlined', 'menu:add', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 4, 3, '0,2,4', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:39:20', 0),\n(15, 'ant-design:plus-circle-outlined', 'menu:addChild', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 4, 3, '0,2,4', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:39:03', 0),\n(16, 'ant-design:edit-filled', 'menu:update', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 4, 3, '0,2,4', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:38:12', 0),\n(17, 'ant-design:delete-outlined', 'menu:delete', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 4, 3, '0,2,4', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:37:02', 0),\n(18, 'ant-design:plus-outlined', 'role:add', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 8, 3, '0,2,8', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:36:08', 0),\n(19, 'ant-design:edit-filled', 'role:update', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 8, 3, '0,2,8', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:36:22', 0),\n(20, 'ant-design:delete-outlined', 'role:delete', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 8, 3, '0,2,8', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:32:12', 0),\n(21, 'ant-design:plus-outlined', 'department:add', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 9, 3, '0,2,9', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:29:43', 0),\n(22, 'ant-design:plus-circle-outlined', 'department:addChild', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 9, 3, '0,2,9', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:29:07', 0),\n(23, 'ant-design:edit-filled', 'department:update', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 9, 3, '0,2,9', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:33:40', 0),\n(24, 'ant-design:user-switch-outlined', 'department:bindRole', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 9, 3, '0,2,9', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:27:57', 0),\n(25, 'ant-design:delete-outlined', 'department:delete', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 9, 3, '0,2,9', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:26:52', 0),\n(26, 'ant-design:edit-filled', 'api:update', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 3, 3, '0,2,3', 0, '', '2025-11-15 10:06:52', '2025-11-18 17:39:39', 0),\n(28, 'ant-design:plus-circle-outlined', 'role:addChild', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 8, 3, '0,2,8', 0, '', '2025-11-17 17:46:21', '2025-11-18 17:41:45', 0),\n(29, 'ep:tickets', '', 'log', '/log', 'RequestLog', 'Log', '', '', '', 0.00, 1, 1, 1, 0, 0, 98, 1, 0, 1, '0', 3, '', '2025-11-20 16:16:47', '2025-11-20 16:17:04', 0),\n(30, 'ep:document', '', 'request-log', '/log/request-log', '', 'RequestLog', 'log/request.vue', '', '', 0.00, 1, 1, 1, 0, 0, 100, 2, 29, 2, '0,29', 4, '', '2025-11-20 16:14:39', '2025-11-25 17:25:13', 0),\n(31, 'ep:document', '', 'admin-login-log', '/log/admin-login-log', '', 'AdminLoginLog', 'log/adminLogin.vue', '', '', 0.00, 1, 1, 1, 0, 0, 100, 2, 29, 2, '0,29', 2, '', '2025-11-20 16:16:47', '2025-11-25 17:24:30', 0),\n(32, 'ep:document', 'adminLoginLog:detail', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 31, 3, '0,29,31', 0, '', '2025-11-22 11:48:10', '2025-11-22 11:48:10', 0),\n(33, 'ep:document', 'requestLog:detail', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 30, 3, '0,29,30', 0, '', '2025-11-22 11:48:44', '2025-11-22 11:48:44', 0),\n(34, 'ant-design:search-outlined', 'adminUser:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 7, 3, '0,2,7', 0, '', '2025-11-25 17:20:23', '2025-11-25 17:20:23', 0),\n(35, 'ant-design:search-outlined', 'department:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 9, 3, '0,2,9', 0, '', '2025-11-25 17:21:22', '2025-11-25 17:21:22', 0),\n(36, 'ant-design:search-outlined', 'role:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 8, 3, '0,2,8', 0, '', '2025-11-25 17:22:13', '2025-11-25 17:22:13', 0),\n(37, 'ant-design:search-outlined', 'menu:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 4, 3, '0,2,4', 0, '', '2025-11-25 17:23:02', '2025-11-25 17:23:02', 0),\n(38, 'ant-design:search-outlined', 'api:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 3, 3, '0,2,3', 0, '', '2025-11-25 17:23:35', '2025-11-25 17:23:35', 0),\n(39, 'ant-design:search-outlined', 'adminLoginLog:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 31, 3, '0,29,31', 0, '', '2025-11-25 17:24:20', '2025-11-25 17:24:20', 0),\n(40, 'ant-design:search-outlined', 'requestLog:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 30, 3, '0,29,30', 0, '', '2025-11-25 17:25:04', '2025-11-25 17:25:04', 0),\n(42, 'ep:setting', '', 'system', '/system', 'SysConfig', 'System', '', '', '', 0.00, 1, 1, 1, 0, 0, 96, 1, 0, 1, '0', 3, '', NOW(), NOW(), 0),\n(43, 'ep:operation', '', 'config', '/system/config', '', 'SysConfig', 'system/config.vue', '', '', 0.00, 1, 1, 1, 0, 0, 100, 2, 42, 2, '0,42', 8, '', NOW(), NOW(), 0),\n(44, 'ep:collection-tag', '', 'dict', '/system/dict', '', 'SysDict', 'system/dict.vue', '', '', 0.00, 1, 1, 1, 0, 0, 90, 2, 42, 2, '0,42', 4, '', NOW(), NOW(), 0),\n(45, 'ant-design:search-outlined', 'sysConfig:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 43, 3, '0,42,43', 0, '', NOW(), NOW(), 0),\n(46, 'ant-design:plus-outlined', 'sysConfig:add', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 90, 3, 43, 3, '0,42,43', 0, '', NOW(), NOW(), 0),\n(47, 'ant-design:edit-filled', 'sysConfig:update', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 80, 3, 43, 3, '0,42,43', 0, '', NOW(), NOW(), 0),\n(48, 'ant-design:delete-outlined', 'sysConfig:delete', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 70, 3, 43, 3, '0,42,43', 0, '', NOW(), NOW(), 0),\n(49, 'ep:refresh', 'sysConfig:refresh', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 60, 3, 43, 3, '0,42,43', 0, '', NOW(), NOW(), 0),\n(50, 'ant-design:search-outlined', 'sysDict:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 44, 3, '0,42,44', 0, '', NOW(), NOW(), 0),\n(51, 'ant-design:plus-outlined', 'sysDict:add', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 90, 3, 44, 3, '0,42,44', 0, '', NOW(), NOW(), 0),\n(52, 'ant-design:edit-filled', 'sysDict:update', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 80, 3, 44, 3, '0,42,44', 0, '', NOW(), NOW(), 0),\n(53, 'ant-design:delete-outlined', 'sysDict:delete', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 70, 3, 44, 3, '0,42,44', 0, '', NOW(), NOW(), 0),\n(54, 'ri:task-line', '', 'task', '/task', 'TaskCenter', 'Task', '', '', '', 0.00, 1, 1, 1, 0, 0, 97, 1, 0, 1, '0', 1, '', NOW(), NOW(), 0),\n(55, 'ri:task-line', '', 'center', '/task/center', '', 'TaskCenter', 'system/task.vue', '', '', 0.00, 1, 1, 1, 0, 0, 100, 2, 54, 2, '0,54', 7, '', NOW(), NOW(), 0),\n(56, 'ant-design:search-outlined', 'task:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 55, 3, '0,54,55', 0, '', NOW(), NOW(), 0),\n(57, 'ep:video-play', 'task:trigger', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 90, 3, 55, 3, '0,54,55', 0, '', NOW(), NOW(), 0),\n(58, 'ep:refresh-right', 'task:retry', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 80, 3, 55, 3, '0,54,55', 0, '', NOW(), NOW(), 0),\n(59, 'ep:circle-close', 'task:cancel', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 70, 3, 55, 3, '0,54,55', 0, '', NOW(), NOW(), 0),\n(60, 'ep:view', 'task:detail', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 60, 3, 55, 3, '0,54,55', 0, '', NOW(), NOW(), 0),\n(61, 'ant-design:search-outlined', 'requestLog:export', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 80, 3, 30, 3, '0,29,30', 0, '', NOW(), NOW(), 0),\n(62, 'ep:setting', 'requestLog:maskConfig', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 70, 3, 30, 3, '0,29,30', 0, '', NOW(), NOW(), 0),\n(63, 'ep:folder-opened', '', 'file', '/system/file', '', 'FileResource', 'system/file.vue', '', '', 0.00, 1, 1, 1, 0, 0, 80, 2, 42, 2, '0,42', 2, '', NOW(), NOW(), 0),\n(64, 'ant-design:search-outlined', 'file:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 63, 3, '0,42,63', 0, '', NOW(), NOW(), 0),\n(65, 'ant-design:delete-outlined', 'file:delete', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 90, 3, 63, 3, '0,42,63', 0, '', NOW(), NOW(), 0),\n(66, 'ep:monitor', '', 'session', '/log/session', '', 'OnlineSession', 'log/session.vue', '', '', 0.00, 1, 1, 1, 0, 0, 90, 2, 29, 2, '0,29', 2, '', NOW(), NOW(), 0),\n(67, 'ant-design:search-outlined', 'session:list', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 66, 3, '0,29,66', 0, '', NOW(), NOW(), 0),\n(68, 'ep:circle-close', 'session:revoke', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 90, 3, 66, 3, '0,29,66', 0, '', NOW(), NOW(), 0),\n(70, 'ant-design:search-outlined', 'storage:config', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 100, 3, 43, 3, '0,42,43', 0, '', NOW(), NOW(), 0),\n(71, 'ant-design:edit-filled', 'storage:update', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 90, 3, 43, 3, '0,42,43', 0, '', NOW(), NOW(), 0),\n(72, 'ep:connection', 'storage:test', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 80, 3, 43, 3, '0,42,43', 0, '', NOW(), NOW(), 0),\n(73, 'ep:refresh-left', 'file:restore', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 80, 3, 63, 3, '0,42,63', 0, '', NOW(), NOW(), 0),\n(74, 'ant-design:delete-filled', 'file:destroy', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 70, 3, 63, 3, '0,42,63', 0, '', NOW(), NOW(), 0),\n(75, 'ant-design:edit-filled', 'file:update', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 85, 3, 63, 3, '0,42,63', 0, '', NOW(), NOW(), 0),\n(76, 'ant-design:plus-outlined', 'file:create', '', '', '', '', '', '', '', 0.00, 1, 1, 1, 0, 0, 95, 3, 63, 3, '0,42,63', 0, '', NOW(), NOW(), 0);\n\nINSERT INTO `menu_i18n` (`menu_id`, `locale`, `title`, `created_at`, `updated_at`)\nSELECT `id`,\n       'zh-CN',\n       CASE `id`\n           WHEN 1 THEN '首页'\n           WHEN 2 THEN '权限管理'\n           WHEN 3 THEN '接口'\n           WHEN 4 THEN '菜单'\n           WHEN 5 THEN '关于'\n           WHEN 7 THEN '管理员'\n           WHEN 8 THEN '角色'\n           WHEN 9 THEN '部门'\n           WHEN 10 THEN '编辑'\n           WHEN 11 THEN '新增管理员'\n           WHEN 12 THEN '绑定角色'\n           WHEN 13 THEN '删除'\n           WHEN 14 THEN '新增菜单'\n           WHEN 15 THEN '新增下级'\n           WHEN 16 THEN '编辑'\n           WHEN 17 THEN '删除'\n           WHEN 18 THEN '新增角色'\n           WHEN 19 THEN '编辑'\n           WHEN 20 THEN '删除'\n           WHEN 21 THEN '新增部门'\n           WHEN 22 THEN '新增'\n           WHEN 23 THEN '编辑'\n           WHEN 24 THEN '绑定角色'\n           WHEN 25 THEN '删除'\n           WHEN 26 THEN '编辑'\n           WHEN 28 THEN '新增'\n           WHEN 29 THEN '日志管理'\n           WHEN 30 THEN '请求日志'\n           WHEN 31 THEN '管理员登录日志'\n           WHEN 32 THEN '详情'\n           WHEN 33 THEN '详情'\n           WHEN 34 THEN '列表'\n           WHEN 35 THEN '列表'\n           WHEN 36 THEN '列表'\n           WHEN 37 THEN '列表'\n           WHEN 38 THEN '列表'\n           WHEN 39 THEN '列表'\n           WHEN 40 THEN '列表'\n           WHEN 42 THEN '系统管理'\n           WHEN 43 THEN '系统参数'\n           WHEN 44 THEN '字典管理'\n           WHEN 45 THEN '列表'\n           WHEN 46 THEN '新增'\n           WHEN 47 THEN '编辑'\n           WHEN 48 THEN '删除'\n           WHEN 49 THEN '刷新缓存'\n           WHEN 50 THEN '列表'\n           WHEN 51 THEN '新增'\n           WHEN 52 THEN '编辑'\n           WHEN 53 THEN '删除'\n           WHEN 54 THEN '任务中心'\n           WHEN 55 THEN '任务管理'\n           WHEN 56 THEN '列表'\n           WHEN 57 THEN '触发任务'\n           WHEN 58 THEN '重试任务'\n           WHEN 59 THEN '取消任务'\n           WHEN 60 THEN '详情'\n           WHEN 61 THEN '导出'\n           WHEN 62 THEN '脱敏配置'\n           WHEN 63 THEN '文件资源'\n           WHEN 64 THEN '列表'\n           WHEN 65 THEN '删除'\n           WHEN 66 THEN '在线会话'\n           WHEN 67 THEN '列表'\n           WHEN 68 THEN '撤销'\n           WHEN 70 THEN '配置'\n           WHEN 71 THEN '更新'\n           WHEN 72 THEN '测试'\n           WHEN 73 THEN '恢复'\n           WHEN 74 THEN '硬删除'\n           WHEN 75 THEN '编辑'\n           WHEN 76 THEN '新增'\n           ELSE ''\n           END,\n       `created_at`,\n       `updated_at`\nFROM `menu`\nWHERE `deleted_at` = 0;\n\nINSERT INTO `menu_i18n` (`menu_id`, `locale`, `title`, `created_at`, `updated_at`)\nSELECT `id`,\n       'en-US',\n       CASE `id`\n           WHEN 1 THEN 'Home'\n           WHEN 2 THEN 'Permission'\n           WHEN 3 THEN 'API'\n           WHEN 4 THEN 'Menu'\n           WHEN 5 THEN 'About'\n           WHEN 7 THEN 'Administrators'\n           WHEN 8 THEN 'Roles'\n           WHEN 9 THEN 'Departments'\n           WHEN 10 THEN 'Edit'\n           WHEN 11 THEN 'Add Administrator'\n           WHEN 12 THEN 'Bind Roles'\n           WHEN 13 THEN 'Delete'\n           WHEN 14 THEN 'Add Menu'\n           WHEN 15 THEN 'Add Child'\n           WHEN 16 THEN 'Edit'\n           WHEN 17 THEN 'Delete'\n           WHEN 18 THEN 'Add Role'\n           WHEN 19 THEN 'Edit'\n           WHEN 20 THEN 'Delete'\n           WHEN 21 THEN 'Add Department'\n           WHEN 22 THEN 'Add'\n           WHEN 23 THEN 'Edit'\n           WHEN 24 THEN 'Bind Roles'\n           WHEN 25 THEN 'Delete'\n           WHEN 26 THEN 'Edit'\n           WHEN 28 THEN 'Add'\n           WHEN 29 THEN 'Log Management'\n           WHEN 30 THEN 'Request Logs'\n           WHEN 31 THEN 'Admin Login Logs'\n           WHEN 32 THEN 'Detail'\n           WHEN 33 THEN 'Detail'\n           WHEN 34 THEN 'List'\n           WHEN 35 THEN 'List'\n           WHEN 36 THEN 'List'\n           WHEN 37 THEN 'List'\n           WHEN 38 THEN 'List'\n           WHEN 39 THEN 'List'\n           WHEN 40 THEN 'List'\n           WHEN 42 THEN 'System'\n           WHEN 43 THEN 'System Config'\n           WHEN 44 THEN 'Dictionary'\n           WHEN 45 THEN 'List'\n           WHEN 46 THEN 'Add'\n           WHEN 47 THEN 'Edit'\n           WHEN 48 THEN 'Delete'\n           WHEN 49 THEN 'Refresh Cache'\n           WHEN 50 THEN 'List'\n           WHEN 51 THEN 'Add'\n           WHEN 52 THEN 'Edit'\n           WHEN 53 THEN 'Delete'\n           WHEN 54 THEN 'Task Center'\n           WHEN 55 THEN 'Task Management'\n           WHEN 56 THEN 'List'\n           WHEN 57 THEN 'Trigger Task'\n           WHEN 58 THEN 'Retry Task'\n           WHEN 59 THEN 'Cancel Task'\n           WHEN 60 THEN 'Detail'\n           WHEN 61 THEN 'Export'\n           WHEN 62 THEN 'Mask Config'\n           WHEN 63 THEN 'File Resources'\n           WHEN 64 THEN 'List'\n           WHEN 65 THEN 'Delete'\n           WHEN 66 THEN 'Online Sessions'\n           WHEN 67 THEN 'List'\n           WHEN 68 THEN 'Revoke'\n           WHEN 70 THEN 'Config'\n           WHEN 71 THEN 'Update'\n           WHEN 72 THEN 'Test'\n           WHEN 73 THEN 'Restore'\n           WHEN 74 THEN 'Destroy'\n           WHEN 75 THEN 'Edit'\n           WHEN 76 THEN 'Create'\n           ELSE ''\n           END,\n       `created_at`,\n       `updated_at`\nFROM `menu`\nWHERE `deleted_at` = 0;\n\nINSERT INTO `role_menu_map` (`role_id`, `menu_id`, `created_at`, `updated_at`)\nSELECT 1, `id`, '2023-05-01 00:00:00', '2023-05-01 00:00:00'\nFROM `menu`\nWHERE `deleted_at` = 0;\n\nINSERT INTO `sys_config` (`config_key`, `config_value`, `value_type`, `group_code`, `is_system`,\n                          `is_sensitive`, `is_visible`, `manage_tab`, `status`, `sort`, `remark`, `created_at`, `updated_at`, `deleted_at`)\nVALUES ('auth.login_lock_enabled', 'true', 'bool', 'auth', 1, 0, 1, '', 1, 89, '是否开启登录失败锁定', NOW(), NOW(), 0),\n       ('auth.login_max_failures', '5', 'number', 'auth', 1, 0, 1, '', 1, 88, '登录连续失败阈值', NOW(), NOW(), 0),\n       ('auth.login_lock_minutes', '15', 'number', 'auth', 1, 0, 1, '', 1, 87, '登录锁定时长（分钟）', NOW(), NOW(), 0),\n       ('task.cron_demo_enabled', 'false', 'bool', 'task', 1, 0, 1, '', 1, 80, '是否启用演示定时任务，默认关闭', NOW(), NOW(), 0),\n       ('audit.sensitive_fields', '{\"common\":[\"password\",\"pwd\",\"passwd\",\"pass\",\"secret\",\"token\",\"access_token\",\"refresh_token\",\"api_key\",\"apikey\",\"apiKey\",\"pin\",\"cvv\",\"cvc\",\"cvv2\",\"security_code\"],\"request_header\":[\"authorization\",\"auth\",\"cookie\",\"x-api-key\",\"x-access-token\",\"x-auth-token\",\"x-token\"],\"request_body\":[\"password\",\"pwd\",\"passwd\",\"pass\",\"secret\",\"token\",\"access_token\",\"refresh_token\",\"api_key\",\"apikey\",\"apiKey\",\"phone\",\"mobile\",\"tel\",\"telephone\",\"phone_number\",\"mobile_number\",\"email\",\"mail\",\"id_card\",\"idcard\",\"identity\",\"id_number\",\"bank_card\",\"bankcard\",\"card_number\",\"card_no\",\"cvv\",\"cvc\",\"cvv2\",\"security_code\",\"pin\",\"ssn\",\"social_security\",\"real_name\",\"realname\",\"name\"],\"response_header\":[\"set-cookie\",\"authorization\",\"auth\",\"x-api-key\",\"x-access-token\",\"x-auth-token\",\"x-token\",\"x-refresh-token\",\"refresh-access-token\",\"refresh-exp\",\"cookie\"],\"response_body\":[\"password\",\"pwd\",\"passwd\",\"pass\",\"secret\",\"token\",\"access_token\",\"refresh_token\",\"api_key\",\"apikey\",\"apiKey\",\"phone\",\"mobile\",\"tel\",\"telephone\",\"phone_number\",\"mobile_number\",\"email\",\"mail\",\"id_card\",\"idcard\",\"identity\",\"id_number\",\"bank_card\",\"bankcard\",\"card_number\",\"card_no\",\"cvv\",\"cvc\",\"cvv2\",\"security_code\",\"pin\",\"ssn\",\"social_security\"]}', 'json', 'audit', 1, 1, 0, 'audit_mask', 1, 95, '请求日志脱敏字段配置', NOW(), NOW(), 0),\n       ('storage.active_driver', 'local', 'string', 'storage', 1, 0, 0, 'storage', 1, 90, '当前启用的文件存储驱动', NOW(), NOW(), 0),\n       ('storage.config', '{\"local\":{},\"aliyun_oss\":{},\"s3\":{},\"signed_url_ttl_seconds\":300,\"max_file_size_mb\":10,\"allowed_mime_types\":[]}', 'json', 'storage', 1, 1, 0, 'storage', 1, 80, '文件存储配置', NOW(), NOW(), 0)\nON DUPLICATE KEY UPDATE `updated_at` = VALUES(`updated_at`);\n\nINSERT INTO `sys_dict_type` (`type_code`, `is_system`, `status`, `sort`, `remark`, `created_at`,\n                             `updated_at`, `deleted_at`)\nVALUES ('common_status', 1, 1, 100, '0=禁用,1=启用', NOW(), NOW(), 0),\n       ('yes_no', 1, 1, 90, '0=否,1=是', NOW(), NOW(), 0),\n       ('menu_type', 1, 1, 80, '1=目录,2=菜单,3=按钮', NOW(), NOW(), 0),\n       ('api_auth_mode', 1, 1, 70, '0=无需登录,1=需要登录,2=需要接口权限', NOW(), NOW(), 0),\n       ('http_method', 1, 1, 60, '常用 HTTP 方法', NOW(), NOW(), 0),\n       ('task_kind', 1, 1, 50, '任务类型', NOW(), NOW(), 0),\n       ('task_source', 1, 1, 40, '任务来源', NOW(), NOW(), 0),\n       ('task_run_status', 1, 1, 30, '任务执行状态', NOW(), NOW(), 0)\nON DUPLICATE KEY UPDATE `updated_at` = VALUES(`updated_at`);\n\nINSERT INTO `sys_dict_item` (`type_code`, `value`, `color`, `tag_type`, `is_default`, `is_system`, `status`,\n                             `sort`, `remark`, `created_at`, `updated_at`, `deleted_at`)\nVALUES ('common_status', '0', '#909399', 'info', 0, 1, 1, 10, '', NOW(), NOW(), 0),\n       ('common_status', '1', '#67c23a', 'success', 1, 1, 1, 20, '', NOW(), NOW(), 0),\n       ('yes_no', '0', '#909399', 'info', 1, 1, 1, 10, '', NOW(), NOW(), 0),\n       ('yes_no', '1', '#67c23a', 'success', 0, 1, 1, 20, '', NOW(), NOW(), 0),\n       ('menu_type', '1', '#409eff', 'primary', 0, 1, 1, 30, '', NOW(), NOW(), 0),\n       ('menu_type', '2', '#67c23a', 'success', 1, 1, 1, 20, '', NOW(), NOW(), 0),\n       ('menu_type', '3', '#e6a23c', 'warning', 0, 1, 1, 10, '', NOW(), NOW(), 0),\n       ('api_auth_mode', '0', '#909399', 'info', 0, 1, 1, 30, '', NOW(), NOW(), 0),\n       ('api_auth_mode', '1', '#409eff', 'primary', 1, 1, 1, 20, '', NOW(), NOW(), 0),\n       ('api_auth_mode', '2', '#f56c6c', 'danger', 0, 1, 1, 10, '', NOW(), NOW(), 0),\n       ('http_method', 'GET', '#67c23a', 'success', 1, 1, 1, 70, '', NOW(), NOW(), 0),\n       ('http_method', 'POST', '#409eff', 'primary', 0, 1, 1, 60, '', NOW(), NOW(), 0),\n       ('http_method', 'PUT', '#e6a23c', 'warning', 0, 1, 1, 50, '', NOW(), NOW(), 0),\n       ('http_method', 'DELETE', '#f56c6c', 'danger', 0, 1, 1, 40, '', NOW(), NOW(), 0),\n       ('http_method', 'PATCH', '#909399', 'info', 0, 1, 1, 30, '', NOW(), NOW(), 0),\n       ('http_method', 'OPTIONS', '#909399', 'info', 0, 1, 1, 20, '', NOW(), NOW(), 0),\n       ('http_method', 'HEAD', '#909399', 'info', 0, 1, 1, 10, '', NOW(), NOW(), 0),\n       ('task_kind', 'async', '#909399', 'info', 1, 1, 1, 20, '', NOW(), NOW(), 0),\n       ('task_kind', 'cron', '#e6a23c', 'warning', 0, 1, 1, 10, '', NOW(), NOW(), 0),\n       ('task_source', 'queue', '#409eff', 'primary', 1, 1, 1, 30, '', NOW(), NOW(), 0),\n       ('task_source', 'cron', '#e6a23c', 'warning', 0, 1, 1, 20, '', NOW(), NOW(), 0),\n       ('task_source', 'manual', '#67c23a', 'success', 0, 1, 1, 10, '', NOW(), NOW(), 0),\n       ('task_run_status', 'pending', '#909399', 'info', 1, 1, 1, 60, '', NOW(), NOW(), 0),\n       ('task_run_status', 'running', '#e6a23c', 'warning', 0, 1, 1, 50, '', NOW(), NOW(), 0),\n       ('task_run_status', 'success', '#67c23a', 'success', 0, 1, 1, 40, '', NOW(), NOW(), 0),\n       ('task_run_status', 'failed', '#f56c6c', 'danger', 0, 1, 1, 30, '', NOW(), NOW(), 0),\n       ('task_run_status', 'canceled', '#909399', 'info', 0, 1, 1, 20, '', NOW(), NOW(), 0),\n       ('task_run_status', 'retrying', '#e6a23c', 'warning', 0, 1, 1, 10, '', NOW(), NOW(), 0)\nON DUPLICATE KEY UPDATE `updated_at` = VALUES(`updated_at`);\n\nINSERT INTO `sys_config_i18n` (`config_id`, `locale`, `config_name`, `created_at`, `updated_at`)\nSELECT `id`,\n       'zh-CN',\n       CASE\n           WHEN `config_key` = 'auth.login_lock_enabled' THEN '登录失败锁定开关'\n           WHEN `config_key` = 'auth.login_max_failures' THEN '登录失败锁定阈值'\n           WHEN `config_key` = 'auth.login_lock_minutes' THEN '登录失败锁定时长（分钟）'\n           WHEN `config_key` = 'task.cron_demo_enabled' THEN '演示定时任务开关'\n           WHEN `config_key` = 'audit.sensitive_fields' THEN '请求日志脱敏配置'\n           WHEN `config_key` = 'storage.active_driver' THEN '当前存储驱动'\n           WHEN `config_key` = 'storage.config' THEN '文件存储配置'\n           ELSE ''\n           END,\n       NOW(),\n       NOW()\nFROM `sys_config`\nWHERE `deleted_at` = 0\n  AND `config_key` IN ('auth.login_lock_enabled', 'auth.login_max_failures', 'auth.login_lock_minutes', 'task.cron_demo_enabled', 'audit.sensitive_fields', 'storage.active_driver', 'storage.config')\nON DUPLICATE KEY UPDATE `config_name` = VALUES(`config_name`), `updated_at` = VALUES(`updated_at`);\n\nINSERT INTO `sys_config_i18n` (`config_id`, `locale`, `config_name`, `created_at`, `updated_at`)\nSELECT `id`,\n       'en-US',\n       CASE\n           WHEN `config_key` = 'auth.login_lock_enabled' THEN 'Login Lock Enabled'\n           WHEN `config_key` = 'auth.login_max_failures' THEN 'Login Lock Max Failures'\n           WHEN `config_key` = 'auth.login_lock_minutes' THEN 'Login Lock Minutes'\n           WHEN `config_key` = 'task.cron_demo_enabled' THEN 'Cron Demo Enabled'\n           WHEN `config_key` = 'audit.sensitive_fields' THEN 'Request Log Mask Config'\n           WHEN `config_key` = 'storage.active_driver' THEN 'Active Storage Driver'\n           WHEN `config_key` = 'storage.config' THEN 'File Storage Config'\n           ELSE ''\n           END,\n       NOW(),\n       NOW()\nFROM `sys_config`\nWHERE `deleted_at` = 0\n  AND `config_key` IN ('auth.login_lock_enabled', 'auth.login_max_failures', 'auth.login_lock_minutes', 'task.cron_demo_enabled', 'audit.sensitive_fields', 'storage.active_driver', 'storage.config')\nON DUPLICATE KEY UPDATE `config_name` = VALUES(`config_name`), `updated_at` = VALUES(`updated_at`);\n\nINSERT INTO `sys_dict_type_i18n` (`dict_type_id`, `locale`, `type_name`, `created_at`, `updated_at`)\nSELECT `id`,\n       'zh-CN',\n       CASE\n           WHEN `type_code` = 'common_status' THEN '通用状态'\n           WHEN `type_code` = 'yes_no' THEN '是否选项'\n           WHEN `type_code` = 'menu_type' THEN '菜单类型'\n           WHEN `type_code` = 'api_auth_mode' THEN '接口鉴权模式'\n           WHEN `type_code` = 'http_method' THEN 'HTTP 方法'\n           WHEN `type_code` = 'task_kind' THEN '任务类型'\n           WHEN `type_code` = 'task_source' THEN '任务来源'\n           WHEN `type_code` = 'task_run_status' THEN '任务执行状态'\n           ELSE ''\n           END,\n       NOW(),\n       NOW()\nFROM `sys_dict_type`\nWHERE `deleted_at` = 0\n  AND `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')\nON DUPLICATE KEY UPDATE `type_name` = VALUES(`type_name`), `updated_at` = VALUES(`updated_at`);\n\nINSERT INTO `sys_dict_type_i18n` (`dict_type_id`, `locale`, `type_name`, `created_at`, `updated_at`)\nSELECT `id`,\n       'en-US',\n       CASE\n           WHEN `type_code` = 'common_status' THEN 'Common Status'\n           WHEN `type_code` = 'yes_no' THEN 'Yes/No'\n           WHEN `type_code` = 'menu_type' THEN 'Menu Type'\n           WHEN `type_code` = 'api_auth_mode' THEN 'API Auth Mode'\n           WHEN `type_code` = 'http_method' THEN 'HTTP Method'\n           WHEN `type_code` = 'task_kind' THEN 'Task Kind'\n           WHEN `type_code` = 'task_source' THEN 'Task Source'\n           WHEN `type_code` = 'task_run_status' THEN 'Task Run Status'\n           ELSE ''\n           END,\n       NOW(),\n       NOW()\nFROM `sys_dict_type`\nWHERE `deleted_at` = 0\n  AND `type_code` IN ('common_status', 'yes_no', 'menu_type', 'api_auth_mode', 'http_method', 'task_kind', 'task_source', 'task_run_status')\nON DUPLICATE KEY UPDATE `type_name` = VALUES(`type_name`), `updated_at` = VALUES(`updated_at`);\n\nINSERT INTO `sys_dict_item_i18n` (`dict_item_id`, `locale`, `label`, `created_at`, `updated_at`)\nSELECT `id`,\n       'zh-CN',\n       CASE\n           WHEN `type_code` = 'common_status' AND `value` = '0' THEN '禁用'\n           WHEN `type_code` = 'common_status' AND `value` = '1' THEN '启用'\n           WHEN `type_code` = 'yes_no' AND `value` = '0' THEN '否'\n           WHEN `type_code` = 'yes_no' AND `value` = '1' THEN '是'\n           WHEN `type_code` = 'menu_type' AND `value` = '1' THEN '目录'\n           WHEN `type_code` = 'menu_type' AND `value` = '2' THEN '菜单'\n           WHEN `type_code` = 'menu_type' AND `value` = '3' THEN '按钮'\n           WHEN `type_code` = 'api_auth_mode' AND `value` = '0' THEN '无需登录'\n           WHEN `type_code` = 'api_auth_mode' AND `value` = '1' THEN '需要登录'\n           WHEN `type_code` = 'api_auth_mode' AND `value` = '2' THEN '需要接口权限'\n           WHEN `type_code` = 'http_method' THEN `value`\n           WHEN `type_code` = 'task_kind' AND `value` = 'async' THEN '异步'\n           WHEN `type_code` = 'task_kind' AND `value` = 'cron' THEN '定时'\n           WHEN `type_code` = 'task_source' AND `value` = 'queue' THEN '队列'\n           WHEN `type_code` = 'task_source' AND `value` = 'cron' THEN '定时'\n           WHEN `type_code` = 'task_source' AND `value` = 'manual' THEN '手动'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'pending' THEN '等待中'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'running' THEN '执行中'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'success' THEN '成功'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'failed' THEN '失败'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'canceled' THEN '已取消'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'retrying' THEN '重试中'\n           ELSE ''\n           END,\n       NOW(),\n       NOW()\nFROM `sys_dict_item`\nWHERE `deleted_at` = 0\n  AND (\n    (`type_code` = 'common_status' AND `value` IN ('0', '1')) OR\n    (`type_code` = 'yes_no' AND `value` IN ('0', '1')) OR\n    (`type_code` = 'menu_type' AND `value` IN ('1', '2', '3')) OR\n    (`type_code` = 'api_auth_mode' AND `value` IN ('0', '1', '2')) OR\n    (`type_code` = 'http_method' AND `value` IN ('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD')) OR\n    (`type_code` = 'task_kind' AND `value` IN ('async', 'cron')) OR\n    (`type_code` = 'task_source' AND `value` IN ('queue', 'cron', 'manual')) OR\n    (`type_code` = 'task_run_status' AND `value` IN ('pending', 'running', 'success', 'failed', 'canceled', 'retrying'))\n    )\nON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `updated_at` = VALUES(`updated_at`);\n\nINSERT INTO `sys_dict_item_i18n` (`dict_item_id`, `locale`, `label`, `created_at`, `updated_at`)\nSELECT `id`,\n       'en-US',\n       CASE\n           WHEN `type_code` = 'common_status' AND `value` = '0' THEN 'Disabled'\n           WHEN `type_code` = 'common_status' AND `value` = '1' THEN 'Enabled'\n           WHEN `type_code` = 'yes_no' AND `value` = '0' THEN 'No'\n           WHEN `type_code` = 'yes_no' AND `value` = '1' THEN 'Yes'\n           WHEN `type_code` = 'menu_type' AND `value` = '1' THEN 'Directory'\n           WHEN `type_code` = 'menu_type' AND `value` = '2' THEN 'Menu'\n           WHEN `type_code` = 'menu_type' AND `value` = '3' THEN 'Button'\n           WHEN `type_code` = 'api_auth_mode' AND `value` = '0' THEN 'No Auth'\n           WHEN `type_code` = 'api_auth_mode' AND `value` = '1' THEN 'Login Required'\n           WHEN `type_code` = 'api_auth_mode' AND `value` = '2' THEN 'Permission Required'\n           WHEN `type_code` = 'http_method' THEN `value`\n           WHEN `type_code` = 'task_kind' AND `value` = 'async' THEN 'Async'\n           WHEN `type_code` = 'task_kind' AND `value` = 'cron' THEN 'Cron'\n           WHEN `type_code` = 'task_source' AND `value` = 'queue' THEN 'Queue'\n           WHEN `type_code` = 'task_source' AND `value` = 'cron' THEN 'Cron'\n           WHEN `type_code` = 'task_source' AND `value` = 'manual' THEN 'Manual'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'pending' THEN 'Pending'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'running' THEN 'Running'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'success' THEN 'Success'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'failed' THEN 'Failed'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'canceled' THEN 'Canceled'\n           WHEN `type_code` = 'task_run_status' AND `value` = 'retrying' THEN 'Retrying'\n           ELSE ''\n           END,\n       NOW(),\n       NOW()\nFROM `sys_dict_item`\nWHERE `deleted_at` = 0\n  AND (\n    (`type_code` = 'common_status' AND `value` IN ('0', '1')) OR\n    (`type_code` = 'yes_no' AND `value` IN ('0', '1')) OR\n    (`type_code` = 'menu_type' AND `value` IN ('1', '2', '3')) OR\n    (`type_code` = 'api_auth_mode' AND `value` IN ('0', '1', '2')) OR\n    (`type_code` = 'http_method' AND `value` IN ('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD')) OR\n    (`type_code` = 'task_kind' AND `value` IN ('async', 'cron')) OR\n    (`type_code` = 'task_source' AND `value` IN ('queue', 'cron', 'manual')) OR\n    (`type_code` = 'task_run_status' AND `value` IN ('pending', 'running', 'success', 'failed', 'canceled', 'retrying'))\n    )\nON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `updated_at` = VALUES(`updated_at`);\n\nINSERT INTO `api_group` (`id`, `pid`, `code`, `name`, `created_at`, `updated_at`)\nVALUES (10, 0, 'system', '系统管理模块', NOW(), NOW()),\n       (11, 10, 'sysConfig', '系统参数模块', NOW(), NOW()),\n       (12, 10, 'sysDict', '系统字典模块', NOW(), NOW()),\n       (13, 0, 'task', '任务中心模块', NOW(), NOW()),\n       (14, 10, 'file', '文件资源模块', NOW(), NOW()),\n       (15, 3, 'session', '在线会话模块', NOW(), NOW())\nON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `updated_at` = VALUES(`updated_at`);\n\nCOMMIT;\n"
  },
  {
    "path": "data/migrations/20260515010000_upload_file_objects.down.sql",
    "content": "ALTER TABLE `upload_files`\n    DROP KEY `idx_file_object_id`,\n    DROP COLUMN `file_object_id`;\n\nDROP TABLE IF EXISTS `upload_file_objects`;\n"
  },
  {
    "path": "data/migrations/20260515010000_upload_file_objects.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS `upload_file_objects`\n(\n    `id`             int unsigned NOT NULL AUTO_INCREMENT,\n    `storage_driver` varchar(20)  NOT NULL DEFAULT 'local' COMMENT '存储驱动:local,aliyun_oss,s3',\n    `storage_base`   varchar(512) NOT NULL DEFAULT '' COMMENT '存储基础位置',\n    `bucket`         varchar(128) NOT NULL DEFAULT '' COMMENT '存储桶',\n    `storage_path`   varchar(512) NOT NULL DEFAULT '' COMMENT '实际存储路径',\n    `object_key`     varchar(512) NOT NULL DEFAULT '' COMMENT '对象key',\n    `size`           int unsigned NOT NULL DEFAULT '0' COMMENT '文件大小（字节）',\n    `hash`           varchar(64)  NOT NULL DEFAULT '' COMMENT '文件SHA256哈希值',\n    `mime_type`      varchar(100) NOT NULL DEFAULT '' COMMENT 'MIME类型',\n    `etag`           varchar(128) NOT NULL DEFAULT '' COMMENT '对象ETag',\n    `status`         varchar(32)  NOT NULL DEFAULT 'stored' COMMENT '物理对象状态:stored,delete_failed',\n    `created_at`     datetime DEFAULT NULL COMMENT '创建时间',\n    `updated_at`     datetime DEFAULT NULL COMMENT '更新时间',\n    PRIMARY KEY (`id`),\n    UNIQUE KEY `uniq_driver_bucket_hash` (`storage_driver`, `bucket`, `hash`),\n    KEY `idx_local_hash` (`storage_driver`, `hash`),\n    KEY `idx_remote_bucket_hash` (`storage_driver`, `bucket`, `hash`),\n    KEY `idx_status` (`status`)\n) ENGINE = InnoDB\n  DEFAULT CHARSET = utf8mb4\n  COLLATE = utf8mb4_unicode_ci COMMENT ='上传文件物理对象表';\n\nALTER TABLE `upload_files`\n    ADD COLUMN `file_object_id` int unsigned NOT NULL DEFAULT '0' COMMENT '物理对象ID' AFTER `id`,\n    ADD KEY `idx_file_object_id` (`file_object_id`);\n"
  },
  {
    "path": "data/mysql.go",
    "content": "package data\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"gorm.io/driver/mysql\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/logger\"\n\t\"gorm.io/gorm/schema\"\n\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"go.uber.org/zap\"\n)\n\nvar (\n\tmysqlDB        *gorm.DB\n\tmysqlOnce      sync.Once\n\tmysqlInitError error\n\tmysqlValue     atomic.Value\n\tmysqlMu        sync.Mutex\n\tmysqlHealth    = newRuntimeHealthCache(defaultRuntimeHealthTTL)\n)\n\ntype mysqlSlot struct {\n\tdb *gorm.DB\n}\n\nconst mysqlProbeTimeout = 2 * time.Second\n\nvar mysqlRuntimeProbe = func(db *gorm.DB) error {\n\tsqlDB := getSQLDB(db)\n\tif sqlDB == nil {\n\t\treturn errors.New(\"mysql sql.DB is unavailable\")\n\t}\n\tctx, cancel := context.WithTimeout(context.Background(), mysqlProbeTimeout)\n\tdefer cancel()\n\treturn sqlDB.PingContext(ctx)\n}\n\n// Writer 定义 GORM 自定义日志写入接口。\ntype Writer interface {\n\tPrintf(string, ...interface{})\n}\n\n// WriterLog 将 GORM SQL 日志转发到项目日志组件。\ntype WriterLog struct{}\n\n// Printf 实现 GORM logger.Writer 接口。\nfunc (w WriterLog) Printf(format string, args ...interface{}) {\n\tif c.GetConfig().Mysql.PrintSql {\n\t\tlog.Logger.Sugar().Infof(format, args...)\n\t}\n}\n\n// GenerateDSN 生成带固定连接参数的 MySQL DSN。\nfunc GenerateDSN(cfg *c.Conf) string {\n\t// 防御性编码\n\tif cfg == nil || cfg.Mysql.Host == \"\" || cfg.Mysql.Database == \"\" {\n\t\treturn \"\"\n\t}\n\n\t// 特殊字符处理\n\tusername := strings.Replace(url.QueryEscape(cfg.Mysql.Username), \"%\", \"%25\", -1)\n\tpassword := strings.Replace(url.QueryEscape(cfg.Mysql.Password), \"%\", \"%25\", -1)\n\n\t// IPv6处理\n\thost := cfg.Mysql.Host\n\tif strings.Contains(host, \":\") && !strings.HasPrefix(host, \"[\") {\n\t\thost = \"[\" + host + \"]\"\n\t}\n\n\t// 强制关键参数\n\tcharset := \"utf8mb4\"\n\tdsn := fmt.Sprintf(\"%s:%s@tcp(%s:%d)/%s\",\n\t\tusername,\n\t\tpassword,\n\t\thost,\n\t\tcfg.Mysql.Port,\n\t\tcfg.Mysql.Database,\n\t)\n\n\t// 参数显式排序\n\tparams := url.Values{\n\t\t\"charset\":      []string{charset},\n\t\t\"parseTime\":    []string{\"true\"},\n\t\t\"loc\":          []string{\"Local\"},\n\t\t\"timeout\":      []string{\"5s\"},\n\t\t\"readTimeout\":  []string{\"30s\"},\n\t\t\"writeTimeout\": []string{\"60s\"},\n\t}\n\n\treturn dsn + \"?\" + params.Encode()\n}\n\n// initMysql 初始化当前配置下的 MySQL 连接。\nfunc initMysql() error {\n\treturn reloadMysql(c.GetConfig())\n}\n\nfunc reloadMysql(cfg *c.Conf) error {\n\tmysqlMu.Lock()\n\tdefer mysqlMu.Unlock()\n\n\tnext, err := openMysql(cfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\told := currentMysql()\n\toldSQLDB := getSQLDB(old)\n\tmysqlDB = next\n\tmysqlValue.Store(mysqlSlot{db: next})\n\tmysqlInitError = nil\n\tif next != nil {\n\t\tmysqlHealth.SeedReady()\n\t} else {\n\t\tmysqlHealth.Reset()\n\t}\n\tif oldSQLDB != nil {\n\t\tif err := oldSQLDB.Close(); err != nil {\n\t\t\tlog.Logger.Warn(\"关闭旧 MySQL 连接池失败\", zap.Error(err))\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc openMysql(cfg *c.Conf) (*gorm.DB, error) {\n\tif cfg == nil || !cfg.Mysql.Enable {\n\t\treturn nil, nil\n\t}\n\t// Validate configuration parameters\n\tif cfg.Mysql.MaxIdleConns < 0 || cfg.Mysql.MaxOpenConns < 0 || cfg.Mysql.MaxLifetime < 0 {\n\t\treturn nil, fmt.Errorf(\"invalid MySQL configuration: MaxIdleConns, MaxOpenConns, and MaxLifetime must be non-negative\")\n\t}\n\n\t// Initialize logger\n\tlogConfig := logger.New(\n\t\tWriterLog{},\n\t\tlogger.Config{\n\t\t\tSlowThreshold:             0,\n\t\t\tLogLevel:                  logger.LogLevel(cfg.Mysql.LogLevel),\n\t\t\tIgnoreRecordNotFoundError: false,\n\t\t\tColorful:                  false,\n\t\t},\n\t)\n\n\t// Configure GORM settings\n\tconfigs := &gorm.Config{\n\t\tNamingStrategy: schema.NamingStrategy{\n\t\t\tTablePrefix: cfg.Mysql.TablePrefix,\n\t\t},\n\t\tLogger:                 logConfig,\n\t\tSkipDefaultTransaction: true,\n\t}\n\n\t// Open database connection\n\tdsn := GenerateDSN(cfg)\n\tdb, err := gorm.Open(mysql.Open(dsn), configs)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to connect to MySQL: %s\", err.Error())\n\t}\n\n\t// Get underlying sql.DB and configure connection pool\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get sql.DB: %s\", err.Error())\n\t}\n\n\tsqlDB.SetMaxIdleConns(cfg.Mysql.MaxIdleConns)\n\tsqlDB.SetMaxOpenConns(cfg.Mysql.MaxOpenConns)\n\tsqlDB.SetConnMaxLifetime(cfg.Mysql.MaxLifetime)\n\tif err := sqlDB.Ping(); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"failed to ping MySQL: %s\", err.Error())\n\t}\n\n\treturn db, nil\n}\n\n// MysqlDB 返回当前生效的 MySQL 连接实例。\nfunc MysqlDB() *gorm.DB {\n\tif db := currentMysql(); db != nil {\n\t\treturn db\n\t}\n\tif mysqlDB == nil {\n\t\tmysqlOnce.Do(func() {\n\t\t\tmysqlInitError = initMysql()\n\t\t})\n\t}\n\treturn currentMysql()\n}\n\n// MysqlInitError 返回 MySQL 初始化阶段记录的错误。\nfunc MysqlInitError() error {\n\treturn mysqlInitError\n}\n\n// MysqlRuntimeStatus 返回带缓存的 MySQL 运行时健康探测结果。\nfunc MysqlRuntimeStatus() RuntimeHealthStatus {\n\tdb := MysqlDB()\n\tif db == nil {\n\t\tmysqlHealth.Reset()\n\t\treturn RuntimeHealthStatus{\n\t\t\tReady:     false,\n\t\t\tError:     mysqlUnavailableError(),\n\t\t\tCheckedAt: time.Now(),\n\t\t}\n\t}\n\tstatus := mysqlHealth.Check(func() error {\n\t\treturn mysqlRuntimeProbe(db)\n\t})\n\tif !status.Ready && status.Error == nil {\n\t\tstatus.Error = mysqlUnavailableError()\n\t}\n\treturn status\n}\n\n// MysqlReady 判断 MySQL 当前是否可用。\nfunc MysqlReady() bool {\n\treturn MysqlRuntimeStatus().Ready\n}\n\n// ReloadMysql 重新加载 MySQL 连接。\nfunc ReloadMysql(cfg *c.Conf) error {\n\treturn reloadMysql(cfg)\n}\n\n// CloseMysql 关闭当前 MySQL 连接池。\nfunc CloseMysql() error {\n\tmysqlMu.Lock()\n\tdefer mysqlMu.Unlock()\n\n\tcurrent := currentMysql()\n\tmysqlDB = nil\n\tmysqlValue.Store(mysqlSlot{})\n\tmysqlInitError = nil\n\tmysqlHealth.Reset()\n\tif current == nil {\n\t\treturn nil\n\t}\n\n\tsqlDB := getSQLDB(current)\n\tif sqlDB == nil {\n\t\treturn nil\n\t}\n\treturn sqlDB.Close()\n}\n\nfunc currentMysql() *gorm.DB {\n\tif slot, ok := mysqlValue.Load().(mysqlSlot); ok {\n\t\treturn slot.db\n\t}\n\treturn mysqlDB\n}\n\nfunc getSQLDB(db *gorm.DB) *sql.DB {\n\tif db == nil {\n\t\treturn nil\n\t}\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn sqlDB\n}\n\nfunc mysqlUnavailableError() error {\n\tif mysqlInitError != nil {\n\t\treturn mysqlInitError\n\t}\n\treturn ErrDBUnavailable\n}\n\n// ErrDBUnavailable 表示 MySQL 连接当前不可用。\nvar ErrDBUnavailable = errors.New(\"mysql connection is unavailable\")\n"
  },
  {
    "path": "data/redis.go",
    "content": "package data\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/redis/go-redis/v9\"\n\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n)\n\nvar (\n\tredisDb        *redis.Client\n\tredisOnce      sync.Once\n\tredisInitError error\n\tredisValue     atomic.Value\n\tredisMu        sync.Mutex\n\tredisHealth    = newRuntimeHealthCache(defaultRuntimeHealthTTL)\n)\n\ntype redisSlot struct {\n\tclient *redis.Client\n}\n\nconst redisProbeTimeout = 2 * time.Second\n\nvar redisRuntimeProbe = func(client *redis.Client) error {\n\tctx, cancel := context.WithTimeout(context.Background(), redisProbeTimeout)\n\tdefer cancel()\n\treturn client.Ping(ctx).Err()\n}\n\nfunc initRedis() error {\n\treturn reloadRedis(c.GetConfig())\n}\n\nfunc reloadRedis(cfg *c.Conf) error {\n\tredisMu.Lock()\n\tdefer redisMu.Unlock()\n\n\tnext, err := openRedis(cfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\told := currentRedis()\n\tredisDb = next\n\tredisValue.Store(redisSlot{client: next})\n\tredisInitError = nil\n\tif next != nil {\n\t\tredisHealth.SeedReady()\n\t} else {\n\t\tredisHealth.Reset()\n\t}\n\tif old != nil {\n\t\t_ = old.Close()\n\t}\n\treturn nil\n}\n\nfunc openRedis(cfg *c.Conf) (*redis.Client, error) {\n\tif cfg == nil || !cfg.Redis.Enable {\n\t\treturn nil, nil\n\t}\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\topts := &redis.Options{\n\t\tAddr:            cfg.Redis.Host + \":\" + cfg.Redis.Port,\n\t\tPassword:        cfg.Redis.Password,\n\t\tDB:              cfg.Redis.Database,\n\t\tPoolSize:        cfg.Redis.PoolSize,\n\t\tMinIdleConns:    cfg.Redis.MinIdleConns,\n\t\tConnMaxLifetime: cfg.Redis.ConnMaxLifetime,\n\t\tConnMaxIdleTime: cfg.Redis.ConnMaxIdle,\n\t\tReadTimeout:     cfg.Redis.ReadTimeout,\n\t\tWriteTimeout:    cfg.Redis.WriteTimeout,\n\t}\n\n\tclient := redis.NewClient(opts)\n\n\t_, err := client.Ping(ctx).Result()\n\tif err != nil {\n\t\t_ = client.Close()\n\t\treturn nil, err\n\t}\n\treturn client, nil\n}\n\n// RedisClient 返回 Redis 客户端和初始化错误\nfunc RedisClient() *redis.Client {\n\tif client := currentRedis(); client != nil {\n\t\treturn client\n\t}\n\tif redisDb == nil {\n\t\tredisOnce.Do(func() {\n\t\t\tredisInitError = initRedis()\n\t\t})\n\t}\n\treturn currentRedis()\n}\n\n// GetRedisInitError 返回 Redis 初始化错误，供外部检查\nfunc GetRedisInitError() error {\n\treturn redisInitError\n}\n\n// RedisRuntimeStatus 返回带缓存的 Redis 运行时健康探测结果。\nfunc RedisRuntimeStatus() RuntimeHealthStatus {\n\tclient := RedisClient()\n\tif client == nil {\n\t\tredisHealth.Reset()\n\t\treturn RuntimeHealthStatus{\n\t\t\tReady:     false,\n\t\t\tError:     redisUnavailableError(),\n\t\t\tCheckedAt: time.Now(),\n\t\t}\n\t}\n\tstatus := redisHealth.Check(func() error {\n\t\treturn redisRuntimeProbe(client)\n\t})\n\tif !status.Ready && status.Error == nil {\n\t\tstatus.Error = redisUnavailableError()\n\t}\n\treturn status\n}\n\n// RedisReady 判断 Redis 当前是否可用。\nfunc RedisReady() bool {\n\treturn RedisRuntimeStatus().Ready\n}\n\n// ReloadRedis 重新加载 Redis 客户端。\nfunc ReloadRedis(cfg *c.Conf) error {\n\treturn reloadRedis(cfg)\n}\n\n// CloseRedis 关闭当前 Redis 客户端。\nfunc CloseRedis() error {\n\tredisMu.Lock()\n\tdefer redisMu.Unlock()\n\n\tcurrent := currentRedis()\n\tredisDb = nil\n\tredisValue.Store(redisSlot{})\n\tredisInitError = nil\n\tredisHealth.Reset()\n\tif current == nil {\n\t\treturn nil\n\t}\n\treturn current.Close()\n}\n\nfunc currentRedis() *redis.Client {\n\tif slot, ok := redisValue.Load().(redisSlot); ok {\n\t\treturn slot.client\n\t}\n\treturn redisDb\n}\n\nfunc redisUnavailableError() error {\n\tif redisInitError != nil {\n\t\treturn redisInitError\n\t}\n\treturn ErrRedisUnavailable\n}\n\n// ErrRedisUnavailable 表示 Redis 客户端当前不可用。\nvar ErrRedisUnavailable = errors.New(\"redis client is unavailable\")\n"
  },
  {
    "path": "data/runtime_health.go",
    "content": "package data\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\nconst defaultRuntimeHealthTTL = 3 * time.Second\n\n// RuntimeHealthStatus 表示依赖最近一次运行时探测结果。\ntype RuntimeHealthStatus struct {\n\tReady     bool\n\tError     error\n\tCheckedAt time.Time\n}\n\ntype runtimeHealthCache struct {\n\tttl    time.Duration\n\tmu     sync.Mutex\n\tstatus RuntimeHealthStatus\n}\n\nfunc newRuntimeHealthCache(ttl time.Duration) *runtimeHealthCache {\n\tif ttl <= 0 {\n\t\tttl = defaultRuntimeHealthTTL\n\t}\n\treturn &runtimeHealthCache{ttl: ttl}\n}\n\nfunc (c *runtimeHealthCache) Check(probe func() error) RuntimeHealthStatus {\n\tnow := time.Now()\n\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\n\tif !c.status.CheckedAt.IsZero() && now.Sub(c.status.CheckedAt) < c.ttl {\n\t\treturn c.status\n\t}\n\n\terr := probe()\n\tc.status = RuntimeHealthStatus{\n\t\tReady:     err == nil,\n\t\tError:     err,\n\t\tCheckedAt: now,\n\t}\n\treturn c.status\n}\n\nfunc (c *runtimeHealthCache) SeedReady() {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tc.status = RuntimeHealthStatus{\n\t\tReady:     true,\n\t\tCheckedAt: time.Now(),\n\t}\n}\n\nfunc (c *runtimeHealthCache) Reset() {\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tc.status = RuntimeHealthStatus{}\n}\n"
  },
  {
    "path": "data/runtime_health_test.go",
    "content": "package data\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/redis/go-redis/v9\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc TestMysqlRuntimeStatusCachesProbeResult(t *testing.T) {\n\tdb, err := gorm.Open(sqlite.Open(\"file::memory:?cache=shared\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite failed: %v\", err)\n\t}\n\n\trestore := backupMysqlState()\n\tdefer restore()\n\n\tmysqlDB = db\n\tmysqlValue.Store(mysqlSlot{db: db})\n\tmysqlInitError = nil\n\tmysqlHealth = newRuntimeHealthCache(time.Hour)\n\n\tprobeCount := 0\n\toriginalProbe := mysqlRuntimeProbe\n\tmysqlRuntimeProbe = func(current *gorm.DB) error {\n\t\tif current != db {\n\t\t\tt.Fatalf(\"unexpected db pointer\")\n\t\t}\n\t\tprobeCount++\n\t\treturn nil\n\t}\n\tdefer func() {\n\t\tmysqlRuntimeProbe = originalProbe\n\t}()\n\n\tstatus1 := MysqlRuntimeStatus()\n\tstatus2 := MysqlRuntimeStatus()\n\n\tif !status1.Ready || !status2.Ready {\n\t\tt.Fatalf(\"expected mysql runtime status to stay ready, got %+v %+v\", status1, status2)\n\t}\n\tif probeCount != 1 {\n\t\tt.Fatalf(\"expected mysql probe to run once, got %d\", probeCount)\n\t}\n}\n\nfunc TestMysqlRuntimeStatusReturnsProbeFailure(t *testing.T) {\n\tdb, err := gorm.Open(sqlite.Open(\"file::memory:?cache=shared\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite failed: %v\", err)\n\t}\n\n\trestore := backupMysqlState()\n\tdefer restore()\n\n\tmysqlDB = db\n\tmysqlValue.Store(mysqlSlot{db: db})\n\tmysqlInitError = nil\n\tmysqlHealth = newRuntimeHealthCache(time.Hour)\n\n\twantErr := errors.New(\"mysql down\")\n\toriginalProbe := mysqlRuntimeProbe\n\tmysqlRuntimeProbe = func(*gorm.DB) error { return wantErr }\n\tdefer func() {\n\t\tmysqlRuntimeProbe = originalProbe\n\t}()\n\n\tstatus := MysqlRuntimeStatus()\n\tif status.Ready {\n\t\tt.Fatal(\"expected mysql runtime status to be not ready\")\n\t}\n\tif !errors.Is(status.Error, wantErr) {\n\t\tt.Fatalf(\"expected error %v, got %v\", wantErr, status.Error)\n\t}\n\tif MysqlReady() {\n\t\tt.Fatal(\"expected MysqlReady to be false\")\n\t}\n}\n\nfunc TestRedisRuntimeStatusCachesProbeResult(t *testing.T) {\n\tclient := redis.NewClient(&redis.Options{Addr: \"127.0.0.1:0\"})\n\tdefer client.Close()\n\n\trestore := backupRedisState()\n\tdefer restore()\n\n\tredisDb = client\n\tredisValue.Store(redisSlot{client: client})\n\tredisInitError = nil\n\tredisHealth = newRuntimeHealthCache(time.Hour)\n\n\tprobeCount := 0\n\toriginalProbe := redisRuntimeProbe\n\tredisRuntimeProbe = func(current *redis.Client) error {\n\t\tif current != client {\n\t\t\tt.Fatalf(\"unexpected redis client pointer\")\n\t\t}\n\t\tprobeCount++\n\t\treturn nil\n\t}\n\tdefer func() {\n\t\tredisRuntimeProbe = originalProbe\n\t}()\n\n\tstatus1 := RedisRuntimeStatus()\n\tstatus2 := RedisRuntimeStatus()\n\n\tif !status1.Ready || !status2.Ready {\n\t\tt.Fatalf(\"expected redis runtime status to stay ready, got %+v %+v\", status1, status2)\n\t}\n\tif probeCount != 1 {\n\t\tt.Fatalf(\"expected redis probe to run once, got %d\", probeCount)\n\t}\n}\n\nfunc TestRedisRuntimeStatusReturnsProbeFailure(t *testing.T) {\n\tclient := redis.NewClient(&redis.Options{Addr: \"127.0.0.1:0\"})\n\tdefer client.Close()\n\n\trestore := backupRedisState()\n\tdefer restore()\n\n\tredisDb = client\n\tredisValue.Store(redisSlot{client: client})\n\tredisInitError = nil\n\tredisHealth = newRuntimeHealthCache(time.Hour)\n\n\twantErr := errors.New(\"redis down\")\n\toriginalProbe := redisRuntimeProbe\n\tredisRuntimeProbe = func(*redis.Client) error { return wantErr }\n\tdefer func() {\n\t\tredisRuntimeProbe = originalProbe\n\t}()\n\n\tstatus := RedisRuntimeStatus()\n\tif status.Ready {\n\t\tt.Fatal(\"expected redis runtime status to be not ready\")\n\t}\n\tif !errors.Is(status.Error, wantErr) {\n\t\tt.Fatalf(\"expected error %v, got %v\", wantErr, status.Error)\n\t}\n\tif RedisReady() {\n\t\tt.Fatal(\"expected RedisReady to be false\")\n\t}\n}\n\nfunc backupMysqlState() func() {\n\tpreviousDB := mysqlDB\n\tpreviousInitErr := mysqlInitError\n\tpreviousHealth := mysqlHealth\n\tvar previousSlot mysqlSlot\n\tif slot, ok := mysqlValue.Load().(mysqlSlot); ok {\n\t\tpreviousSlot = slot\n\t}\n\treturn func() {\n\t\tmysqlDB = previousDB\n\t\tmysqlInitError = previousInitErr\n\t\tmysqlHealth = previousHealth\n\t\tmysqlValue.Store(previousSlot)\n\t}\n}\n\nfunc backupRedisState() func() {\n\tpreviousClient := redisDb\n\tpreviousInitErr := redisInitError\n\tpreviousHealth := redisHealth\n\tvar previousSlot redisSlot\n\tif slot, ok := redisValue.Load().(redisSlot); ok {\n\t\tpreviousSlot = slot\n\t}\n\treturn func() {\n\t\tredisDb = previousClient\n\t\tredisInitError = previousInitErr\n\t\tredisHealth = previousHealth\n\t\tredisValue.Store(previousSlot)\n\t}\n}\n"
  },
  {
    "path": "docs/COMMANDS_AND_TASKS.en.md",
    "content": "# Commands, Scheduled Jobs, and Queue Usage\n\nThis document covers:\n\n- Available project commands and what they do\n- How to start `service`, `worker`, and `cron`\n- How to add scheduled jobs\n- How to create, publish, and consume async queue jobs\n- How to send jobs to a specific queue and configure multiple queues\n\nFor system config and dictionary boundaries, see [docs/SYSTEM_CONFIG_AND_DICT_GUIDELINES.en.md](/Users/liuml/data/go/src/go-layout/docs/SYSTEM_CONFIG_AND_DICT_GUIDELINES.en.md).\n\nIf you are new to the project, start with \"Runtime Model\" and \"Commands\", then move to \"Scheduled Jobs\" and \"Queue Usage\".\n\n## Runtime Model\n\nThe project currently splits runtime responsibilities into 3 process types:\n\n- `service`\n  - serves the HTTP API\n  - handles login, permissions, menus, uploads, logs, and business requests\n  - can publish async jobs in some flows\n\n- `worker`\n  - consumes async jobs\n  - currently handles async request-audit persistence\n\n- `cron`\n  - handles recurring schedules\n  - currently uses `robfig/cron`\n  - fits fixed-time tasks such as \"run every day at 2 AM\"\n\nIn short:\n\n- `service` serves requests\n- `worker` consumes jobs\n- `cron` triggers recurring tasks\n\n## Commands\n\nThe root command entry is in [cmd/root.go](/Users/liuml/data/go/src/go-layout/cmd/root.go).\n\n### 0. Migration Commands\n\nCommon entry points:\n\n```bash\ngo run main.go -c ./config.yaml command migrate check\ngo run main.go -c ./config.yaml command migrate up\n```\n\nSee [docs/MIGRATE_COMMANDS.en.md](/Users/liuml/data/go/src/go-layout/docs/MIGRATE_COMMANDS.en.md) for full details.\n\n### 1. Show Help\n\n```bash\ngo run main.go -h\ngo run main.go command -h\n```\n\n### 2. Start the API Service\n\n```bash\ngo run main.go service\n```\n\nUsed for:\n\n- starting the Gin HTTP server\n- loading config, logger, database, and other base resources\n- serving business APIs\n\nCommon scenarios:\n\n- local development\n- single-node API deployment\n- main application process in a container\n\n### 3. Start the Async Worker\n\n```bash\ngo run main.go worker\n```\n\nEntry file:\n\n- [cmd/worker/worker.go](/Users/liuml/data/go/src/go-layout/cmd/worker/worker.go)\n\nUsed for:\n\n- connecting to Redis\n- registering all async job handlers\n- starting the Asynq worker and consuming jobs\n\nCurrently registered:\n\n- `audit:request_log.write`\n\n### 4. Start the Scheduler\n\n```bash\ngo run main.go cron\n```\n\nEntry file:\n\n- [cmd/cron/cron.go](/Users/liuml/data/go/src/go-layout/cmd/cron/cron.go)\n\nUsed for:\n\n- starting the scheduler\n- registering the current recurring jobs\n- shutting down gracefully on process signals\n\nTask definition boundary:\n\n- `task_definitions` is currently a read-only mirror of built-in task definitions for the task center UI, manual trigger checks, and retry checks.\n- The `cron` scheduler still uses `BuiltinTaskDefinitions(cfg)` as its source of truth and does not read manually edited `task_definitions` rows from DB.\n- If the admin UI should become the scheduler configuration source, update the scheduler to read DB first and change built-in sync from overwrite to missing-record initialization.\n\n### 5. Run One-Off Commands\n\n```bash\ngo run main.go command api-route\ngo run main.go command rebuild-user-permissions\ngo run main.go command init-system\ngo run main.go -c ./config.yaml command migrate up\n```\n\nEntry file:\n\n- [cmd/command/command.go](/Users/liuml/data/go/src/go-layout/cmd/command/command.go)\n\nSupported subcommands:\n\n- `api-route`\n  - scans the declarative route tree and rebuilds the `api` route table\n- `rebuild-user-permissions`\n  - rebuilds final user API permissions from database relationships\n- `init-system`\n  - rolls back migrations, reruns migrations, initializes API routes, and rebuilds user permissions\n- `demo`\n  - example command\n- `migrate`\n  - migration management subcommands: `create/check/up/down/goto/force/version`\n  - full guide: [docs/MIGRATE_COMMANDS.en.md](/Users/liuml/data/go/src/go-layout/docs/MIGRATE_COMMANDS.en.md)\n- `task scan-async`\n  - scans async queue tasks registered in code and compares them with the `task_definitions` mirror\n- `task scan-cron`\n  - scans built-in cron task definitions and compares them with the `task_definitions` mirror\n\n### 6. Show Version\n\n```bash\ngo run main.go version\n```\n\n## Common Startup Combinations\n\n### 1. API Only\n\nUseful when:\n\n- you only need to debug HTTP APIs locally\n- your scenario does not depend on async jobs\n\n```bash\ngo run main.go service\n```\n\n### 2. API + Worker\n\nUseful when:\n\n- async jobs need to be consumed\n- Redis is enabled\n\n```bash\ngo run main.go service\ngo run main.go worker\n```\n\n### 3. API + Worker + Cron\n\nUseful when:\n\n- you need the API, async jobs, and recurring tasks at the same time\n\n```bash\ngo run main.go service\ngo run main.go worker\ngo run main.go cron\n```\n\n## Scheduled Jobs\n\nThe current scheduling code is split into 3 files:\n\n- [cmd/cron/cron.go](/Users/liuml/data/go/src/go-layout/cmd/cron/cron.go)\n  - startup and shutdown only\n- [cmd/cron/schedule.go](/Users/liuml/data/go/src/go-layout/cmd/cron/schedule.go)\n  - scheduling DSL\n- [cmd/cron/tasks.go](/Users/liuml/data/go/src/go-layout/cmd/cron/tasks.go)\n  - filters enabled cron definitions and registers them into the scheduler\n- [internal/cron/registry.go](/Users/liuml/data/go/src/go-layout/internal/cron/registry.go)\n  - owns built-in task definitions, cron handler registration, and task-center mirror sync\n\n### Current Style\n\nThe project now recommends declaring built-in task definitions in `internal/cron/registry.go`, while `cmd/cron/tasks.go` registers enabled cron definitions into the scheduler:\n\n```go\nfunc BuiltinTaskDefinitions(cfg *config.Conf) []model.TaskDefinition {\n\treturn []model.TaskDefinition{\n\t\t{\n\t\t\tCode:     \"cron:cleanup-cache\",\n\t\t\tKind:     model.TaskKindCron,\n\t\t\tCronSpec: \"0 */10 * * * *\",\n\t\t\tHandler:  \"cron.cleanup-cache\",\n\t\t\tStatus:   model.TaskStatusEnabled,\n\t\t},\n\t}\n}\n```\n\nWhy this is simpler:\n\n- task-center display, manual trigger checks, retry checks, and cron registration share the same built-in code definition\n- name, schedule, handler, status, and high-risk flags are visible at a glance\n- risky jobs can be explicitly enabled instead of being registered into cron by default\n- no need to repeat `AddJob`, `Chain`, and `Recover` manually\n\n### Available Methods\n\nCurrently supported:\n\n- `Call(name, func())`\n  - register a function with no return value\n- `CallE(name, func() error)`\n  - register a function returning `error`\n- `Cron(spec)`\n  - use a raw cron expression\n- `EveryFiveSeconds()`\n  - run every 5 seconds\n- `DailyAt(\"02:00:00\")`\n  - run at a fixed time every day\n- `WithoutOverlapping()`\n  - skip the next run if the current run is still active\n- `AllowOverlap()`\n  - allow overlapping executions\n\n### Example 1: Run Every 10 Minutes\n\n```go\nschedule.Call(\"cleanup-cache\", cleanupCache).\n\tCron(\"0 */10 * * * *\").\n\tWithoutOverlapping()\n```\n\n### Example 2: Run Every Day At 3 AM\n\n```go\nschedule.CallE(\"sync-report\", reportService.SyncDailyReport).\n\tDailyAt(\"03:00:00\").\n\tWithoutOverlapping()\n```\n\n### Example 3: Allow Overlap\n\n```go\nschedule.Call(\"heartbeat\", heartbeat).\n\tCron(\"0/30 * * * * *\").\n\tAllowOverlap()\n```\n\n### Steps To Add A Scheduled Job\n\n1. Prepare the task function in the business layer.\n2. Register its handler in [registry.go](/Users/liuml/data/go/src/go-layout/internal/cron/registry.go).\n3. Add a `kind=cron` task definition to `BuiltinTaskDefinitions`.\n4. Run `go run main.go -c ./config.yaml command task scan-cron` to compare built-in definitions with the DB mirror.\n5. Restart the `cron` process.\n\n## Queue Usage\n\nThe queue system is built on top of `Asynq`, but business code does not talk to `asynq.Client` directly. It uses the project-level queue API instead.\n\nCore files:\n\n- [internal/queue/queue.go](/Users/liuml/data/go/src/go-layout/internal/queue/queue.go)\n- [internal/queue/asynqx/asynq.go](/Users/liuml/data/go/src/go-layout/internal/queue/asynqx/asynq.go)\n- [internal/jobs/registry.go](/Users/liuml/data/go/src/go-layout/internal/jobs/registry.go)\n\n### Queue Flow\n\nA typical async job flows like this:\n\n1. business code builds a payload\n2. it calls `queue.PublishJSON(...)`\n3. the job enters the target Redis queue\n4. `worker` starts and registers handlers\n5. Asynq pulls the job from Redis\n6. the corresponding handler runs the business logic\n\n### Recommended API\n\nPublish a job:\n\n```go\n_, err := queue.PublishJSON(ctx, taskType, queueName, payload, opts...)\n```\n\nRegister a consumer:\n\n```go\nqueue.RegisterJSON(registry, taskType, func(ctx context.Context, payload PayloadType) error {\n\treturn handle(payload)\n})\n```\n\nCompared to the older pattern, this is easier because:\n\n- you do not need to implement a full custom `Job` type\n- you do not need to manually `json.Marshal` and `json.Unmarshal`\n- payloads are easier to read and reuse\n- new jobs are easier to add by copying a small template\n\n## How To Add A New Queue Job\n\nThe example below uses \"send email\".\n\n### 1. Define The Payload\n\n```go\ntype EmailPayload struct {\n\tTo      string `json:\"to\"`\n\tSubject string `json:\"subject\"`\n\tBody    string `json:\"body\"`\n}\n\nfunc (p EmailPayload) Validate() error {\n\tif p.To == \"\" {\n\t\treturn errors.New(\"to is required\")\n\t}\n\treturn nil\n}\n```\n\nIf the payload implements `Validate() error`, it will be validated automatically before the handler runs. Validation failures are treated as non-retryable.\n\n### 2. Publish The Job\n\n```go\nfunc EnqueueEmail(ctx context.Context, payload EmailPayload) error {\n\t_, err := queue.PublishJSON(\n\t\tctx,\n\t\t\"notify:email.send\",\n\t\t\"default\",\n\t\tpayload,\n\t\tqueue.WithMaxRetry(5),\n\t\tqueue.WithTimeout(30*time.Second),\n\t)\n\treturn err\n}\n```\n\nThe parameters mean:\n\n- `\"notify:email.send\"`\n  - task type\n- `\"default\"`\n  - queue name\n- `payload`\n  - task data\n- `WithMaxRetry`\n  - maximum retry count\n- `WithTimeout`\n  - execution timeout\n\n### 3. Register The Consumer\n\n```go\nfunc registerEmail(registry queue.Registry) {\n\tqueue.RegisterJSON(registry, \"notify:email.send\", func(ctx context.Context, payload EmailPayload) error {\n\t\treturn sendEmail(payload)\n\t})\n}\n```\n\n### 4. Register It In The Unified Entry\n\nCurrent recommended pattern:\n\n```go\nfunc RegisterAll(registry queue.Registry) {\n\tqueue.RegisterJSON(registry, AuditLogTaskType, handleAuditLog)\n\tregisterEmail(registry)\n}\n```\n\n### 5. Worker Consumes It Automatically\n\nWhen the worker starts, it does this:\n\n```go\nregistry := jobs.NewRegistry()\nserver, mux, err := asynqx.NewServer(cfg, registry)\n```\n\nSo if your handler is already registered in `RegisterAll`, the worker will consume it automatically.\n\n## How To Send A Job To A Specific Queue\n\nThe queue name is the third argument of `PublishJSON`:\n\n```go\nqueue.PublishJSON(ctx, \"notify:email.send\", \"critical\", payload)\n```\n\nHere `\"critical\"` is the queue name.\n\nTypical usage:\n\n- audit logs go to `audit`\n- normal jobs go to `default`\n- high-priority jobs go to `critical`\n- cleanup and low-priority jobs go to `low`\n\n## How To Configure Multiple Queues\n\nExample config:\n\n```yaml\nqueue:\n  enable: true\n  namespace: go_layout\n  concurrency: 8\n  strict_priority: false\n  queues:\n    critical: 4\n    default: 2\n    audit: 2\n    low: 1\n  audit_max_retry: 3\n  audit_timeout_seconds: 10\n```\n\nMeaning:\n\n- `queues`\n  - which queues the worker should listen to\n- numbers\n  - relative weights for queue scheduling\n- `concurrency`\n  - worker concurrency\n- `strict_priority`\n  - whether to strictly prefer higher-priority queues\n\n### Multi-Queue Example\n\n```yaml\nqueue:\n  queues:\n    critical: 5\n    default: 3\n    audit: 2\n    low: 1\n```\n\nOne possible split:\n\n- `critical`\n  - permission repair, account-state sync\n- `default`\n  - normal async business jobs\n- `audit`\n  - request audit logs\n- `low`\n  - cleanup, reporting, compensation jobs\n\n## Real Example In This Project\n\nThe currently integrated job is the audit log job:\n\n- task type: `audit:request_log.write`\n- queue: `audit`\n\nFile:\n\n- [internal/jobs/audit_log.go](/Users/liuml/data/go/src/go-layout/internal/jobs/audit_log.go)\n\nProducer:\n\n```go\nfunc EnqueueAuditLog(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {\n\tpayload, err := NewAuditLogPayload(kind, snapshot)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = queue.PublishJSON(ctx, AuditLogTaskType, AuditQueueName, payload, auditLogOptions()...)\n\treturn err\n}\n```\n\nConsumer registration:\n\n```go\nfunc RegisterAll(registry queue.Registry) {\n\tqueue.RegisterJSON(registry, AuditLogTaskType, handleAuditLog)\n}\n```\n\nWorker startup:\n\n```go\nregistry := jobs.NewRegistry()\nserver, mux, err := asynqx.NewServer(cfg, registry)\n```\n\n## When To Use Cron vs Queue\n\nRecommended split by responsibility:\n\n- `cron`\n  - decides when something should run\n  - good for recurring schedules\n- `queue`\n  - decides how something runs asynchronously\n  - good for retries, decoupling, and background consumption\n\n### Typical Cases\n\nUse `cron` for:\n\n- resetting system data every day\n- clearing cache every hour\n- syncing statistics every 5 minutes\n\nUse `queue` for:\n\n- audit-log persistence\n- email sending\n- webhook delivery\n- incremental permission sync\n\nUse both together when needed:\n\n- `cron` triggers on schedule\n- `cron` publishes a queue job\n- `worker` performs the actual work\n\n## FAQ\n\n### 1. Why Was The Job Published But Not Consumed?\n\nCheck these first:\n\n- Redis is available\n- `queue.enable` is enabled\n- `worker` is running\n- the task is registered in `RegisterAll`\n\n### 2. Why Do Invalid Payloads Not Retry?\n\nBecause `RegisterJSON` treats JSON decode failures and `Validate()` failures as invalid input. Retrying those usually does not help.\n\n### 3. Why Does The Physical Queue Name Include A Prefix?\n\nBecause the current implementation supports `namespace`, for example:\n\n- configured queue name: `audit`\n- actual Redis queue name: `go_layout:audit`\n\nBusiness code should keep using the logical queue name.\n\n### 4. When Should I Implement `queue.Job` Myself?\n\nMost business scenarios do not need it.\n\nOnly consider it when you need:\n\n- a custom non-JSON payload flow\n- a special payload generation path\n- unusual job object behavior\n\nBy default, prefer:\n\n- `queue.PublishJSON`\n- `queue.RegisterJSON`\n\n## Recommended Practices\n\n- keep scheduled jobs in [tasks.go](/Users/liuml/data/go/src/go-layout/cmd/cron/tasks.go)\n- keep async jobs under `internal/jobs`\n- keep one clear payload per task type\n- keep payloads small and stable\n- separate high-priority and low-priority jobs by queue\n- avoid calling Asynq APIs directly from business code\n- prefer the project-level helpers: `PublishJSON` and `RegisterJSON`\n"
  },
  {
    "path": "docs/COMMANDS_AND_TASKS.md",
    "content": "# 命令、定时任务与队列使用说明\n\n本文档集中说明以下内容：\n\n- 项目支持的命令及用途\n- 如何启动 `service`、`worker`、`cron`\n- 如何新增定时任务\n- 如何创建、发布、消费异步队列任务\n- 如何把任务放入指定队列，以及如何配置多队列\n\n系统配置与系统字典的接入边界见：[docs/SYSTEM_CONFIG_AND_DICT_GUIDELINES.md](/Users/liuml/data/go/src/go-layout/docs/SYSTEM_CONFIG_AND_DICT_GUIDELINES.md)。\n\n如果你刚接手项目，建议先看“运行模型”和“命令说明”，再看“定时任务”和“队列”两节。\n\n## 运行模型\n\n当前项目把后台运行能力拆成了 3 类进程：\n\n- `service`\n  - 提供 HTTP API\n  - 处理登录、权限、菜单、上传、日志等业务\n  - 某些场景下会往队列里发布异步任务\n\n- `worker`\n  - 消费异步任务\n  - 当前已经接入请求审计日志异步落库\n\n- `cron`\n  - 负责周期性调度\n  - 当前使用 `robfig/cron`\n  - 适合“每天凌晨 2 点执行一次”这类固定时间任务\n\n可以把它理解成：\n\n- `service` 负责对外服务\n- `worker` 负责后台消费\n- `cron` 负责按时间触发\n\n## 命令说明\n\n项目根命令入口在 [cmd/root.go](/Users/liuml/data/go/src/go-layout/cmd/root.go)。\n\n### 0. 迁移命令\n\n常用入口：\n\n```bash\ngo run main.go -c ./config.yaml command migrate check\ngo run main.go -c ./config.yaml command migrate up\n```\n\n详细说明见：[docs/MIGRATE_COMMANDS.md](/Users/liuml/data/go/src/go-layout/docs/MIGRATE_COMMANDS.md)。\n\n### 1. 查看帮助\n\n```bash\ngo run main.go -h\ngo run main.go command -h\n```\n\n### 2. 启动 API 服务\n\n```bash\ngo run main.go service\n```\n\n用途：\n\n- 启动 Gin HTTP 服务\n- 加载配置、日志、数据库等基础资源\n- 提供业务 API\n\n常见场景：\n\n- 本地开发\n- 单机部署 API 服务\n- 容器内主应用进程\n\n### 3. 启动异步任务消费进程\n\n```bash\ngo run main.go worker\n```\n\n入口文件：\n\n- [cmd/worker/worker.go](/Users/liuml/data/go/src/go-layout/cmd/worker/worker.go)\n\n用途：\n\n- 连接 Redis\n- 注册所有异步任务处理器\n- 启动 Asynq worker，消费队列中的任务\n\n当前已接入任务：\n\n- `audit:request_log.write`\n\n### 4. 启动定时任务调度器\n\n```bash\ngo run main.go cron\n```\n\n入口文件：\n\n- [cmd/cron/cron.go](/Users/liuml/data/go/src/go-layout/cmd/cron/cron.go)\n\n用途：\n\n- 启动周期调度器\n- 注册当前定义的周期任务\n- 收到退出信号后优雅关闭\n\n任务定义边界：\n\n- `task_definitions` 当前是代码内置任务定义的只读镜像，用于任务中心展示、手动触发与重试校验。\n- `cron` 调度器的真实来源仍是 `BuiltinTaskDefinitions(cfg)`，不会读取 DB 中被人工修改的 `task_definitions`。\n- 如需让后台成为调度配置入口，必须先调整 scheduler 读取 DB，并把内置同步策略从覆盖改为初始化缺失记录。\n\n### 5. 运行一次性命令\n\n```bash\ngo run main.go command api-route\ngo run main.go command rebuild-user-permissions\ngo run main.go command init-system\ngo run main.go -c ./config.yaml command migrate up\n```\n\n入口文件：\n\n- [cmd/command/command.go](/Users/liuml/data/go/src/go-layout/cmd/command/command.go)\n\n支持的子命令：\n\n- `api-route`\n  - 扫描声明式路由树并重建 `api` 路由表\n- `rebuild-user-permissions`\n  - 按数据库关系重建用户最终 API 权限\n- `init-system`\n  - 回滚迁移、重新执行迁移、初始化 API 路由、重建用户权限\n- `demo`\n  - 示例命令\n- `migrate`\n  - 迁移管理子命令，支持 `create/check/up/down/goto/force/version`\n  - 详细说明见 [docs/MIGRATE_COMMANDS.md](/Users/liuml/data/go/src/go-layout/docs/MIGRATE_COMMANDS.md)\n- `task scan-async`\n  - 扫描代码注册的异步队列任务，并与 `task_definitions` 镜像做对比\n- `task scan-cron`\n  - 扫描代码内置的 cron 任务定义，并与 `task_definitions` 镜像做对比\n\n### 6. 查看版本\n\n```bash\ngo run main.go version\n```\n\n## 常见启动组合\n\n### 1. 只启动 API\n\n适合：\n\n- 本地只调接口\n- 不依赖异步任务的场景\n\n```bash\ngo run main.go service\n```\n\n### 2. 启动 API + Worker\n\n适合：\n\n- 需要消费异步任务\n- 已启用 Redis\n\n```bash\ngo run main.go service\ngo run main.go worker\n```\n\n### 3. 启动 API + Worker + Cron\n\n适合：\n\n- 同时需要接口服务、异步消费和定时调度\n\n```bash\ngo run main.go service\ngo run main.go worker\ngo run main.go cron\n```\n\n## 定时任务使用方式\n\n当前定时任务代码分成 3 个文件：\n\n- [cmd/cron/cron.go](/Users/liuml/data/go/src/go-layout/cmd/cron/cron.go)\n  - 只负责启动与关闭\n- [cmd/cron/schedule.go](/Users/liuml/data/go/src/go-layout/cmd/cron/schedule.go)\n  - 提供任务声明 DSL\n- [cmd/cron/tasks.go](/Users/liuml/data/go/src/go-layout/cmd/cron/tasks.go)\n  - 从内置任务定义中筛选启用的 cron 任务并注册到调度器\n- [internal/cron/registry.go](/Users/liuml/data/go/src/go-layout/internal/cron/registry.go)\n  - 维护内置任务定义、cron handler 注册表和任务中心镜像同步\n\n### 当前写法\n\n当前项目推荐先在 `internal/cron/registry.go` 声明内置任务定义，再由 `cmd/cron/tasks.go` 统一注册启用的 cron 任务：\n\n```go\nfunc BuiltinTaskDefinitions(cfg *config.Conf) []model.TaskDefinition {\n\treturn []model.TaskDefinition{\n\t\t{\n\t\t\tCode:     \"cron:cleanup-cache\",\n\t\t\tKind:     model.TaskKindCron,\n\t\t\tCronSpec: \"0 */10 * * * *\",\n\t\t\tHandler:  \"cron.cleanup-cache\",\n\t\t\tStatus:   model.TaskStatusEnabled,\n\t\t},\n\t}\n}\n```\n\n这样做的好处是：\n\n- 任务中心展示、手动触发、重试校验和 cron 注册共用一份代码内置定义\n- 名称、时间规则、handler、状态和高风险标记一眼能看出来\n- 高风险任务可以显式配置启用，默认不参与 cron 注册\n- 不需要每次手写 `AddJob`、`Chain`、`Recover`\n\n### 可用方法\n\n目前已经支持：\n\n- `Call(name, func())`\n  - 注册无返回值任务\n- `CallE(name, func() error)`\n  - 注册返回 `error` 的任务\n- `Cron(spec)`\n  - 直接使用 cron 表达式\n- `EveryFiveSeconds()`\n  - 每 5 秒执行一次\n- `DailyAt(\"02:00:00\")`\n  - 每天固定时间执行\n- `WithoutOverlapping()`\n  - 任务运行期间不允许重入\n- `AllowOverlap()`\n  - 允许重入\n\n### 示例 1：每 10 分钟执行一次\n\n```go\nschedule.Call(\"cleanup-cache\", cleanupCache).\n\tCron(\"0 */10 * * * *\").\n\tWithoutOverlapping()\n```\n\n### 示例 2：每天凌晨 3 点执行\n\n```go\nschedule.CallE(\"sync-report\", reportService.SyncDailyReport).\n\tDailyAt(\"03:00:00\").\n\tWithoutOverlapping()\n```\n\n### 示例 3：允许重入\n\n```go\nschedule.Call(\"heartbeat\", heartbeat).\n\tCron(\"0/30 * * * * *\").\n\tAllowOverlap()\n```\n\n### 新增定时任务步骤\n\n1. 在业务层准备好任务函数。\n2. 在 [registry.go](/Users/liuml/data/go/src/go-layout/internal/cron/registry.go) 注册 handler。\n3. 在 `BuiltinTaskDefinitions` 中新增 `kind=cron` 的任务定义。\n4. 运行 `go run main.go -c ./config.yaml command task scan-cron` 检查内置定义与 DB 镜像。\n5. 重启 `cron` 进程。\n\n## 队列使用说明\n\n当前队列基于 `Asynq`，但业务层不直接依赖 `asynq.Client`，而是使用项目自己的统一接口。\n\n核心代码：\n\n- [internal/queue/queue.go](/Users/liuml/data/go/src/go-layout/internal/queue/queue.go)\n- [internal/queue/asynqx/asynq.go](/Users/liuml/data/go/src/go-layout/internal/queue/asynqx/asynq.go)\n- [internal/jobs/registry.go](/Users/liuml/data/go/src/go-layout/internal/jobs/registry.go)\n\n### 队列完整链路\n\n一条异步任务的执行链路如下：\n\n1. 业务代码构造 payload\n2. 调用 `queue.PublishJSON(...)` 发布任务\n3. 任务进入 Redis 对应队列\n4. `worker` 启动后注册任务处理器\n5. Asynq 从 Redis 拉取任务\n6. 调用对应 handler 执行业务逻辑\n\n### 当前推荐 API\n\n发布任务：\n\n```go\n_, err := queue.PublishJSON(ctx, taskType, queueName, payload, opts...)\n```\n\n注册消费：\n\n```go\nqueue.RegisterJSON(registry, taskType, func(ctx context.Context, payload PayloadType) error {\n\treturn handle(payload)\n})\n```\n\n相比旧写法，这样有几个好处：\n\n- 不需要单独实现一个 `Job` 结构体\n- 不需要手动做 `json.Marshal` / `json.Unmarshal`\n- payload 结构更直观\n- 新任务更容易复制模板\n\n## 如何创建一个新队列任务\n\n下面用“发送邮件”举例。\n\n### 1. 定义 payload\n\n```go\ntype EmailPayload struct {\n\tTo      string `json:\"to\"`\n\tSubject string `json:\"subject\"`\n\tBody    string `json:\"body\"`\n}\n\nfunc (p EmailPayload) Validate() error {\n\tif p.To == \"\" {\n\t\treturn errors.New(\"to is required\")\n\t}\n\treturn nil\n}\n```\n\n如果 payload 实现了 `Validate() error`，消费前会自动校验。校验失败会按“不重试”处理。\n\n### 2. 发布任务\n\n```go\nfunc EnqueueEmail(ctx context.Context, payload EmailPayload) error {\n\t_, err := queue.PublishJSON(\n\t\tctx,\n\t\t\"notify:email.send\",\n\t\t\"default\",\n\t\tpayload,\n\t\tqueue.WithMaxRetry(5),\n\t\tqueue.WithTimeout(30*time.Second),\n\t)\n\treturn err\n}\n```\n\n这里的参数分别表示：\n\n- `\"notify:email.send\"`\n  - 任务类型\n- `\"default\"`\n  - 队列名\n- `payload`\n  - 任务数据\n- `WithMaxRetry`\n  - 最大重试次数\n- `WithTimeout`\n  - 执行超时时间\n\n### 3. 注册消费处理器\n\n```go\nfunc registerEmail(registry queue.Registry) {\n\tqueue.RegisterJSON(registry, \"notify:email.send\", func(ctx context.Context, payload EmailPayload) error {\n\t\treturn sendEmail(payload)\n\t})\n}\n```\n\n### 4. 注册到统一入口\n\n当前推荐把所有任务注册放在一个地方，例如：\n\n```go\nfunc RegisterAll(registry queue.Registry) {\n\tqueue.RegisterJSON(registry, AuditLogTaskType, handleAuditLog)\n\tregisterEmail(registry)\n}\n```\n\n### 5. Worker 自动消费\n\n`worker` 启动时会调用：\n\n```go\nregistry := jobs.NewRegistry()\nserver, mux, err := asynqx.NewServer(cfg, registry)\n```\n\n所以只要你的任务已经注册进 `RegisterAll`，worker 就会自动消费。\n\n## 如何把任务放到指定队列\n\n看 `PublishJSON` 的第三个参数：\n\n```go\nqueue.PublishJSON(ctx, \"notify:email.send\", \"critical\", payload)\n```\n\n这里的 `\"critical\"` 就是队列名。\n\n常见用法：\n\n- 审计日志放 `audit`\n- 普通任务放 `default`\n- 高优先级任务放 `critical`\n- 低优先级清理任务放 `low`\n\n## 如何配置多队列\n\n配置文件示例：\n\n```yaml\nqueue:\n  enable: true\n  namespace: go_layout\n  concurrency: 8\n  strict_priority: false\n  queues:\n    critical: 4\n    default: 2\n    audit: 2\n    low: 1\n  audit_max_retry: 3\n  audit_timeout_seconds: 10\n```\n\n含义：\n\n- `queues`\n  - 声明 worker 需要监听哪些队列\n- 数字\n  - 表示各队列的权重\n- `concurrency`\n  - worker 并发度\n- `strict_priority`\n  - 是否严格优先消费高优先级队列\n\n### 多队列示例\n\n```yaml\nqueue:\n  queues:\n    critical: 5\n    default: 3\n    audit: 2\n    low: 1\n```\n\n你可以这样分配：\n\n- `critical`\n  - 权限修复、账号状态同步\n- `default`\n  - 普通异步业务\n- `audit`\n  - 请求审计日志\n- `low`\n  - 清理、统计、补偿任务\n\n## 当前项目中的真实示例\n\n当前已经接入的任务是审计日志任务：\n\n- 任务类型：`audit:request_log.write`\n- 队列：`audit`\n\n对应文件：\n\n- [internal/jobs/audit_log.go](/Users/liuml/data/go/src/go-layout/internal/jobs/audit_log.go)\n\n发布端：\n\n```go\nfunc EnqueueAuditLog(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {\n\tpayload, err := NewAuditLogPayload(kind, snapshot)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = queue.PublishJSON(ctx, AuditLogTaskType, AuditQueueName, payload, auditLogOptions()...)\n\treturn err\n}\n```\n\n消费端：\n\n```go\nfunc RegisterAll(registry queue.Registry) {\n\tqueue.RegisterJSON(registry, AuditLogTaskType, handleAuditLog)\n}\n```\n\nworker 启动：\n\n```go\nregistry := jobs.NewRegistry()\nserver, mux, err := asynqx.NewServer(cfg, registry)\n```\n\n## 何时使用 Cron，何时使用 Queue\n\n建议按职责区分：\n\n- `cron`\n  - 负责“什么时候触发”\n  - 适合固定时间周期任务\n- `queue`\n  - 负责“任务如何异步执行”\n  - 适合削峰、重试、异步消费\n\n### 典型场景\n\n用 `cron`：\n\n- 每天凌晨重置系统数据\n- 每小时清理缓存\n- 每 5 分钟同步统计\n\n用 `queue`：\n\n- 审计日志落库\n- 邮件发送\n- Webhook 投递\n- 权限增量同步\n\n组合使用：\n\n- `cron` 到点触发\n- `cron` 内部把任务投递到 `queue`\n- `worker` 真正执行任务\n\n## 常见问题\n\n### 1. 为什么发布了任务，但没有被消费？\n\n先检查：\n\n- Redis 是否可用\n- `queue.enable` 是否开启\n- `worker` 是否已启动\n- 任务是否已经注册到 `RegisterAll`\n\n### 2. 为什么任务校验失败后不重试？\n\n因为 `RegisterJSON` 会把 payload 反序列化失败或 `Validate()` 失败视为“无效任务”，这类问题通常重试没有意义。\n\n### 3. 为什么任务进了带前缀的物理队列？\n\n因为当前实现支持 `namespace`，例如：\n\n- 配置队列名：`audit`\n- 实际 Redis 队列名：`go_layout:audit`\n\n业务代码里仍然使用逻辑队列名即可。\n\n### 4. 什么时候需要自己实现 `queue.Job`？\n\n大多数业务场景都不需要。\n\n只有当你需要：\n\n- 自定义更复杂的 payload 生成过程\n- 不想走 JSON\n- 需要对任务对象做特殊封装\n\n才建议自己实现 `queue.Job`。\n\n默认情况下，优先使用：\n\n- `queue.PublishJSON`\n- `queue.RegisterJSON`\n\n## 推荐实践\n\n- 定时任务统一写在 [tasks.go](/Users/liuml/data/go/src/go-layout/cmd/cron/tasks.go)\n- 异步任务统一写在 `internal/jobs`\n- 一个任务类型只对应一个清晰的 payload\n- payload 尽量保持小而稳定\n- 高优先级任务和低优先级任务分队列\n- 不要在业务代码里直接使用 Asynq 的底层 API\n- 优先使用项目封装好的 `PublishJSON` / `RegisterJSON`\n"
  },
  {
    "path": "docs/DONATE.en.md",
    "content": "# <div align=\"center\">Support gin-layout</div>\n\n<div align=\"center\">\n  <a href=\"./DONATE.md\">中文</a> | <strong>English</strong>\n</div>\n\n<br />\n\nThank you for using `gin-layout`.\n\nIf this project helps you, you are welcome to support its ongoing development and maintenance through any of the options below.\n\n## Alipay\n\n<div align=\"center\">\n  <img src=\"./images/alipay.jpg\" alt=\"Alipay QR code\" width=\"280\" />\n</div>\n\n## WeChat Pay\n\n<div align=\"center\">\n  <img src=\"./images/wechat_pay.jpg\" alt=\"WeChat Pay QR code\" width=\"280\" />\n</div>\n\n## ETH\n\nWallet address:\n\n```text\n0xf7bC2dc45f433D1Fa29D337Badc7174fE8251da3\n```\n\n<div align=\"center\">\n  <img src=\"./images/eth.jpg\" alt=\"ETH QR code\" width=\"280\" />\n</div>\n\n## Notes\n\n- Support is completely optional and does not affect access to any feature.\n- Your support helps with ongoing maintenance, bug fixes, and documentation updates.\n- If donating is not convenient right now, starring the repo, opening issues, or submitting pull requests also helps.\n"
  },
  {
    "path": "docs/DONATE.md",
    "content": "# <div align=\"center\">赞助 gin-layout</div>\n\n<div align=\"center\">\n  <strong>简体中文</strong> | <a href=\"./DONATE.en.md\">English</a>\n</div>\n\n<br />\n\n感谢你使用 `gin-layout`。\n\n如果这个项目对你有帮助，欢迎通过下面的方式支持项目的持续开发与维护。\n\n## 支付宝\n\n<div align=\"center\">\n  <img src=\"./images/alipay.jpg\" alt=\"支付宝收款码\" width=\"280\" />\n</div>\n\n## 微信支付\n\n<div align=\"center\">\n  <img src=\"./images/wechat_pay.jpg\" alt=\"微信支付收款码\" width=\"280\" />\n</div>\n\n## ETH\n\n钱包地址：\n\n```text\n0xf7bC2dc45f433D1Fa29D337Badc7174fE8251da3\n```\n\n<div align=\"center\">\n  <img src=\"./images/eth.jpg\" alt=\"ETH 收款码\" width=\"280\" />\n</div>\n\n## 说明\n\n- 赞助完全自愿，不影响任何功能使用。\n- 你的支持会用于项目的持续维护、问题修复和文档更新。\n- 如果暂时不方便赞助，也欢迎通过 Star、Issue 或 Pull Request 支持项目。\n"
  },
  {
    "path": "docs/MIGRATE_COMMANDS.en.md",
    "content": "# Migration Command Guide\n\nThis document explains the built-in `command migrate` command group, including:\n\n- why the project uses its own command entry\n- migration filename rules\n- concrete usage for `create/check/up/down/goto/force/version`\n- practical execution notes\n\nThe command is registered in [cmd/command/command.go](/Users/liuml/data/go/src/go-layout/cmd/command/command.go), and implemented in [internal/console/migrate/migrate.go](/Users/liuml/data/go/src/go-layout/internal/console/migrate/migrate.go).\n\n## Why Use The Project Command\n\nUse:\n\n```bash\ngo run main.go -c ./config.yaml command migrate ...\n```\n\nThis keeps migration operations:\n\n- independent from an extra root-level `migrate` binary\n- independent from shell wrapper scripts in `scripts/`\n- aligned with the project's own config loading and migration path resolution\n- unified across `create/check/up/down/goto/force/version`\n\n## Filename Convention\n\nDefault timestamp-based filenames:\n\n```text\nYYYYMMDDHHMMSS_desc.up.sql\nYYYYMMDDHHMMSS_desc.down.sql\n```\n\nExample:\n\n```text\n20260425143015_add_task_center_tables.up.sql\n20260425143015_add_task_center_tables.down.sql\n```\n\nRules:\n\n- `version` must be a unique increasing integer\n- `desc` is normalized into lower_snake_case\n- every version must have exactly one `up` and one `down`\n\nDefault time format: `20060102150405`  \nDefault timezone: `UTC`\n\n## Command Overview\n\n```bash\ngo run main.go command migrate\ngo run main.go -c ./config.yaml command migrate create <name>\ngo run main.go -c ./config.yaml command migrate check\ngo run main.go -c ./config.yaml command migrate up [N]\ngo run main.go -c ./config.yaml command migrate down [N]\ngo run main.go -c ./config.yaml command migrate down --all\ngo run main.go -c ./config.yaml command migrate goto <version>\ngo run main.go -c ./config.yaml command migrate force <version>\ngo run main.go -c ./config.yaml command migrate version\n```\n\nNotes:\n\n- `go run main.go command migrate` defaults to `go run main.go command migrate up`\n\nShared flags:\n\n- `--path`, `-p`\n  - migration directory path\n  - defaults to auto-resolved `data/migrations`\n- `--yes`, `-y`\n  - skip confirmation prompts\n  - mainly for destructive actions such as `down --all`\n\n## create\n\nCreate a migration pair:\n\n```bash\ngo run main.go -c ./config.yaml command migrate create add_task_center_tables\n```\n\nDefault behavior:\n\n- normalizes the name to `add_task_center_tables`\n- creates:\n  - `data/migrations/<version>_add_task_center_tables.up.sql`\n  - `data/migrations/<version>_add_task_center_tables.down.sql`\n\nDefault template:\n\n```sql\nBEGIN;\n\n-- TODO: write migration up SQL.\n\nCOMMIT;\n```\n\nOptional flags:\n\n- `--seq`\n  - switch to sequential numbering\n- `--digits`\n  - digit width for sequential numbering, default `6`\n- `--format`\n  - timestamp format, default `20060102150405`\n- `--tz`\n  - timestamp timezone, default `UTC`\n- `--ext`\n  - file extension, default `sql`\n\nExamples:\n\n```bash\ngo run main.go -c ./config.yaml command migrate create add_menu_seed --format 20060102150405 --tz Asia/Shanghai\ngo run main.go -c ./config.yaml command migrate create backfill_demo_data --seq --digits 6\n```\n\nRecommendation:\n\n- avoid `--seq` for parallel development\n- when a feature is still under initial development and not released yet, update the existing migration files directly instead of creating extra migrations for small iterative changes\n\n## check\n\nValidate migration filenames and version pairing:\n\n```bash\ngo run main.go -c ./config.yaml command migrate check\n```\n\nWhat it validates:\n\n- filename pattern `^(\\d+)_(.+)\\.(up|down)\\.([^.]+)$`\n- exactly one `up` and one `down` per version\n- first version, last version, and file counts\n\nOptional flag:\n\n- `--strict`\n  - default `true`\n  - if disabled, non-matching files can be tolerated more loosely\n\n## up\n\nApply migrations:\n\n```bash\ngo run main.go -c ./config.yaml command migrate up\n```\n\nApply a specific count:\n\n```bash\ngo run main.go -c ./config.yaml command migrate up 1\n```\n\nNotes:\n\n- without `N`, all pending migrations are applied\n- requires a valid database config in `config.yaml`\n\n## down\n\nRollback migrations:\n\n```bash\ngo run main.go -c ./config.yaml command migrate down 1\n```\n\nRollback all:\n\n```bash\ngo run main.go -c ./config.yaml command migrate down --all -y\n```\n\nNotes:\n\n- without an argument, it rolls back one version\n- `--all` rolls back to nil version and should usually be paired with `-y`\n\n## goto\n\nMigrate to a specific version:\n\n```bash\ngo run main.go -c ./config.yaml command migrate goto 20260425143015\n```\n\nUseful for:\n\n- local debugging against an intermediate version\n- moving back to a known version before another verification step\n\n## force\n\nForce-set the migration version without running SQL:\n\n```bash\ngo run main.go -c ./config.yaml command migrate force 20260425143015\n```\n\nUse cases:\n\n- repairing a dirty migration state\n- correcting the version cursor when the actual database state is already known to be correct\n\nNotes:\n\n- this is a repair command, not a normal migration workflow step\n- verify the real database schema before using it\n\n## version\n\nShow the current migration version:\n\n```bash\ngo run main.go -c ./config.yaml command migrate version\n```\n\nExample output:\n\n```text\nversion: 20260425143015, dirty: false\n```\n\nIf no migration has been applied yet:\n\n```text\nversion: none, dirty: false\n```\n\n## Custom Migration Path\n\nBy default the command auto-resolves `data/migrations`. To override it:\n\n```bash\ngo run main.go -c ./config.yaml command migrate --path ./data/migrations check\ngo run main.go -c ./config.yaml command migrate --path ./data/migrations up\n```\n\n## Recommended Workflow\n\n### Add a new migration\n\n```bash\ngo run main.go -c ./config.yaml command migrate create add_sys_notice_tables\ngo run main.go -c ./config.yaml command migrate check\ngo run main.go -c ./config.yaml command migrate up\n```\n\n### Adjust migrations during initial development\n\nIf a feature is still under development and has not been released:\n\n- update the existing migration files for that feature directly\n- do not create extra migration files for iterative local adjustments\n- make sure `check` passes before merging\n\n### Repair a dirty state\n\n```bash\ngo run main.go -c ./config.yaml command migrate version\ngo run main.go -c ./config.yaml command migrate force <version>\n```\n\n## Notes\n\n- prefer explicit `-c ./config.yaml` when using `go run`\n- use timestamp versions by default for parallel development\n- use `force` only when you are certain about the real database state\n- `down --all` is destructive\n\n## References\n\n- [README.en.md](/Users/liuml/data/go/src/go-layout/README.en.md)\n- [docs/COMMANDS_AND_TASKS.en.md](/Users/liuml/data/go/src/go-layout/docs/COMMANDS_AND_TASKS.en.md)\n- [golang-migrate CLI README](https://github.com/golang-migrate/migrate/blob/master/cmd/migrate/README.md)\n"
  },
  {
    "path": "docs/MIGRATE_COMMANDS.md",
    "content": "# 迁移命令详细说明\n\n本文档说明项目内置的 `command migrate` 命令组，包括：\n\n- 为什么统一使用项目命令\n- 迁移文件命名规范\n- `create/check/up/down/goto/force/version` 的具体用法\n- 常见执行建议与注意事项\n\n命令入口注册于 [cmd/command/command.go](/Users/liuml/data/go/src/go-layout/cmd/command/command.go)，具体实现位于 [internal/console/migrate/migrate.go](/Users/liuml/data/go/src/go-layout/internal/console/migrate/migrate.go)。\n\n## 设计原则\n\n项目已经内置迁移管理能力，推荐统一使用：\n\n```bash\ngo run main.go -c ./config.yaml command migrate ...\n```\n\n这样做的原因：\n\n- 不依赖仓库根目录额外放一个 `migrate` 二进制\n- 不依赖 `scripts/` 里的 shell 包装脚本\n- 与当前项目配置加载、迁移目录解析逻辑保持一致\n- `up/down/goto/force/version` 与 `create/check` 入口统一\n\n## 文件命名规范\n\n默认使用时间戳版本：\n\n```text\nYYYYMMDDHHMMSS_desc.up.sql\nYYYYMMDDHHMMSS_desc.down.sql\n```\n\n示例：\n\n```text\n20260425143015_add_task_center_tables.up.sql\n20260425143015_add_task_center_tables.down.sql\n```\n\n约束：\n\n- `version` 必须是递增且唯一的整数\n- `desc` 会被规范化为小写下划线风格\n- 每个版本必须恰好存在一对 `up/down`\n\n默认时间格式为 `20060102150405`，默认时区为 `UTC`。\n\n## 命令总览\n\n```bash\ngo run main.go command migrate\ngo run main.go -c ./config.yaml command migrate create <name>\ngo run main.go -c ./config.yaml command migrate check\ngo run main.go -c ./config.yaml command migrate up [N]\ngo run main.go -c ./config.yaml command migrate down [N]\ngo run main.go -c ./config.yaml command migrate down --all\ngo run main.go -c ./config.yaml command migrate goto <version>\ngo run main.go -c ./config.yaml command migrate force <version>\ngo run main.go -c ./config.yaml command migrate version\n```\n\n说明：\n\n- `go run main.go command migrate` 默认等价于 `go run main.go command migrate up`\n\n公共参数：\n\n- `--path`, `-p`\n  - 指定迁移目录\n  - 默认自动解析 `data/migrations`\n- `--yes`, `-y`\n  - 跳过确认提示\n  - 主要用于 `down --all` 这类破坏性操作\n\n## create\n\n创建一对迁移文件：\n\n```bash\ngo run main.go -c ./config.yaml command migrate create add_task_center_tables\n```\n\n默认行为：\n\n- 自动将名称规范化为 `add_task_center_tables`\n- 生成：\n  - `data/migrations/<version>_add_task_center_tables.up.sql`\n  - `data/migrations/<version>_add_task_center_tables.down.sql`\n\n默认模板内容：\n\n```sql\nBEGIN;\n\n-- TODO: write migration up SQL.\n\nCOMMIT;\n```\n\n可选参数：\n\n- `--seq`\n  - 改为顺序号模式\n- `--digits`\n  - 顺序号位数，默认 `6`\n- `--format`\n  - 时间戳格式，默认 `20060102150405`\n- `--tz`\n  - 时间戳时区，默认 `UTC`\n- `--ext`\n  - 文件扩展名，默认 `sql`\n\n示例：\n\n```bash\ngo run main.go -c ./config.yaml command migrate create add_menu_seed --format 20060102150405 --tz Asia/Shanghai\ngo run main.go -c ./config.yaml command migrate create backfill_demo_data --seq --digits 6\n```\n\n建议：\n\n- 并行开发默认不要使用 `--seq`\n- 未发布阶段如需调整同一功能下的迁移，可直接改当前未提交迁移文件，不必为了开发中微调新增多份迁移\n\n## check\n\n校验迁移目录中的文件格式和版本配对关系：\n\n```bash\ngo run main.go -c ./config.yaml command migrate check\n```\n\n校验内容：\n\n- 文件名是否匹配 `^(\\d+)_(.+)\\.(up|down)\\.([^.]+)$`\n- 每个 `version` 是否恰好一份 `up` 和一份 `down`\n- 输出首个版本、末尾版本、总文件数\n\n可选参数：\n\n- `--strict`\n  - 默认为 `true`\n  - 若关闭，则遇到不匹配模式的文件时可放宽处理\n\n## up\n\n执行迁移：\n\n```bash\ngo run main.go -c ./config.yaml command migrate up\n```\n\n执行指定数量：\n\n```bash\ngo run main.go -c ./config.yaml command migrate up 1\n```\n\n说明：\n\n- 不传 `N` 时执行全部未应用迁移\n- 依赖当前 `config.yaml` 中可用的数据库配置\n\n## down\n\n回滚迁移：\n\n```bash\ngo run main.go -c ./config.yaml command migrate down 1\n```\n\n全部回滚：\n\n```bash\ngo run main.go -c ./config.yaml command migrate down --all -y\n```\n\n说明：\n\n- 不传参数时默认回滚 1 个版本\n- `--all` 会回滚到空版本，建议显式带 `-y`\n\n## goto\n\n迁移到指定版本：\n\n```bash\ngo run main.go -c ./config.yaml command migrate goto 20260425143015\n```\n\n适合场景：\n\n- 本地调试某个中间版本\n- 需要回到指定版本再继续验证\n\n## force\n\n强制设置数据库迁移版本，不实际执行 SQL：\n\n```bash\ngo run main.go -c ./config.yaml command migrate force 20260425143015\n```\n\n用途：\n\n- 手动修复 dirty migration 状态\n- 在已确认数据库状态正确时修正版本游标\n\n注意：\n\n- 这是修复命令，不是正常迁移流程常规入口\n- 使用前应先确认数据库真实结构与迁移状态一致\n\n## version\n\n查看当前数据库迁移版本：\n\n```bash\ngo run main.go -c ./config.yaml command migrate version\n```\n\n输出示例：\n\n```text\nversion: 20260425143015, dirty: false\n```\n\n若数据库尚未执行任何迁移，会输出：\n\n```text\nversion: none, dirty: false\n```\n\n## 指定迁移目录\n\n默认会自动解析 `data/migrations`。若需要指定其他目录：\n\n```bash\ngo run main.go -c ./config.yaml command migrate --path ./data/migrations check\ngo run main.go -c ./config.yaml command migrate --path ./data/migrations up\n```\n\n## 推荐工作流\n\n### 新增迁移\n\n```bash\ngo run main.go -c ./config.yaml command migrate create add_sys_notice_tables\ngo run main.go -c ./config.yaml command migrate check\ngo run main.go -c ./config.yaml command migrate up\n```\n\n### 调整未发布功能下的迁移\n\n开发阶段同一功能若尚未提交发布：\n\n- 直接修改当前功能对应的迁移文件\n- 不为一次功能内的多次微调新增新的迁移文件\n- 合并前确保 `check` 通过\n\n### 排查 dirty 状态\n\n```bash\ngo run main.go -c ./config.yaml command migrate version\ngo run main.go -c ./config.yaml command migrate force <version>\n```\n\n## 注意事项\n\n- 使用 `go run` 时建议显式带 `-c ./config.yaml`\n- 并行开发默认使用时间戳版本，避免多个分支都生成 `000004_*`\n- `force` 只能在你明确知道数据库真实状态时使用\n- `down --all` 具有破坏性，不要在不明确的环境执行\n\n## 参考\n\n- [README.md](/Users/liuml/data/go/src/go-layout/README.md)\n- [docs/COMMANDS_AND_TASKS.md](/Users/liuml/data/go/src/go-layout/docs/COMMANDS_AND_TASKS.md)\n- [golang-migrate 官方文档](https://github.com/golang-migrate/migrate/blob/master/cmd/migrate/README.md)\n"
  },
  {
    "path": "docs/SECURITY_PERMISSION_FIXES_2026-05.md",
    "content": "# Security and Permission Fixes - 2026-05\n\n## Scope\n\n- Backend project: `go-layout`\n- Frontend project: `x-l-admin-vue3`\n- Goal: keep existing business behavior, fix permission/token consistency risks, and improve readability around high-risk flows.\n\n## Backend Changes\n\n- User token revocation now runs after user mutation transactions commit successfully, avoiding blacklist state that is newer than database state.\n- Disabled users can no longer refresh tokens.\n- Token validation checks database revocation records when Redis reports a miss, reducing the window caused by cache loss or Redis flushes.\n- Menu and role deletion now clean related mappings and synchronize affected user permissions inside the same transaction.\n- API permission updates refresh route cache and synchronize affected user permissions after successful transaction commit.\n- Menu uniqueness checks only allow known fields, preventing accidental dynamic column misuse.\n- Admin user phone updates now validate and persist the final `full_phone_number` value, including clearing the value when phone number is cleared.\n\n## Frontend Changes\n\n- Login redirect targets are normalized through a shared helper to prevent external redirect injection.\n- Auth store reset now removes the correct persisted key and guards stale `refreshUserInfo` responses.\n- Route generation rejects duplicate route names and paths, and missing components fall back to a controlled `NotFound` component.\n- Permission directive defaults unauthorized elements to hidden state and avoids duplicate disabled-click bindings.\n- Sensitive admin-user field reveal now has per-row loading state, failure rollback, and a short-lived row cache.\n- Blob JSON API errors now go through normal response handling, so 401 and business errors behave consistently.\n- Upload requests no longer force `Content-Type`; the browser can attach the correct multipart boundary.\n- Menu button form submission no longer silently overwrites `is_show`.\n\n## Verification\n\n- Backend: `go test ./...`\n- Frontend: `npm run type-check`\n- Frontend: `npm test -- --run`\n- Frontend: `npm run lint`\n- Frontend: `npm run build:production`\n\n## Notes\n\n- No intentional business behavior changes were introduced.\n- Permission cache synchronization still depends on the existing coordinator and policy reload mechanisms.\n"
  },
  {
    "path": "docs/SYSTEM_CONFIG_AND_DICT_GUIDELINES.en.md",
    "content": "# Backend Guidelines for System Config and Dictionary\n\nThis document defines the `sys_config` and `sys_dict` boundary from the backend perspective. The main goal is to prevent backend-owned core rules, such as permissions, state machines, data structure meaning, and security boundaries, from becoming admin-editable configuration, while still providing a clear, auditable, and testable integration path for runtime policies and display-oriented enums.\n\n## Core Principle\n\n- `sys_config` owns runtime policy. Backend reads must have type constraints, defaults, and fallback behavior.\n- `sys_dict` owns display options. Backend code should use it at most to return display data such as labels, tags, and colors.\n- Model constants, validators, service state machines, and migrations own core rules.\n\nAdmins may adjust selected operational thresholds and display behavior, but they must not bypass code-owned permission checks, state transitions, field meanings, audit semantics, or security constraints.\n\n## When to Use `sys_config`\n\nUse `sys_config` from backend code for:\n\n- Runtime switches for low-risk features.\n- Operational thresholds, such as login lock limits, lock duration, and log retention windows.\n- Security policy configuration, such as request-log masking fields.\n- Parameters that affect behavior, may be adjusted without a release, and have explicit fallback behavior.\n\nEvery new config should include:\n\n- A config key constant. Do not scatter raw key strings in business code.\n- A value type, default value, and allowed range.\n- A domain read helper, such as `BoolValue`, `IntValue`, or a dedicated service method.\n- Fallback behavior for read failures, missing config, or type errors.\n- A sensitivity flag when needed.\n- Cache refresh, startup warmup, or runtime reload behavior.\n- Seed data in migrations.\n- Unit tests or critical-path tests.\n\nBackend `sys_config` integration must guarantee:\n\n- The service layer keeps business fallbacks and does not treat config values as trusted input.\n- Validators remain responsible for request parameter legality and are not replaced by config-table values.\n- Sensitive configs must not leak through generic detail, value, request-log, or change-diff paths.\n- Protected built-in configs must not allow stable fields such as key, value type, or group code to be changed.\n- When config changes affect runtime behavior, it must be clear whether cache refresh or process restart is required.\n\n## When Not to Use `sys_config`\n\nDo not put these into `sys_config`:\n\n- Permission rules.\n- Route definitions.\n- Database schema meaning.\n- Core state transitions.\n- The scheduler source of truth, unless scheduler has explicitly been redesigned to read DB definitions.\n- Rules where an admin-side edit would silently change core system behavior and make troubleshooting harder.\n\nThese should remain enforced by code constants, validators, service state machines, database constraints, and migrations.\n\n## When to Use `sys_dict`\n\nUse `sys_dict` from backend code to provide:\n\n- Display-layer select options.\n- Table tag labels, colors, and tag types.\n- Localized display labels.\n- Enumerations whose changes mainly affect display, not backend rules.\n\nRecommended display dictionaries:\n\n- `common_status`: enabled/disabled labels.\n- `yes_no`: yes/no labels.\n- `menu_type`: menu type labels.\n- `api_auth_mode`: API auth mode labels.\n- `http_method`: HTTP method labels.\n- `task_kind`: task kind labels.\n- `task_source`: task source labels.\n- `task_run_status`: task run status labels.\n\nWhen backend code provides dictionary options, it is only responsible for display data output. Even if dictionary items are disabled, deleted, or edited incorrectly, backend core APIs must still enforce legality through validators, model constants, and service logic.\n\n## When Not to Use `sys_dict`\n\nDo not use `sys_dict` as the backend source of truth for:\n\n- Validator legal values.\n- Task-run state transitions.\n- Role, menu, or user enable/disable checks.\n- Actual permission-auth-mode execution.\n- Core audit diff decisions.\n\nBackend code may reuse dictionary data for display, but dictionary edits must not weaken business rules. For example, `status=2` must not become valid just because a new dictionary option was added.\n\n## Backend Implementation Requirements\n\n- Put new config or dictionary seed data in initialization migrations first.\n- During unreleased development, do not add permanent runtime sync logic for seed data unless necessary.\n- For shipped features or shared environments, evolve data through new migrations or explicit idempotent sync entry points.\n- Keep config reads in services or helpers. Controllers should not assemble business rules directly.\n- Core enums must be explicitly expressed in form validators and model constants.\n- Audit diffs may use label mappings for readability, but diff fields and security masking rules must be controlled by code.\n- Sensitive config detail and list responses return only a masked placeholder. When an update receives the unchanged masked placeholder, backend code must keep the original value instead of writing the placeholder as the real config value.\n- i18n updates for system config names, dictionary type names, and dictionary item labels use PATCH semantics: only submitted locales are inserted or updated, and omitted locales keep their existing values.\n- API documentation must describe config/dictionary field semantics, enum meanings, and the current i18n PATCH update semantics.\n\n## Display API Boundary\n\n- `dict/options` returns display options only. It does not return backend business rules.\n- Fields such as `status`, `is_*`, and task statuses may use dictionary labels for display, while legal values remain enforced by backend validators and model constants.\n- When a dictionary type code is added, backend seed data, API documentation, and API tests must be updated together. Display fallback behavior belongs to the caller project's documentation.\n- Backend business semantics must not change when dictionary APIs fail, dictionary items are edited incorrectly, or dictionary items are missing.\n\n## Decision Table\n\n| Need | Recommendation |\n| --- | --- |\n| Select options, table tags, colors, localized labels | Use `sys_dict` |\n| Login lock limits, request-log masking fields, operational thresholds | Use `sys_config` |\n| Validator enums, permission modes, state transitions | Use code constants and validators |\n| Real cron enable/disable from admin UI | Decide scheduler source of truth first |\n| Backend needs runtime policy reads | Read `sys_config` through domain helpers and keep defaults |\n| Display layer wants less enum hardcoding | Backend provides `dict/options`; caller fallbacks are maintained by the caller project |\n"
  },
  {
    "path": "docs/SYSTEM_CONFIG_AND_DICT_GUIDELINES.md",
    "content": "# 系统配置与系统字典后端接入规范\n\n本文档从后端视角约定 `sys_config` 与 `sys_dict` 的使用边界。核心目标是避免把权限、状态机、数据结构、安全边界等后端核心规则做成后台可随意修改的配置，同时为运行时策略和展示型枚举提供清晰、可审计、可测试的接入方式。\n\n## 总体原则\n\n- `sys_config` 管运行时策略，后端读取后必须有类型约束、默认值和降级行为。\n- `sys_dict` 管展示选项，后端最多用于返回 label、tag、颜色等展示数据。\n- model 常量、validator、service 状态机和 migration 管核心规则。\n\n也就是说，后台可以调整“某些策略阈值”和“怎么展示”，但不能绕过代码里的权限判断、状态流转、字段含义、审计语义和安全约束。\n\n## `sys_config` 适用场景\n\n适合由后端接入 `sys_config` 的内容：\n\n- 运行期可调的开关，例如某个低风险功能是否开启。\n- 运维阈值，例如登录失败锁定次数、锁定时长、日志保留天数。\n- 安全策略配置，例如 request log 脱敏字段。\n- 对业务行为有影响，但允许在不发版的情况下调整，且能接受明确降级策略的参数。\n\n新增系统配置时必须同时补齐：\n\n- 配置 key 常量，禁止在业务代码中散落字符串。\n- value type、默认值和允许范围。\n- 领域化读取 helper，例如 `BoolValue`、`IntValue` 或专门 service 方法。\n- 读取失败、配置缺失、类型错误时的降级行为。\n- 是否敏感的安全标记。\n- 缓存刷新、启动预热或运行时 reload 策略。\n- 初始化 migration 数据。\n- 单元测试或关键链路测试。\n\n后端接入 `sys_config` 时必须保证：\n\n- service 层仍保留业务兜底，不能把配置值直接当作可信输入。\n- validator 仍负责接口入参合法性，不能依赖配置表替代校验。\n- 敏感配置不能通过 detail、value、request log、change diff 等通用链路泄露原值。\n- protected 配置禁止修改稳定字段，例如 key、value type、group code。\n- 配置变更影响运行态时，必须明确是否需要刷新缓存或重启进程。\n\n## `sys_config` 不适用场景\n\n以下内容不建议放入 `sys_config`：\n\n- 权限判断规则。\n- 路由定义。\n- 数据库结构含义。\n- 核心状态机流转规则。\n- 任务调度器的真实 source-of-truth，除非已专门改造 scheduler 从 DB 读取。\n- 会导致“后台一改，系统核心行为立即改变且难以审计”的规则。\n\n这类规则应继续由代码常量、validator、service 状态机、数据库约束和 migration 负责。\n\n## `sys_dict` 适用场景\n\n适合由后端通过 `sys_dict` 提供的内容：\n\n- 对外展示层下拉选项。\n- 列表 tag 文案、颜色、tag type。\n- 多语言展示 label。\n- 变化主要影响展示，不改变后端业务规则的枚举。\n\n当前推荐接入的展示型字典包括：\n\n- `common_status`：通用启用/禁用展示。\n- `yes_no`：是否展示。\n- `menu_type`：菜单类型展示。\n- `api_auth_mode`：接口鉴权模式展示。\n- `http_method`：HTTP 方法展示。\n- `task_kind`：任务类型展示。\n- `task_source`：任务来源展示。\n- `task_run_status`：任务执行状态展示。\n\n后端提供字典 options 时只承担展示数据输出职责。即使字典项被禁用、删除或误改，后端核心接口仍必须由 validator、model 常量和 service 逻辑保证合法性。\n\n## `sys_dict` 不适用场景\n\n以下内容不建议把 `sys_dict` 作为后端唯一判断来源：\n\n- validator 合法值。\n- 任务执行状态流转。\n- 角色、菜单、用户状态的后端启停判断。\n- 权限鉴权模式的实际执行逻辑。\n- 审计 diff 的核心字段判断。\n\n后端可以复用字典做展示，但不能因为字典被误改就放宽核心业务规则。例如 `status=2` 不能因为字典中新增了一个选项就自动变成合法状态。\n\n## 后端实现要求\n\n- 新增配置或字典默认数据优先放初始化 migration。\n- 未发布开发阶段不额外加常驻同步逻辑，避免把 seed 数据做成启动副作用。\n- 已发布或共享环境需要演进时，通过新增 migration 或明确的幂等同步入口处理。\n- 配置读取应集中在 service/helper，controller 不直接拼装业务规则。\n- 核心枚举必须在 form validator 和 model 常量中显式表达。\n- 审计 diff 可以使用 label 映射提升可读性，但 diff 字段集合和安全脱敏规则必须由代码控制。\n- 敏感配置详情和列表只返回脱敏占位符；更新时收到未改动的脱敏占位符应保留原值，不能把占位符写成真实配置值。\n- 系统配置名称、字典类型名称、字典项 label 的 i18n 更新采用 PATCH 语义：只新增/更新请求中出现的 locale，未传 locale 保留旧值。\n- 接口文档必须说明配置/字典字段的业务语义、枚举含义和当前 i18n PATCH 更新语义。\n\n## 对外展示接口边界\n\n- `dict/options` 只输出展示选项，不输出后端业务规则。\n- `status`、`is_*`、任务状态等字段可以通过字典返回展示 label，但合法值仍由后端 validator 和 model 常量决定。\n- 新增字典 type code 时，后端必须同步默认数据、接口文档和接口测试；具体展示层 fallback 由调用方项目文档维护。\n- 字典接口失败、字典项误改或字典项缺失时，后端核心业务语义不能改变。\n\n## 决策表\n\n| 需求 | 建议 |\n| --- | --- |\n| 页面下拉、tag、颜色、多语言 label | 放 `sys_dict` |\n| 登录锁定次数、审计脱敏字段、可运营阈值 | 放 `sys_config` |\n| validator 枚举、权限模式、状态机流转 | 放代码常量与校验 |\n| 定时任务是否真实启停 | 先明确 scheduler source-of-truth，再决定是否进 DB 配置 |\n| 后端需要运行时读取策略 | 通过领域化 helper 读取 `sys_config`，并保留默认值 |\n| 展示层需要减少硬编码枚举 | 后端提供 `dict/options`；调用方 fallback 由调用方项目维护 |\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/wannanbigpig/gin-layout\n\ngo 1.26\n\nrequire (\n\tgithub.com/casbin/casbin/v3 v3.10.0\n\tgithub.com/casbin/gorm-adapter/v3 v3.41.0\n\tgithub.com/fsnotify/fsnotify v1.9.0\n\tgithub.com/gin-gonic/gin v1.12.0\n\tgithub.com/go-playground/locales v0.14.1\n\tgithub.com/go-playground/universal-translator v0.18.1\n\tgithub.com/go-playground/validator/v10 v10.30.2\n\tgithub.com/go-sql-driver/mysql v1.9.3\n\tgithub.com/golang-jwt/jwt/v5 v5.3.1\n\tgithub.com/golang-migrate/migrate/v4 v4.19.1\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/h2non/filetype v1.1.3\n\tgithub.com/hibiken/asynq v0.26.0\n\tgithub.com/jinzhu/copier v0.4.0\n\tgithub.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible\n\tgithub.com/mojocn/base64Captcha v1.3.8\n\tgithub.com/mssola/useragent v1.0.0\n\tgithub.com/patrickmn/go-cache v2.1.0+incompatible\n\tgithub.com/redis/go-redis/v9 v9.18.0\n\tgithub.com/robfig/cron/v3 v3.0.1\n\tgithub.com/samber/lo v1.53.0\n\tgithub.com/spf13/cobra v1.10.2\n\tgithub.com/spf13/viper v1.21.0\n\tgithub.com/stretchr/testify v1.11.1\n\tgo.uber.org/zap v1.27.1\n\tgolang.org/x/crypto v0.49.0\n\tgopkg.in/natefinch/lumberjack.v2 v2.2.1\n\tgorm.io/driver/mysql v1.6.0\n\tgorm.io/driver/sqlite v1.6.0\n\tgorm.io/gorm v1.31.1\n\tgorm.io/plugin/soft_delete v1.2.1\n)\n\nrequire (\n\tgithub.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 // indirect\n\tgithub.com/aws/aws-sdk-go-v2 v1.41.7 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect\n\tgithub.com/aws/smithy-go v1.25.1 // indirect\n\tgithub.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect\n\tgithub.com/mattn/go-sqlite3 v1.14.22 // indirect\n\tgithub.com/shopspring/decimal v1.4.0 // indirect\n\tgo.mongodb.org/mongo-driver/v2 v2.5.0 // indirect\n\tgo.uber.org/atomic v1.11.0 // indirect\n\tgolang.org/x/image v0.38.0 // indirect\n\tgolang.org/x/time v0.15.0 // indirect\n\tgolang.org/x/tools v0.43.0 // indirect\n)\n\nrequire (\n\tfilippo.io/edwards25519 v1.2.0 // indirect\n\tgithub.com/bmatcuk/doublestar/v4 v4.10.0 // indirect\n\tgithub.com/bytedance/gopkg v0.1.4 // indirect\n\tgithub.com/bytedance/sonic v1.15.0 // indirect\n\tgithub.com/bytedance/sonic/loader v0.5.1 // indirect\n\tgithub.com/casbin/govaluate v1.10.0 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.6 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.13 // indirect\n\tgithub.com/gin-contrib/sse v1.1.1 // indirect\n\tgithub.com/glebarez/go-sqlite v1.22.0 // indirect\n\tgithub.com/glebarez/sqlite v1.11.0 // indirect\n\tgithub.com/go-viper/mapstructure/v2 v2.5.0 // indirect\n\tgithub.com/goccy/go-json v0.10.6 // indirect\n\tgithub.com/goccy/go-yaml v1.19.2 // indirect\n\tgithub.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect\n\tgithub.com/golang-sql/sqlexp v0.1.0 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // 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/jonboulle/clockwork v0.5.0 // 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/lestrrat-go/strftime v1.1.1 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/microsoft/go-mssqldb v1.9.8 // 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-strftime v1.0.0 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.3.0 // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgithub.com/sagikazarmark/locafero v0.12.0 // indirect\n\tgithub.com/spf13/afero v1.15.0 // indirect\n\tgithub.com/spf13/cast v1.10.0 // indirect\n\tgithub.com/spf13/pflag v1.0.10 // indirect\n\tgithub.com/subosito/gotenv v1.6.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.3.1 // indirect\n\tgo.uber.org/multierr v1.11.0 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/arch v0.25.0 // indirect\n\tgolang.org/x/net v0.52.0 // indirect\n\tgolang.org/x/sync v0.20.0\n\tgolang.org/x/sys v0.42.0 // indirect\n\tgolang.org/x/text v0.35.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.11 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tgorm.io/driver/postgres v1.6.0 // indirect\n\tgorm.io/driver/sqlserver v1.6.3 // indirect\n\tgorm.io/plugin/dbresolver v1.6.2 // indirect\n\t// SQLite dependencies (BSD-3-Clause licensed) - indirect dependencies via casbin/gorm-adapter/v3\n\t// Note: Project only uses MySQL, these are pulled in by the adapter's SQLite support\n\t// All modernc.org packages use BSD-3-Clause license (compatible with MIT)\n\tmodernc.org/libc v1.70.0 // indirect; BSD-3-Clause\n\tmodernc.org/mathutil v1.7.1 // indirect; BSD-3-Clause\n\tmodernc.org/memory v1.11.0 // indirect; BSD-3-Clause\n\tmodernc.org/sqlite v1.48.0 // indirect; BSD-3-Clause\n)\n\n// License Compliance Notes:\n//\n// 1. github.com/golang/freetype (FreeType License OR GPL-2.0) - ✅ COMPATIBLE:\n//    - Via: github.com/mojocn/base64Captcha (Apache-2.0) -> github.com/golang/freetype\n//    - Used for: Font rendering in captcha images (alphanumeric support)\n//    - Status: Uses FreeType License (not GPL) - compatible with MIT\n//    - Note: This package offers dual licensing; we use FreeType License\n//\n// 2. modernc.org/* packages (BSD-3-Clause) - ✅ COMPATIBLE:\n//    - Via: casbin/gorm-adapter/v3 -> SQLite support\n//    - Actual license: BSD-3-Clause (NOT GPL - see LICENSE files in mod cache)\n//    - Status: BSD-3-Clause is MIT-compatible\n//    - Note: Project uses MySQL, SQLite code is not executed at runtime\n//\n// 3. github.com/go-sql-driver/mysql (MPL-2.0) - ✅ COMPATIBLE:\n//    - Used for: MySQL database connection\n//    - Status: MPL-2.0 is MIT-compatible for library usage\n//    - Note: Only requires open source if you modify the driver itself\n//\n// Summary: All dependencies are compatible with MIT license.\n//          This project can be safely released under MIT.\n"
  },
  {
    "path": "go.sum",
    "content": "filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=\nfilippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=\ngithub.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=\ngithub.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=\ngithub.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=\ngithub.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=\ngithub.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=\ngithub.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=\ngithub.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=\ngithub.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=\ngithub.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=\ngithub.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=\ngithub.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=\ngithub.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=\ngithub.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=\ngithub.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=\ngithub.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1 h1:vtiFd0hhPAbyYJjztl0wYUq/PqEGkIlDmVuTIy6zw8Y=\ngithub.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.1/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=\ngithub.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=\ngithub.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=\ngithub.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=\ngithub.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=\ngithub.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=\ngithub.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=\ngithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=\ngithub.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=\ngithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=\ngithub.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=\ngithub.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=\ngithub.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=\ngithub.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=\ngithub.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=\ngithub.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=\ngithub.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=\ngithub.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=\ngithub.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=\ngithub.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=\ngithub.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=\ngithub.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=\ngithub.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=\ngithub.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=\ngithub.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=\ngithub.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=\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.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=\ngithub.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=\ngithub.com/casbin/casbin/v3 v3.10.0 h1:039ORla55vCeIZWd0LfzWFt1yiEA5X4W41xBW2bQuHs=\ngithub.com/casbin/casbin/v3 v3.10.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=\ngithub.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM=\ngithub.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrHfzpBpPcNTyMGeGA=\ngithub.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=\ngithub.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=\ngithub.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=\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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=\ngithub.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=\ngithub.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=\ngithub.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=\ngithub.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=\ngithub.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=\ngithub.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=\ngithub.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=\ngithub.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=\ngithub.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=\ngithub.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=\ngithub.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=\ngithub.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=\ngithub.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=\ngithub.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=\ngithub.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=\ngithub.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=\ngithub.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=\ngithub.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=\ngithub.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=\ngithub.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=\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/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=\ngithub.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=\ngithub.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=\ngithub.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=\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.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=\ngithub.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=\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/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=\ngithub.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=\ngithub.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=\ngithub.com/goccy/go-json v0.10.6/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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=\ngithub.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=\ngithub.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=\ngithub.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=\ngithub.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=\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-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=\ngithub.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=\ngithub.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=\ngithub.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=\ngithub.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=\ngithub.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=\ngithub.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=\ngithub.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=\ngithub.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\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/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=\ngithub.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=\ngithub.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=\ngithub.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=\ngithub.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=\ngithub.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\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/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=\ngithub.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=\ngithub.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=\ngithub.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=\ngithub.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=\ngithub.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=\ngithub.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=\ngithub.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=\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.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=\ngithub.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=\ngithub.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=\ngithub.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=\ngithub.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=\ngithub.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=\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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\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/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8=\ngithub.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is=\ngithub.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible h1:Y6sqxHMyB1D2YSzWkLibYKgg+SwmyFU9dF2hn6MdTj4=\ngithub.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible/go.mod h1:ZQnN8lSECaebrkQytbHj4xNgtg8CR7RYXnPok8e0EHA=\ngithub.com/lestrrat-go/strftime v1.1.1 h1:zgf8QCsgj27GlKBy3SU9/8MMgegZ8UCzlCyHYrUF0QU=\ngithub.com/lestrrat-go/strftime v1.1.1/go.mod h1:YDrzHJAODYQ+xxvrn5SG01uFIQAeDTzpxNVppCz7Nmw=\ngithub.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=\ngithub.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\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/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=\ngithub.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=\ngithub.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=\ngithub.com/microsoft/go-mssqldb v1.9.8 h1:d4IFMvF/o+HdpXUqbBfzHvn/NlFA75YGcfHUUvDFJEM=\ngithub.com/microsoft/go-mssqldb v1.9.8/go.mod h1:eGSRSGAW4hKMy5YcAenhCDjIRm2rhqIdmmwgciMzLus=\ngithub.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=\ngithub.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=\ngithub.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=\ngithub.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=\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/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=\ngithub.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg=\ngithub.com/mojocn/base64Captcha v1.3.8/go.mod h1:QFZy927L8HVP3+VV5z2b1EAEiv1KxVJKZbAucVgLUy4=\ngithub.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=\ngithub.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=\ngithub.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=\ngithub.com/mssola/useragent v1.0.0 h1:WRlDpXyxHDNfvZaPEut5Biveq86Ze4o4EMffyMxmH5o=\ngithub.com/mssola/useragent v1.0.0/go.mod h1:hz9Cqz4RXusgg1EdI4Al0INR62kP7aPSRNHnpU+b85Y=\ngithub.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=\ngithub.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=\ngithub.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=\ngithub.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=\ngithub.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=\ngithub.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=\ngithub.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=\ngithub.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=\ngithub.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=\ngithub.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\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/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=\ngithub.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=\ngithub.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=\ngithub.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=\ngithub.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=\ngithub.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=\ngithub.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=\ngithub.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=\ngithub.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=\ngithub.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=\ngithub.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=\ngithub.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=\ngithub.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=\ngithub.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=\ngithub.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=\ngithub.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=\ngithub.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=\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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=\ngithub.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=\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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngithub.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=\ngithub.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=\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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=\ngo.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=\ngo.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=\ngo.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=\ngo.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=\ngo.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=\ngo.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=\ngo.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=\ngo.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=\ngo.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=\ngo.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=\ngo.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=\ngo.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngo.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=\ngo.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=\ngolang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=\ngolang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=\ngolang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=\ngolang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=\ngolang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=\ngolang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=\ngolang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=\ngolang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=\ngolang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=\ngolang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=\ngolang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=\ngolang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=\ngolang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=\ngolang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=\ngolang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=\ngolang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=\ngolang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=\ngolang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=\ngolang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=\ngolang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=\ngolang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=\ngolang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=\ngolang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=\ngolang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=\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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=\ngolang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=\ngolang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=\ngolang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=\ngolang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=\ngolang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=\ngolang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=\ngolang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=\ngolang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=\ngolang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=\ngolang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=\ngolang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=\ngolang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=\ngolang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=\ngolang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=\ngolang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=\ngolang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=\ngolang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=\ngolang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=\ngopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\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/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=\ngorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=\ngorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=\ngorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=\ngorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=\ngorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=\ngorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=\ngorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=\ngorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=\ngorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=\ngorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=\ngorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=\ngorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU=\ngorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk=\nmodernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=\nmodernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=\nmodernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=\nmodernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=\nmodernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=\nmodernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=\nmodernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=\nmodernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=\nmodernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=\nmodernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=\nmodernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=\nmodernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=\nmodernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=\nmodernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=\nmodernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=\nmodernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=\nmodernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=\nmodernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=\nmodernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=\nmodernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=\nmodernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=\nmodernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=\nmodernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=\nmodernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=\nmodernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=\nmodernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\n"
  },
  {
    "path": "internal/access/casbin/adapter.go",
    "content": "package casbinx\n\nimport (\n\t\"github.com/casbin/casbin/v3\"\n\t\"github.com/casbin/casbin/v3/model\"\n\tgormadapter \"github.com/casbin/gorm-adapter/v3\"\n\t\"gorm.io/gorm\"\n)\n\nfunc newAdapter(db *gorm.DB) (*gormadapter.Adapter, error) {\n\tgormadapter.TurnOffAutoMigrate(db)\n\treturn gormadapter.NewAdapterByDB(db)\n}\n\nfunc newEnforcerFromDB(m model.Model, db *gorm.DB) (*casbin.Enforcer, error) {\n\tadapter, err := newAdapter(db)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tenforcer, err := casbin.NewEnforcer(m, adapter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tenforcer.EnableAutoSave(true)\n\treturn enforcer, nil\n}\n"
  },
  {
    "path": "internal/access/casbin/casbin.go",
    "content": "package casbinx\n\nimport (\n\t\"errors\"\n\t\"sync\"\n\n\t\"github.com/casbin/casbin/v3\"\n\t\"github.com/casbin/casbin/v3/model\"\n\t_ \"github.com/go-sql-driver/mysql\"\n\t\"gorm.io/gorm\"\n)\n\n// CasbinEnforcer 封装共享 Enforcer 与事务态 Enforcer 的切换逻辑。\ntype CasbinEnforcer struct {\n\t*casbin.Enforcer\n\terrInit error\n\tmodel   model.Model\n\ttx      *gorm.DB\n}\n\nvar (\n\tcasbinManager = &CasbinEnforcer{}\n\tmanagerMu     sync.RWMutex\n)\n\n// InitEnforcer 初始化 Casbin Enforcer（仅执行一次）\nfunc InitEnforcer() error {\n\tmanagerMu.Lock()\n\tdefer managerMu.Unlock()\n\tif casbinManager.Enforcer != nil {\n\t\treturn casbinManager.errInit\n\t}\n\treturn initEnforcerLocked()\n}\n\n// GetEnforcer 返回已初始化的 Enforcer 实例\nfunc GetEnforcer() (*CasbinEnforcer, error) {\n\tmanagerMu.RLock()\n\tcurrent := casbinManager\n\tmanagerMu.RUnlock()\n\tif current.Enforcer == nil {\n\t\tif err := InitEnforcer(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tmanagerMu.RLock()\n\tdefer managerMu.RUnlock()\n\tif casbinManager.Enforcer == nil {\n\t\treturn nil, errors.New(\"casbin enforcer not initialized\")\n\t}\n\treturn casbinManager, nil\n}\n\n// ReloadPolicy 重新加载策略\nfunc ReloadPolicy() error {\n\tenforcer, err := GetEnforcer()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn enforcer.LoadPolicy()\n}\n\n// SetDB 返回一个绑定到指定事务的新 CasbinEnforcer。\nfunc (e *CasbinEnforcer) SetDB(tx *gorm.DB) *CasbinEnforcer {\n\treturn &CasbinEnforcer{\n\t\tEnforcer: e.Enforcer,\n\t\terrInit:  e.errInit,\n\t\tmodel:    e.model,\n\t\ttx:       tx,\n\t}\n}\n\n// RegisterCustomFunctions 注册自定义函数\nfunc (e *CasbinEnforcer) registerCustomFunctions() {\n\t// 注册自定义函数\n}\n"
  },
  {
    "path": "internal/access/casbin/enforcer_init.go",
    "content": "package casbinx\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/casbin/casbin/v3/model\"\n\t\"gorm.io/gorm\"\n\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n)\n\n// getModelPath 获取 rbac_model.conf 路径并校验是否存在\nfunc getModelPath() (string, error) {\n\tpath := filepath.Join(c.GetConfig().BasePath, \"rbac_model.conf\")\n\tif _, err := os.Stat(path); os.IsNotExist(err) {\n\t\treturn \"\", fmt.Errorf(\"模型文件不存在: %s\", path)\n\t}\n\treturn path, nil\n}\n\n// isInTransaction 判断是否在事务中\nfunc isInTransaction(db *gorm.DB) bool {\n\treturn db != nil && db.Statement != nil && db.Statement.ConnPool != db.ConnPool\n}\n\n// ReloadEnforcer 重新加载 Casbin Enforcer。\nfunc ReloadEnforcer() error {\n\tmanagerMu.Lock()\n\tdefer managerMu.Unlock()\n\treturn initEnforcerLocked()\n}\n\nfunc initEnforcerLocked() error {\n\tmodelPath, err := getModelPath()\n\tif err != nil {\n\t\tcasbinManager.errInit = err\n\t\treturn err\n\t}\n\n\tm, err := model.NewModelFromFile(modelPath)\n\tif err != nil {\n\t\tcasbinManager.errInit = fmt.Errorf(\"加载模型失败: %w\", err)\n\t\treturn casbinManager.errInit\n\t}\n\n\tdb := data.MysqlDB()\n\tif db == nil {\n\t\tcasbinManager.errInit = errors.New(\"mysql not initialized\")\n\t\treturn casbinManager.errInit\n\t}\n\n\tenforcer, err := newEnforcerFromDB(m, db)\n\tif err != nil {\n\t\tcasbinManager.errInit = fmt.Errorf(\"创建 Enforcer 失败: %w\", err)\n\t\treturn casbinManager.errInit\n\t}\n\tnext := &CasbinEnforcer{\n\t\tEnforcer: enforcer,\n\t\tmodel:    m,\n\t}\n\tnext.registerCustomFunctions()\n\tcasbinManager = next\n\treturn nil\n}\n"
  },
  {
    "path": "internal/access/casbin/policy_ops.go",
    "content": "package casbinx\n\nimport (\n\t\"errors\"\n\n\t\"github.com/casbin/casbin/v3\"\n\t\"gorm.io/gorm\"\n)\n\n// execute 使用共享 Enforcer 或事务级 Enforcer 执行 Casbin 操作。\nfunc (e *CasbinEnforcer) execute(tx *gorm.DB, fn func(enforcer casbin.IEnforcer) error) error {\n\tif tx == nil {\n\t\ttx = e.tx\n\t}\n\tif tx == nil {\n\t\treturn fn(e.Enforcer)\n\t}\n\tif !isInTransaction(tx) {\n\t\treturn errors.New(\"请先通过 GORM 开启事务\")\n\t}\n\n\ttxEnforcer, err := newEnforcerFromDB(e.model, tx)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn fn(txEnforcer)\n}\n\n// EditPolicyPermissions 编辑策略权限\nfunc (e *CasbinEnforcer) EditPolicyPermissions(user string, policy [][]string, tx ...*gorm.DB) error {\n\treturn e.execute(firstTx(tx), func(enforcer casbin.IEnforcer) error {\n\t\treturn replacePermissions(enforcer, user, policy)\n\t})\n}\n\n// EditPolicyPermissionsBatch 批量覆盖多个 subject 的权限策略。\nfunc (e *CasbinEnforcer) EditPolicyPermissionsBatch(subjectPolicies map[string][][]string, tx ...*gorm.DB) error {\n\treturn e.execute(firstTx(tx), func(enforcer casbin.IEnforcer) error {\n\t\tfor subject, policy := range subjectPolicies {\n\t\t\tif err := replacePermissions(enforcer, subject, policy); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// EditPolicyRoles 编辑策略角色\nfunc (e *CasbinEnforcer) EditPolicyRoles(user string, policy []string, tx ...*gorm.DB) error {\n\treturn e.execute(firstTx(tx), func(enforcer casbin.IEnforcer) error {\n\t\t_, err := enforcer.DeleteRolesForUser(user)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\trules := make([][]string, 0, len(policy))\n\t\tfor _, role := range policy {\n\t\t\tif role != \"\" {\n\t\t\t\trules = append(rules, []string{user, role})\n\t\t\t}\n\t\t}\n\t\tif len(rules) == 0 {\n\t\t\treturn nil\n\t\t}\n\n\t\tok, err := enforcer.AddGroupingPolicies(rules)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif !ok {\n\t\t\treturn errors.New(\"添加权限失败~\")\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// WithTransaction 在指定事务下执行 Casbin 操作。\nfunc (e *CasbinEnforcer) WithTransaction(tx *gorm.DB, fn func(enforcer casbin.IEnforcer) error) error {\n\treturn e.execute(tx, fn)\n}\n\n// firstTx 返回可选事务切片中的第一个事务。\nfunc firstTx(tx []*gorm.DB) *gorm.DB {\n\tif len(tx) == 0 {\n\t\treturn nil\n\t}\n\treturn tx[0]\n}\n\nfunc replacePermissions(enforcer casbin.IEnforcer, subject string, policy [][]string) error {\n\tif _, err := enforcer.DeletePermissionsForUser(subject); err != nil {\n\t\treturn err\n\t}\n\n\tpolicies := toSubjectPolicies(subject, policy)\n\tif len(policies) == 0 {\n\t\treturn nil\n\t}\n\n\tok, err := enforcer.AddPolicies(policies)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !ok {\n\t\treturn errors.New(\"添加权限失败\")\n\t}\n\treturn nil\n}\n\nfunc toSubjectPolicies(subject string, policy [][]string) [][]string {\n\tpolicies := make([][]string, 0, len(policy))\n\tfor _, item := range policy {\n\t\tif len(item) > 0 {\n\t\t\tpolicies = append(policies, append([]string{subject}, item...))\n\t\t}\n\t}\n\treturn policies\n}\n"
  },
  {
    "path": "internal/console/confirm.go",
    "content": "package console\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"go.uber.org/zap\"\n)\n\n// ConfirmOperation 确认操作；assumeYes=true 时跳过交互确认。\nfunc ConfirmOperation(prompt string, assumeYes bool) bool {\n\tif assumeYes {\n\t\treturn true\n\t}\n\n\tscanner := bufio.NewScanner(os.Stdin)\n\tfmt.Print(prompt)\n\n\tif !scanner.Scan() {\n\t\tif err := scanner.Err(); err != nil {\n\t\t\tlog.Logger.Error(\"Failed to read user input\", zap.Error(err))\n\t\t\t_, _ = fmt.Fprintln(os.Stderr, \"reading standard input:\", err)\n\t\t}\n\t\treturn false\n\t}\n\n\tinput := strings.TrimSpace(strings.ToLower(scanner.Text()))\n\treturn input == \"y\" || input == \"yes\"\n}\n"
  },
  {
    "path": "internal/console/demo/demo.go",
    "content": "package demo\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n)\n\nvar (\n\tCmd = &cobra.Command{\n\t\tUse:     \"demo\",\n\t\tShort:   \"这是一个demo\",\n\t\tExample: \"go-layout command demo\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tdemo()\n\t\t\treturn nil\n\t\t},\n\t}\n\ttest string\n)\n\nfunc init() {\n\tCmd.Flags().StringVarP(&test, \"test\", \"t\", \"test\", \"测试接收参数\")\n}\n\nfunc demo() {\n\tfmt.Println(\"hello console!\", test)\n}\n"
  },
  {
    "path": "internal/console/init/init.go",
    "content": "package init\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"go.uber.org/zap\"\n\n\tconsolex \"github.com/wannanbigpig/gin-layout/internal/console\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/system\"\n)\n\nconst (\n\tmsgProcessingComplete     = \"Processing complete.\"\n\tmsgFailedToSaveRoute      = \"Failed to save the initial route data to the routing table.\"\n\tmsgUserPermissionComplete = \"User API permissions rebuilt successfully.\"\n\tmsgFailedToRebuildPerms   = \"Failed to rebuild final user API permissions.\"\n)\n\nvar (\n\tapiRouteAssumeYes           bool\n\trebuildPermissionsAssumeYes bool\n\n\tApiRouteCmd = &cobra.Command{\n\t\tUse:   \"api-route\",\n\t\tShort: \"Initialize API route table\",\n\t\tLong:  \"This command scans all defined API routes in the system and stores them in the api table for permission management and API documentation.\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runInitApiRoute()\n\t\t},\n\t}\n\n\tRebuildUserPermissionsCmd = &cobra.Command{\n\t\tUse:   \"rebuild-user-permissions\",\n\t\tShort: \"Rebuild final user API permissions from database relationships\",\n\t\tLong:  \"This command rebuilds final user API permissions from database user, department, role, menu, and API relationships.\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runRebuildUserPermissions()\n\t\t},\n\t}\n)\n\nfunc init() {\n\tApiRouteCmd.Flags().BoolVarP(&apiRouteAssumeYes, \"yes\", \"y\", false, \"Skip confirmation prompt\")\n\tRebuildUserPermissionsCmd.Flags().BoolVarP(&rebuildPermissionsAssumeYes, \"yes\", \"y\", false, \"Skip confirmation prompt\")\n}\n\n// runInitApiRoute 执行API路由表初始化\nfunc runInitApiRoute() error {\n\t// 用户确认\n\tif !consolex.ConfirmOperation(\"This command is used to obtain the defined API in the system and store it in the api table. Are you sure to perform the operation? [Y/N]: \", apiRouteAssumeYes) {\n\t\tfmt.Println(\"Operation cancelled.\")\n\t\treturn nil\n\t}\n\n\t// 调用服务层方法\n\tif err := system.InitApiRoutes(); err != nil {\n\t\tlog.Logger.Error(msgFailedToSaveRoute, zap.Error(err))\n\t\tfmt.Println(msgFailedToSaveRoute)\n\t\treturn err\n\t}\n\n\tfmt.Println(msgProcessingComplete)\n\treturn nil\n}\n\n// runRebuildUserPermissions 执行用户最终 API 权限重建。\nfunc runRebuildUserPermissions() error {\n\t// 用户确认\n\tif !consolex.ConfirmOperation(\"This command rebuilds final user API permissions from database relationships. Are you sure to perform the operation? [Y/N]: \", rebuildPermissionsAssumeYes) {\n\t\tfmt.Println(\"Operation cancelled.\")\n\t\treturn nil\n\t}\n\n\t// 调用服务层方法\n\tif err := system.RebuildUserPermissions(); err != nil {\n\t\tlog.Logger.Error(msgFailedToRebuildPerms, zap.Error(err))\n\t\tfmt.Println(msgFailedToRebuildPerms)\n\t\treturn err\n\t}\n\n\tfmt.Println(msgUserPermissionComplete)\n\treturn nil\n}\n"
  },
  {
    "path": "internal/console/migrate/migrate.go",
    "content": "package migrate\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/golang-migrate/migrate/v4\"\n\t\"github.com/spf13/cobra\"\n\n\tconsolex \"github.com/wannanbigpig/gin-layout/internal/console\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/system\"\n)\n\nconst (\n\tdefaultMigrationDir    = \"data/migrations\"\n\tdefaultMigrationExt    = \"sql\"\n\tdefaultTimeFormat      = \"20060102150405\"\n\tdefaultMigrationDigits = 6\n)\n\nvar migrationFilePattern = regexp.MustCompile(`^(\\d+)_(.+)\\.(up|down)\\.([^.]+)$`)\n\nvar (\n\tmigratePath          string\n\tmigrateAssumeYes     bool\n\tcreateUseSeq         bool\n\tcreateDigits         int\n\tcreateFormat         string\n\tcreateTZ             string\n\tcreateExt            string\n\tdownAll              bool\n\tmigrateCheckStrict   bool\n\tmigrationNameCleaner = regexp.MustCompile(`[^a-z0-9_]+`)\n\n\t// Cmd 迁移命令入口。\n\tCmd = &cobra.Command{\n\t\tUse:   \"migrate\",\n\t\tShort: \"Database migration management commands (defaults to up)\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runUp(nil)\n\t\t},\n\t}\n\n\tcreateCmd = &cobra.Command{\n\t\tUse:   \"create NAME\",\n\t\tShort: \"Create a migration pair (up/down)\",\n\t\tArgs:  cobra.ExactArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runCreate(args[0])\n\t\t},\n\t}\n\n\tcheckCmd = &cobra.Command{\n\t\tUse:   \"check\",\n\t\tShort: \"Validate migration filename format and version pairing\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runCheck()\n\t\t},\n\t}\n\n\tupCmd = &cobra.Command{\n\t\tUse:   \"up [N]\",\n\t\tShort: \"Apply all or N up migrations\",\n\t\tArgs:  cobra.MaximumNArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runUp(args)\n\t\t},\n\t}\n\n\tdownCmd = &cobra.Command{\n\t\tUse:   \"down [N]\",\n\t\tShort: \"Apply 1, N, or all down migrations\",\n\t\tArgs:  cobra.MaximumNArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runDown(args)\n\t\t},\n\t}\n\n\tgotoCmd = &cobra.Command{\n\t\tUse:   \"goto VERSION\",\n\t\tShort: \"Migrate to a specific version\",\n\t\tArgs:  cobra.ExactArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tversion, err := parseUint64Arg(args[0], \"VERSION\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn runWithMigrator(func(m *migrate.Migrate) error {\n\t\t\t\tif err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tfmt.Printf(\"migrate goto %d complete\\n\", version)\n\t\t\t\treturn nil\n\t\t\t})\n\t\t},\n\t}\n\n\tforceCmd = &cobra.Command{\n\t\tUse:   \"force VERSION\",\n\t\tShort: \"Set migration version without running migrations\",\n\t\tArgs:  cobra.ExactArgs(1),\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\tversion, err := parseIntArg(args[0], \"VERSION\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn runWithMigrator(func(m *migrate.Migrate) error {\n\t\t\t\tif err := m.Force(version); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tfmt.Printf(\"migrate force %d complete\\n\", version)\n\t\t\t\treturn nil\n\t\t\t})\n\t\t},\n\t}\n\n\tversionCmd = &cobra.Command{\n\t\tUse:   \"version\",\n\t\tShort: \"Print current migration version\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runWithMigrator(func(m *migrate.Migrate) error {\n\t\t\t\tversion, dirty, err := m.Version()\n\t\t\t\tif errors.Is(err, migrate.ErrNilVersion) {\n\t\t\t\t\tfmt.Println(\"version: none, dirty: false\")\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tfmt.Printf(\"version: %d, dirty: %v\\n\", version, dirty)\n\t\t\t\treturn nil\n\t\t\t})\n\t\t},\n\t}\n)\n\nfunc init() {\n\tregisterFlags()\n\tregisterSubCommands()\n}\n\nfunc registerFlags() {\n\tCmd.PersistentFlags().StringVarP(&migratePath, \"path\", \"p\", \"\", \"Migration directory path (default auto detect)\")\n\tCmd.PersistentFlags().BoolVarP(&migrateAssumeYes, \"yes\", \"y\", false, \"Skip confirmation prompt\")\n\n\tcreateCmd.Flags().BoolVar(&createUseSeq, \"seq\", false, \"Generate sequential migration versions\")\n\tcreateCmd.Flags().IntVar(&createDigits, \"digits\", defaultMigrationDigits, \"Number of digits for sequential versions\")\n\tcreateCmd.Flags().StringVar(&createFormat, \"format\", defaultTimeFormat, \"Go time format for timestamp version\")\n\tcreateCmd.Flags().StringVar(&createTZ, \"tz\", \"UTC\", \"Timezone used for timestamp version\")\n\tcreateCmd.Flags().StringVar(&createExt, \"ext\", defaultMigrationExt, \"Migration file extension\")\n\n\tdownCmd.Flags().BoolVar(&downAll, \"all\", false, \"Apply all down migrations\")\n\n\tcheckCmd.Flags().BoolVar(&migrateCheckStrict, \"strict\", true, \"Fail if filename does not match migration pattern\")\n}\n\nfunc registerSubCommands() {\n\tCmd.AddCommand(createCmd)\n\tCmd.AddCommand(checkCmd)\n\tCmd.AddCommand(upCmd)\n\tCmd.AddCommand(downCmd)\n\tCmd.AddCommand(gotoCmd)\n\tCmd.AddCommand(forceCmd)\n\tCmd.AddCommand(versionCmd)\n}\n\nfunc runCreate(rawName string) error {\n\tdir, err := resolveMigrationDirForCreate()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tname := normalizeMigrationName(rawName)\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"migration name is empty after normalization\")\n\t}\n\n\text := strings.TrimPrefix(strings.TrimSpace(createExt), \".\")\n\tif ext == \"\" {\n\t\text = defaultMigrationExt\n\t}\n\n\tfiles, err := loadMigrationFiles(dir, migrateCheckStrict)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tversion, err := nextMigrationVersion(files)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tupFile := filepath.Join(dir, fmt.Sprintf(\"%s_%s.up.%s\", version, name, ext))\n\tdownFile := filepath.Join(dir, fmt.Sprintf(\"%s_%s.down.%s\", version, name, ext))\n\tif _, err := os.Stat(upFile); err == nil {\n\t\treturn fmt.Errorf(\"target file already exists: %s\", upFile)\n\t}\n\tif _, err := os.Stat(downFile); err == nil {\n\t\treturn fmt.Errorf(\"target file already exists: %s\", downFile)\n\t}\n\n\tif err := os.WriteFile(upFile, []byte(\"BEGIN;\\n\\n-- TODO: write migration up SQL.\\n\\nCOMMIT;\\n\"), 0o644); err != nil {\n\t\treturn fmt.Errorf(\"write up migration failed: %w\", err)\n\t}\n\tif err := os.WriteFile(downFile, []byte(\"BEGIN;\\n\\n-- TODO: write migration down SQL.\\n\\nCOMMIT;\\n\"), 0o644); err != nil {\n\t\treturn fmt.Errorf(\"write down migration failed: %w\", err)\n\t}\n\n\tfmt.Println(upFile)\n\tfmt.Println(downFile)\n\treturn nil\n}\n\nfunc runCheck() error {\n\tdir, err := resolveMigrationDirForCheck()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfiles, err := loadMigrationFiles(dir, migrateCheckStrict)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(files) == 0 {\n\t\treturn fmt.Errorf(\"no migration files found in %s\", dir)\n\t}\n\n\tgrouped := make(map[string]map[string]int, len(files))\n\tfor _, file := range files {\n\t\tif _, ok := grouped[file.Version]; !ok {\n\t\t\tgrouped[file.Version] = map[string]int{\"up\": 0, \"down\": 0}\n\t\t}\n\t\tgrouped[file.Version][file.Direction]++\n\t}\n\n\tversions := make([]string, 0, len(grouped))\n\tfor version := range grouped {\n\t\tversions = append(versions, version)\n\t}\n\tsort.Slice(versions, func(i, j int) bool {\n\t\tleft, _ := strconv.ParseUint(versions[i], 10, 64)\n\t\tright, _ := strconv.ParseUint(versions[j], 10, 64)\n\t\treturn left < right\n\t})\n\n\tfor _, version := range versions {\n\t\tentry := grouped[version]\n\t\tif entry[\"up\"] != 1 || entry[\"down\"] != 1 {\n\t\t\treturn fmt.Errorf(\"invalid version %s: up=%d down=%d (expect up=1 down=1)\", version, entry[\"up\"], entry[\"down\"])\n\t\t}\n\t}\n\n\tfmt.Printf(\"[OK] migration check passed: %d versions, %d files.\\n\", len(versions), len(files))\n\tfmt.Printf(\"     first version: %s\\n\", versions[0])\n\tfmt.Printf(\"     last version:  %s\\n\", versions[len(versions)-1])\n\treturn nil\n}\n\nfunc runUp(args []string) error {\n\tsteps := 0\n\tif len(args) == 1 {\n\t\tn, err := parseIntArg(args[0], \"N\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif n <= 0 {\n\t\t\treturn fmt.Errorf(\"N must be greater than 0\")\n\t\t}\n\t\tsteps = n\n\t}\n\n\treturn runWithMigrator(func(m *migrate.Migrate) error {\n\t\tif err := remapLegacySequentialVersionIfNeeded(m); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar err error\n\t\tif steps > 0 {\n\t\t\terr = m.Steps(steps)\n\t\t} else {\n\t\t\terr = m.Up()\n\t\t}\n\t\tif err != nil && !errors.Is(err, migrate.ErrNoChange) {\n\t\t\treturn err\n\t\t}\n\t\tif steps > 0 {\n\t\t\tfmt.Printf(\"migrate up %d complete\\n\", steps)\n\t\t} else {\n\t\t\tfmt.Println(\"migrate up complete\")\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc runDown(args []string) error {\n\tif downAll && len(args) > 0 {\n\t\treturn fmt.Errorf(\"cannot use N with --all\")\n\t}\n\n\tsteps := 1\n\tif len(args) == 1 {\n\t\tn, err := parseIntArg(args[0], \"N\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif n <= 0 {\n\t\t\treturn fmt.Errorf(\"N must be greater than 0\")\n\t\t}\n\t\tsteps = n\n\t}\n\n\tif downAll {\n\t\tif !consolex.ConfirmOperation(\"This will apply all down migrations. Continue? [Y/N]: \", migrateAssumeYes) {\n\t\t\tfmt.Println(\"Operation cancelled.\")\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn runWithMigrator(func(m *migrate.Migrate) error {\n\t\tif err := remapLegacySequentialVersionIfNeeded(m); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tvar err error\n\t\tif downAll {\n\t\t\terr = m.Down()\n\t\t} else {\n\t\t\terr = m.Steps(-steps)\n\t\t}\n\t\tif err != nil && !errors.Is(err, migrate.ErrNoChange) {\n\t\t\treturn err\n\t\t}\n\t\tif downAll {\n\t\t\tfmt.Println(\"migrate down --all complete\")\n\t\t} else {\n\t\t\tfmt.Printf(\"migrate down %d complete\\n\", steps)\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc runWithMigrator(fn func(*migrate.Migrate) error) error {\n\tm, err := buildMigrator()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer m.Close()\n\n\treturn fn(m)\n}\n\nfunc buildMigrator() (*migrate.Migrate, error) {\n\tpath := strings.TrimSpace(migratePath)\n\tif path == \"\" {\n\t\treturn system.NewMigrator()\n\t}\n\treturn system.NewMigratorWithPath(path)\n}\n\nfunc resolveMigrationDirForCreate() (string, error) {\n\tif strings.TrimSpace(migratePath) != \"\" {\n\t\treturn ensureDirExists(migratePath)\n\t}\n\tif _, err := os.Stat(defaultMigrationDir); err == nil {\n\t\treturn ensureDirExists(defaultMigrationDir)\n\t}\n\treturn resolveMigrationDirForCheck()\n}\n\nfunc resolveMigrationDirForCheck() (string, error) {\n\tif strings.TrimSpace(migratePath) != \"\" {\n\t\treturn ensureDirExists(migratePath)\n\t}\n\tpath, err := system.ResolveMigrationsPath()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn ensureDirExists(path)\n}\n\nfunc ensureDirExists(path string) (string, error) {\n\ttrimmed := strings.TrimSpace(strings.TrimPrefix(path, \"file://\"))\n\tif trimmed == \"\" {\n\t\treturn \"\", fmt.Errorf(\"migration path is empty\")\n\t}\n\tabsPath, err := filepath.Abs(trimmed)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"resolve migration path failed: %w\", err)\n\t}\n\tinfo, err := os.Stat(absPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"migration path not found: %s\", absPath)\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", fmt.Errorf(\"migration path is not a directory: %s\", absPath)\n\t}\n\treturn absPath, nil\n}\n\nfunc normalizeMigrationName(raw string) string {\n\tname := strings.ToLower(strings.TrimSpace(raw))\n\tname = strings.ReplaceAll(name, \"-\", \"_\")\n\tname = strings.ReplaceAll(name, \" \", \"_\")\n\tname = migrationNameCleaner.ReplaceAllString(name, \"_\")\n\tname = strings.Trim(name, \"_\")\n\tname = strings.ReplaceAll(name, \"__\", \"_\")\n\treturn name\n}\n\nfunc nextMigrationVersion(files []migrationFile) (string, error) {\n\tif createUseSeq {\n\t\tif createDigits <= 0 {\n\t\t\treturn \"\", fmt.Errorf(\"digits must be greater than 0\")\n\t\t}\n\t\tmaxVersion := 0\n\t\tfor _, file := range files {\n\t\t\tv, err := strconv.Atoi(file.Version)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif v > maxVersion {\n\t\t\t\tmaxVersion = v\n\t\t\t}\n\t\t}\n\t\treturn fmt.Sprintf(\"%0*d\", createDigits, maxVersion+1), nil\n\t}\n\n\tloc, err := time.LoadLocation(strings.TrimSpace(createTZ))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"invalid timezone: %w\", err)\n\t}\n\tversion := time.Now().In(loc).Format(createFormat)\n\tif _, err := strconv.ParseUint(version, 10, 64); err != nil {\n\t\treturn \"\", fmt.Errorf(\"invalid time format result: %s (must be uint64)\", version)\n\t}\n\tfor _, file := range files {\n\t\tif file.Version == version {\n\t\t\treturn \"\", fmt.Errorf(\"duplicate migration version %s, please retry\", version)\n\t\t}\n\t}\n\treturn version, nil\n}\n\ntype migrationFile struct {\n\tVersion   string\n\tDirection string\n\tName      string\n}\n\nfunc loadMigrationFiles(dir string, strict bool) ([]migrationFile, error) {\n\tentries, err := os.ReadDir(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]migrationFile, 0, len(entries))\n\tfor _, entry := range entries {\n\t\tif entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\t\tname := entry.Name()\n\t\tif !strings.HasSuffix(name, \".sql\") {\n\t\t\tcontinue\n\t\t}\n\t\tmatch := migrationFilePattern.FindStringSubmatch(name)\n\t\tif len(match) != 5 {\n\t\t\tif strict {\n\t\t\t\treturn nil, fmt.Errorf(\"invalid migration filename format: %s\", name)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tresult = append(result, migrationFile{\n\t\t\tVersion:   match[1],\n\t\t\tDirection: match[3],\n\t\t\tName:      name,\n\t\t})\n\t}\n\tsort.Slice(result, func(i, j int) bool {\n\t\tif result[i].Version == result[j].Version {\n\t\t\treturn result[i].Name < result[j].Name\n\t\t}\n\t\treturn result[i].Version < result[j].Version\n\t})\n\treturn result, nil\n}\n\nfunc parseIntArg(value string, argName string) (int, error) {\n\tparsed, err := strconv.Atoi(strings.TrimSpace(value))\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"invalid %s: %s\", argName, value)\n\t}\n\treturn parsed, nil\n}\n\nfunc parseUint64Arg(value string, argName string) (uint, error) {\n\tparsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"invalid %s: %s\", argName, value)\n\t}\n\tif parsed > uint64(^uint(0)) {\n\t\treturn 0, fmt.Errorf(\"%s out of range: %s\", argName, value)\n\t}\n\treturn uint(parsed), nil\n}\n\nfunc remapLegacySequentialVersionIfNeeded(m *migrate.Migrate) error {\n\tcurrentVersion, dirty, err := m.Version()\n\tif errors.Is(err, migrate.ErrNilVersion) {\n\t\treturn nil\n\t}\n\tif err != nil || dirty {\n\t\treturn nil\n\t}\n\n\tdir, err := resolveMigrationDirForCheck()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tfiles, err := loadMigrationFiles(dir, migrateCheckStrict)\n\tif err != nil || len(files) == 0 {\n\t\treturn nil\n\t}\n\n\tversionSet := make(map[uint64]struct{}, len(files))\n\tuniqueVersions := make([]uint64, 0, len(files))\n\tallTimestampStyle := true\n\tfor _, file := range files {\n\t\tif len(file.Version) < 10 {\n\t\t\tallTimestampStyle = false\n\t\t}\n\t\tv, parseErr := strconv.ParseUint(file.Version, 10, 64)\n\t\tif parseErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := versionSet[v]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tversionSet[v] = struct{}{}\n\t\tuniqueVersions = append(uniqueVersions, v)\n\t}\n\tif len(uniqueVersions) == 0 || !allTimestampStyle {\n\t\treturn nil\n\t}\n\n\tsort.Slice(uniqueVersions, func(i, j int) bool { return uniqueVersions[i] < uniqueVersions[j] })\n\n\tif _, ok := versionSet[uint64(currentVersion)]; ok {\n\t\treturn nil\n\t}\n\n\tif currentVersion == 0 || int(currentVersion) > len(uniqueVersions) {\n\t\treturn nil\n\t}\n\n\ttarget := uniqueVersions[int(currentVersion)-1]\n\tif target <= 999999999 {\n\t\treturn nil\n\t}\n\n\tif err := m.Force(int(target)); err != nil {\n\t\treturn fmt.Errorf(\"failed to remap legacy migration version %d to %d: %w\", currentVersion, target, err)\n\t}\n\tfmt.Printf(\"detected legacy sequential migration version %d, remapped to timestamp version %d\\n\", currentVersion, target)\n\treturn nil\n}\n"
  },
  {
    "path": "internal/console/system_init/system_init.go",
    "content": "package system_init\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"go.uber.org/zap\"\n\n\tconsolex \"github.com/wannanbigpig/gin-layout/internal/console\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/system\"\n)\n\nvar (\n\tinitSystemAssumeYes bool\n\n\t// InitSystemCmd 手动执行初始化系统命令\n\tInitSystemCmd = &cobra.Command{\n\t\tUse:   \"init-system\",\n\t\tShort: \"Initialize system data manually\",\n\t\tLong: `This command manually initializes the system data, which includes:\n1. Rollback all database migrations\n2. Re-execute all migrations\n3. Re-initialize API routes\n4. Rebuild final user API permissions\n\nThis is the same task that runs automatically at 2:00 AM daily.`,\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runInitSystem()\n\t\t},\n\t}\n)\n\nfunc init() {\n\tInitSystemCmd.Flags().BoolVarP(&initSystemAssumeYes, \"yes\", \"y\", false, \"Skip confirmation prompt\")\n}\n\n// runInitSystem 执行初始化系统\nfunc runInitSystem() error {\n\t// 用户确认\n\tif !consolex.ConfirmOperation(\"此命令将执行系统初始化，包括回滚迁移、重新执行迁移、重新初始化 API 路由并重建用户最终 API 权限。此操作会清空现有数据，确定要继续吗？ [Y/N]: \", initSystemAssumeYes) {\n\t\tfmt.Println(\"操作已取消。\")\n\t\treturn nil\n\t}\n\n\tfmt.Println(\"开始执行初始化系统任务...\")\n\tlog.Logger.Info(\"手动执行初始化系统任务\")\n\n\tif err := system.ReinitializeSystemData(); err != nil {\n\t\tlog.Logger.Error(\"初始化系统任务执行失败\", zap.Error(err))\n\t\tfmt.Printf(\"初始化系统失败: %v\\n\", err)\n\t\treturn err\n\t}\n\n\tfmt.Println(\"初始化系统任务执行完成！\")\n\tlog.Logger.Info(\"手动执行初始化系统任务完成\")\n\treturn nil\n}\n"
  },
  {
    "path": "internal/console/task/task.go",
    "content": "package task\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"sort\"\n\t\"strings\"\n\t\"text/tabwriter\"\n\n\t\"github.com/spf13/cobra\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\ttaskcron \"github.com/wannanbigpig/gin-layout/internal/cron\"\n\t\"github.com/wannanbigpig/gin-layout/internal/jobs\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n)\n\ntype asyncScanRow struct {\n\tTaskType  string\n\tInBuiltin bool\n\tInDB      bool\n\tQueue     string\n}\n\nvar (\n\tCmd = &cobra.Command{\n\t\tUse:   \"task\",\n\t\tShort: \"Task helper commands\",\n\t}\n\n\tscanAsyncCmd = &cobra.Command{\n\t\tUse:   \"scan-async\",\n\t\tShort: \"Scan registered async queue tasks and compare definitions\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runScanAsync()\n\t\t},\n\t}\n\n\tscanCronCmd = &cobra.Command{\n\t\tUse:   \"scan-cron\",\n\t\tShort: \"Scan built-in cron tasks and compare definitions\",\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn runScanCron()\n\t\t},\n\t}\n)\n\nfunc init() {\n\tCmd.AddCommand(scanAsyncCmd)\n\tCmd.AddCommand(scanCronCmd)\n}\n\nfunc runScanAsync() error {\n\ttaskTypes := collectRegistryTaskTypes(jobs.NewRegistry())\n\tif len(taskTypes) == 0 {\n\t\tfmt.Println(\"未扫描到已注册的异步任务。\")\n\t\treturn nil\n\t}\n\n\tbuiltinMap := collectBuiltinDefinitionsByKind(model.TaskKindAsync)\n\tdbMap, dbReady, dbErr := loadDBDefinitionsByKind(model.TaskKindAsync)\n\tif dbErr != nil {\n\t\treturn dbErr\n\t}\n\n\trows := buildAsyncScanRows(taskTypes, builtinMap, dbMap, dbReady)\n\tprintScanRows(rows, dbReady)\n\tprintScanSummary(\"代码注册异步任务\", taskTypes, rows, builtinMap, dbMap, dbReady)\n\treturn nil\n}\n\nfunc runScanCron() error {\n\tbuiltinMap := collectBuiltinDefinitionsByKind(model.TaskKindCron)\n\ttaskTypes := sortedDefinitionCodes(builtinMap)\n\tif len(taskTypes) == 0 {\n\t\tfmt.Println(\"未扫描到内置定时任务。\")\n\t\treturn nil\n\t}\n\n\tdbMap, dbReady, dbErr := loadDBDefinitionsByKind(model.TaskKindCron)\n\tif dbErr != nil {\n\t\treturn dbErr\n\t}\n\n\trows := buildAsyncScanRows(taskTypes, builtinMap, dbMap, dbReady)\n\tprintScanRows(rows, dbReady)\n\tprintScanSummary(\"内置定时任务\", taskTypes, rows, builtinMap, dbMap, dbReady)\n\treturn nil\n}\n\nfunc collectRegistryTaskTypes(registry queue.Registry) []string {\n\tif registry == nil {\n\t\treturn nil\n\t}\n\tentries := registry.Entries()\n\ttaskTypeSet := make(map[string]struct{}, len(entries))\n\tfor _, entry := range entries {\n\t\ttaskType := strings.TrimSpace(entry.TaskType)\n\t\tif taskType == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\ttaskTypeSet[taskType] = struct{}{}\n\t}\n\ttaskTypes := make([]string, 0, len(taskTypeSet))\n\tfor taskType := range taskTypeSet {\n\t\ttaskTypes = append(taskTypes, taskType)\n\t}\n\tsort.Strings(taskTypes)\n\treturn taskTypes\n}\n\nfunc collectBuiltinDefinitionsByKind(kind string) map[string]model.TaskDefinition {\n\tdefinitions := taskcron.BuiltinTaskDefinitions(config.GetConfig())\n\tresult := make(map[string]model.TaskDefinition, len(definitions))\n\tfor _, definition := range definitions {\n\t\tif definition.Kind != kind {\n\t\t\tcontinue\n\t\t}\n\t\tcode := strings.TrimSpace(definition.Code)\n\t\tif code == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tresult[code] = definition\n\t}\n\treturn result\n}\n\nfunc sortedDefinitionCodes(definitions map[string]model.TaskDefinition) []string {\n\tcodes := make([]string, 0, len(definitions))\n\tfor code := range definitions {\n\t\tcodes = append(codes, code)\n\t}\n\tsort.Strings(codes)\n\treturn codes\n}\n\nfunc loadDBDefinitionsByKind(kind string) (map[string]model.TaskDefinition, bool, error) {\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn map[string]model.TaskDefinition{}, false, nil\n\t}\n\tif !db.Migrator().HasTable(model.NewTaskDefinition().TableName()) {\n\t\treturn map[string]model.TaskDefinition{}, false, nil\n\t}\n\n\tdefinitions := make([]model.TaskDefinition, 0)\n\tif err := db.Where(\"kind = ? AND deleted_at = 0\", kind).Find(&definitions).Error; err != nil {\n\t\treturn nil, true, err\n\t}\n\n\tresult := make(map[string]model.TaskDefinition, len(definitions))\n\tfor _, definition := range definitions {\n\t\tcode := strings.TrimSpace(definition.Code)\n\t\tif code == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tresult[code] = definition\n\t}\n\treturn result, true, nil\n}\n\nfunc buildAsyncScanRows(taskTypes []string, builtinMap map[string]model.TaskDefinition, dbMap map[string]model.TaskDefinition, dbReady bool) []asyncScanRow {\n\trows := make([]asyncScanRow, 0, len(taskTypes))\n\tfor _, taskType := range taskTypes {\n\t\trow := asyncScanRow{\n\t\t\tTaskType:  taskType,\n\t\t\tInBuiltin: false,\n\t\t\tInDB:      false,\n\t\t\tQueue:     \"-\",\n\t\t}\n\t\tif definition, ok := builtinMap[taskType]; ok {\n\t\t\trow.InBuiltin = true\n\t\t\tif strings.TrimSpace(definition.Queue) != \"\" {\n\t\t\t\trow.Queue = definition.Queue\n\t\t\t}\n\t\t}\n\t\tif dbReady {\n\t\t\tif definition, ok := dbMap[taskType]; ok {\n\t\t\t\trow.InDB = true\n\t\t\t\tif row.Queue == \"-\" && strings.TrimSpace(definition.Queue) != \"\" {\n\t\t\t\t\trow.Queue = definition.Queue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\trows = append(rows, row)\n\t}\n\treturn rows\n}\n\nfunc printScanRows(rows []asyncScanRow, dbReady bool) {\n\twriter := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)\n\tif dbReady {\n\t\t_, _ = fmt.Fprintln(writer, \"TASK_TYPE\\tIN_BUILTIN\\tIN_DB\\tQUEUE\")\n\t} else {\n\t\t_, _ = fmt.Fprintln(writer, \"TASK_TYPE\\tIN_BUILTIN\\tQUEUE\")\n\t}\n\tfor _, row := range rows {\n\t\tif dbReady {\n\t\t\t_, _ = fmt.Fprintf(writer, \"%s\\t%s\\t%s\\t%s\\n\",\n\t\t\t\trow.TaskType, yesNo(row.InBuiltin), yesNo(row.InDB), row.Queue)\n\t\t} else {\n\t\t\t_, _ = fmt.Fprintf(writer, \"%s\\t%s\\t%s\\n\",\n\t\t\t\trow.TaskType, yesNo(row.InBuiltin), row.Queue)\n\t\t}\n\t}\n\t_ = writer.Flush()\n}\n\nfunc printScanSummary(sourceLabel string, taskTypes []string, rows []asyncScanRow, builtinMap map[string]model.TaskDefinition, dbMap map[string]model.TaskDefinition, dbReady bool) {\n\tmissingBuiltin := make([]string, 0)\n\tmissingDB := make([]string, 0)\n\tfor _, row := range rows {\n\t\tif !row.InBuiltin {\n\t\t\tmissingBuiltin = append(missingBuiltin, row.TaskType)\n\t\t}\n\t\tif dbReady && !row.InDB {\n\t\t\tmissingDB = append(missingDB, row.TaskType)\n\t\t}\n\t}\n\n\tstaleBuiltin := make([]string, 0)\n\tfor code := range builtinMap {\n\t\tif !contains(taskTypes, code) {\n\t\t\tstaleBuiltin = append(staleBuiltin, code)\n\t\t}\n\t}\n\tsort.Strings(staleBuiltin)\n\n\tstaleDB := make([]string, 0)\n\tif dbReady {\n\t\tfor code := range dbMap {\n\t\t\tif !contains(taskTypes, code) {\n\t\t\t\tstaleDB = append(staleDB, code)\n\t\t\t}\n\t\t}\n\t\tsort.Strings(staleDB)\n\t}\n\n\tfmt.Printf(\"\\n%s: %d\\n\", sourceLabel, len(taskTypes))\n\tfmt.Printf(\"缺失内置定义: %d\\n\", len(missingBuiltin))\n\tif dbReady {\n\t\tfmt.Printf(\"缺失数据库定义: %d\\n\", len(missingDB))\n\t} else {\n\t\tfmt.Println(\"数据库定义状态: 未连接或未初始化，已跳过 IN_DB 对比\")\n\t}\n\tfmt.Printf(\"内置定义疑似冗余: %d\\n\", len(staleBuiltin))\n\tif dbReady {\n\t\tfmt.Printf(\"数据库定义疑似冗余: %d\\n\", len(staleDB))\n\t}\n\n\tif len(missingBuiltin) > 0 {\n\t\tfmt.Printf(\"缺失内置定义任务: %s\\n\", strings.Join(missingBuiltin, \", \"))\n\t}\n\tif dbReady && len(missingDB) > 0 {\n\t\tfmt.Printf(\"缺失数据库定义任务: %s\\n\", strings.Join(missingDB, \", \"))\n\t}\n\tif len(staleBuiltin) > 0 {\n\t\tfmt.Printf(\"内置定义冗余任务: %s\\n\", strings.Join(staleBuiltin, \", \"))\n\t}\n\tif dbReady && len(staleDB) > 0 {\n\t\tfmt.Printf(\"数据库定义冗余任务: %s\\n\", strings.Join(staleDB, \", \"))\n\t}\n}\n\nfunc contains(values []string, target string) bool {\n\tfor _, value := range values {\n\t\tif value == target {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc yesNo(value bool) string {\n\tif value {\n\t\treturn \"Y\"\n\t}\n\treturn \"N\"\n}\n"
  },
  {
    "path": "internal/console/task/task_test.go",
    "content": "package task\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\nfunc TestBuildAsyncScanRowsMarksMissingDefinitions(t *testing.T) {\n\ttaskTypes := []string{\"audit:request_log.write\", \"demo:send\"}\n\tbuiltin := map[string]model.TaskDefinition{\n\t\t\"audit:request_log.write\": {\n\t\t\tCode:  \"audit:request_log.write\",\n\t\t\tQueue: \"audit\",\n\t\t},\n\t}\n\tdbDefs := map[string]model.TaskDefinition{\n\t\t\"audit:request_log.write\": {\n\t\t\tCode:  \"audit:request_log.write\",\n\t\t\tQueue: \"audit\",\n\t\t},\n\t}\n\n\trows := buildAsyncScanRows(taskTypes, builtin, dbDefs, true)\n\tif len(rows) != 2 {\n\t\tt.Fatalf(\"unexpected row count: %d\", len(rows))\n\t}\n\tif rows[0].TaskType != \"audit:request_log.write\" || !rows[0].InBuiltin || !rows[0].InDB {\n\t\tt.Fatalf(\"unexpected first row: %+v\", rows[0])\n\t}\n\tif rows[1].TaskType != \"demo:send\" || rows[1].InBuiltin || rows[1].InDB {\n\t\tt.Fatalf(\"unexpected second row: %+v\", rows[1])\n\t}\n}\n\nfunc TestBuildAsyncScanRowsSkipsDBStateWhenDBNotReady(t *testing.T) {\n\ttaskTypes := []string{\"audit:request_log.write\"}\n\tbuiltin := map[string]model.TaskDefinition{\n\t\t\"audit:request_log.write\": {\n\t\t\tCode:  \"audit:request_log.write\",\n\t\t\tQueue: \"audit\",\n\t\t},\n\t}\n\tdbDefs := map[string]model.TaskDefinition{\n\t\t\"audit:request_log.write\": {\n\t\t\tCode:  \"audit:request_log.write\",\n\t\t\tQueue: \"audit\",\n\t\t},\n\t}\n\n\trows := buildAsyncScanRows(taskTypes, builtin, dbDefs, false)\n\tif len(rows) != 1 {\n\t\tt.Fatalf(\"unexpected row count: %d\", len(rows))\n\t}\n\tif rows[0].InDB {\n\t\tt.Fatalf(\"expected InDB=false when dbReady=false, got %+v\", rows[0])\n\t}\n}\n\nfunc TestSortedDefinitionCodes(t *testing.T) {\n\tcodes := sortedDefinitionCodes(map[string]model.TaskDefinition{\n\t\t\"cron:reset-system-data\": {Code: \"cron:reset-system-data\"},\n\t\t\"cron:demo\":              {Code: \"cron:demo\"},\n\t})\n\n\tif len(codes) != 2 || codes[0] != \"cron:demo\" || codes[1] != \"cron:reset-system-data\" {\n\t\tt.Fatalf(\"unexpected sorted codes: %#v\", codes)\n\t}\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\treq \"github.com/wannanbigpig/gin-layout/internal/pkg/request\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/auth\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n\t\"github.com/wannanbigpig/gin-layout/pkg/utils/captcha\"\n)\n\n// LoginController 登录控制器\ntype LoginController struct {\n\tcontroller.Api\n}\n\n// NewLoginController 创建登录控制器实例\nfunc NewLoginController() *LoginController {\n\treturn &LoginController{}\n}\n\n// Login 管理员用户登录\nfunc (api LoginController) Login(c *gin.Context) {\n\tparams := form.NewLoginForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\t// 构建登录日志信息\n\tloginService := auth.NewLoginService()\n\tlogInfo := loginService.BuildLoginLogInfo(c)\n\tif err := loginService.CheckLoginAllowed(params.UserName); err != nil {\n\t\tloginService.HandleLoginFailure(params.UserName, loginService.ExtractErrorMessage(err), logInfo, false)\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\t// 校验验证码\n\tif !captcha.Verify(params.CaptchaID, params.Captcha) {\n\t\t// 记录验证码错误日志\n\t\tloginService.HandleLoginFailure(params.UserName, \"验证码错误\", logInfo, true)\n\t\tapi.FailCode(c, errors.CaptchaErr)\n\t\treturn\n\t}\n\n\t// 执行登录\n\tresult, err := loginService.Login(params.UserName, params.PassWord, logInfo)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tdiff := auditdiff.Marshal(auditdiff.BuildFieldDiff(nil, map[string]any{\n\t\t\"action\":   \"login\",\n\t\t\"username\": params.UserName,\n\t}, []auditdiff.FieldRule{\n\t\t{Field: \"action\", Label: \"操作\"},\n\t\t{Field: \"username\", Label: \"用户名\"},\n\t}))\n\tmiddleware.SetAuditChangeDiffRaw(c, diff)\n\tapi.Success(c, result)\n}\n\n// LoginCaptcha 生成登录验证码\nfunc (api LoginController) LoginCaptcha(c *gin.Context) {\n\tresult, err := captcha.Generate()\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, result)\n}\n\n// Logout 管理员用户退出登录\nfunc (api LoginController) Logout(c *gin.Context) {\n\taccessToken, err := req.GetAccessToken(c)\n\tif err != nil {\n\t\t// Token提取失败，视为已退出\n\t\tapi.Success(c, nil)\n\t\treturn\n\t}\n\n\tif err := auth.NewLoginService().Logout(accessToken); err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tdiff := auditdiff.Marshal(auditdiff.BuildFieldDiff(nil, map[string]any{\n\t\t\"action\": \"logout\",\n\t}, []auditdiff.FieldRule{\n\t\t{Field: \"action\", Label: \"操作\"},\n\t}))\n\tmiddleware.SetAuditChangeDiffRaw(c, diff)\n\tapi.Success(c, nil)\n}\n\n// CheckToken 检查Token是否有效\nfunc (api LoginController) CheckToken(c *gin.Context) {\n\taccessToken, err := req.GetAccessToken(c)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tloginService := auth.NewLoginService()\n\tloginService.SetCtx(c)\n\t_, ok := loginService.CheckToken(accessToken)\n\n\tapi.Success(c, ok)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_admin_user.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/admin\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// AdminUserController 管理员用户控制器\ntype AdminUserController struct {\n\tcontroller.Api\n}\n\n// NewAdminUserController 创建管理员用户控制器实例\nfunc NewAdminUserController() *AdminUserController {\n\treturn &AdminUserController{}\n}\n\n// GetUserInfo 获取当前登录用户基本信息\nfunc (api AdminUserController) GetUserInfo(c *gin.Context) {\n\tresult, err := api.GetCurrentAdminUserDetail(c)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, result)\n}\n\n// UpdateProfile 更新个人资料（只能更新自己的手机号、邮箱、密码、昵称）\nfunc (api AdminUserController) UpdateProfile(c *gin.Context) {\n\tuid := api.GetCurrentUserID(c)\n\tparams := form.NewUpdateProfile()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := admin.NewAdminUserService().UpdateProfileWithAuditDiff(uid, params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// GetUserMenuInfo 获取当前登录用户权限信息\nfunc (api AdminUserController) GetUserMenuInfo(c *gin.Context) {\n\tuid := api.GetCurrentUserID(c)\n\tresult, err := admin.NewAdminUserService().GetUserMenuInfo(uid, middleware.LocaleFromContext(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, result)\n}\n\n// Create 新增管理员\nfunc (api AdminUserController) Create(c *gin.Context) {\n\tparams := form.NewCreateAdminUser()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := admin.NewAdminUserService().CreateWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// Update 更新管理员\nfunc (api AdminUserController) Update(c *gin.Context) {\n\tparams := form.NewUpdateAdminUser()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := admin.NewAdminUserService().UpdateWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// List 分页查询管理员用户列表\nfunc (api AdminUserController) List(c *gin.Context) {\n\tparams := form.NewAdminUserListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult := admin.NewAdminUserService().List(params)\n\tapi.Success(c, result)\n}\n\n// Delete 删除管理员\nfunc (api AdminUserController) Delete(c *gin.Context) {\n\tparams := form.NewIdForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := admin.NewAdminUserService().DeleteWithAuditDiff(params.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// BindRole 管理员绑定角色\nfunc (api AdminUserController) BindRole(c *gin.Context) {\n\tparams := form.NewBindRole()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := admin.NewAdminUserService().BindRoleWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// Detail 获取管理员详情\nfunc (api AdminUserController) Detail(c *gin.Context) {\n\tquery := form.NewIdForm()\n\tif err := validator.CheckQueryParams(c, &query); err != nil {\n\t\treturn\n\t}\n\n\tdetail, err := admin.NewAdminUserService().GetUserInfo(query.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, detail)\n}\n\n// GetFullPhone 获取管理员完整手机号\nfunc (api AdminUserController) GetFullPhone(c *gin.Context) {\n\tquery := form.NewIdForm()\n\tif err := validator.CheckQueryParams(c, &query); err != nil {\n\t\treturn\n\t}\n\n\tuserInfo, err := admin.NewAdminUserService().GetUserInfo(query.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, map[string]string{\n\t\t\"phone_number\": userInfo.PhoneNumber,\n\t})\n}\n\n// GetFullEmail 获取管理员完整邮箱\nfunc (api AdminUserController) GetFullEmail(c *gin.Context) {\n\tquery := form.NewIdForm()\n\tif err := validator.CheckQueryParams(c, &query); err != nil {\n\t\treturn\n\t}\n\n\tuserInfo, err := admin.NewAdminUserService().GetUserInfo(query.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, map[string]string{\n\t\t\"email\": userInfo.Email,\n\t})\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_api.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/api_permission\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// ApiController API权限控制器\ntype ApiController struct {\n\tcontroller.Api\n}\n\n// NewApiController 创建API控制器实例\nfunc NewApiController() *ApiController {\n\treturn &ApiController{}\n}\n\n// Update 更新API权限\nfunc (api ApiController) Update(c *gin.Context) {\n\tparams := form.NewUpdateApiForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := api_permission.NewApiService().UpdateWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// List 分页查询API权限列表\nfunc (api ApiController) List(c *gin.Context) {\n\tparams := form.NewListApiQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult := api_permission.NewApiService().ListPage(params)\n\tapi.Success(c, result)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_dept.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/dept\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// DeptController 部门控制器\ntype DeptController struct {\n\tcontroller.Api\n}\n\n// NewDeptController 创建部门控制器实例\nfunc NewDeptController() *DeptController {\n\treturn &DeptController{}\n}\n\n// List 查询部门列表\nfunc (api DeptController) List(c *gin.Context) {\n\tparams := form.NewDeptListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult := dept.NewDeptService().List(params)\n\tapi.Success(c, result)\n}\n\n// Create 新增部门\nfunc (api DeptController) Create(c *gin.Context) {\n\tparams := form.NewCreateDeptForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := dept.NewDeptService().CreateWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// Update 更新部门\nfunc (api DeptController) Update(c *gin.Context) {\n\tparams := form.NewUpdateDeptForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := dept.NewDeptService().UpdateWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// Delete 删除部门\nfunc (api DeptController) Delete(c *gin.Context) {\n\tparams := form.NewIdForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := dept.NewDeptService().DeleteWithAuditDiff(params.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// Detail 获取部门详情\nfunc (api DeptController) Detail(c *gin.Context) {\n\tquery := form.NewIdForm()\n\tif err := validator.CheckQueryParams(c, &query); err != nil {\n\t\treturn\n\t}\n\n\tdetail, err := dept.NewDeptService().Detail(query.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, detail)\n}\n\n// BindRole 绑定角色到部门\nfunc (api DeptController) BindRole(c *gin.Context) {\n\tparams := form.NewDeptBindRole()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := dept.NewDeptService().BindRoleWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_file_resource.go",
    "content": "package admin_v1\n\nimport (\n\tstderrors \"errors\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\tpkgErrors \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// FileResourceController 文件资源管理控制器。\ntype FileResourceController struct {\n\tcontroller.Api\n}\n\nfunc NewFileResourceController() *FileResourceController {\n\treturn &FileResourceController{}\n}\n\n// List 分页查询文件资源列表。\nfunc (api FileResourceController) List(c *gin.Context) {\n\tparams := form.NewFileResourceListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult := service.NewFileResourceService().List(params)\n\tapi.Success(c, result)\n}\n\n// Detail 查询文件资源详情。\nfunc (api FileResourceController) Detail(c *gin.Context) {\n\tquery := form.NewFileResourceIDForm()\n\tif err := validator.CheckQueryParams(c, &query); err != nil {\n\t\treturn\n\t}\n\n\tdetail, err := service.NewFileResourceService().Detail(query.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, detail)\n}\n\n// Delete 删除文件资源。\nfunc (api FileResourceController) Delete(c *gin.Context) {\n\tparams := form.NewFileResourceIDForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tif err := service.NewFileResourceService().Delete(params.ID, api.GetCurrentUserID(c), params.DeletedReason); err != nil {\n\t\tvar referencedErr *service.FileReferencedDeleteError\n\t\tif stderrors.As(err, &referencedErr) {\n\t\t\tmessage := \"文件存在引用，不能删除\"\n\t\t\tbusinessErr := referencedErr.BusinessError()\n\t\t\tif businessErr != nil {\n\t\t\t\tmessage = businessErr.GetMessage()\n\t\t\t}\n\t\t\tapi.Fail(c, pkgErrors.FileReferenced, message, gin.H{\"references\": referencedErr.References})\n\t\t\treturn\n\t\t}\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, nil)\n}\n\nfunc (api FileResourceController) TrashList(c *gin.Context) {\n\tparams := form.NewFileResourceListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tdeleted := uint8(1)\n\tparams.IsDeleted = &deleted\n\tresult := service.NewFileResourceService().List(params)\n\tapi.Success(c, result)\n}\n\nfunc (api FileResourceController) Restore(c *gin.Context) {\n\tparams := form.NewFileResourceIDForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tif err := service.NewFileResourceService().Restore(params.ID); err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, nil)\n}\n\nfunc (api FileResourceController) Destroy(c *gin.Context) {\n\tparams := form.NewFileResourceIDForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tif err := service.NewFileResourceService().Destroy(params.ID); err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, nil)\n}\n\nfunc (api FileResourceController) References(c *gin.Context) {\n\tparams := form.NewFileReferenceListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tresult := service.NewFileResourceService().References(params)\n\tapi.Success(c, result)\n}\n\nfunc (api FileResourceController) FolderTree(c *gin.Context) {\n\tresult, err := service.NewFileResourceService().FolderTree()\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, result)\n}\n\nfunc (api FileResourceController) FolderCreate(c *gin.Context) {\n\tparams := form.NewFileFolderCreateForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tresult, err := service.NewFileResourceService().CreateFolder(params, api.GetCurrentUserID(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, result)\n}\n\nfunc (api FileResourceController) FolderUpdate(c *gin.Context) {\n\tparams := form.NewFileFolderUpdateForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tresult, err := service.NewFileResourceService().UpdateFolder(params, api.GetCurrentUserID(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, result)\n}\n\nfunc (api FileResourceController) FolderDelete(c *gin.Context) {\n\tparams := form.NewFileFolderDeleteForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tif err := service.NewFileResourceService().DeleteFolder(params.ID); err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, nil)\n}\n\nfunc (api FileResourceController) FolderMove(c *gin.Context) {\n\tparams := form.NewFileFolderMoveForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tresult, err := service.NewFileResourceService().MoveFolder(params, api.GetCurrentUserID(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, result)\n}\n\nfunc (api FileResourceController) Move(c *gin.Context) {\n\tparams := form.NewFileMoveForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tresult, err := service.NewFileResourceService().MoveFiles(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, result)\n}\n\nfunc (api FileResourceController) UploadLocal(c *gin.Context) {\n\tparams := form.NewFileLocalUploadForm()\n\tif err := c.ShouldBind(params); err != nil {\n\t\tapi.FailCode(c, pkgErrors.InvalidParameter)\n\t\treturn\n\t}\n\tmultipartForm, err := c.MultipartForm()\n\tif err != nil {\n\t\tapi.FailCode(c, pkgErrors.InvalidParameter)\n\t\treturn\n\t}\n\tresult, err := service.NewFileResourceService().UploadLocal(multipartForm.File[\"files\"], params, api.GetCurrentUserID(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, result)\n}\n\nfunc (api FileResourceController) UploadCredential(c *gin.Context) {\n\tparams := form.NewFileUploadCredentialForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tresult, err := service.NewFileResourceService().UploadCredential(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, result)\n}\n\nfunc (api FileResourceController) UploadComplete(c *gin.Context) {\n\tparams := form.NewFileUploadCompleteForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tresult, err := service.NewFileResourceService().CompleteDirectUpload(params, api.GetCurrentUserID(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, result)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_login_log.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/audit\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// AdminLoginLogController 登录日志控制器\ntype AdminLoginLogController struct {\n\tcontroller.Api\n}\n\n// NewAdminLoginLogController 创建登录日志控制器实例\nfunc NewAdminLoginLogController() *AdminLoginLogController {\n\treturn &AdminLoginLogController{}\n}\n\n// List 分页查询管理员登录日志列表\nfunc (api AdminLoginLogController) List(c *gin.Context) {\n\tparams := form.NewAdminLoginLogListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult := audit.NewAdminLoginLogService().List(params)\n\tapi.Success(c, result)\n}\n\n// Detail 获取管理员登录日志详情\nfunc (api AdminLoginLogController) Detail(c *gin.Context) {\n\tquery := form.NewIdForm()\n\tif err := validator.CheckQueryParams(c, &query); err != nil {\n\t\treturn\n\t}\n\n\tdetail, err := audit.NewAdminLoginLogService().Detail(query.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, detail)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_menu.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/menu\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// MenuController 菜单控制器\ntype MenuController struct {\n\tcontroller.Api\n}\n\n// NewMenuController 创建菜单控制器实例\nfunc NewMenuController() *MenuController {\n\treturn &MenuController{}\n}\n\n// Create 新增菜单\nfunc (api MenuController) Create(c *gin.Context) {\n\tparams := form.NewCreateMenuForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := menu.NewMenuService().CreateWithAuditDiff(params, middleware.LocaleFromContext(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// Update 更新菜单\nfunc (api MenuController) Update(c *gin.Context) {\n\tparams := form.NewUpdateMenuForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := menu.NewMenuService().UpdateWithAuditDiff(params, middleware.LocaleFromContext(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// UpdateAllMenuPermissions 批量更新菜单权限到casbin\nfunc (api MenuController) UpdateAllMenuPermissions(c *gin.Context) {\n\tif err := menu.NewMenuService().UpdateAllMenuPermissions(); err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tdiff := auditdiff.Marshal(auditdiff.BuildFieldDiff(nil, map[string]any{\n\t\t\"action\": \"sync_all_menu_permissions\",\n\t}, []auditdiff.FieldRule{\n\t\t{Field: \"action\", Label: \"操作\"},\n\t}))\n\tmiddleware.SetAuditChangeDiffRaw(c, diff)\n\tapi.Success(c, nil)\n}\n\n// Detail 获取菜单详情\nfunc (api MenuController) Detail(c *gin.Context) {\n\tquery := form.NewIdForm()\n\tif err := validator.CheckQueryParams(c, &query); err != nil {\n\t\treturn\n\t}\n\n\tdetail, err := menu.NewMenuService().Detail(query.ID, middleware.LocaleFromContext(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, detail)\n}\n\n// List 查询菜单列表\nfunc (api MenuController) List(c *gin.Context) {\n\tparams := form.NewMenuListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tresult := menu.NewMenuService().List(params, middleware.LocaleFromContext(c))\n\tapi.Success(c, result)\n}\n\n// Delete 删除菜单\nfunc (api MenuController) Delete(c *gin.Context) {\n\tparams := form.NewIdForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := menu.NewMenuService().DeleteWithAuditDiff(params.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_request_log.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/audit\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// RequestLogController 请求日志控制器\ntype RequestLogController struct {\n\tcontroller.Api\n}\n\n// NewRequestLogController 创建请求日志控制器实例\nfunc NewRequestLogController() *RequestLogController {\n\treturn &RequestLogController{}\n}\n\n// List 分页查询请求日志列表\nfunc (api RequestLogController) List(c *gin.Context) {\n\tparams := form.NewRequestLogListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult := audit.NewRequestLogService().List(params)\n\tapi.Success(c, result)\n}\n\n// Detail 获取请求日志详情\nfunc (api RequestLogController) Detail(c *gin.Context) {\n\tquery := form.NewIdForm()\n\tif err := validator.CheckQueryParams(c, &query); err != nil {\n\t\treturn\n\t}\n\n\tdetail, err := audit.NewRequestLogService().Detail(query.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, detail)\n}\n\n// Export 导出请求日志 CSV。\nfunc (api RequestLogController) Export(c *gin.Context) {\n\tparams := form.NewRequestLogExportQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tcontent, fileName, err := audit.NewRequestLogService().ExportCSV(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tc.Header(\"Content-Type\", \"text/csv; charset=utf-8\")\n\tc.Header(\"Content-Disposition\", \"attachment; filename=\"+fileName)\n\tc.Data(200, \"text/csv; charset=utf-8\", content)\n}\n\n// MaskConfig 获取敏感字段脱敏配置。\nfunc (api RequestLogController) MaskConfig(c *gin.Context) {\n\tresult := audit.NewRequestLogService().GetMaskConfig()\n\tapi.Success(c, result)\n}\n\n// UpdateMaskConfig 更新敏感字段脱敏配置（运行时生效）。\nfunc (api RequestLogController) UpdateMaskConfig(c *gin.Context) {\n\tparams := form.NewRequestLogMaskConfigForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult, changeDiff, err := audit.NewRequestLogService().UpdateMaskConfigWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, result)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_role.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/role\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// RoleController 角色控制器\ntype RoleController struct {\n\tcontroller.Api\n}\n\n// NewRoleController 创建角色控制器实例\nfunc NewRoleController() *RoleController {\n\treturn &RoleController{}\n}\n\n// List 分页查询角色列表\nfunc (api RoleController) List(c *gin.Context) {\n\tparams := form.NewRoleListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult := role.NewRoleService().List(params)\n\tapi.Success(c, result)\n}\n\n// Create 新增角色\nfunc (api RoleController) Create(c *gin.Context) {\n\tparams := form.NewCreateRoleForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := role.NewRoleService().CreateWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// Update 更新角色\nfunc (api RoleController) Update(c *gin.Context) {\n\tparams := form.NewUpdateRoleForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := role.NewRoleService().UpdateWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// Delete 删除角色\nfunc (api RoleController) Delete(c *gin.Context) {\n\tparams := form.NewIdForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tchangeDiff, err := role.NewRoleService().DeleteWithAuditDiff(params.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\n// Detail 获取角色详情\nfunc (api RoleController) Detail(c *gin.Context) {\n\tquery := form.NewIdForm()\n\tif err := validator.CheckQueryParams(c, &query); err != nil {\n\t\treturn\n\t}\n\n\tdetail, err := role.NewRoleService().Detail(query.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, detail)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_session.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/auth\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// SessionController 在线会话管理控制器。\ntype SessionController struct {\n\tcontroller.Api\n}\n\nfunc NewSessionController() *SessionController {\n\treturn &SessionController{}\n}\n\n// List 分页查询在线会话列表。\nfunc (api SessionController) List(c *gin.Context) {\n\tparams := form.NewSessionListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult := auth.NewLoginService().ListSessions(params)\n\tapi.Success(c, result)\n}\n\n// Revoke 撤销在线会话。\nfunc (api SessionController) Revoke(c *gin.Context) {\n\tparams := form.NewSessionRevokeForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tif err := auth.NewLoginService().RevokeSession(c.Request.Context(), params.ID, params.Reason); err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, nil)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_storage_config.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/sys_config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\ntype StorageConfigController struct {\n\tcontroller.Api\n}\n\nfunc NewStorageConfigController() *StorageConfigController {\n\treturn &StorageConfigController{}\n}\n\nfunc (api StorageConfigController) Config(c *gin.Context) {\n\tsettings, err := service.NewStorageConfigService().Get(true)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, settings)\n}\n\nfunc (api StorageConfigController) Save(c *gin.Context) {\n\tparams := form.NewStorageConfigPayload()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tif err := service.NewStorageConfigService().Save(service.StorageSettings{\n\t\tActiveDriver: params.ActiveDriver,\n\t\tConfig:       params.Config,\n\t}); err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tif err := sys_config.NewSysConfigService().RefreshCache(); err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, nil)\n}\n\nfunc (api StorageConfigController) Test(c *gin.Context) {\n\tparams := form.NewStorageConfigPayload()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tif err := service.NewStorageConfigService().Test(c.Request.Context(), service.StorageSettings{\n\t\tActiveDriver: params.ActiveDriver,\n\t\tConfig:       params.Config,\n\t}); err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, nil)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_sys_config.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/sys_config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// SysConfigController 系统参数控制器。\ntype SysConfigController struct {\n\tcontroller.Api\n}\n\nfunc NewSysConfigController() *SysConfigController {\n\treturn &SysConfigController{}\n}\n\nfunc (api SysConfigController) List(c *gin.Context) {\n\tparams := form.NewSysConfigListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tapi.Success(c, sys_config.NewSysConfigService().List(params, middleware.LocaleFromContext(c)))\n}\n\nfunc (api SysConfigController) Detail(c *gin.Context) {\n\tparams := form.NewIdForm()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tdetail, err := sys_config.NewSysConfigService().Detail(params.ID, middleware.LocaleFromContext(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, detail)\n}\n\nfunc (api SysConfigController) Value(c *gin.Context) {\n\tparams := form.NewSysConfigKeyQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tvalue, err := sys_config.NewSysConfigService().PublicValue(params.ConfigKey)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, value)\n}\n\nfunc (api SysConfigController) Create(c *gin.Context) {\n\tparams := form.NewCreateSysConfigForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tservice := sys_config.NewSysConfigService()\n\tmiddleware.SetAuditRequestBodyRaw(c, service.MaskedAuditRequestBody(0, &params.SysConfigPayload))\n\tchangeDiff, err := service.CreateWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\nfunc (api SysConfigController) Update(c *gin.Context) {\n\tparams := form.NewUpdateSysConfigForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tservice := sys_config.NewSysConfigService()\n\tmiddleware.SetAuditRequestBodyRaw(c, service.MaskedAuditRequestBody(params.Id, &params.SysConfigPayload))\n\tchangeDiff, err := service.UpdateWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\nfunc (api SysConfigController) Delete(c *gin.Context) {\n\tparams := form.NewIdForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tchangeDiff, err := sys_config.NewSysConfigService().DeleteWithAuditDiff(params.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\nfunc (api SysConfigController) Refresh(c *gin.Context) {\n\tif err := sys_config.NewSysConfigService().RefreshCache(); err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tdiff := auditdiff.Marshal(auditdiff.BuildFieldDiff(nil, map[string]any{\n\t\t\"action\": \"refresh_cache\",\n\t}, []auditdiff.FieldRule{\n\t\t{Field: \"action\", Label: \"操作\"},\n\t}))\n\tmiddleware.SetAuditChangeDiffRaw(c, diff)\n\tapi.Success(c, nil)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_sys_dict.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/sys_dict\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// SysDictController 系统字典控制器。\ntype SysDictController struct {\n\tcontroller.Api\n}\n\nfunc NewSysDictController() *SysDictController {\n\treturn &SysDictController{}\n}\n\nfunc (api SysDictController) TypeList(c *gin.Context) {\n\tparams := form.NewSysDictTypeListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tapi.Success(c, sys_dict.NewSysDictService().TypeList(params, middleware.LocaleFromContext(c)))\n}\n\nfunc (api SysDictController) TypeDetail(c *gin.Context) {\n\tparams := form.NewIdForm()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tdetail, err := sys_dict.NewSysDictService().TypeDetail(params.ID, middleware.LocaleFromContext(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, detail)\n}\n\nfunc (api SysDictController) TypeCreate(c *gin.Context) {\n\tparams := form.NewCreateSysDictTypeForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tchangeDiff, err := sys_dict.NewSysDictService().CreateTypeWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\nfunc (api SysDictController) TypeUpdate(c *gin.Context) {\n\tparams := form.NewUpdateSysDictTypeForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tchangeDiff, err := sys_dict.NewSysDictService().UpdateTypeWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\nfunc (api SysDictController) TypeDelete(c *gin.Context) {\n\tparams := form.NewIdForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tchangeDiff, err := sys_dict.NewSysDictService().DeleteTypeWithAuditDiff(params.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\nfunc (api SysDictController) ItemList(c *gin.Context) {\n\tparams := form.NewSysDictItemListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tapi.Success(c, sys_dict.NewSysDictService().ItemList(params, middleware.LocaleFromContext(c)))\n}\n\nfunc (api SysDictController) ItemCreate(c *gin.Context) {\n\tparams := form.NewCreateSysDictItemForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tchangeDiff, err := sys_dict.NewSysDictService().CreateItemWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\nfunc (api SysDictController) ItemUpdate(c *gin.Context) {\n\tparams := form.NewUpdateSysDictItemForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tchangeDiff, err := sys_dict.NewSysDictService().UpdateItemWithAuditDiff(params)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\nfunc (api SysDictController) ItemDelete(c *gin.Context) {\n\tparams := form.NewIdForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\tchangeDiff, err := sys_dict.NewSysDictService().DeleteItemWithAuditDiff(params.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, changeDiff)\n\tapi.Success(c, nil)\n}\n\nfunc (api SysDictController) Options(c *gin.Context) {\n\tparams := form.NewSysDictOptionsQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\toptions, err := sys_dict.NewSysDictService().Options(params.TypeCode, middleware.LocaleFromContext(c))\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, options)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_task_center.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/taskcenter\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// TaskCenterController 任务中心控制器。\ntype TaskCenterController struct {\n\tcontroller.Api\n}\n\nfunc NewTaskCenterController() *TaskCenterController {\n\treturn &TaskCenterController{}\n}\n\n// TaskList 分页查询任务定义列表。\nfunc (api TaskCenterController) TaskList(c *gin.Context) {\n\tparams := form.NewTaskDefinitionListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult := taskcenter.NewTaskCenterService().ListTaskDefinitions(params)\n\tapi.Success(c, result)\n}\n\n// RunList 分页查询任务执行记录列表。\nfunc (api TaskCenterController) RunList(c *gin.Context) {\n\tparams := form.NewTaskRunListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult := taskcenter.NewTaskCenterService().ListTaskRuns(params)\n\tapi.Success(c, result)\n}\n\n// RunDetail 查询任务执行记录详情。\nfunc (api TaskCenterController) RunDetail(c *gin.Context) {\n\tquery := form.NewIdForm()\n\tif err := validator.CheckQueryParams(c, &query); err != nil {\n\t\treturn\n\t}\n\n\tdetail, err := taskcenter.NewTaskCenterService().TaskRunDetail(query.ID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, detail)\n}\n\n// RunEvents 查询任务执行事件列表。\nfunc (api TaskCenterController) RunEvents(c *gin.Context) {\n\tquery := form.NewTaskRunEventsQuery()\n\tif err := validator.CheckQueryParams(c, &query); err != nil {\n\t\treturn\n\t}\n\n\tevents, err := taskcenter.NewTaskCenterService().TaskRunEvents(query.RunID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tapi.Success(c, events)\n}\n\n// CronStateList 分页查询定时任务最近状态列表。\nfunc (api TaskCenterController) CronStateList(c *gin.Context) {\n\tparams := form.NewCronTaskStateListQuery()\n\tif err := validator.CheckQueryParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tresult := taskcenter.NewTaskCenterService().ListCronTaskStates(params)\n\tapi.Success(c, result)\n}\n\n// Trigger 手动触发任务。\nfunc (api TaskCenterController) Trigger(c *gin.Context) {\n\tparams := form.NewTaskTriggerForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tuid := api.GetCurrentUserID(c)\n\taccount := \"\"\n\tif user := api.GetCurrentAdminUserSnapshot(c); user != nil {\n\t\taccount = user.Username\n\t}\n\n\tservice := taskcenter.NewTaskCenterService()\n\tresult, err := service.TriggerTask(c.Request.Context(), params, uid, account)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, taskcenter.BuildTriggerAuditDiff(params, result))\n\tapi.Success(c, result)\n}\n\n// Retry 重试失败任务。\nfunc (api TaskCenterController) Retry(c *gin.Context) {\n\tparams := form.NewTaskRetryForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tuid := api.GetCurrentUserID(c)\n\taccount := \"\"\n\tif user := api.GetCurrentAdminUserSnapshot(c); user != nil {\n\t\taccount = user.Username\n\t}\n\n\tservice := taskcenter.NewTaskCenterService()\n\tbefore, _ := service.TaskRunAuditSnapshot(params.RunID)\n\tresult, err := service.RetryTask(c.Request.Context(), params.RunID, uid, account)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, taskcenter.BuildRetryAuditDiff(before, result))\n\tapi.Success(c, result)\n}\n\n// Cancel 取消任务。\nfunc (api TaskCenterController) Cancel(c *gin.Context) {\n\tparams := form.NewTaskCancelForm()\n\tif err := validator.CheckPostParams(c, &params); err != nil {\n\t\treturn\n\t}\n\n\tuid := api.GetCurrentUserID(c)\n\taccount := \"\"\n\tif user := api.GetCurrentAdminUserSnapshot(c); user != nil {\n\t\taccount = user.Username\n\t}\n\n\tservice := taskcenter.NewTaskCenterService()\n\tbefore, _ := service.TaskRunAuditSnapshot(params.RunID)\n\tresult, err := service.CancelTask(c.Request.Context(), params.RunID, uid, account, params.Reason)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tmiddleware.SetAuditChangeDiffRaw(c, taskcenter.BuildCancelAuditDiff(before, result))\n\tapi.Success(c, result)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/auth_test.go",
    "content": "package admin_v1\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\treq \"github.com/wannanbigpig/gin-layout/internal/pkg/request\"\n\tauthservice \"github.com/wannanbigpig/gin-layout/internal/service/auth\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n)\n\n// TestExtractAccessToken 验证请求头中的访问令牌提取逻辑。\nfunc TestExtractAccessToken(t *testing.T) {\n\tinitControllerAuthTest(t)\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodGet, \"/admin/v1/auth/check-token\", nil)\n\tctx.Request.Header.Set(\"Authorization\", \"Bearer token-value\")\n\n\taccessToken, err := req.GetAccessToken(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"expected token to be extracted, got error %v\", err)\n\t}\n\tif accessToken != \"token-value\" {\n\t\tt.Fatalf(\"unexpected token value %s\", accessToken)\n\t}\n}\n\n// TestCheckTokenWithoutAuthorization 验证缺少 token 时返回错误响应。\nfunc TestCheckTokenWithoutAuthorization(t *testing.T) {\n\tinitControllerAuthTest(t)\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodGet, \"/admin/v1/auth/check-token\", nil)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"test-request-id\")\n\n\tNewLoginController().CheckToken(ctx)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected http status 200, got %d\", recorder.Code)\n\t}\n\tif !strings.Contains(recorder.Body.String(), `\"code\":500`) {\n\t\tt.Fatalf(\"expected server error response, got %s\", recorder.Body.String())\n\t}\n}\n\n// TestLoginWithoutRequiredParams 验证登录参数校验失败路径。\nfunc TestLoginWithoutRequiredParams(t *testing.T) {\n\tinitControllerAuthTest(t)\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/admin/v1/login\", nil)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"test-request-id\")\n\n\tNewLoginController().Login(ctx)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected http status 200, got %d\", recorder.Code)\n\t}\n\tif !strings.Contains(recorder.Body.String(), `\"code\":10000`) {\n\t\tt.Fatalf(\"expected validation error response, got %s\", recorder.Body.String())\n\t}\n}\n\n// TestBuildLoginLogInfo 验证登录日志上下文构造。\nfunc TestBuildLoginLogInfo(t *testing.T) {\n\tinitControllerAuthTest(t)\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/admin/v1/login\", nil)\n\tctx.Request.Header.Set(\"User-Agent\", \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36\")\n\tctx.Request.RemoteAddr = \"192.0.2.1:1234\"\n\n\tlogInfo := authservice.NewLoginService().BuildLoginLogInfo(ctx)\n\tif logInfo.IP == \"\" {\n\t\tt.Fatal(\"expected client ip in login log info\")\n\t}\n\tif logInfo.UserAgent == \"\" {\n\t\tt.Fatal(\"expected user agent in login log info\")\n\t}\n\tif logInfo.OS == \"\" || logInfo.OS == \"Unknown\" {\n\t\tt.Fatalf(\"expected parsed os in login log info, got %q\", logInfo.OS)\n\t}\n\tif logInfo.Browser != \"Chrome\" {\n\t\tt.Fatalf(\"expected Chrome browser in login log info, got %q\", logInfo.Browser)\n\t}\n}\n\nfunc TestBuildLoginLogInfoFallbacksUnknownForMissingUserAgent(t *testing.T) {\n\tinitControllerAuthTest(t)\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/admin/v1/login\", nil)\n\tctx.Request.RemoteAddr = \"192.0.2.1:1234\"\n\n\tlogInfo := authservice.NewLoginService().BuildLoginLogInfo(ctx)\n\tif logInfo.OS != \"Unknown\" {\n\t\tt.Fatalf(\"expected unknown os fallback, got %q\", logInfo.OS)\n\t}\n\tif logInfo.Browser != \"Unknown\" {\n\t\tt.Fatalf(\"expected unknown browser fallback, got %q\", logInfo.Browser)\n\t}\n}\n\nfunc initControllerAuthTest(t *testing.T) {\n\tt.Helper()\n\n\t_, file, _, ok := runtime.Caller(0)\n\tif !ok {\n\t\tt.Fatal(\"failed to resolve test file path\")\n\t}\n\tprojectRoot := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(file))))\n\tconfigPath := filepath.Join(projectRoot, \"config.yaml\")\n\tif _, err := os.Stat(configPath); err != nil {\n\t\texamplePath := filepath.Join(projectRoot, \"config\", \"config.yaml.example\")\n\t\tcontent, readErr := os.ReadFile(examplePath)\n\t\tif readErr != nil {\n\t\t\tt.Fatalf(\"read example config failed: %v\", readErr)\n\t\t}\n\t\tconfigPath = filepath.Join(t.TempDir(), \"config.yaml\")\n\t\tif writeErr := os.WriteFile(configPath, content, 0o600); writeErr != nil {\n\t\t\tt.Fatalf(\"write temp config failed: %v\", writeErr)\n\t\t}\n\t}\n\tif err := config.InitConfig(configPath); err != nil {\n\t\tt.Fatalf(\"init config failed: %v\", err)\n\t}\n\tif err := logger.InitLogger(); err != nil {\n\t\tt.Fatalf(\"init logger failed: %v\", err)\n\t}\n\tif err := validator.InitValidatorTrans(\"zh\"); err != nil {\n\t\tt.Fatalf(\"init validator failed: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/dashboard.go",
    "content": "package admin_v1\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/dashboard\"\n)\n\ntype DashboardController struct {\n\tcontroller.Api\n}\n\nfunc NewDashboardController() *DashboardController {\n\treturn &DashboardController{}\n}\n\nfunc (api DashboardController) Overview(c *gin.Context) {\n\tservice := dashboard.NewOverviewService()\n\tservice.SetAdminUserId(api.GetCurrentUserID(c))\n\n\toverview, err := service.Overview()\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, overview)\n}\n"
  },
  {
    "path": "internal/controller/admin_v1/sys_common.go",
    "content": "package admin_v1\n\nimport (\n\t\"os\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n)\n\nconst (\n\tdefaultUploadPath = \"default\"\n)\n\n// CommonController 通用控制器\ntype CommonController struct {\n\tcontroller.Api\n}\n\n// NewCommonController 创建通用控制器实例\nfunc NewCommonController() *CommonController {\n\treturn &CommonController{}\n}\n\n// Upload 上传文件\nfunc (api CommonController) Upload(c *gin.Context) {\n\tform, err := c.MultipartForm()\n\tif err != nil {\n\t\tapi.FailCode(c, errors.InvalidParameter)\n\t\treturn\n\t}\n\n\t// 获取用户ID\n\tuid := c.GetUint(global.ContextKeyUID)\n\tcommonService := service.NewCommonService()\n\tcommonService.SetAdminUserId(uid)\n\n\t// 获取上传路径参数。\n\tpath := defaultUploadPath\n\tif values := form.Value[\"path\"]; len(values) > 0 && values[0] != \"\" {\n\t\tpath = values[0]\n\t}\n\n\t// 执行文件上传\n\tresult, err := commonService.UploadImages(form.File[\"files\"], path)\n\tif err != nil {\n\t\tif service.IsPartialImageUploadError(err) {\n\t\t\tapi.FailCode(c, errors.FileUploadPartialFail, result)\n\t\t\treturn\n\t\t}\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\n\tapi.Success(c, result)\n}\n\n// GetFile 获取文件（支持公开和私有文件访问）\n// 公开文件：无需认证，直接访问\n// 私有文件：需要认证，只能由文件所有者访问\n// 路由: GET /admin/v1/file/:uuid\n// 参数: uuid - 文件的UUID（32位十六进制字符串，不带连字符）\nfunc (api CommonController) GetFile(c *gin.Context) {\n\tfileUUID := c.Param(\"uuid\")\n\tif fileUUID == \"\" {\n\t\tapi.FailCode(c, errors.InvalidParameter)\n\t\treturn\n\t}\n\n\tcommonService := service.NewCommonService()\n\n\t// 获取当前用户ID（如果已登录）\n\tvar currentUID uint\n\tvar checkAuth bool\n\tif uid, exists := c.Get(global.ContextKeyUID); exists {\n\t\tcurrentUID = uid.(uint)\n\t\tcheckAuth = true\n\t\tcommonService.SetAdminUserId(currentUID)\n\t} else {\n\t\tcheckAuth = false\n\t}\n\n\t// 获取文件访问方式（会自动检查权限）\n\taccess, err := commonService.GetFileAccessPath(fileUUID, checkAuth, currentUID)\n\tif err != nil {\n\t\tapi.Err(c, err)\n\t\treturn\n\t}\n\tif access.RedirectURL != \"\" {\n\t\tc.Redirect(302, access.RedirectURL)\n\t\treturn\n\t}\n\n\t// 检查文件是否存在\n\tif _, err := os.Stat(access.LocalPath); os.IsNotExist(err) {\n\t\tapi.FailCode(c, errors.NotFound)\n\t\treturn\n\t}\n\n\t// 返回文件\n\tc.File(access.LocalPath)\n}\n"
  },
  {
    "path": "internal/controller/sys_base.go",
    "content": "package controller\n\nimport (\n\tstderrors \"errors\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\tr \"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/admin\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/auth\"\n)\n\n// Api 控制器基类\ntype Api struct {\n\terrors.Error\n}\n\n// Success 业务成功响应\nfunc (api Api) Success(c *gin.Context, data ...any) {\n\tresponse := r.Resp()\n\tif len(data) > 0 && data[0] != nil {\n\t\tresponse.WithDataSuccess(c, data[0])\n\t\treturn\n\t}\n\tresponse.Success(c)\n}\n\n// FailCode 业务失败响应（使用错误码）\nfunc (api Api) FailCode(c *gin.Context, code int, data ...any) {\n\tresponse := r.Resp()\n\tif len(data) > 0 && data[0] != nil {\n\t\tresponse.WithData(data[0]).FailCode(c, code)\n\t\treturn\n\t}\n\tresponse.FailCode(c, code)\n}\n\n// FailCodeByKey 业务失败响应（使用错误码 + 文案 key）。\nfunc (api Api) FailCodeByKey(c *gin.Context, code int, key string, args ...any) {\n\tr.Resp().FailCodeByKey(c, code, key, args...)\n}\n\n// Fail 业务失败响应（自定义错误消息）\nfunc (api Api) Fail(c *gin.Context, code int, message string, data ...any) {\n\tresponse := r.Resp()\n\tif len(data) > 0 && data[0] != nil {\n\t\tresponse.WithData(data[0]).Fail(c, code, message)\n\t\treturn\n\t}\n\tresponse.Fail(c, code, message)\n}\n\n// Err 统一错误处理\n// 判断错误类型是自定义类型则自动返回错误中携带的code和message，否则返回服务器错误\nfunc (api Api) Err(c *gin.Context, err error) {\n\tbusinessError, parseErr := api.AsBusinessError(err)\n\tif parseErr != nil {\n\t\trequestID := c.GetString(global.ContextKeyRequestID)\n\t\tif errors.IsDependencyNotReady(parseErr) || stderrors.Is(parseErr, model.ErrDBUninitialized) {\n\t\t\tlog.Logger.Warn(\"Service dependency not ready\",\n\t\t\t\tzap.String(\"requestId\", requestID),\n\t\t\t\tzap.Error(parseErr))\n\t\t\tapi.FailCode(c, errors.ServiceDependencyNotReady)\n\t\t\treturn\n\t\t}\n\t\tlog.Logger.Warn(\"Unknown error:\", zap.String(\"requestId\", requestID), zap.Error(parseErr))\n\t\tapi.FailCode(c, errors.ServerErr)\n\t\treturn\n\t}\n\n\tif businessError.HasExplicitMessage() {\n\t\tapi.Fail(c, businessError.GetCode(), businessError.GetMessage())\n\t\treturn\n\t}\n\tif businessError.HasMessageKey() {\n\t\tapi.FailCodeByKey(c, businessError.GetCode(), businessError.GetMessageKey(), businessError.GetMessageArgs()...)\n\t\treturn\n\t}\n\tapi.FailCode(c, businessError.GetCode())\n}\n\n// GetCurrentUserID 获取当前登录用户的ID\nfunc (api Api) GetCurrentUserID(c *gin.Context) uint {\n\treturn c.GetUint(global.ContextKeyUID)\n}\n\n// GetCurrentAdminUserSnapshot 获取当前登录用户的 claims 快照投影，不代表数据库最新状态。\nfunc (api Api) GetCurrentAdminUserSnapshot(c *gin.Context) *model.AdminUser {\n\tif principal := auth.GetAuthPrincipal(c); principal != nil {\n\t\treturn principal.AdminUser()\n\t}\n\treturn nil\n}\n\n// GetCurrentAdminUserDetail 获取当前登录用户的数据库最新详情。\nfunc (api Api) GetCurrentAdminUserDetail(c *gin.Context) (*resources.AdminUserResources, error) {\n\tuid := api.GetCurrentUserID(c)\n\tif uid == 0 {\n\t\treturn nil, errors.NewBusinessError(errors.NotLogin)\n\t}\n\treturn admin.NewAdminUserService().GetUserInfo(uid)\n}\n"
  },
  {
    "path": "internal/controller/sys_base_test.go",
    "content": "package controller\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\nfunc TestApiErrMapsDBUninitializedToDependencyNotReady(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodGet, \"/admin/v1/demo\", nil)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"test-request-id\")\n\n\tApi{}.Err(ctx, model.ErrDBUninitialized)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected http status 200, got %d\", recorder.Code)\n\t}\n\tif !strings.Contains(recorder.Body.String(), `\"code\":10003`) {\n\t\tt.Fatalf(\"expected dependency not ready response, got %s\", recorder.Body.String())\n\t}\n}\n"
  },
  {
    "path": "internal/controller/sys_demo.go",
    "content": "package controller\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// DemoController Demo控制器\ntype DemoController struct {\n\tApi\n}\n\n// NewDemoController 创建Demo控制器实例\nfunc NewDemoController() *DemoController {\n\treturn &DemoController{}\n}\n\n// HelloWorld Demo示例接口\nfunc (api DemoController) HelloWorld(c *gin.Context) {\n\tname, ok := c.GetQuery(\"name\")\n\tif !ok {\n\t\tname = \"gin-layout\"\n\t}\n\n\tid := c.Param(\"id\")\n\tresult := fmt.Sprintf(\"hello %s %s\", name, id)\n\tapi.Success(c, result)\n}\n"
  },
  {
    "path": "internal/cron/queue_fallback.go",
    "content": "package taskcron\n\nimport (\n\t\"context\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n)\n\n// RegisterQueueFallbackHandlers registers non-high-risk cron handlers for\n// historical Asynq tasks that were enqueued before cron/worker boundaries were split.\nfunc RegisterQueueFallbackHandlers(registry queue.Registry, cfg *config.Conf) int {\n\tif registry == nil {\n\t\treturn 0\n\t}\n\n\tregistered := 0\n\tfor _, definition := range BuiltinTaskDefinitions(cfg) {\n\t\tif definition.Kind != model.TaskKindCron || definition.IsHighRisk == model.TaskHighRisk {\n\t\t\tcontinue\n\t\t}\n\t\ttaskCode := definition.Code\n\t\thandler := definition.Handler\n\t\tif taskCode == \"\" || handler == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tqueue.RegisterJSON(registry, taskCode, func(ctx context.Context, payload map[string]any) error {\n\t\t\treturn ExecuteHandler(ctx, handler, payload)\n\t\t})\n\t\tregistered++\n\t}\n\treturn registered\n}\n"
  },
  {
    "path": "internal/cron/queue_fallback_test.go",
    "content": "package taskcron\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/config/autoload\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n)\n\nfunc TestRegisterQueueFallbackHandlersRegistersDisabledNonHighRiskCron(t *testing.T) {\n\tregistry := queue.NewRegistry()\n\n\tcount := RegisterQueueFallbackHandlers(registry, &config.Conf{})\n\tif count != 1 {\n\t\tt.Fatalf(\"unexpected fallback handler count: got=%d want=1\", count)\n\t}\n\n\tentry, ok := findRegistration(registry, TaskCodeCronDemo)\n\tif !ok {\n\t\tt.Fatalf(\"expected %s fallback handler to be registered\", TaskCodeCronDemo)\n\t}\n\tif err := entry.Handler(context.Background(), []byte(`{}`)); err != nil {\n\t\tt.Fatalf(\"fallback handler returned error: %v\", err)\n\t}\n}\n\nfunc TestRegisterQueueFallbackHandlersSkipsHighRiskCron(t *testing.T) {\n\tregistry := queue.NewRegistry()\n\n\tRegisterQueueFallbackHandlers(registry, &config.Conf{\n\t\tAppConfig: autoload.AppConfig{EnableResetSystemCron: true},\n\t})\n\tif _, ok := findRegistration(registry, TaskCodeCronResetSystemData); ok {\n\t\tt.Fatalf(\"did not expect %s fallback handler to be registered\", TaskCodeCronResetSystemData)\n\t}\n}\n\nfunc findRegistration(registry queue.Registry, taskType string) (queue.Registration, bool) {\n\tfor _, entry := range registry.Entries() {\n\t\tif entry.TaskType == taskType {\n\t\t\treturn entry, true\n\t\t}\n\t}\n\treturn queue.Registration{}, false\n}\n"
  },
  {
    "path": "internal/cron/registry.go",
    "content": "package taskcron\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/jobs\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/sys_config\"\n\t\"go.uber.org/zap\"\n)\n\nconst (\n\tTaskCodeCronDemo            = \"cron:demo\"\n\tTaskCodeCronResetSystemData = \"cron:reset-system-data\"\n\n\tHandlerCronDemo            = \"cron.demo\"\n\tHandlerCronResetSystemData = \"cron.reset-system-data\"\n)\n\nconst cronLogTimeFormat = \"2006-01-02 15:04:05\"\n\ntype HandlerFunc func(ctx context.Context, payload map[string]any) error\n\nvar (\n\thandlersMu sync.RWMutex\n\thandlers   = map[string]HandlerFunc{\n\t\tHandlerCronDemo: func(_ context.Context, _ map[string]any) error {\n\t\t\tlog.Logger.Info(\"计划任务 demo 执行：\", zap.String(\"time\", time.Now().Format(cronLogTimeFormat)))\n\t\t\treturn nil\n\t\t},\n\t}\n)\n\nfunc RegisterHandler(handler string, fn HandlerFunc) {\n\thandler = strings.TrimSpace(handler)\n\tif handler == \"\" || fn == nil {\n\t\treturn\n\t}\n\thandlersMu.Lock()\n\thandlers[handler] = fn\n\thandlersMu.Unlock()\n}\n\nfunc ExecuteHandler(ctx context.Context, handler string, payload map[string]any) error {\n\thandler = strings.TrimSpace(handler)\n\thandlersMu.RLock()\n\tfn, ok := handlers[handler]\n\thandlersMu.RUnlock()\n\tif !ok {\n\t\treturn fmt.Errorf(\"unsupported cron handler: %s\", handler)\n\t}\n\treturn fn(ctx, payload)\n}\n\n// BuiltinTaskDefinitions 返回系统内置任务定义（任务中心列表与 cron 注册共用此定义源）。\nfunc BuiltinTaskDefinitions(cfg *config.Conf) []model.TaskDefinition {\n\t// 高风险“系统重建”任务默认禁用，仅在配置显式开启时启用调度。\n\tresetStatus := model.TaskStatusDisabled\n\tif cfg != nil && cfg.EnableResetSystemCron {\n\t\tresetStatus = model.TaskStatusEnabled\n\t}\n\tdemoStatus := model.TaskStatusEnabled\n\tif !sys_config.BoolValue(sys_config.TaskCronDemoEnabledConfigKey, false) {\n\t\tdemoStatus = model.TaskStatusDisabled\n\t}\n\n\treturn []model.TaskDefinition{\n\t\t{\n\t\t\tCode:        jobs.AuditLogTaskType,\n\t\t\tName:        \"请求日志异步落库\",\n\t\t\tKind:        model.TaskKindAsync,\n\t\t\tQueue:       jobs.AuditQueueName,\n\t\t\tCronSpec:    \"\",\n\t\t\tHandler:     \"jobs.audit_log.write\",\n\t\t\tStatus:      model.TaskStatusEnabled,\n\t\t\tAllowManual: model.TaskManualNotAllowed,\n\t\t\tAllowRetry:  model.TaskRetryAllowed,\n\t\t\tIsHighRisk:  model.TaskNotHighRisk,\n\t\t\tRemark:      \"写入 request_logs 审计日志\",\n\t\t},\n\t\t{\n\t\t\tCode:        TaskCodeCronDemo,\n\t\t\tName:        \"演示定时任务\",\n\t\t\tKind:        model.TaskKindCron,\n\t\t\tQueue:       \"\",\n\t\t\tCronSpec:    \"0/5 * * * * *\",\n\t\t\tHandler:     HandlerCronDemo,\n\t\t\tStatus:      demoStatus,\n\t\t\tAllowManual: model.TaskManualAllowed,\n\t\t\tAllowRetry:  model.TaskRetryNotAllowed,\n\t\t\tIsHighRisk:  model.TaskNotHighRisk,\n\t\t\tRemark:      \"开发演示任务\",\n\t\t},\n\t\t{\n\t\t\tCode:     TaskCodeCronResetSystemData,\n\t\t\tName:     \"系统重建定时任务\",\n\t\t\tKind:     model.TaskKindCron,\n\t\t\tQueue:    \"\",\n\t\t\tCronSpec: \"0 0 2 * * *\",\n\t\t\tHandler:  HandlerCronResetSystemData,\n\t\t\t// 任务定义保留在任务中心可见；是否参与 cron 调度由 status 决定。\n\t\t\tStatus:      resetStatus,\n\t\t\tAllowManual: model.TaskManualAllowed,\n\t\t\tAllowRetry:  model.TaskRetryNotAllowed,\n\t\t\tIsHighRisk:  model.TaskHighRisk,\n\t\t\tRemark:      \"高风险任务，默认关闭\",\n\t\t},\n\t}\n}\n\n// SyncBuiltinDefinitionsIfAvailable 在任务定义表存在时同步内置任务定义。\nfunc SyncBuiltinDefinitionsIfAvailable(cfg *config.Conf) error {\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !db.Migrator().HasTable(model.NewTaskDefinition().TableName()) {\n\t\treturn nil\n\t}\n\treturn syncBuiltinDefinitions(db, cfg)\n}\n\nfunc syncBuiltinDefinitions(db *gorm.DB, cfg *config.Conf) error {\n\tif db == nil {\n\t\treturn nil\n\t}\n\n\tfor _, definition := range BuiltinTaskDefinitions(cfg) {\n\t\tif err := upsertTaskDefinition(db, definition); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc upsertTaskDefinition(db *gorm.DB, definition model.TaskDefinition) error {\n\treturn db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{\n\t\t\t{Name: \"code\"},\n\t\t\t{Name: \"deleted_at\"},\n\t\t},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\"name\",\n\t\t\t\"kind\",\n\t\t\t\"queue\",\n\t\t\t\"cron_spec\",\n\t\t\t\"handler\",\n\t\t\t\"status\",\n\t\t\t\"allow_manual\",\n\t\t\t\"allow_retry\",\n\t\t\t\"is_high_risk\",\n\t\t\t\"remark\",\n\t\t\t\"updated_at\",\n\t\t}),\n\t}).Create(&definition).Error\n}\n"
  },
  {
    "path": "internal/cron/registry_test.go",
    "content": "package taskcron\n\nimport (\n\t\"testing\"\n\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/jobs\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\nfunc TestSyncBuiltinDefinitionsUpsert(t *testing.T) {\n\tdb := newTaskDefinitionSyncTestDB(t)\n\n\tcfg := &config.Conf{}\n\tcfg.EnableResetSystemCron = false\n\tif err := syncBuiltinDefinitions(db, cfg); err != nil {\n\t\tt.Fatalf(\"sync builtin definitions failed: %v\", err)\n\t}\n\n\tassertTaskDefinitionCount(t, db, 3)\n\tassertTaskStatus(t, db, TaskCodeCronResetSystemData, 0)\n\tassertTaskStatus(t, db, TaskCodeCronDemo, 0)\n\tassertTaskStatus(t, db, jobs.AuditLogTaskType, 1)\n\tassertTaskAllowManual(t, db, TaskCodeCronDemo, 1)\n\tassertTaskAllowManual(t, db, TaskCodeCronResetSystemData, 1)\n\tassertTaskAllowManual(t, db, jobs.AuditLogTaskType, 0)\n\n\tcfg.EnableResetSystemCron = true\n\tif err := syncBuiltinDefinitions(db, cfg); err != nil {\n\t\tt.Fatalf(\"sync builtin definitions on second run failed: %v\", err)\n\t}\n\n\tassertTaskDefinitionCount(t, db, 3)\n\tassertTaskStatus(t, db, TaskCodeCronResetSystemData, 1)\n}\n\nfunc assertTaskDefinitionCount(t *testing.T, db *gorm.DB, expected int64) {\n\tt.Helper()\n\n\tvar count int64\n\tif err := db.Model(&model.TaskDefinition{}).Count(&count).Error; err != nil {\n\t\tt.Fatalf(\"count task definitions failed: %v\", err)\n\t}\n\tif count != expected {\n\t\tt.Fatalf(\"unexpected task definition count: got=%d want=%d\", count, expected)\n\t}\n}\n\nfunc assertTaskStatus(t *testing.T, db *gorm.DB, code string, expected uint8) {\n\tt.Helper()\n\n\tvar definition model.TaskDefinition\n\tif err := db.Where(\"code = ?\", code).First(&definition).Error; err != nil {\n\t\tt.Fatalf(\"query task definition(%s) failed: %v\", code, err)\n\t}\n\tif definition.Status != expected {\n\t\tt.Fatalf(\"unexpected status for %s: got=%d want=%d\", code, definition.Status, expected)\n\t}\n}\n\nfunc assertTaskAllowManual(t *testing.T, db *gorm.DB, code string, expected uint8) {\n\tt.Helper()\n\n\tvar definition model.TaskDefinition\n\tif err := db.Where(\"code = ?\", code).First(&definition).Error; err != nil {\n\t\tt.Fatalf(\"query task definition(%s) failed: %v\", code, err)\n\t}\n\tif definition.AllowManual != expected {\n\t\tt.Fatalf(\"unexpected allow_manual for %s: got=%d want=%d\", code, definition.AllowManual, expected)\n\t}\n}\n\nfunc newTaskDefinitionSyncTestDB(t *testing.T) *gorm.DB {\n\tt.Helper()\n\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite failed: %v\", err)\n\t}\n\n\tstatement := `\nCREATE TABLE task_definitions (\n    id integer primary key autoincrement,\n    code text NOT NULL DEFAULT '',\n    name text NOT NULL DEFAULT '',\n    kind text NOT NULL DEFAULT '',\n    queue text NOT NULL DEFAULT '',\n    cron_spec text NOT NULL DEFAULT '',\n    handler text NOT NULL DEFAULT '',\n    status integer NOT NULL DEFAULT 1,\n    allow_manual integer NOT NULL DEFAULT 0,\n    allow_retry integer NOT NULL DEFAULT 1,\n    is_high_risk integer NOT NULL DEFAULT 0,\n    remark text NOT NULL DEFAULT '',\n    created_at datetime,\n    updated_at datetime,\n    deleted_at integer NOT NULL DEFAULT 0,\n    UNIQUE(code, deleted_at)\n)`\n\tif err := db.Exec(statement).Error; err != nil {\n\t\tt.Fatalf(\"create task_definitions table failed: %v\", err)\n\t}\n\treturn db\n}\n"
  },
  {
    "path": "internal/filestorage/aliyun_oss.go",
    "content": "package filestorage\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss\"\n\t\"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials\"\n)\n\ntype AliyunOSSDriver struct {\n\tclient       *oss.Client\n\tbucket       string\n\tpublicDomain string\n}\n\nfunc NewAliyunOSSDriver(config AliyunOSSConfig) *AliyunOSSDriver {\n\tendpoint := firstNonEmpty(config.InternalEndpoint, config.Endpoint)\n\tcfg := oss.LoadDefaultConfig().\n\t\tWithRegion(config.Region).\n\t\tWithEndpoint(endpoint).\n\t\tWithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.AccessKeySecret)).\n\t\tWithUsePathStyle(config.ForcePathStyle)\n\treturn &AliyunOSSDriver{\n\t\tclient:       oss.NewClient(cfg),\n\t\tbucket:       config.Bucket,\n\t\tpublicDomain: strings.TrimRight(config.PublicDomain, \"/\"),\n\t}\n}\n\nfunc (d *AliyunOSSDriver) Name() string { return \"aliyun_oss\" }\n\nfunc (d *AliyunOSSDriver) Put(ctx context.Context, input PutInput) (PutResult, error) {\n\tbucket := firstNonEmpty(input.Bucket, d.bucket)\n\tout, err := d.client.PutObject(ctx, &oss.PutObjectRequest{\n\t\tBucket:        oss.Ptr(bucket),\n\t\tKey:           oss.Ptr(input.ObjectKey),\n\t\tBody:          input.Reader,\n\t\tContentLength: oss.Ptr(input.Size),\n\t\tContentType:   oss.Ptr(input.ContentType),\n\t})\n\tif err != nil {\n\t\treturn PutResult{}, err\n\t}\n\treturn PutResult{Bucket: bucket, ObjectKey: input.ObjectKey, ETag: strings.Trim(oss.ToString(out.ETag), \"\\\"\")}, nil\n}\n\nfunc (d *AliyunOSSDriver) Open(ctx context.Context, bucket, objectKey string) (io.ReadCloser, error) {\n\tout, err := d.client.GetObject(ctx, &oss.GetObjectRequest{Bucket: oss.Ptr(firstNonEmpty(bucket, d.bucket)), Key: oss.Ptr(objectKey)})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out.Body, nil\n}\n\nfunc (d *AliyunOSSDriver) Exists(ctx context.Context, bucket, objectKey string) (bool, error) {\n\treturn d.client.IsObjectExist(ctx, firstNonEmpty(bucket, d.bucket), objectKey)\n}\n\nfunc (d *AliyunOSSDriver) Delete(ctx context.Context, bucket, objectKey string) error {\n\t_, err := d.client.DeleteObject(ctx, &oss.DeleteObjectRequest{Bucket: oss.Ptr(firstNonEmpty(bucket, d.bucket)), Key: oss.Ptr(objectKey)})\n\treturn err\n}\n\nfunc (d *AliyunOSSDriver) URL(bucket, objectKey string, isPublic bool) string {\n\tif !isPublic || d.publicDomain == \"\" || objectKey == \"\" {\n\t\treturn \"\"\n\t}\n\treturn d.publicDomain + \"/\" + strings.TrimLeft(objectKey, \"/\")\n}\n\nfunc (d *AliyunOSSDriver) SignedURL(ctx context.Context, bucket, objectKey string, ttl time.Duration) (string, error) {\n\tout, err := d.client.Presign(ctx, &oss.GetObjectRequest{Bucket: oss.Ptr(firstNonEmpty(bucket, d.bucket)), Key: oss.Ptr(objectKey)}, func(options *oss.PresignOptions) {\n\t\toptions.Expires = ttl\n\t})\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn out.URL, nil\n}\n"
  },
  {
    "path": "internal/filestorage/local.go",
    "content": "package filestorage\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype LocalDriver struct {\n\tpublicBasePath  string\n\tprivateBasePath string\n}\n\nfunc NewLocalDriver(config LocalConfig, fallbackPublicPath, fallbackPrivatePath string) *LocalDriver {\n\tpublicPath := firstNonEmpty(config.PublicBasePath, config.BasePath, fallbackPublicPath)\n\tprivatePath := firstNonEmpty(config.PrivateBasePath, config.BasePath, fallbackPrivatePath)\n\treturn &LocalDriver{publicBasePath: publicPath, privateBasePath: privatePath}\n}\n\nfunc (d *LocalDriver) Name() string { return \"local\" }\n\nfunc (d *LocalDriver) Put(_ context.Context, input PutInput) (PutResult, error) {\n\ttarget, err := d.resolve(input.Bucket, input.ObjectKey)\n\tif err != nil {\n\t\treturn PutResult{}, err\n\t}\n\tif err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {\n\t\treturn PutResult{}, err\n\t}\n\tfile, err := os.Create(target)\n\tif err != nil {\n\t\treturn PutResult{}, err\n\t}\n\tdefer file.Close()\n\tif _, err := io.Copy(file, input.Reader); err != nil {\n\t\treturn PutResult{}, err\n\t}\n\treturn PutResult{Bucket: input.Bucket, ObjectKey: input.ObjectKey}, nil\n}\n\nfunc (d *LocalDriver) Open(_ context.Context, bucket, objectKey string) (io.ReadCloser, error) {\n\tpath, err := d.resolve(bucket, objectKey)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn os.Open(path)\n}\n\nfunc (d *LocalDriver) Exists(_ context.Context, bucket, objectKey string) (bool, error) {\n\tpath, err := d.resolve(bucket, objectKey)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\t_, err = os.Stat(path)\n\tif err == nil {\n\t\treturn true, nil\n\t}\n\tif os.IsNotExist(err) {\n\t\treturn false, nil\n\t}\n\treturn false, err\n}\n\nfunc (d *LocalDriver) Delete(_ context.Context, bucket, objectKey string) error {\n\tpath, err := d.resolve(bucket, objectKey)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := os.Remove(path); err != nil && !os.IsNotExist(err) {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (d *LocalDriver) URL(bucket, objectKey string, _ bool) string {\n\tif objectKey == \"\" {\n\t\treturn \"\"\n\t}\n\treturn \"/\" + strings.TrimLeft(url.PathEscape(objectKey), \"/\")\n}\n\nfunc (d *LocalDriver) SignedURL(_ context.Context, bucket, objectKey string, _ time.Duration) (string, error) {\n\treturn d.URL(bucket, objectKey, false), nil\n}\n\nfunc (d *LocalDriver) resolve(bucket, objectKey string) (string, error) {\n\tif objectKey == \"\" {\n\t\treturn \"\", fmt.Errorf(\"object_key is required\")\n\t}\n\tbase := d.privateBasePath\n\tif bucket == \"public\" {\n\t\tbase = d.publicBasePath\n\t}\n\tabsBase, err := filepath.Abs(base)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\ttarget := filepath.Join(absBase, filepath.Clean(strings.ReplaceAll(objectKey, \"\\\\\", \"/\")))\n\tabsTarget, err := filepath.Abs(target)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif absTarget != absBase && !strings.HasPrefix(absTarget, absBase+string(filepath.Separator)) {\n\t\treturn \"\", fmt.Errorf(\"object_key escapes storage root\")\n\t}\n\treturn absTarget, nil\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif strings.TrimSpace(value) != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/filestorage/local_test.go",
    "content": "package filestorage\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestLocalDriverPutExistsOpenDelete(t *testing.T) {\n\tpublicDir := t.TempDir()\n\tprivateDir := t.TempDir()\n\tdriver := NewLocalDriver(LocalConfig{}, publicDir, privateDir)\n\tctx := context.Background()\n\n\tresult, err := driver.Put(ctx, PutInput{Bucket: \"public\", ObjectKey: \"avatars/a.txt\", Reader: strings.NewReader(\"ok\"), Size: 2, ContentType: \"text/plain\"})\n\tif err != nil {\n\t\tt.Fatalf(\"put failed: %v\", err)\n\t}\n\tif result.ObjectKey != \"avatars/a.txt\" {\n\t\tt.Fatalf(\"unexpected object key: %s\", result.ObjectKey)\n\t}\n\texists, err := driver.Exists(ctx, \"public\", \"avatars/a.txt\")\n\tif err != nil || !exists {\n\t\tt.Fatalf(\"expected object to exist, exists=%v err=%v\", exists, err)\n\t}\n\tbody, err := driver.Open(ctx, \"public\", \"avatars/a.txt\")\n\tif err != nil {\n\t\tt.Fatalf(\"open failed: %v\", err)\n\t}\n\tdefer body.Close()\n\tcontent, _ := io.ReadAll(body)\n\tif string(content) != \"ok\" {\n\t\tt.Fatalf(\"unexpected content: %s\", string(content))\n\t}\n\tif _, err := driver.SignedURL(ctx, \"public\", \"avatars/a.txt\", time.Minute); err != nil {\n\t\tt.Fatalf(\"signed url failed: %v\", err)\n\t}\n\tif err := driver.Delete(ctx, \"public\", \"avatars/a.txt\"); err != nil {\n\t\tt.Fatalf(\"delete failed: %v\", err)\n\t}\n\texists, err = driver.Exists(ctx, \"public\", \"avatars/a.txt\")\n\tif err != nil || exists {\n\t\tt.Fatalf(\"expected object to be deleted, exists=%v err=%v\", exists, err)\n\t}\n}\n"
  },
  {
    "path": "internal/filestorage/s3.go",
    "content": "package filestorage\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\tawsconfig \"github.com/aws/aws-sdk-go-v2/config\"\n\t\"github.com/aws/aws-sdk-go-v2/credentials\"\n\t\"github.com/aws/aws-sdk-go-v2/service/s3\"\n)\n\ntype S3Driver struct {\n\tclient       *s3.Client\n\tpresign      *s3.PresignClient\n\tbucket       string\n\tpublicDomain string\n}\n\nfunc NewS3Driver(ctx context.Context, config S3Config) (*S3Driver, error) {\n\tcfg, err := awsconfig.LoadDefaultConfig(ctx,\n\t\tawsconfig.WithRegion(config.Region),\n\t\tawsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.SecretAccessKey, \"\")),\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tclient := s3.NewFromConfig(cfg, func(o *s3.Options) {\n\t\tif config.Endpoint != \"\" {\n\t\t\to.BaseEndpoint = aws.String(config.Endpoint)\n\t\t}\n\t\to.UsePathStyle = config.ForcePathStyle\n\t})\n\treturn &S3Driver{\n\t\tclient:       client,\n\t\tpresign:      s3.NewPresignClient(client),\n\t\tbucket:       config.Bucket,\n\t\tpublicDomain: strings.TrimRight(config.PublicDomain, \"/\"),\n\t}, nil\n}\n\nfunc (d *S3Driver) Name() string { return \"s3\" }\n\nfunc (d *S3Driver) Put(ctx context.Context, input PutInput) (PutResult, error) {\n\tbucket := firstNonEmpty(input.Bucket, d.bucket)\n\tout, err := d.client.PutObject(ctx, &s3.PutObjectInput{\n\t\tBucket:        aws.String(bucket),\n\t\tKey:           aws.String(input.ObjectKey),\n\t\tBody:          input.Reader,\n\t\tContentLength: aws.Int64(input.Size),\n\t\tContentType:   aws.String(input.ContentType),\n\t})\n\tif err != nil {\n\t\treturn PutResult{}, err\n\t}\n\treturn PutResult{Bucket: bucket, ObjectKey: input.ObjectKey, ETag: strings.Trim(aws.ToString(out.ETag), \"\\\"\")}, nil\n}\n\nfunc (d *S3Driver) Open(ctx context.Context, bucket, objectKey string) (io.ReadCloser, error) {\n\tout, err := d.client.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(firstNonEmpty(bucket, d.bucket)), Key: aws.String(objectKey)})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn out.Body, nil\n}\n\nfunc (d *S3Driver) Exists(ctx context.Context, bucket, objectKey string) (bool, error) {\n\t_, err := d.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(firstNonEmpty(bucket, d.bucket)), Key: aws.String(objectKey)})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (d *S3Driver) Delete(ctx context.Context, bucket, objectKey string) error {\n\t_, err := d.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(firstNonEmpty(bucket, d.bucket)), Key: aws.String(objectKey)})\n\treturn err\n}\n\nfunc (d *S3Driver) URL(bucket, objectKey string, isPublic bool) string {\n\tif !isPublic || d.publicDomain == \"\" || objectKey == \"\" {\n\t\treturn \"\"\n\t}\n\treturn d.publicDomain + \"/\" + strings.TrimLeft(objectKey, \"/\")\n}\n\nfunc (d *S3Driver) SignedURL(ctx context.Context, bucket, objectKey string, ttl time.Duration) (string, error) {\n\tout, err := d.presign.PresignGetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(firstNonEmpty(bucket, d.bucket)), Key: aws.String(objectKey)}, func(options *s3.PresignOptions) {\n\t\toptions.Expires = ttl\n\t})\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn out.URL, nil\n}\n"
  },
  {
    "path": "internal/filestorage/types.go",
    "content": "package filestorage\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"time\"\n)\n\nconst MaskPlaceholder = \"******\"\n\ntype Config struct {\n\tLocal               LocalConfig     `json:\"local\"`\n\tAliyunOSS           AliyunOSSConfig `json:\"aliyun_oss\"`\n\tS3                  S3Config        `json:\"s3\"`\n\tSignedURLTTLSeconds int             `json:\"signed_url_ttl_seconds\"`\n\tMaxFileSizeMB       int             `json:\"max_file_size_mb\"`\n\tAllowedMimeTypes    []string        `json:\"allowed_mime_types\"`\n}\n\ntype LocalConfig struct {\n\tBasePath        string `json:\"base_path\"`\n\tPublicBasePath  string `json:\"public_base_path\"`\n\tPrivateBasePath string `json:\"private_base_path\"`\n}\n\ntype AliyunOSSConfig struct {\n\tEndpoint         string `json:\"endpoint\"`\n\tRegion           string `json:\"region\"`\n\tBucket           string `json:\"bucket\"`\n\tAccessKeyID      string `json:\"access_key_id\"`\n\tAccessKeySecret  string `json:\"access_key_secret\"`\n\tPublicDomain     string `json:\"public_domain\"`\n\tInternalEndpoint string `json:\"internal_endpoint\"`\n\tForcePathStyle   bool   `json:\"force_path_style\"`\n}\n\ntype S3Config struct {\n\tEndpoint        string `json:\"endpoint\"`\n\tRegion          string `json:\"region\"`\n\tBucket          string `json:\"bucket\"`\n\tAccessKeyID     string `json:\"access_key_id\"`\n\tSecretAccessKey string `json:\"secret_access_key\"`\n\tPublicDomain    string `json:\"public_domain\"`\n\tForcePathStyle  bool   `json:\"force_path_style\"`\n}\n\ntype PutInput struct {\n\tBucket      string\n\tObjectKey   string\n\tReader      io.Reader\n\tSize        int64\n\tContentType string\n}\n\ntype PutResult struct {\n\tBucket    string\n\tObjectKey string\n\tETag      string\n}\n\ntype Driver interface {\n\tName() string\n\tPut(ctx context.Context, input PutInput) (PutResult, error)\n\tOpen(ctx context.Context, bucket, objectKey string) (io.ReadCloser, error)\n\tExists(ctx context.Context, bucket, objectKey string) (bool, error)\n\tDelete(ctx context.Context, bucket, objectKey string) error\n\tURL(bucket, objectKey string, isPublic bool) string\n\tSignedURL(ctx context.Context, bucket, objectKey string, ttl time.Duration) (string, error)\n}\n\nfunc DefaultConfig() Config {\n\treturn Config{\n\t\tSignedURLTTLSeconds: 300,\n\t\tMaxFileSizeMB:       10,\n\t\tAllowedMimeTypes:    []string{},\n\t}\n}\n"
  },
  {
    "path": "internal/global/api_auth_mode.go",
    "content": "package global\n\n// ApiAuthMode 定义 API 路由的鉴权模式。\ntype ApiAuthMode uint8\n\nconst (\n\t// ApiAuthModeNone 无需登录，无需 API 权限校验。\n\tApiAuthModeNone ApiAuthMode = iota\n\t// ApiAuthModeLogin 需要登录，但无需 API 权限校验。\n\tApiAuthModeLogin\n\t// ApiAuthModeAuth 需要登录且需要 API 权限校验。\n\tApiAuthModeAuth\n)\n\n// RequiresLogin 返回该模式是否要求用户先登录。\nfunc (m ApiAuthMode) RequiresLogin() bool {\n\treturn m != ApiAuthModeNone\n}\n\n// RequiresAPIPermission 返回该模式是否要求 API 权限校验。\nfunc (m ApiAuthMode) RequiresAPIPermission() bool {\n\treturn m == ApiAuthModeAuth\n}\n\n// Label 返回该模式的人类可读名称。\nfunc (m ApiAuthMode) Label() string {\n\tswitch m {\n\tcase ApiAuthModeNone:\n\t\treturn \"无需登录\"\n\tcase ApiAuthModeLogin:\n\t\treturn \"需要登录\"\n\tcase ApiAuthModeAuth:\n\t\treturn \"需要登录和API权限\"\n\tdefault:\n\t\treturn \"-\"\n\t}\n}\n"
  },
  {
    "path": "internal/global/api_auth_mode_test.go",
    "content": "package global\n\nimport \"testing\"\n\nfunc TestApiAuthModeRequiresLogin(t *testing.T) {\n\tif ApiAuthModeNone.RequiresLogin() {\n\t\tt.Fatal(\"expected none mode to not require login\")\n\t}\n\tif !ApiAuthModeLogin.RequiresLogin() {\n\t\tt.Fatal(\"expected login mode to require login\")\n\t}\n\tif !ApiAuthModeAuth.RequiresLogin() {\n\t\tt.Fatal(\"expected authz mode to require login\")\n\t}\n}\n\nfunc TestApiAuthModeRequiresAPIPermission(t *testing.T) {\n\tif ApiAuthModeNone.RequiresAPIPermission() {\n\t\tt.Fatal(\"expected none mode to not require api permission\")\n\t}\n\tif ApiAuthModeLogin.RequiresAPIPermission() {\n\t\tt.Fatal(\"expected login mode to not require api permission\")\n\t}\n\tif !ApiAuthModeAuth.RequiresAPIPermission() {\n\t\tt.Fatal(\"expected authz mode to require api permission\")\n\t}\n}\n\nfunc TestApiAuthModeLabel(t *testing.T) {\n\tcases := map[ApiAuthMode]string{\n\t\tApiAuthModeNone:  \"无需登录\",\n\t\tApiAuthModeLogin: \"需要登录\",\n\t\tApiAuthModeAuth:  \"需要登录和API权限\",\n\t\tApiAuthMode(99):  \"-\",\n\t}\n\n\tfor mode, expected := range cases {\n\t\tif got := mode.Label(); got != expected {\n\t\t\tt.Fatalf(\"unexpected label for mode %d: got %q want %q\", mode, got, expected)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/global/auth.go",
    "content": "package global\n\nconst (\n\tSuperAdminId          uint = 1\n\tIssuer                     = \"go-layout\"\n\tPcAdminSubject             = \"pc-admin-token\"\n\tCasbinAdminUserPrefix      = \"adminUser\"\n\tCasbinRolePrefix           = \"role\"\n\tCasbinMenuPrefix           = \"menu\"\n\tCasbinDeptPrefix           = \"dept\"\n\tCasbinSeparator            = \":\"\n)\n"
  },
  {
    "path": "internal/global/common.go",
    "content": "package global\n\nconst (\n\t// Version is the current gin-layout version.\n\tVersion = \"0.9.2\"\n\t// PerPage is the default per page size\n\tPerPage = 10\n\t// Yes is the value of yes\n\tYes uint8 = 1\n\t// No is the value of no\n\tNo uint8 = 0\n\t// ChinaCountryCode is the value of China country code\n\tChinaCountryCode = \"86\"\n\t// SUCCESS is the value of success\n\tSUCCESS = \"SUCCESS\"\n\t// ERROR is the value of error\n\tERROR = \"ERROR\"\n)\n"
  },
  {
    "path": "internal/global/context_keys.go",
    "content": "package global\n\nconst (\n\tContextKeyUID              = \"uid\"\n\tContextKeyAdminUser        = \"admin_user\"\n\tContextKeyAuthPrincipal    = \"auth_principal\"\n\tContextKeyLocale           = \"locale\"\n\tContextKeyRequestID        = \"requestId\"\n\tContextKeyRequestStartTime = \"requestStartTime\"\n\tContextKeyAuditChangeDiff  = \"auditChangeDiff\"\n\tContextKeyAuditHighRisk    = \"auditHighRisk\"\n\tContextKeyAuditRequestBody = \"auditRequestBody\"\n)\n"
  },
  {
    "path": "internal/global/system_defaults.go",
    "content": "package global\n\nconst (\n\tDefaultDepartmentCode = \"default_department\"\n\tSuperAdminRoleCode    = \"super_admin\"\n)\n"
  },
  {
    "path": "internal/jobs/audit_log.go",
    "content": "package jobs\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n\tauditsvc \"github.com/wannanbigpig/gin-layout/internal/service/audit\"\n\t\"go.uber.org/zap\"\n)\n\nconst (\n\tAuditLogTaskType = \"audit:request_log.write\"\n\tAuditQueueName   = \"audit\"\n\n\tAuditLogKindRequest = \"request\"\n\tAuditLogKindPanic   = \"panic\"\n)\n\n// AuditLogHandlerDeps 描述审计日志任务处理器可注入依赖。\ntype AuditLogHandlerDeps struct {\n\tPersist func(snapshot *auditsvc.AuditLogSnapshot) error\n}\n\n// AuditLogPayload 表示异步审计日志任务 payload。\ntype AuditLogPayload struct {\n\tKind     string                     `json:\"kind\"`\n\tSnapshot *auditsvc.AuditLogSnapshot `json:\"snapshot\"`\n}\n\n// NewAuditLogPayload 创建审计日志 payload。\nfunc NewAuditLogPayload(kind string, snapshot *auditsvc.AuditLogSnapshot) (AuditLogPayload, error) {\n\tpayload := AuditLogPayload{\n\t\tKind:     kind,\n\t\tSnapshot: snapshot,\n\t}\n\tif err := payload.Validate(); err != nil {\n\t\treturn AuditLogPayload{}, err\n\t}\n\treturn payload, nil\n}\n\n// Validate 校验 payload 是否满足最小要求。\nfunc (p AuditLogPayload) Validate() error {\n\tif p.Kind != AuditLogKindRequest && p.Kind != AuditLogKindPanic {\n\t\treturn fmt.Errorf(\"invalid audit log kind %q\", p.Kind)\n\t}\n\tif p.Snapshot == nil || p.Snapshot.RequestID == \"\" {\n\t\treturn fmt.Errorf(\"audit log snapshot is invalid\")\n\t}\n\treturn nil\n}\n\n// EnqueueAuditLog 发布异步审计日志任务。\nfunc EnqueueAuditLog(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {\n\tpayload, err := NewAuditLogPayload(kind, snapshot)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = queue.PublishJSON(ctx, AuditLogTaskType, AuditQueueName, payload, auditLogOptions()...)\n\treturn err\n}\n\nfunc auditLogOptions() []queue.JobOption {\n\tcfg := config.GetConfig()\n\tmaxRetry := 3\n\ttimeout := 10 * time.Second\n\tif cfg != nil {\n\t\tif cfg.Queue.AuditMaxRetry > 0 {\n\t\t\tmaxRetry = cfg.Queue.AuditMaxRetry\n\t\t}\n\t\tif cfg.Queue.AuditTimeoutSeconds > 0 {\n\t\t\ttimeout = time.Duration(cfg.Queue.AuditTimeoutSeconds) * time.Second\n\t\t}\n\t}\n\treturn []queue.JobOption{\n\t\tqueue.WithMaxRetry(maxRetry),\n\t\tqueue.WithTimeout(timeout),\n\t}\n}\n\n// RegisterAll 注册当前版本的全部异步任务。\nfunc RegisterAll(registry queue.Registry) {\n\tRegisterAllWithDeps(registry, AuditLogHandlerDeps{})\n}\n\n// RegisterAllWithDeps 注册全部异步任务并支持依赖注入。\nfunc RegisterAllWithDeps(registry queue.Registry, deps AuditLogHandlerDeps) {\n\tif registry == nil {\n\t\treturn\n\t}\n\tpersistFn := deps.Persist\n\tif persistFn == nil {\n\t\tpersistFn = auditsvc.PersistAuditLog\n\t}\n\tqueue.RegisterJSON(registry, AuditLogTaskType, func(ctx context.Context, payload AuditLogPayload) error {\n\t\t_ = ctx\n\t\tif err := persistFn(payload.Snapshot); err != nil {\n\t\t\tlog.Error(\"Persist audit log failed\",\n\t\t\t\tzap.String(\"request_id\", payload.Snapshot.RequestID),\n\t\t\t\tzap.Error(err))\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n}\n"
  },
  {
    "path": "internal/jobs/audit_log_test.go",
    "content": "package jobs\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n\tauditsvc \"github.com/wannanbigpig/gin-layout/internal/service/audit\"\n)\n\nfunc TestNewAuditLogPayload(t *testing.T) {\n\tpayload, err := NewAuditLogPayload(AuditLogKindRequest, &auditsvc.AuditLogSnapshot{RequestID: \"req-1\"})\n\tif err != nil {\n\t\tt.Fatalf(\"NewAuditLogPayload returned error: %v\", err)\n\t}\n\tif payload.Kind != AuditLogKindRequest {\n\t\tt.Fatalf(\"expected kind %q, got %q\", AuditLogKindRequest, payload.Kind)\n\t}\n\tif payload.Snapshot == nil || payload.Snapshot.RequestID != \"req-1\" {\n\t\tt.Fatalf(\"unexpected snapshot: %#v\", payload.Snapshot)\n\t}\n}\n\nfunc TestAuditLogHandlerReturnsSkipRetryForInvalidPayload(t *testing.T) {\n\tregistry := queue.NewRegistry()\n\tRegisterAll(registry)\n\n\tentries := registry.Entries()\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 registry entry, got %d\", len(entries))\n\t}\n\n\terr := entries[0].Handler(context.Background(), []byte(`{\"kind\":\"invalid\"}`))\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tif !errors.Is(err, queue.ErrSkipRetry) {\n\t\tt.Fatalf(\"expected skip retry error, got %v\", err)\n\t}\n}\n\nfunc TestAuditLogHandlerPersistsSnapshot(t *testing.T) {\n\tcalled := false\n\tdeps := AuditLogHandlerDeps{\n\t\tPersist: func(snapshot *auditsvc.AuditLogSnapshot) error {\n\t\t\tcalled = true\n\t\t\tif snapshot == nil || snapshot.RequestID != \"req-2\" {\n\t\t\t\tt.Fatalf(\"unexpected snapshot: %#v\", snapshot)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tregistry := queue.NewRegistry()\n\tRegisterAllWithDeps(registry, deps)\n\tentries := registry.Entries()\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 registry entry, got %d\", len(entries))\n\t}\n\n\tpayload, err := NewAuditLogPayload(AuditLogKindPanic, &auditsvc.AuditLogSnapshot{RequestID: \"req-2\"})\n\tif err != nil {\n\t\tt.Fatalf(\"NewAuditLogPayload returned error: %v\", err)\n\t}\n\n\tjob := queue.NewJSONJob(AuditLogTaskType, AuditQueueName, payload)\n\traw, err := job.Payload()\n\tif err != nil {\n\t\tt.Fatalf(\"Payload returned error: %v\", err)\n\t}\n\n\tif err := entries[0].Handler(context.Background(), raw); err != nil {\n\t\tt.Fatalf(\"Handle returned error: %v\", err)\n\t}\n\tif !called {\n\t\tt.Fatal(\"expected persist function to be called\")\n\t}\n}\n"
  },
  {
    "path": "internal/jobs/registry.go",
    "content": "package jobs\n\nimport \"github.com/wannanbigpig/gin-layout/internal/queue\"\n\n// NewRegistry 创建并注册当前版本的全部任务处理器。\nfunc NewRegistry() queue.Registry {\n\tregistry := queue.NewRegistry()\n\tRegisterAll(registry)\n\treturn registry\n}\n"
  },
  {
    "path": "internal/middleware/admin_auth.go",
    "content": "package middleware\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/zap\"\n\n\tcasbinx \"github.com/wannanbigpig/gin-layout/internal/access/casbin\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n\taccesssvc \"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/auth\"\n)\n\ntype routeAuthChecker interface {\n\tCheckoutRouteIsAuth(route string, method string) bool\n}\n\ntype permissionDeps struct {\n\t// loadEnforcer 加载 Casbin enforcer。\n\tloadEnforcer func() (*casbinx.CasbinEnforcer, error)\n\t// routeChecker 判断路由是否需要权限拦截。\n\trouteChecker routeAuthChecker\n}\n\nfunc newDefaultPermissionDeps() permissionDeps {\n\treturn permissionDeps{\n\t\tloadEnforcer: casbinx.GetEnforcer,\n\t\trouteChecker: accesssvc.NewApiRouteCacheService(),\n\t}\n}\n\n// AdminAuthHandler 依赖 ParseTokenHandler 预先写入用户上下文。\nfunc AdminAuthHandler() gin.HandlerFunc {\n\treturn AdminAuthHandlerWithDeps(newDefaultPermissionDeps())\n}\n\n// AdminAuthHandlerWithDeps 支持注入依赖，便于测试隔离。\nfunc AdminAuthHandlerWithDeps(deps permissionDeps) gin.HandlerFunc {\n\tif deps.loadEnforcer == nil {\n\t\tdeps.loadEnforcer = casbinx.GetEnforcer\n\t}\n\tif deps.routeChecker == nil {\n\t\tdeps.routeChecker = accesssvc.NewApiRouteCacheService()\n\t}\n\treturn func(c *gin.Context) {\n\t\tuid := c.GetUint(global.ContextKeyUID)\n\t\tif uid == 0 {\n\t\t\tresponse.FailCode(c, e.NotLogin)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tprincipal := auth.GetAuthPrincipal(c)\n\t\tif principal == nil {\n\t\t\tresponse.FailCodeByKey(c, e.NotLogin, e.MsgKeyAuthSessionExpired)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tif !isSuperAdmin(principal) {\n\t\t\tif err := checkPermission(c, principal.UserID, deps); err != nil {\n\t\t\t\tif businessErr, ok := err.(*e.BusinessError); ok {\n\t\t\t\t\tif businessErr.HasMessageKey() {\n\t\t\t\t\t\tresponse.FailCodeByKey(c, businessErr.GetCode(), businessErr.GetMessageKey(), businessErr.GetMessageArgs()...)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tresponse.FailCode(c, businessErr.GetCode())\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tresponse.FailCode(c, e.ServerErr)\n\t\t\t\t}\n\t\t\t\tc.Abort()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// isSuperAdmin 判断是否为超级管理员\nfunc isSuperAdmin(principal *auth.AuthPrincipal) bool {\n\treturn principal != nil && (principal.IsSuperAdmin == global.Yes || principal.UserID == global.SuperAdminId)\n}\n\n// checkPermission 检查接口权限\nfunc checkPermission(c *gin.Context, userID uint, deps permissionDeps) error {\n\tenforcer, err := loadEnforcer(deps)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn enforcePermission(enforcer, c, userID, deps)\n}\n\nfunc loadEnforcer(deps permissionDeps) (*casbinx.CasbinEnforcer, error) {\n\tenforcer, err := deps.loadEnforcer()\n\tif err != nil {\n\t\tlog.Logger.Error(\"权限验证初始化失败\", zap.Error(err))\n\t\treturn nil, e.NewBusinessErrorWithKey(e.ServerErr, e.MsgKeyAuthPermissionInitFailed)\n\t}\n\treturn enforcer, nil\n}\n\nfunc enforcePermission(enforcer *casbinx.CasbinEnforcer, c *gin.Context, userID uint, deps permissionDeps) error {\n\tuserKey := fmt.Sprintf(\"%s%s%d\", global.CasbinAdminUserPrefix, global.CasbinSeparator, userID)\n\tpath := c.Request.URL.Path\n\tmethod := c.Request.Method\n\n\tok, err := enforcer.Enforce(userKey, path, method)\n\tif err != nil {\n\t\tlog.Logger.Error(\"权限验证失败\", zap.Error(err))\n\t\treturn e.NewBusinessErrorWithKey(e.ServerErr, e.MsgKeyAuthPermissionCheckFailed)\n\t}\n\n\tif !ok {\n\t\tif deps.routeChecker.CheckoutRouteIsAuth(path, method) {\n\t\t\treturn e.NewBusinessErrorWithKey(e.AuthorizationErr, e.MsgKeyAuthAPIOperationDenied)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/middleware/admin_auth_test.go",
    "content": "package middleware\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/casbin/casbin/v3\"\n\tcasbinmodel \"github.com/casbin/casbin/v3/model\"\n\t\"github.com/gin-gonic/gin\"\n\n\tcasbinx \"github.com/wannanbigpig/gin-layout/internal/access/casbin\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/auth\"\n)\n\ntype stubRouteChecker struct {\n\trequiresAuth bool\n}\n\nfunc (s stubRouteChecker) CheckoutRouteIsAuth(string, string) bool {\n\treturn s.requiresAuth\n}\n\nfunc TestAdminAuthHandlerWithDepsAllowsPublicRouteWhenDenied(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tenforcer := buildTestEnforcer(t)\n\tdeps := permissionDeps{\n\t\tloadEnforcer: func() (*casbinx.CasbinEnforcer, error) {\n\t\t\treturn &casbinx.CasbinEnforcer{Enforcer: enforcer}, nil\n\t\t},\n\t\trouteChecker: stubRouteChecker{requiresAuth: false},\n\t}\n\n\trouter := gin.New()\n\trouter.Use(func(c *gin.Context) {\n\t\tauth.StoreAuthPrincipal(c, &auth.AuthPrincipal{\n\t\t\tUserID:       2,\n\t\t\tIsSuperAdmin: global.No,\n\t\t})\n\t\tc.Next()\n\t})\n\trouter.Use(AdminAuthHandlerWithDeps(deps))\n\trouter.GET(\"/public\", func(c *gin.Context) {\n\t\tc.String(http.StatusOK, \"ok\")\n\t})\n\n\trecorder := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/public\", nil)\n\trouter.ServeHTTP(recorder, req)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, recorder.Code)\n\t}\n\tif recorder.Body.String() != \"ok\" {\n\t\tt.Fatalf(\"expected body ok, got %q\", recorder.Body.String())\n\t}\n}\n\nfunc TestAdminAuthHandlerWithDepsReturnsServerErrWhenEnforcerLoadFails(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tdeps := permissionDeps{\n\t\tloadEnforcer: func() (*casbinx.CasbinEnforcer, error) {\n\t\t\treturn nil, errors.New(\"casbin unavailable\")\n\t\t},\n\t\trouteChecker: stubRouteChecker{requiresAuth: true},\n\t}\n\n\trouter := gin.New()\n\trouter.Use(func(c *gin.Context) {\n\t\tauth.StoreAuthPrincipal(c, &auth.AuthPrincipal{\n\t\t\tUserID:       2,\n\t\t\tIsSuperAdmin: global.No,\n\t\t})\n\t\tc.Next()\n\t})\n\trouter.Use(AdminAuthHandlerWithDeps(deps))\n\trouter.GET(\"/protected\", func(c *gin.Context) {\n\t\tc.String(http.StatusOK, \"ok\")\n\t})\n\n\trecorder := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/protected\", nil)\n\trouter.ServeHTTP(recorder, req)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, recorder.Code)\n\t}\n\n\tresult := decodeResult(t, recorder.Body.Bytes())\n\tif result.Code != e.ServerErr {\n\t\tt.Fatalf(\"expected code %d, got %d\", e.ServerErr, result.Code)\n\t}\n\tif result.Msg != \"权限验证初始化失败\" {\n\t\tt.Fatalf(\"expected localized msg, got %q\", result.Msg)\n\t}\n}\n\nfunc TestAdminAuthHandlerWithDepsReturnsAuthorizationMessageWhenDenied(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tenforcer := buildTestEnforcer(t)\n\tdeps := permissionDeps{\n\t\tloadEnforcer: func() (*casbinx.CasbinEnforcer, error) {\n\t\t\treturn &casbinx.CasbinEnforcer{Enforcer: enforcer}, nil\n\t\t},\n\t\trouteChecker: stubRouteChecker{requiresAuth: true},\n\t}\n\n\trouter := gin.New()\n\trouter.Use(func(c *gin.Context) {\n\t\tauth.StoreAuthPrincipal(c, &auth.AuthPrincipal{\n\t\t\tUserID:       2,\n\t\t\tIsSuperAdmin: global.No,\n\t\t})\n\t\tc.Next()\n\t})\n\trouter.Use(AdminAuthHandlerWithDeps(deps))\n\trouter.GET(\"/protected\", func(c *gin.Context) {\n\t\tc.String(http.StatusOK, \"ok\")\n\t})\n\n\trecorder := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/protected\", nil)\n\trouter.ServeHTTP(recorder, req)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, recorder.Code)\n\t}\n\n\tresult := decodeResult(t, recorder.Body.Bytes())\n\tif result.Code != e.AuthorizationErr {\n\t\tt.Fatalf(\"expected code %d, got %d\", e.AuthorizationErr, result.Code)\n\t}\n\tif result.Msg != \"暂无接口操作权限\" {\n\t\tt.Fatalf(\"expected localized msg, got %q\", result.Msg)\n\t}\n}\n\nfunc buildTestEnforcer(t *testing.T) *casbin.Enforcer {\n\tt.Helper()\n\n\tm, err := casbinmodel.NewModelFromString(`\n[request_definition]\nr = sub, obj, act\n[policy_definition]\np = sub, obj, act\n[policy_effect]\ne = some(where (p.eft == allow))\n[matchers]\nm = r.sub == p.sub && r.obj == p.obj && r.act == p.act\n`)\n\tif err != nil {\n\t\tt.Fatalf(\"build casbin model failed: %v\", err)\n\t}\n\n\tenforcer, err := casbin.NewEnforcer(m)\n\tif err != nil {\n\t\tt.Fatalf(\"build casbin enforcer failed: %v\", err)\n\t}\n\treturn enforcer\n}\n\nfunc decodeResult(t *testing.T, body []byte) *response.Result {\n\tt.Helper()\n\n\tresult := new(response.Result)\n\tif err := json.Unmarshal(body, result); err != nil {\n\t\tt.Fatalf(\"decode response failed: %v, body=%s\", err, string(body))\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "internal/middleware/audit_context.go",
    "content": "package middleware\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n)\n\n// SetAuditChangeDiff 设置本次请求的关键变更前后差异。\nfunc SetAuditChangeDiff(c *gin.Context, before any, after any) {\n\tif c == nil {\n\t\treturn\n\t}\n\traw, err := json.Marshal(map[string]any{\n\t\t\"before\": before,\n\t\t\"after\":  after,\n\t})\n\tif err != nil {\n\t\treturn\n\t}\n\tc.Set(global.ContextKeyAuditChangeDiff, string(raw))\n}\n\n// SetAuditChangeDiffRaw 直接写入变更差异 JSON 字符串。\nfunc SetAuditChangeDiffRaw(c *gin.Context, rawJSON string) {\n\tif c == nil {\n\t\treturn\n\t}\n\tc.Set(global.ContextKeyAuditChangeDiff, rawJSON)\n}\n\n// SetAuditHighRisk 设置本次请求是否按高危操作记录。\nfunc SetAuditHighRisk(c *gin.Context, highRisk bool) {\n\tif c == nil {\n\t\treturn\n\t}\n\tc.Set(global.ContextKeyAuditHighRisk, highRisk)\n}\n\n// SetAuditRequestBodyRaw 覆盖本次请求日志中的请求体快照。\nfunc SetAuditRequestBodyRaw(c *gin.Context, rawJSON string) {\n\tif c == nil || strings.TrimSpace(rawJSON) == \"\" {\n\t\treturn\n\t}\n\tc.Set(global.ContextKeyAuditRequestBody, rawJSON)\n}\n"
  },
  {
    "path": "internal/middleware/audit_queue.go",
    "content": "package middleware\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/jobs\"\n\tperrors \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n\taudit \"github.com/wannanbigpig/gin-layout/internal/service/audit\"\n)\n\nconst auditEnqueueTimeout = 2 * time.Second\n\nvar (\n\tenqueueAuditTaskFn = jobs.EnqueueAuditLog\n\tpersistAuditLogFn  = audit.PersistAuditLog\n\n\tqueueUnavailableLogged atomic.Bool\n\tstorageUnavailable     atomic.Bool\n)\n\ntype auditQueueDeps struct {\n\tEnqueue func(ctx context.Context, kind string, snapshot *audit.AuditLogSnapshot) error\n\tPersist func(snapshot *audit.AuditLogSnapshot) error\n}\n\nfunc setAuditQueueDepsForTesting(deps auditQueueDeps) func() {\n\tpreviousEnqueue := enqueueAuditTaskFn\n\tpreviousPersist := persistAuditLogFn\n\tpreviousQueueUnavailable := queueUnavailableLogged.Load()\n\tpreviousStorageUnavailable := storageUnavailable.Load()\n\n\tenqueueAuditTaskFn = deps.Enqueue\n\tif enqueueAuditTaskFn == nil {\n\t\tenqueueAuditTaskFn = jobs.EnqueueAuditLog\n\t}\n\tpersistAuditLogFn = deps.Persist\n\tif persistAuditLogFn == nil {\n\t\tpersistAuditLogFn = audit.PersistAuditLog\n\t}\n\n\tqueueUnavailableLogged.Store(false)\n\tstorageUnavailable.Store(false)\n\n\treturn func() {\n\t\tenqueueAuditTaskFn = previousEnqueue\n\t\tpersistAuditLogFn = previousPersist\n\t\tqueueUnavailableLogged.Store(previousQueueUnavailable)\n\t\tstorageUnavailable.Store(previousStorageUnavailable)\n\t}\n}\n\nfunc enqueueAuditLog(c *gin.Context, kind string, snapshot *audit.AuditLogSnapshot) {\n\tif snapshot == nil {\n\t\treturn\n\t}\n\n\tcfg := config.GetConfig()\n\tif cfg == nil || !cfg.Queue.Enable {\n\t\treportAuditPersistenceResult(kind, snapshot, \"sync_direct\")\n\t\treturn\n\t}\n\n\tctx := context.Background()\n\tif c != nil && c.Request != nil {\n\t\tctx = c.Request.Context()\n\t}\n\tctx, cancel := context.WithTimeout(ctx, auditEnqueueTimeout)\n\tdefer cancel()\n\n\tif err := enqueueAuditTaskFn(ctx, kind, snapshot); err != nil {\n\t\tif errors.Is(err, queue.ErrPublisherUnavailable) {\n\t\t\tif queueUnavailableLogged.CompareAndSwap(false, true) {\n\t\t\t\tlog.Warn(\"Audit queue publisher unavailable, fallback to sync persist\",\n\t\t\t\t\tzap.String(\"operation\", \"enqueue_audit_log\"),\n\t\t\t\t\tzap.String(\"kind\", kind),\n\t\t\t\t\tzap.String(\"request_id\", snapshot.RequestID))\n\t\t\t}\n\t\t} else {\n\t\t\tlog.Warn(\"Enqueue audit log failed, fallback to sync persist\",\n\t\t\t\tzap.String(\"operation\", \"enqueue_audit_log\"),\n\t\t\t\tzap.String(\"kind\", kind),\n\t\t\t\tzap.String(\"request_id\", snapshot.RequestID),\n\t\t\t\tzap.Error(err))\n\t\t}\n\t\treportAuditPersistenceResult(kind, snapshot, \"sync_fallback\")\n\t\treturn\n\t}\n\n\t// 队列恢复后复位告警开关，保证下次不可用时仍能打首条告警。\n\tqueueUnavailableLogged.Store(false)\n}\n\nfunc reportAuditPersistenceResult(kind string, snapshot *audit.AuditLogSnapshot, mode string) {\n\tif snapshot == nil {\n\t\treturn\n\t}\n\n\tif err := persistAuditLogFn(snapshot); err != nil {\n\t\tif perrors.IsDependencyNotReady(err) {\n\t\t\tif storageUnavailable.CompareAndSwap(false, true) {\n\t\t\t\tlog.Warn(\"Audit log storage unavailable, skip persistence\",\n\t\t\t\t\tzap.String(\"kind\", kind),\n\t\t\t\t\tzap.String(\"mode\", mode),\n\t\t\t\t\tzap.String(\"request_id\", snapshot.RequestID),\n\t\t\t\t\tzap.Error(err))\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tlog.Error(\"Persist audit log failed\",\n\t\t\tzap.String(\"kind\", kind),\n\t\t\tzap.String(\"mode\", mode),\n\t\t\tzap.String(\"request_id\", snapshot.RequestID),\n\t\t\tzap.Error(err))\n\t\treturn\n\t}\n\n\tstorageUnavailable.Store(false)\n}\n"
  },
  {
    "path": "internal/middleware/audit_queue_test.go",
    "content": "package middleware\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n\tauditsvc \"github.com/wannanbigpig/gin-layout/internal/service/audit\"\n)\n\nfunc TestEnqueueAuditLogDelegatesToPublisher(t *testing.T) {\n\tcalled := false\n\trestoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{\n\t\tEnqueue: func(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {\n\t\t\tcalled = true\n\t\t\tif kind != \"request\" {\n\t\t\t\tt.Fatalf(\"unexpected kind: %s\", kind)\n\t\t\t}\n\t\t\tif snapshot == nil || snapshot.RequestID != \"req-1\" {\n\t\t\t\tt.Fatalf(\"unexpected snapshot: %#v\", snapshot)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t})\n\tdefer restoreDeps()\n\n\trestoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {\n\t\tcfg.Queue.Enable = true\n\t})\n\tdefer restoreConfig()\n\n\trestoreLogger := log.ReplaceLoggerForTesting(zap.NewNop())\n\tdefer restoreLogger()\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/demo\", bytes.NewBufferString(`{\"name\":\"codex\"}`))\n\tctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\tctx.Set(global.ContextKeyRequestID, \"req-1\")\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tcacheRequestBody(ctx)\n\n\trespRecorder := createResponseRecorder(ctx)\n\trespRecorder.body.WriteString(`{\"code\":0,\"msg\":\"ok\",\"data\":{}}`)\n\n\tlogRequest(ctx, respRecorder)\n\tif !called {\n\t\tt.Fatal(\"expected enqueue to be called\")\n\t}\n}\n\nfunc TestEnqueueAuditLogFailureDoesNotPanic(t *testing.T) {\n\trestoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{\n\t\tEnqueue: func(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {\n\t\t\treturn errors.New(\"enqueue failed\")\n\t\t},\n\t})\n\tdefer restoreDeps()\n\n\tenqueueAuditLog(nil, \"request\", &auditsvc.AuditLogSnapshot{RequestID: \"req-2\"})\n}\n\nfunc TestEnqueueAuditLogResetsUnavailableFlagAfterSuccess(t *testing.T) {\n\trestoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{\n\t\tEnqueue: func(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {\n\t\t\treturn nil\n\t\t},\n\t})\n\tdefer restoreDeps()\n\n\trestoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {\n\t\tcfg.Queue.Enable = true\n\t})\n\tdefer restoreConfig()\n\n\tqueueUnavailableLogged.Store(true)\n\tenqueueAuditLog(nil, \"request\", &auditsvc.AuditLogSnapshot{RequestID: \"req-3\"})\n\n\tif queueUnavailableLogged.Load() {\n\t\tt.Fatal(\"expected unavailable flag to be reset after successful enqueue\")\n\t}\n}\n\nfunc TestEnqueueAuditLogMarksUnavailableWhenPublisherUnavailable(t *testing.T) {\n\trestoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{\n\t\tEnqueue: func(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {\n\t\t\treturn queue.ErrPublisherUnavailable\n\t\t},\n\t})\n\tdefer restoreDeps()\n\n\trestoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {\n\t\tcfg.Queue.Enable = true\n\t})\n\tdefer restoreConfig()\n\n\tqueueUnavailableLogged.Store(false)\n\tenqueueAuditLog(nil, \"request\", &auditsvc.AuditLogSnapshot{RequestID: \"req-4\"})\n\n\tif !queueUnavailableLogged.Load() {\n\t\tt.Fatal(\"expected unavailable flag to be set when publisher unavailable\")\n\t}\n}\n\nfunc TestEnqueueAuditLogPersistsSynchronouslyWhenQueueDisabled(t *testing.T) {\n\tpersistCalled := false\n\trestoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{\n\t\tEnqueue: func(ctx context.Context, kind string, snapshot *auditsvc.AuditLogSnapshot) error {\n\t\t\tt.Fatal(\"enqueue should not be called when queue is disabled\")\n\t\t\treturn nil\n\t\t},\n\t\tPersist: func(snapshot *auditsvc.AuditLogSnapshot) error {\n\t\t\tpersistCalled = true\n\t\t\tif snapshot == nil || snapshot.RequestID != \"req-db-unavailable\" {\n\t\t\t\tt.Fatalf(\"unexpected snapshot: %#v\", snapshot)\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t})\n\tdefer restoreDeps()\n\n\trestoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {\n\t\tcfg.Queue.Enable = false\n\t})\n\tdefer restoreConfig()\n\n\tstorageUnavailable.Store(false)\n\tenqueueAuditLog(nil, \"request\", &auditsvc.AuditLogSnapshot{RequestID: \"req-db-unavailable\"})\n\n\tif !persistCalled {\n\t\tt.Fatal(\"expected synchronous persistence when queue is disabled\")\n\t}\n}\n\nfunc TestEnqueueAuditLogHandlesStorageUnavailableWhenQueueDisabled(t *testing.T) {\n\trestoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{\n\t\tPersist: func(snapshot *auditsvc.AuditLogSnapshot) error {\n\t\t\treturn model.ErrDBUninitialized\n\t\t},\n\t})\n\tdefer restoreDeps()\n\n\trestoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {\n\t\tcfg.Queue.Enable = false\n\t})\n\tdefer restoreConfig()\n\n\tstorageUnavailable.Store(false)\n\tenqueueAuditLog(nil, \"request\", &auditsvc.AuditLogSnapshot{RequestID: \"req-db-unavailable\"})\n\n\tif !storageUnavailable.Load() {\n\t\tt.Fatal(\"expected storage unavailable flag to be set when sync persistence fails\")\n\t}\n}\n\nfunc TestReportAuditPersistenceResultMarksStorageUnavailable(t *testing.T) {\n\trestoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{\n\t\tPersist: func(snapshot *auditsvc.AuditLogSnapshot) error {\n\t\t\treturn model.ErrDBUninitialized\n\t\t},\n\t})\n\tdefer restoreDeps()\n\n\tstorageUnavailable.Store(false)\n\treportAuditPersistenceResult(\"request\", &auditsvc.AuditLogSnapshot{RequestID: \"req-db-unavailable\"}, \"sync_direct\")\n\n\tif !storageUnavailable.Load() {\n\t\tt.Fatal(\"expected storage unavailable flag to be set when db is unavailable\")\n\t}\n}\n\nfunc TestReportAuditPersistenceResultResetsStorageUnavailableAfterSuccess(t *testing.T) {\n\trestoreDeps := setAuditQueueDepsForTesting(auditQueueDeps{\n\t\tPersist: func(snapshot *auditsvc.AuditLogSnapshot) error {\n\t\t\treturn nil\n\t\t},\n\t})\n\tdefer restoreDeps()\n\n\tstorageUnavailable.Store(true)\n\treportAuditPersistenceResult(\"request\", &auditsvc.AuditLogSnapshot{RequestID: \"req-db-ok\"}, \"sync_direct\")\n\n\tif storageUnavailable.Load() {\n\t\tt.Fatal(\"expected storage unavailable flag to be reset after successful persistence\")\n\t}\n}\n"
  },
  {
    "path": "internal/middleware/cors.go",
    "content": "package middleware\n\nimport (\n\t\"fmt\"\n\t\"path\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/wannanbigpig/gin-layout/config\"\n)\n\nvar builtInDefaultMethods = [...]string{\n\t\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\", \"HEAD\", \"OPTIONS\",\n}\n\nfunc CorsHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tcfg := config.GetConfig()\n\t\torigin := c.Request.Header.Get(\"Origin\")\n\n\t\tif c.Request.Method == \"OPTIONS\" {\n\t\t\tif origin != \"\" {\n\t\t\t\tif allowOrigin, ok := resolveAllowOrigin(origin, cfg); ok {\n\t\t\t\t\tc.Header(\"Access-Control-Allow-Origin\", allowOrigin)\n\t\t\t\t\tc.Header(\"Access-Control-Allow-Methods\", strings.Join(getAllowedMethods(cfg), \", \"))\n\t\t\t\t\tc.Header(\"Access-Control-Allow-Headers\", getAllowedHeaders(c, cfg))\n\t\t\t\t\tc.Header(\"Access-Control-Max-Age\", fmt.Sprintf(\"%d\", getMaxAge(cfg)))\n\t\t\t\t\tc.Header(\"Access-Control-Allow-Credentials\", fmt.Sprintf(\"%t\", cfg.CorsCredentials))\n\t\t\t\t} else {\n\t\t\t\t\tc.AbortWithStatus(403)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tc.AbortWithStatus(204)\n\t\t\treturn\n\t\t}\n\n\t\tif origin != \"\" {\n\t\t\tif allowOrigin, ok := resolveAllowOrigin(origin, cfg); ok {\n\t\t\t\tc.Header(\"Access-Control-Allow-Origin\", allowOrigin)\n\t\t\t\tc.Header(\"Access-Control-Expose-Headers\", getExposeHeaders(cfg))\n\t\t\t\tc.Header(\"Access-Control-Allow-Credentials\", fmt.Sprintf(\"%t\", cfg.CorsCredentials))\n\t\t\t} else {\n\t\t\t\tc.AbortWithStatus(403)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\nfunc resolveAllowOrigin(origin string, cfg *config.Conf) (string, bool) {\n\tif !isOriginAllowed(origin, cfg) {\n\t\treturn \"\", false\n\t}\n\tif hasWildcardOrigin(cfg) && !cfg.CorsCredentials {\n\t\treturn \"*\", true\n\t}\n\treturn origin, true\n}\n\nfunc getAllowedMethods(cfg *config.Conf) []string {\n\tif len(cfg.CorsMethods) > 0 {\n\t\tif hasWildcardValue(cfg.CorsMethods) {\n\t\t\treturn defaultMethods()\n\t\t}\n\t\treturn cfg.CorsMethods\n\t}\n\treturn defaultMethods()\n}\n\nfunc defaultMethods() []string {\n\tmethods := make([]string, len(builtInDefaultMethods))\n\tcopy(methods, builtInDefaultMethods[:])\n\treturn methods\n}\n\nfunc getAllowedHeaders(c *gin.Context, cfg *config.Conf) string {\n\tif len(cfg.CorsHeaders) > 0 {\n\t\tif hasWildcardValue(cfg.CorsHeaders) {\n\t\t\trequestHeaders := c.Request.Header.Get(\"Access-Control-Request-Headers\")\n\t\t\tif requestHeaders != \"\" {\n\t\t\t\treturn requestHeaders\n\t\t\t}\n\t\t\treturn \"*\"\n\t\t}\n\t\treturn strings.Join(cfg.CorsHeaders, \", \")\n\t}\n\trequestHeaders := c.Request.Header.Get(\"Access-Control-Request-Headers\")\n\tif requestHeaders != \"\" {\n\t\treturn requestHeaders\n\t}\n\treturn \"*\"\n}\n\nfunc getExposeHeaders(cfg *config.Conf) string {\n\tif len(cfg.CorsExposeHeaders) > 0 {\n\t\tif hasWildcardValue(cfg.CorsExposeHeaders) {\n\t\t\treturn \"*\"\n\t\t}\n\t\treturn strings.Join(cfg.CorsExposeHeaders, \", \")\n\t}\n\treturn \"*\"\n}\n\nfunc getMaxAge(cfg *config.Conf) int {\n\tif cfg.CorsMaxAge > 0 {\n\t\treturn cfg.CorsMaxAge\n\t}\n\treturn 43200\n}\n\nfunc isOriginAllowed(origin string, cfg *config.Conf) bool {\n\tif len(cfg.CorsOrigins) == 0 {\n\t\treturn false\n\t}\n\tfor _, allowedOrigin := range cfg.CorsOrigins {\n\t\tif allowedOrigin == \"*\" {\n\t\t\treturn true\n\t\t}\n\t\tif origin == allowedOrigin {\n\t\t\treturn true\n\t\t}\n\t\tif strings.Contains(allowedOrigin, \"*\") {\n\t\t\tif matched, _ := path.Match(allowedOrigin, origin); matched {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasWildcardOrigin(cfg *config.Conf) bool {\n\tfor _, allowedOrigin := range cfg.CorsOrigins {\n\t\tif allowedOrigin == \"*\" {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc hasWildcardValue(values []string) bool {\n\tfor _, value := range values {\n\t\tif value == \"*\" {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "internal/middleware/cors_test.go",
    "content": "package middleware\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/config/autoload\"\n)\n\nfunc TestCorsHandlerAllowsWildcardOrigin(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\trestoreConfig := config.ReplaceConfigForTesting(&config.Conf{\n\t\tAppConfig: autoload.AppConfig{\n\t\t\tCorsOrigins:     []string{\"*\"},\n\t\t\tCorsCredentials: false,\n\t\t},\n\t})\n\tt.Cleanup(restoreConfig)\n\n\trecorder := httptest.NewRecorder()\n\tctx, engine := gin.CreateTestContext(recorder)\n\tengine.Use(CorsHandler())\n\tengine.GET(\"/demo\", func(c *gin.Context) {\n\t\tc.Status(http.StatusOK)\n\t})\n\n\treq := httptest.NewRequest(http.MethodGet, \"/demo\", nil)\n\treq.Header.Set(\"Origin\", \"http://localhost:3000\")\n\tctx.Request = req\n\n\tengine.HandleContext(ctx)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200, got %d\", recorder.Code)\n\t}\n\tif got := recorder.Header().Get(\"Access-Control-Allow-Origin\"); got != \"*\" {\n\t\tt.Fatalf(\"expected wildcard allow origin, got %q\", got)\n\t}\n}\n\nfunc TestCorsHandlerReflectsOriginWhenCredentialsEnabled(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\trestoreConfig := config.ReplaceConfigForTesting(&config.Conf{\n\t\tAppConfig: autoload.AppConfig{\n\t\t\tCorsOrigins:     []string{\"*\"},\n\t\t\tCorsCredentials: true,\n\t\t},\n\t})\n\tt.Cleanup(restoreConfig)\n\n\trecorder := httptest.NewRecorder()\n\tctx, engine := gin.CreateTestContext(recorder)\n\tengine.Use(CorsHandler())\n\tengine.GET(\"/demo\", func(c *gin.Context) {\n\t\tc.Status(http.StatusOK)\n\t})\n\n\treq := httptest.NewRequest(http.MethodGet, \"/demo\", nil)\n\treq.Header.Set(\"Origin\", \"http://localhost:3000\")\n\tctx.Request = req\n\n\tengine.HandleContext(ctx)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200, got %d\", recorder.Code)\n\t}\n\tif got := recorder.Header().Get(\"Access-Control-Allow-Origin\"); got != \"http://localhost:3000\" {\n\t\tt.Fatalf(\"expected reflected allow origin, got %q\", got)\n\t}\n\tif got := recorder.Header().Get(\"Access-Control-Allow-Credentials\"); got != \"true\" {\n\t\tt.Fatalf(\"expected credentials header true, got %q\", got)\n\t}\n}\n\nfunc TestCorsHandlerRejectsUnknownOrigin(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\trestoreConfig := config.ReplaceConfigForTesting(&config.Conf{\n\t\tAppConfig: autoload.AppConfig{\n\t\t\tCorsOrigins: []string{\"https://example.com\"},\n\t\t},\n\t})\n\tt.Cleanup(restoreConfig)\n\n\trecorder := httptest.NewRecorder()\n\tctx, engine := gin.CreateTestContext(recorder)\n\tengine.Use(CorsHandler())\n\tengine.GET(\"/demo\", func(c *gin.Context) {\n\t\tc.Status(http.StatusOK)\n\t})\n\n\treq := httptest.NewRequest(http.MethodGet, \"/demo\", nil)\n\treq.Header.Set(\"Origin\", \"http://localhost:3000\")\n\tctx.Request = req\n\n\tengine.HandleContext(ctx)\n\n\tif recorder.Code != http.StatusForbidden {\n\t\tt.Fatalf(\"expected status 403, got %d\", recorder.Code)\n\t}\n}\n\nfunc TestCorsHandlerAllowsWildcardMethodsAndHeaders(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\trestoreConfig := config.ReplaceConfigForTesting(&config.Conf{\n\t\tAppConfig: autoload.AppConfig{\n\t\t\tCorsOrigins: []string{\"*\"},\n\t\t\tCorsMethods: []string{\"*\"},\n\t\t\tCorsHeaders: []string{\"*\"},\n\t\t},\n\t})\n\tt.Cleanup(restoreConfig)\n\n\trecorder := httptest.NewRecorder()\n\tctx, engine := gin.CreateTestContext(recorder)\n\tengine.Use(CorsHandler())\n\tengine.OPTIONS(\"/demo\", func(c *gin.Context) {\n\t\tc.Status(http.StatusNoContent)\n\t})\n\n\treq := httptest.NewRequest(http.MethodOptions, \"/demo\", nil)\n\treq.Header.Set(\"Origin\", \"http://localhost:3000\")\n\treq.Header.Set(\"Access-Control-Request-Method\", http.MethodPatch)\n\treq.Header.Set(\"Access-Control-Request-Headers\", \"Authorization,Content-Type\")\n\tctx.Request = req\n\n\tengine.HandleContext(ctx)\n\n\tif recorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"expected status 204, got %d\", recorder.Code)\n\t}\n\tif got := recorder.Header().Get(\"Access-Control-Allow-Methods\"); got != \"GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS\" {\n\t\tt.Fatalf(\"unexpected allow methods: %q\", got)\n\t}\n\tif got := recorder.Header().Get(\"Access-Control-Allow-Headers\"); got != \"Authorization,Content-Type\" {\n\t\tt.Fatalf(\"unexpected allow headers: %q\", got)\n\t}\n}\n\nfunc TestGetExposeHeadersSupportsWildcard(t *testing.T) {\n\tcfg := &config.Conf{\n\t\tAppConfig: autoload.AppConfig{\n\t\t\tCorsExposeHeaders: []string{\"*\"},\n\t\t},\n\t}\n\n\tif got := getExposeHeaders(cfg); got != \"*\" {\n\t\tt.Fatalf(\"expected wildcard expose headers, got %q\", got)\n\t}\n}\n"
  },
  {
    "path": "internal/middleware/database_ready.go",
    "content": "package middleware\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n)\n\n// DatabaseReadyGuard 强制要求 MySQL 已就绪，否则直接返回统一业务错误。\nfunc DatabaseReadyGuard() gin.HandlerFunc {\n\treturn databaseReadyGuard(false)\n}\n\n// OptionalDatabaseReadyGuard 仅在请求已识别出登录用户时校验 MySQL 就绪状态。\n// 用于需要先保留“未登录”语义的受保护路由。\nfunc OptionalDatabaseReadyGuard() gin.HandlerFunc {\n\treturn databaseReadyGuard(true)\n}\n\nfunc databaseReadyGuard(skipWhenNoUID bool) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif skipWhenNoUID && c.GetUint(global.ContextKeyUID) == 0 {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tif data.MysqlReady() {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tresponse.FailCode(c, e.ServiceDependencyNotReady)\n\t}\n}\n"
  },
  {
    "path": "internal/middleware/database_ready_test.go",
    "content": "package middleware\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\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\ntype dependencyErrorResponse struct {\n\tCode int `json:\"code\"`\n}\n\nfunc TestDatabaseReadyGuardBlocksWhenMysqlUnavailable(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trestoreMysql := disableMysqlForGuardTest(t)\n\tdefer restoreMysql()\n\n\tengine := gin.New()\n\tnextCalled := false\n\tengine.Use(DatabaseReadyGuard())\n\tengine.GET(\"/guarded\", func(c *gin.Context) {\n\t\tnextCalled = true\n\t\tc.Status(http.StatusNoContent)\n\t})\n\n\trecorder := httptest.NewRecorder()\n\trequest := httptest.NewRequest(http.MethodGet, \"/guarded\", nil)\n\tengine.ServeHTTP(recorder, request)\n\n\tif nextCalled {\n\t\tt.Fatal(\"expected strict guard to block request when mysql is unavailable\")\n\t}\n\tassertDependencyNotReadyCode(t, recorder)\n}\n\nfunc TestOptionalDatabaseReadyGuardKeepsUnauthenticatedRequests(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trestoreMysql := disableMysqlForGuardTest(t)\n\tdefer restoreMysql()\n\n\tengine := gin.New()\n\tnextCalled := false\n\tengine.Use(OptionalDatabaseReadyGuard())\n\tengine.GET(\"/guarded\", func(c *gin.Context) {\n\t\tnextCalled = true\n\t\tc.Status(http.StatusNoContent)\n\t})\n\n\trecorder := httptest.NewRecorder()\n\trequest := httptest.NewRequest(http.MethodGet, \"/guarded\", nil)\n\tengine.ServeHTTP(recorder, request)\n\n\tif !nextCalled {\n\t\tt.Fatal(\"expected optional guard to skip unauthenticated request\")\n\t}\n\tif recorder.Code != http.StatusNoContent {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusNoContent, recorder.Code)\n\t}\n}\n\nfunc TestOptionalDatabaseReadyGuardBlocksAuthenticatedRequests(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trestoreMysql := disableMysqlForGuardTest(t)\n\tdefer restoreMysql()\n\n\tengine := gin.New()\n\tnextCalled := false\n\tengine.Use(func(c *gin.Context) {\n\t\tc.Set(global.ContextKeyUID, uint(1))\n\t\tc.Next()\n\t})\n\tengine.Use(OptionalDatabaseReadyGuard())\n\tengine.GET(\"/guarded\", func(c *gin.Context) {\n\t\tnextCalled = true\n\t\tc.Status(http.StatusNoContent)\n\t})\n\n\trecorder := httptest.NewRecorder()\n\trequest := httptest.NewRequest(http.MethodGet, \"/guarded\", nil)\n\tengine.ServeHTTP(recorder, request)\n\n\tif nextCalled {\n\t\tt.Fatal(\"expected optional guard to block authenticated request when mysql is unavailable\")\n\t}\n\tassertDependencyNotReadyCode(t, recorder)\n}\n\nfunc disableMysqlForGuardTest(t *testing.T) func() {\n\tt.Helper()\n\n\trestoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {\n\t\tcfg.Mysql.Enable = false\n\t})\n\tif err := data.CloseMysql(); err != nil {\n\t\tt.Fatalf(\"close mysql: %v\", err)\n\t}\n\n\treturn func() {\n\t\trestoreConfig()\n\t\tif err := data.CloseMysql(); err != nil {\n\t\t\tt.Fatalf(\"close mysql: %v\", err)\n\t\t}\n\t}\n}\n\nfunc assertDependencyNotReadyCode(t *testing.T, recorder *httptest.ResponseRecorder) {\n\tt.Helper()\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, recorder.Code)\n\t}\n\n\tvar result dependencyErrorResponse\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\tif result.Code != e.ServiceDependencyNotReady {\n\t\tt.Fatalf(\"expected code %d, got %d\", e.ServiceDependencyNotReady, result.Code)\n\t}\n}\n"
  },
  {
    "path": "internal/middleware/logger.go",
    "content": "package middleware\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/jobs\"\n)\n\nconst (\n\tdefaultStatusCode   = http.StatusOK\n\tmaxRequestBodySize  = 16 * 1024 // 请求体最大记录大小：16KB\n\tmaxResponseBodySize = 32 * 1024 // 响应体最大记录大小：32KB\n)\n\n// CustomLogger 自定义日志中间件\nfunc CustomLogger() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\trecorder := prepareRequestLogging(c)\n\t\tc.Next()\n\t\tfinalizeRequestLogging(c, recorder)\n\t}\n}\n\n// prepareRequestLogging 在业务处理前完成日志采集准备（请求体快照 + 响应录制器）。\nfunc prepareRequestLogging(c *gin.Context) *responseRecorder {\n\t// 在请求处理前缓存请求体，避免后续读取时丢失原始内容。\n\tcacheRequestBody(c)\n\n\t// 替换 Writer 以捕获响应状态码和响应体快照。\n\trecorder := createResponseRecorder(c)\n\tc.Writer = recorder\n\treturn recorder\n}\n\n// finalizeRequestLogging 在业务处理后统一执行日志收尾（仅审计日志）。\nfunc finalizeRequestLogging(c *gin.Context, recorder *responseRecorder) {\n\tif shouldSkipRequestLogging(c, recorder) {\n\t\treturn\n\t}\n\n\tpublishRequestAuditLog(c, recorder)\n}\n\n// logRequest 兼容旧调用入口，统一委托到收尾阶段。\nfunc logRequest(c *gin.Context, recorder *responseRecorder) {\n\tfinalizeRequestLogging(c, recorder)\n}\n\n// shouldSkipRequestLogging 判定是否跳过本次请求的日志记录。\nfunc shouldSkipRequestLogging(c *gin.Context, recorder *responseRecorder) bool {\n\tif c == nil || c.Request == nil || recorder == nil {\n\t\treturn true\n\t}\n\n\t// ping 请求不记录日志\n\tif c.Request.URL.Path == \"/ping\" {\n\t\treturn true\n\t}\n\n\t// 404 请求不记录日志（避免过多无效请求干扰日志分析）\n\treturn recorder.statusCode == http.StatusNotFound\n}\n\n// publishRequestAuditLog 构建请求审计快照并投递到审计日志链路。\nfunc publishRequestAuditLog(c *gin.Context, recorder *responseRecorder) {\n\t// 先提取不可变快照，再发布审计日志，避免在日志中间件内耦合过多细节。\n\tresp := parseResponse(c, recorder)\n\tsnapshot := buildRequestAuditLogSnapshot(c, recorder, resp)\n\tenqueueAuditLog(c, jobs.AuditLogKindRequest, snapshot)\n}\n"
  },
  {
    "path": "internal/middleware/logger_bench_test.go",
    "content": "package middleware\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n)\n\nfunc BenchmarkCustomLoggerJSONPost(b *testing.B) {\n\tgin.SetMode(gin.TestMode)\n\tbody := []byte(`{\"name\":\"codex\",\"email\":\"codex@example.com\",\"password\":\"secret\"}`)\n\n\tb.ReportAllocs()\n\tfor i := 0; i < b.N; i++ {\n\t\trecorder := httptest.NewRecorder()\n\t\tctx, _ := gin.CreateTestContext(recorder)\n\t\tctx.Request = httptest.NewRequest(http.MethodPost, \"/admin/v1/demo\", bytes.NewReader(body))\n\t\tctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\t\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\t\tctx.Set(global.ContextKeyRequestID, \"bench-request\")\n\n\t\tcacheRequestBody(ctx)\n\n\t\trespRecorder := createResponseRecorder(ctx)\n\t\trespRecorder.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = respRecorder.Write([]byte(`{\"code\":0,\"msg\":\"ok\",\"data\":{\"id\":1}}`))\n\n\t\tresp := parseResponse(ctx, respRecorder)\n\t\tif resp == nil {\n\t\t\tresp = &response.Result{Code: 0}\n\t\t}\n\t\tsnapshot := buildRequestAuditLogSnapshot(ctx, respRecorder, resp)\n\t\tif snapshot == nil {\n\t\t\tb.Fatal(\"expected snapshot\")\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/middleware/logger_recorder.go",
    "content": "package middleware\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n)\n\ntype requestBodyCache struct {\n\tbody       []byte\n\ttotalBytes int\n\ttruncated  bool\n}\n\n// responseRecorder 响应记录器，用于记录响应内容。\ntype responseRecorder struct {\n\tgin.ResponseWriter\n\tcaptureBody   bool\n\tbody          *bytes.Buffer\n\tstatusCode    int\n\tresponseBytes int\n\ttruncated     bool\n}\n\n// Write 写入响应数据。\nfunc (r *responseRecorder) Write(b []byte) (int, error) {\n\tr.cacheBody(b)\n\treturn r.ResponseWriter.Write(b)\n}\n\n// WriteString 写入字符串响应。\nfunc (r *responseRecorder) WriteString(s string) (int, error) {\n\tr.cacheBody([]byte(s))\n\treturn r.ResponseWriter.WriteString(s)\n}\n\n// WriteHeader 写入HTTP状态码。\nfunc (r *responseRecorder) WriteHeader(statusCode int) {\n\tr.statusCode = statusCode\n\tr.ResponseWriter.WriteHeader(statusCode)\n}\n\n// cacheRequestBody 缓存请求体到上下文。\nfunc cacheRequestBody(c *gin.Context) {\n\tif c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead {\n\t\treturn\n\t}\n\tif _, exists := c.Get(\"requestBody\"); exists {\n\t\treturn\n\t}\n\tif c.Request.Body == nil {\n\t\treturn\n\t}\n\tif shouldSkipRequestBodyCache(c) {\n\t\treturn\n\t}\n\n\tbodyBytes, totalBytes, truncated, err := snapshotRequestBody(c.Request)\n\tif err != nil || len(bodyBytes) == 0 {\n\t\treturn\n\t}\n\tc.Set(\"requestBody\", &requestBodyCache{\n\t\tbody:       truncateBytes(bodyBytes, maxRequestBodySize),\n\t\ttotalBytes: totalBytes,\n\t\ttruncated:  truncated,\n\t})\n}\n\n// createResponseRecorder 创建响应记录器。\nfunc createResponseRecorder(c *gin.Context) *responseRecorder {\n\treturn &responseRecorder{\n\t\tResponseWriter: c.Writer,\n\t\tcaptureBody:    shouldCaptureResponseBody(c),\n\t\tbody:           bytes.NewBuffer(nil),\n\t\tstatusCode:     defaultStatusCode,\n\t}\n}\n\nfunc (r *responseRecorder) cacheBody(b []byte) {\n\tif !r.captureBody {\n\t\treturn\n\t}\n\tr.responseBytes += len(b)\n\tif r.truncated || len(b) == 0 {\n\t\treturn\n\t}\n\n\tremaining := maxResponseBodySize - r.body.Len()\n\tif remaining <= 0 {\n\t\tr.truncated = true\n\t\treturn\n\t}\n\tif len(b) <= remaining {\n\t\t_, _ = r.body.Write(b)\n\t\treturn\n\t}\n\n\t_, _ = r.body.Write(b[:remaining])\n\tr.truncated = true\n}\n\n// parseResponse 解析响应数据。\nfunc parseResponse(c *gin.Context, recorder *responseRecorder) *response.Result {\n\tif recorder == nil || !recorder.captureBody || recorder.body.Len() == 0 {\n\t\treturn nil\n\t}\n\tif !strings.Contains(strings.ToLower(recorder.Header().Get(\"Content-Type\")), \"json\") {\n\t\treturn nil\n\t}\n\tvar resp response.Result\n\tif err := json.Unmarshal(recorder.body.Bytes(), &resp); err != nil {\n\t\treturn nil\n\t}\n\n\tif c.Request.Method == http.MethodGet {\n\t\tresp.Data = nil\n\t}\n\treturn &resp\n}\n\nfunc shouldCaptureResponseBody(c *gin.Context) bool {\n\tif c == nil || c.Request == nil {\n\t\treturn true\n\t}\n\tif c.Request.URL.Path == \"/ping\" {\n\t\treturn false\n\t}\n\treturn c.Request.Method != http.MethodGet\n}\n\n// readRequestBody 读取请求体缓存。\nfunc readRequestBody(c *gin.Context) []byte {\n\tif body, exists := c.Get(\"requestBody\"); exists {\n\t\tif cached, ok := body.(*requestBodyCache); ok && len(cached.body) > 0 {\n\t\t\treturn cached.body\n\t\t}\n\t}\n\n\tif c.Request.Body == nil {\n\t\treturn nil\n\t}\n\tif shouldSkipRequestBodyCache(c) {\n\t\treturn nil\n\t}\n\n\tbodyBytes, totalBytes, truncated, err := snapshotRequestBody(c.Request)\n\tif err != nil || len(bodyBytes) == 0 {\n\t\treturn nil\n\t}\n\n\tcached := &requestBodyCache{\n\t\tbody:       truncateBytes(bodyBytes, maxRequestBodySize),\n\t\ttotalBytes: totalBytes,\n\t\ttruncated:  truncated,\n\t}\n\tc.Set(\"requestBody\", cached)\n\treturn cached.body\n}\n\nfunc getRequestBodyCache(c *gin.Context) *requestBodyCache {\n\tif body, exists := c.Get(\"requestBody\"); exists {\n\t\tif cached, ok := body.(*requestBodyCache); ok {\n\t\t\treturn cached\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc truncateBytes(body []byte, maxSize int) []byte {\n\tif len(body) <= maxSize {\n\t\treturn body\n\t}\n\treturn body[:maxSize]\n}\n\nfunc shouldSkipRequestBodyCache(c *gin.Context) bool {\n\tcontentType := strings.ToLower(c.GetHeader(\"Content-Type\"))\n\tswitch {\n\tcase strings.HasPrefix(contentType, \"multipart/form-data\"):\n\t\treturn true\n\tcase strings.HasPrefix(contentType, \"application/octet-stream\"):\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc snapshotRequestBody(req *http.Request) ([]byte, int, bool, error) {\n\tif req == nil || req.Body == nil {\n\t\treturn nil, 0, false, nil\n\t}\n\n\tif req.ContentLength >= 0 && req.ContentLength <= maxRequestBodySize {\n\t\tbodyBytes, err := io.ReadAll(req.Body)\n\t\tif err != nil || len(bodyBytes) == 0 {\n\t\t\treturn nil, 0, false, err\n\t\t}\n\t\treq.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n\t\treturn bodyBytes, len(bodyBytes), false, nil\n\t}\n\n\tpeekLimit := int64(maxRequestBodySize + 1)\n\tbodyBytes, err := io.ReadAll(io.LimitReader(req.Body, peekLimit))\n\tif err != nil || len(bodyBytes) == 0 {\n\t\treturn nil, 0, false, err\n\t}\n\n\treq.Body = io.NopCloser(io.MultiReader(bytes.NewReader(bodyBytes), req.Body))\n\n\ttruncated := len(bodyBytes) > maxRequestBodySize\n\ttotalBytes := len(bodyBytes)\n\tif req.ContentLength > int64(totalBytes) {\n\t\ttotalBytes = int(req.ContentLength)\n\t\ttruncated = true\n\t}\n\n\treturn bodyBytes, totalBytes, truncated, nil\n}\n"
  },
  {
    "path": "internal/middleware/logger_storage.go",
    "content": "package middleware\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/mssola/useragent\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/sensitive\"\n\taccesssvc \"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\tauditsvc \"github.com/wannanbigpig/gin-layout/internal/service/audit\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/auth\"\n)\n\nfunc buildRequestAuditLogSnapshot(c *gin.Context, recorder *responseRecorder, resp *response.Result) *auditsvc.AuditLogSnapshot {\n\tif c == nil {\n\t\treturn nil\n\t}\n\treturn buildAuditLogSnapshot(c, recorder, operationStatusFromResponse(recorder, resp), buildMaskedResponseBody(recorder), sensitive.GetMaskedResponseHeaders(recorder.Header()))\n}\n\nfunc buildPanicAuditLogSnapshot(c *gin.Context, panicMessage string) *auditsvc.AuditLogSnapshot {\n\tif c == nil {\n\t\treturn nil\n\t}\n\n\tresponseBody, err := json.Marshal(response.Result{\n\t\tCode:      http.StatusInternalServerError,\n\t\tMsg:       panicMessage,\n\t\tData:      map[string]any{},\n\t\tRequestId: c.GetString(global.ContextKeyRequestID),\n\t})\n\tif err != nil {\n\t\tresponseBody = []byte{}\n\t}\n\n\treturn buildAuditLogSnapshot(c, nil, http.StatusInternalServerError, string(responseBody), \"\")\n}\n\ntype auditRequestMeta struct {\n\trequestID      string\n\tmethod         string\n\tpath           string\n\tip             string\n\tuserAgent      string\n\tos             string\n\tbrowser        string\n\toperationName  string\n\trequestHeaders string\n\trequestQuery   string\n\trequestBody    string\n\texecutionTime  float64\n}\n\ntype auditOperatorMeta struct {\n\toperatorID      uint\n\tjwtID           string\n\toperatorAccount string\n\toperatorName    string\n}\n\n// buildAuditLogSnapshot 组装审计快照（仅负责编排，不包含具体字段提取细节）。\nfunc buildAuditLogSnapshot(c *gin.Context, recorder *responseRecorder, operationStatus int, responseBody string, responseHeader string) *auditsvc.AuditLogSnapshot {\n\trequestMeta := collectAuditRequestMeta(c)\n\tif requestMeta == nil {\n\t\treturn nil\n\t}\n\n\toperatorMeta := collectAuditOperatorMeta(c)\n\tisHighRisk := resolveAuditHighRisk(c, requestMeta.method)\n\tchangeDiff := resolveAuditChangeDiff(c, isHighRisk, requestMeta.requestBody, responseBody)\n\n\treturn &auditsvc.AuditLogSnapshot{\n\t\tRequestID:       requestMeta.requestID,\n\t\tJwtID:           operatorMeta.jwtID,\n\t\tOperatorID:      operatorMeta.operatorID,\n\t\tOperatorAccount: operatorMeta.operatorAccount,\n\t\tOperatorName:    operatorMeta.operatorName,\n\t\tIP:              requestMeta.ip,\n\t\tUserAgent:       requestMeta.userAgent,\n\t\tOS:              requestMeta.os,\n\t\tBrowser:         requestMeta.browser,\n\t\tMethod:          requestMeta.method,\n\t\tBaseURL:         requestMeta.path,\n\t\tOperationName:   requestMeta.operationName,\n\t\tOperationStatus: operationStatus,\n\t\tIsHighRisk:      isHighRisk,\n\t\tRequestHeaders:  requestMeta.requestHeaders,\n\t\tRequestQuery:    requestMeta.requestQuery,\n\t\tRequestBody:     requestMeta.requestBody,\n\t\tChangeDiff:      changeDiff,\n\t\tResponseStatus:  resolveAuditResponseStatus(recorder),\n\t\tResponseBody:    responseBody,\n\t\tResponseHeader:  responseHeader,\n\t\tExecutionTime:   requestMeta.executionTime,\n\t}\n}\n\n// collectAuditRequestMeta 提取请求侧审计信息（请求标识、UA、请求体、耗时等）。\nfunc collectAuditRequestMeta(c *gin.Context) *auditRequestMeta {\n\trequestID := c.GetString(global.ContextKeyRequestID)\n\tif requestID == \"\" {\n\t\treturn nil\n\t}\n\n\tmethod := c.Request.Method\n\tpath := c.Request.URL.Path\n\tuserAgentStr := c.Request.UserAgent()\n\tua := useragent.New(userAgentStr)\n\tbrowser, _ := ua.Browser()\n\n\treturn &auditRequestMeta{\n\t\trequestID:      requestID,\n\t\tmethod:         method,\n\t\tpath:           path,\n\t\tip:             c.ClientIP(),\n\t\tuserAgent:      userAgentStr,\n\t\tos:             ua.OS(),\n\t\tbrowser:        browser,\n\t\toperationName:  getOperationName(path, method, c.GetHeader(\"X-Operation-Name\")),\n\t\trequestHeaders: sensitive.GetMaskedRequestHeaders(c.Request.Header),\n\t\trequestQuery:   sensitive.MaskQueryString(c.Request.URL.RawQuery),\n\t\trequestBody:    resolveAuditRequestBody(c),\n\t\texecutionTime:  calculateExecutionTimeMs(c),\n\t}\n}\n\n// collectAuditOperatorMeta 提取操作者信息（优先 principal，回退到上下文 uid）。\nfunc collectAuditOperatorMeta(c *gin.Context) auditOperatorMeta {\n\tif principal := auth.GetAuthPrincipal(c); principal != nil {\n\t\treturn auditOperatorMeta{\n\t\t\toperatorID:      principal.UserID,\n\t\t\tjwtID:           principal.JWTID,\n\t\t\toperatorAccount: principal.Username,\n\t\t\toperatorName:    principal.Nickname,\n\t\t}\n\t}\n\n\treturn auditOperatorMeta{\n\t\toperatorID: c.GetUint(global.ContextKeyUID),\n\t}\n}\n\nfunc calculateExecutionTimeMs(c *gin.Context) float64 {\n\tduration := time.Since(c.GetTime(global.ContextKeyRequestStartTime))\n\texecutionTime := float64(duration.Nanoseconds()) / 1000000.0\n\treturn float64(int(executionTime*10000+0.5)) / 10000.0\n}\n\nfunc resolveAuditResponseStatus(recorder *responseRecorder) int {\n\tif recorder == nil {\n\t\treturn http.StatusOK\n\t}\n\treturn recorder.statusCode\n}\n\nfunc buildMaskedRequestBody(c *gin.Context) string {\n\tcached := getRequestBodyCache(c)\n\tif cached == nil {\n\t\tbodyBytes := readRequestBody(c)\n\t\tif bodyBytes == nil {\n\t\t\treturn \"\"\n\t\t}\n\t\tcached = getRequestBodyCache(c)\n\t}\n\tif cached == nil || len(cached.body) == 0 {\n\t\treturn \"\"\n\t}\n\n\tcontentType := c.Request.Header.Get(\"Content-Type\")\n\tif !cached.truncated {\n\t\treturn sensitive.GetMaskedRequestBody(cached.body, contentType)\n\t}\n\n\treturn sensitive.GetMaskedRequestBody(cached.body, contentType) + \"...(truncated,total_size=\" + strconv.Itoa(cached.totalBytes) + \"B)\"\n}\n\nfunc resolveAuditRequestBody(c *gin.Context) string {\n\tif c != nil {\n\t\tif raw, exists := c.Get(global.ContextKeyAuditRequestBody); exists {\n\t\t\tif value, ok := raw.(string); ok && strings.TrimSpace(value) != \"\" {\n\t\t\t\treturn value\n\t\t\t}\n\t\t}\n\t}\n\treturn buildMaskedRequestBody(c)\n}\n\nfunc buildMaskedResponseBody(recorder *responseRecorder) string {\n\tif recorder == nil {\n\t\treturn \"\"\n\t}\n\tbodyBytes := recorder.body.Bytes()\n\tif len(bodyBytes) == 0 {\n\t\treturn \"\"\n\t}\n\n\tif !recorder.truncated {\n\t\treturn sensitive.GetMaskedResponseBody(bodyBytes)\n\t}\n\n\treturn sensitive.GetMaskedResponseBody(bodyBytes) + \"...(truncated,total_size=\" + strconv.Itoa(recorder.responseBytes) + \"B)\"\n}\n\nfunc resolveAuditHighRisk(c *gin.Context, method string) uint8 {\n\tif c != nil {\n\t\tif raw, exists := c.Get(global.ContextKeyAuditHighRisk); exists {\n\t\t\tswitch value := raw.(type) {\n\t\t\tcase bool:\n\t\t\t\tif value {\n\t\t\t\t\treturn 1\n\t\t\t\t}\n\t\t\t\treturn 0\n\t\t\tcase uint8:\n\t\t\t\tif value > 0 {\n\t\t\t\t\treturn 1\n\t\t\t\t}\n\t\t\t\treturn 0\n\t\t\tcase int:\n\t\t\t\tif value > 0 {\n\t\t\t\t\treturn 1\n\t\t\t\t}\n\t\t\t\treturn 0\n\t\t\tcase string:\n\t\t\t\tif strings.EqualFold(value, \"1\") || strings.EqualFold(value, \"true\") {\n\t\t\t\t\treturn 1\n\t\t\t\t}\n\t\t\t\tif strings.EqualFold(value, \"0\") || strings.EqualFold(value, \"false\") {\n\t\t\t\t\treturn 0\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tswitch method {\n\tcase http.MethodGet, http.MethodHead, http.MethodOptions:\n\t\treturn 0\n\tdefault:\n\t\treturn 1\n\t}\n}\n\nfunc resolveAuditChangeDiff(c *gin.Context, isHighRisk uint8, requestBody, responseBody string) string {\n\tif c != nil {\n\t\tif raw, exists := c.Get(global.ContextKeyAuditChangeDiff); exists {\n\t\t\tswitch value := raw.(type) {\n\t\t\tcase string:\n\t\t\t\tif strings.TrimSpace(value) != \"\" {\n\t\t\t\t\treturn value\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\tbytes, err := json.Marshal(value)\n\t\t\t\tif err == nil {\n\t\t\t\t\treturn string(bytes)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif isHighRisk == 0 || (requestBody == \"\" && responseBody == \"\") {\n\t\treturn \"\"\n\t}\n\n\tpayload := map[string]any{\n\t\t\"request_body\":  requestBody,\n\t\t\"response_body\": responseBody,\n\t}\n\tbytes, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn string(bytes)\n}\n\nfunc operationStatusFromResponse(recorder *responseRecorder, resp *response.Result) int {\n\tif resp != nil {\n\t\treturn resp.Code\n\t}\n\tif recorder != nil && recorder.statusCode >= http.StatusBadRequest {\n\t\treturn recorder.statusCode\n\t}\n\treturn 0\n}\n\n// getOperationName 获取操作名称。\nfunc getOperationName(route string, method string, headerOperationName string) string {\n\tif operationName := accesssvc.NewApiRouteCacheService().GetApiName(route, method); operationName != \"\" {\n\t\treturn operationName\n\t}\n\tif headerOperationName != \"\" {\n\t\treturn headerOperationName\n\t}\n\treturn route\n}\n"
  },
  {
    "path": "internal/middleware/logger_test.go",
    "content": "package middleware\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/auth\"\n)\n\n// TestCacheRequestBody 验证请求体缓存逻辑。\nfunc TestCacheRequestBody(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/demo\", bytes.NewBufferString(`{\"name\":\"codex\"}`))\n\n\tcacheRequestBody(ctx)\n\tbody := readRequestBody(ctx)\n\tif string(body) != `{\"name\":\"codex\"}` {\n\t\tt.Fatalf(\"unexpected cached body: %s\", string(body))\n\t}\n}\n\n// TestCacheRequestBodySkipsGet 验证 GET 请求不会缓存请求体。\nfunc TestCacheRequestBodySkipsGet(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodGet, \"/demo\", nil)\n\n\tcacheRequestBody(ctx)\n\tif body := readRequestBody(ctx); body != nil {\n\t\tt.Fatalf(\"expected nil body for get request, got %q\", string(body))\n\t}\n}\n\nfunc TestCacheRequestBodySkipsMultipartRequests(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/upload\", bytes.NewBufferString(\"file-content\"))\n\tctx.Request.Header.Set(\"Content-Type\", \"multipart/form-data; boundary=demo\")\n\n\tcacheRequestBody(ctx)\n\tif body := readRequestBody(ctx); body != nil {\n\t\tt.Fatalf(\"expected multipart body to be skipped, got %q\", string(body))\n\t}\n}\n\n// TestParseResponse 验证 JSON 响应解析逻辑。\nfunc TestParseResponse(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/demo\", nil)\n\n\trespRecorder := createResponseRecorder(ctx)\n\trespRecorder.Header().Set(\"Content-Type\", \"application/json\")\n\trespRecorder.body.WriteString(`{\"code\":0,\"msg\":\"ok\",\"data\":{\"name\":\"demo\"}}`)\n\n\tresp := parseResponse(ctx, respRecorder)\n\tif resp == nil {\n\t\tt.Fatal(\"expected parsed response\")\n\t}\n\tif resp.Code != 0 {\n\t\tt.Fatalf(\"expected code 0, got %d\", resp.Code)\n\t}\n}\n\n// TestParseResponseForNonJSON 验证非 JSON 响应不会解析成功。\nfunc TestParseResponseForNonJSON(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/demo\", nil)\n\n\trespRecorder := createResponseRecorder(ctx)\n\trespRecorder.Header().Set(\"Content-Type\", \"text/plain\")\n\trespRecorder.body.WriteString(\"pong\")\n\tif resp := parseResponse(ctx, respRecorder); resp != nil {\n\t\tt.Fatal(\"expected nil response for non-json body\")\n\t}\n}\n\n// TestBuildMaskedBodies 验证请求体和响应体截断逻辑。\nfunc TestBuildMaskedBodies(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/demo\", bytes.NewBuffer(bytes.Repeat([]byte(\"a\"), maxRequestBodySize+10)))\n\tctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\tcacheRequestBody(ctx)\n\n\trequestBody := buildMaskedRequestBody(ctx)\n\tif len(requestBody) == 0 {\n\t\tt.Fatal(\"expected masked request body\")\n\t}\n\tcached := getRequestBodyCache(ctx)\n\tif cached == nil {\n\t\tt.Fatal(\"expected cached request body\")\n\t}\n\tif !cached.truncated {\n\t\tt.Fatal(\"expected request body cache to be marked truncated\")\n\t}\n\tif len(cached.body) != maxRequestBodySize {\n\t\tt.Fatalf(\"expected cached request body length %d, got %d\", maxRequestBodySize, len(cached.body))\n\t}\n\tif !bytes.Contains([]byte(requestBody), []byte(\"truncated\")) {\n\t\tt.Fatalf(\"expected truncated marker in request body, got %s\", requestBody)\n\t}\n\n\trespRecorder := createResponseRecorder(ctx)\n\t_, _ = respRecorder.Write(bytes.Repeat([]byte(\"b\"), maxResponseBodySize+10))\n\tresponseBody := buildMaskedResponseBody(respRecorder)\n\tif len(responseBody) == 0 {\n\t\tt.Fatal(\"expected masked response body\")\n\t}\n\tif !respRecorder.truncated {\n\t\tt.Fatal(\"expected response recorder to mark body as truncated\")\n\t}\n\tif respRecorder.body.Len() != maxResponseBodySize {\n\t\tt.Fatalf(\"expected cached response body length %d, got %d\", maxResponseBodySize, respRecorder.body.Len())\n\t}\n\tif !bytes.Contains([]byte(responseBody), []byte(\"truncated\")) {\n\t\tt.Fatalf(\"expected truncated marker in response body, got %s\", responseBody)\n\t}\n}\n\nfunc TestReadRequestBodyPreservesLargeRequestBody(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\toriginalBody := bytes.Repeat([]byte(\"x\"), maxRequestBodySize+128)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/demo\", bytes.NewReader(originalBody))\n\tctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\n\tcacheRequestBody(ctx)\n\n\tcached := readRequestBody(ctx)\n\tif len(cached) != maxRequestBodySize {\n\t\tt.Fatalf(\"expected cached body length %d, got %d\", maxRequestBodySize, len(cached))\n\t}\n\n\tremaining, err := io.ReadAll(ctx.Request.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected read error: %v\", err)\n\t}\n\tif !bytes.Equal(remaining, originalBody) {\n\t\tt.Fatal(\"expected request body to remain readable after snapshot\")\n\t}\n}\n\n// TestOperationStatusFromResponse 验证操作状态选择逻辑。\nfunc TestOperationStatusFromResponse(t *testing.T) {\n\trecorder := &responseRecorder{statusCode: http.StatusBadRequest}\n\tif got := operationStatusFromResponse(recorder, &response.Result{Code: 10000}); got != 10000 {\n\t\tt.Fatalf(\"expected business code, got %d\", got)\n\t}\n\tif got := operationStatusFromResponse(recorder, nil); got != http.StatusBadRequest {\n\t\tt.Fatalf(\"expected http status, got %d\", got)\n\t}\n\n\trecorder.statusCode = http.StatusOK\n\tif got := operationStatusFromResponse(recorder, nil); got != 0 {\n\t\tt.Fatalf(\"expected default status 0, got %d\", got)\n\t}\n}\n\n// TestLogRequestSkipsPing 验证 ping 请求不会触发后续日志处理。\nfunc TestLogRequestSkipsPing(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodGet, \"/ping\", nil)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\n\trespRecorder := createResponseRecorder(ctx)\n\tlogRequest(ctx, respRecorder)\n}\n\nfunc TestBuildRequestAuditLogSnapshotUsesPrincipal(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/demo\", bytes.NewBufferString(`{\"name\":\"codex\"}`))\n\tctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-1\")\n\tcacheRequestBody(ctx)\n\n\tauth.StoreAuthPrincipal(ctx, &auth.AuthPrincipal{\n\t\tUserID:   12,\n\t\tJWTID:    \"jwt-1\",\n\t\tUsername: \"tester\",\n\t\tNickname: \"Tester\",\n\t})\n\n\trespRecorder := createResponseRecorder(ctx)\n\trespRecorder.body.WriteString(`{\"code\":0,\"msg\":\"ok\",\"data\":{\"name\":\"demo\"}}`)\n\tsnapshot := buildRequestAuditLogSnapshot(ctx, respRecorder, &response.Result{Code: 0})\n\tif snapshot == nil {\n\t\tt.Fatal(\"expected audit snapshot\")\n\t}\n\tif snapshot.OperatorID != 12 || snapshot.JwtID != \"jwt-1\" {\n\t\tt.Fatalf(\"unexpected operator fields: %#v\", snapshot)\n\t}\n\tif snapshot.OperatorAccount != \"tester\" || snapshot.OperatorName != \"Tester\" {\n\t\tt.Fatalf(\"unexpected operator names: %#v\", snapshot)\n\t}\n}\n\nfunc TestBuildRequestAuditLogSnapshotUsesAuditRequestBodyOverride(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/admin/v1/system/config/create\", bytes.NewBufferString(`{\"config_value\":\"plain-secret\"}`))\n\tctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-override\")\n\tcacheRequestBody(ctx)\n\tSetAuditRequestBodyRaw(ctx, `{\"config_value\":\"******\"}`)\n\n\trespRecorder := createResponseRecorder(ctx)\n\trespRecorder.body.WriteString(`{\"code\":0,\"msg\":\"ok\",\"data\":{}}`)\n\tsnapshot := buildRequestAuditLogSnapshot(ctx, respRecorder, &response.Result{Code: 0})\n\tif snapshot == nil {\n\t\tt.Fatal(\"expected audit snapshot\")\n\t}\n\tif snapshot.RequestBody != `{\"config_value\":\"******\"}` {\n\t\tt.Fatalf(\"unexpected request body override: %s\", snapshot.RequestBody)\n\t}\n}\n\nfunc TestBuildRequestAuditLogSnapshotMarksHighRiskAndDiff(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodPost, \"/demo\", bytes.NewBufferString(`{\"name\":\"after\"}`))\n\tctx.Request.Header.Set(\"Content-Type\", \"application/json\")\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-2\")\n\tcacheRequestBody(ctx)\n\n\tSetAuditHighRisk(ctx, true)\n\tSetAuditChangeDiff(ctx, map[string]any{\"name\": \"before\"}, map[string]any{\"name\": \"after\"})\n\n\trespRecorder := createResponseRecorder(ctx)\n\trespRecorder.body.WriteString(`{\"code\":0,\"msg\":\"ok\"}`)\n\n\tsnapshot := buildRequestAuditLogSnapshot(ctx, respRecorder, &response.Result{Code: 0})\n\tif snapshot == nil {\n\t\tt.Fatal(\"expected audit snapshot\")\n\t}\n\tif snapshot.IsHighRisk != 1 {\n\t\tt.Fatalf(\"expected high risk audit snapshot, got %#v\", snapshot.IsHighRisk)\n\t}\n\n\tdiff := map[string]any{}\n\tif err := json.Unmarshal([]byte(snapshot.ChangeDiff), &diff); err != nil {\n\t\tt.Fatalf(\"expected valid change diff json, got err=%v raw=%s\", err, snapshot.ChangeDiff)\n\t}\n\tif _, ok := diff[\"before\"]; !ok {\n\t\tt.Fatalf(\"expected before section in change diff, got %#v\", diff)\n\t}\n\tif _, ok := diff[\"after\"]; !ok {\n\t\tt.Fatalf(\"expected after section in change diff, got %#v\", diff)\n\t}\n}\n\nfunc TestBuildRequestAuditLogSnapshotGetRequestIsNotHighRiskByDefault(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodGet, \"/demo\", nil)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-3\")\n\n\trespRecorder := createResponseRecorder(ctx)\n\trespRecorder.body.WriteString(`{\"code\":0,\"msg\":\"ok\"}`)\n\n\tsnapshot := buildRequestAuditLogSnapshot(ctx, respRecorder, &response.Result{Code: 0})\n\tif snapshot == nil {\n\t\tt.Fatal(\"expected audit snapshot\")\n\t}\n\tif snapshot.IsHighRisk != 0 {\n\t\tt.Fatalf(\"expected get request not high risk, got %#v\", snapshot.IsHighRisk)\n\t}\n\tif snapshot.ChangeDiff != \"\" {\n\t\tt.Fatalf(\"expected empty change diff for get request, got %s\", snapshot.ChangeDiff)\n\t}\n}\n"
  },
  {
    "path": "internal/middleware/parse_token.go",
    "content": "package middleware\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\treq \"github.com/wannanbigpig/gin-layout/internal/pkg/request\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/auth\"\n)\n\n// ParseTokenHandler 全局token解析中间件（所有路由都走）\n// 功能：\n//   - 尝试从请求头提取token（不强制要求）\n//   - 如果token存在且有效，解析并设置用户信息到context\n//   - 如果token不存在或无效，静默继续执行（用于可选认证的路由）\n//\n// 注意：此中间件不会阻止请求，即使token无效也会继续执行\nfunc ParseTokenHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 提前返回：如果没有token，直接继续执行\n\t\taccessToken, err := req.GetAccessToken(c)\n\t\tif err != nil || accessToken == \"\" {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tloginService := auth.NewLoginService()\n\t\tloginService.SetCtx(c)\n\t\tprincipal, ok := loginService.ResolvePrincipal(accessToken)\n\t\tif !ok || principal == nil {\n\t\t\t// token无效，静默继续（可选认证）\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// token有效，设置认证主体到上下文\n\t\tauth.StoreAuthPrincipal(c, principal)\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "internal/middleware/recovery.go",
    "content": "package middleware\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/jobs\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n)\n\nconst (\n\t// panicErrorPrefix 服务器内部错误前缀\n\tpanicErrorPrefix = \"An error occurred in the server's internal code: \"\n\t// panicRecoveredMsg panic恢复日志消息\n\tpanicRecoveredMsg = \"panic recovered\"\n)\n\n// CustomRecovery 自定义错误 (panic) 拦截中间件\n// 对可能发生的 panic 进行拦截、统一记录并返回友好的错误响应\nfunc CustomRecovery() gin.HandlerFunc {\n\terrorWriter := &PanicExceptionRecord{}\n\treturn gin.RecoveryWithWriter(errorWriter, handlePanic)\n}\n\n// handlePanic 处理 panic 恢复逻辑\nfunc handlePanic(c *gin.Context, err interface{}) {\n\terrStr := \"服务器内部错误\"\n\tcfg := config.GetConfig()\n\tif cfg != nil && cfg.Debug {\n\t\terrStr = fmt.Sprintf(\"%v\", err)\n\t}\n\n\t// 记录错误日志\n\tcost := time.Since(c.GetTime(global.ContextKeyRequestStartTime))\n\trequestID := c.GetString(global.ContextKeyRequestID)\n\tlogFields := []zap.Field{\n\t\tzap.String(\"requestId\", requestID),\n\t\tzap.Int(\"status\", c.Writer.Status()),\n\t\tzap.String(\"method\", c.Request.Method),\n\t\tzap.String(\"path\", c.Request.URL.Path),\n\t\tzap.String(\"query\", c.Request.URL.RawQuery),\n\t\tzap.String(\"ip\", c.ClientIP()),\n\t\tzap.String(\"user-agent\", c.Request.UserAgent()),\n\t\tzap.String(\"errors\", errStr),\n\t\tzap.Duration(\"cost\", cost),\n\t}\n\tif gin.Mode() != gin.ReleaseMode {\n\t\tif requestBody := readRequestBody(c); requestBody != nil {\n\t\t\tlogFields = append(logFields, zap.ByteString(\"body\", requestBody))\n\t\t}\n\t}\n\tlogger.Error(panicRecoveredMsg, logFields...)\n\n\t// 为 panic 请求补充异步审计日志，避免绕过 CustomLogger 的落库流程。\n\tsnapshot := buildPanicAuditLogSnapshot(c, errStr)\n\tenqueueAuditLog(c, jobs.AuditLogKindPanic, snapshot)\n\n\t// 返回错误响应\n\tresponse.Resp().\n\t\tSetHttpCode(http.StatusInternalServerError).\n\t\tFailCode(c, e.ServerErr, errStr)\n}\n\n// PanicExceptionRecord panic 异常记录器\n// 实现 io.Writer 接口，用于记录 panic 的完整堆栈信息\ntype PanicExceptionRecord struct{}\n\n// Write 写入 panic 异常信息\nfunc (p *PanicExceptionRecord) Write(b []byte) (n int, err error) {\n\tvar builder strings.Builder\n\tbuilder.Grow(len(panicErrorPrefix) + len(b))\n\tbuilder.WriteString(panicErrorPrefix)\n\tbuilder.Write(b)\n\terrStr := builder.String()\n\tlogger.Error(errStr)\n\treturn len(errStr), errors.New(errStr)\n}\n"
  },
  {
    "path": "internal/middleware/request_cost.go",
    "content": "package middleware\n\nimport (\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n)\n\n// RequestCostHandler 请求耗时和请求ID中间件\n// 功能：\n// 1. 记录请求开始时间，用于后续计算请求耗时\n// 2. 为每个请求生成唯一的请求ID，用于日志追踪\nfunc RequestCostHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 设置请求上下文信息：开始时间 + 请求ID。\n\t\tc.Set(global.ContextKeyRequestStartTime, time.Now())\n\t\tc.Set(global.ContextKeyRequestID, uuid.New().String())\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "internal/middleware/request_locale.go",
    "content": "package middleware\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/i18n\"\n)\n\nconst acceptLanguageHeader = \"Accept-Language\"\n\n// RequestLocaleHandler 解析请求语言并写入上下文。\nfunc RequestLocaleHandler() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tlocale := i18n.ParseAcceptLanguage(c.GetHeader(acceptLanguageHeader))\n\t\tc.Set(global.ContextKeyLocale, locale)\n\t\tc.Next()\n\t}\n}\n\n// LocaleFromContext 从请求上下文中读取归一化语言。\nfunc LocaleFromContext(c *gin.Context) string {\n\tif c == nil {\n\t\treturn i18n.DefaultLocale\n\t}\n\tif locale, exists := c.Get(global.ContextKeyLocale); exists {\n\t\tif localeText, ok := locale.(string); ok {\n\t\t\treturn i18n.NormalizeLocale(localeText)\n\t\t}\n\t}\n\treturn i18n.DefaultLocale\n}\n"
  },
  {
    "path": "internal/middleware/request_locale_test.go",
    "content": "package middleware\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/i18n\"\n)\n\nfunc TestRequestLocaleHandler(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tengine := gin.New()\n\tengine.Use(RequestLocaleHandler())\n\tengine.GET(\"/demo\", func(c *gin.Context) {\n\t\tc.String(http.StatusOK, LocaleFromContext(c))\n\t})\n\n\trequest := httptest.NewRequest(http.MethodGet, \"/demo\", nil)\n\trequest.Header.Set(\"Accept-Language\", \"en-US,en;q=0.9\")\n\trecorder := httptest.NewRecorder()\n\tengine.ServeHTTP(recorder, request)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200, got %d\", recorder.Code)\n\t}\n\tif recorder.Body.String() != i18n.LocaleEnUS {\n\t\tt.Fatalf(\"expected locale %q, got %q\", i18n.LocaleEnUS, recorder.Body.String())\n\t}\n}\n\nfunc TestLocaleFromContextFallback(t *testing.T) {\n\tif got := LocaleFromContext(nil); got != i18n.DefaultLocale {\n\t\tt.Fatalf(\"expected default locale %q, got %q\", i18n.DefaultLocale, got)\n\t}\n}\n"
  },
  {
    "path": "internal/model/admin_login_logs.go",
    "content": "package model\n\nimport (\n\t\"time\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model/modelDict\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// 登录操作类型常量\nconst (\n\tLoginTypeLogin   uint8 = 1 // 登录操作\n\tLoginTypeRefresh uint8 = 2 // 刷新token\n)\n\n// 登录状态常量\nconst (\n\tLoginStatusSuccess uint8 = 1 // 登录成功\n\tLoginStatusFail    uint8 = 0 // 登录失败\n)\n\n// 登录状态字典\nvar LoginStatusDict modelDict.Dict = map[uint8]string{\n\tLoginStatusFail:    \"失败\",\n\tLoginStatusSuccess: \"成功\",\n}\n\n// 登录操作类型字典\nvar LoginTypeDict modelDict.Dict = map[uint8]string{\n\tLoginTypeLogin:   \"登录操作\",\n\tLoginTypeRefresh: \"刷新token\",\n}\n\n// 是否被撤销常量（使用 global.Yes/No，这里定义别名以便使用）\nconst (\n\tIsRevokedNo  = global.No  // 否\n\tIsRevokedYes = global.Yes // 是\n)\n\n// 撤销原因码常量\nconst (\n\tRevokedCodeUserLogout          uint8 = 1 // 用户主动登出（退出登录）\n\tRevokedCodeSystemForce         uint8 = 2 // 系统强制登出（账号被封）\n\tRevokedCodeTokenRefresh        uint8 = 3 // 系统刷新token\n\tRevokedCodeUserDisable         uint8 = 4 // 用户禁用（针对某个设备下线操作）\n\tRevokedCodeOther               uint8 = 5 // 其他原因\n\tRevokedCodePasswordChangeSelf  uint8 = 6 // 用户自己修改密码\n\tRevokedCodePasswordChangeAdmin uint8 = 7 // 管理员修改密码\n)\n\n// RevokedCodeDict 撤销原因码字典\nvar RevokedCodeDict modelDict.Dict = map[uint8]string{\n\tRevokedCodeUserLogout:          \"用户主动登出（退出登录）\",\n\tRevokedCodeSystemForce:         \"系统强制登出（账号被封）\",\n\tRevokedCodeTokenRefresh:        \"系统刷新token\",\n\tRevokedCodeUserDisable:         \"用户禁用（针对某个设备下线操作）\",\n\tRevokedCodeOther:               \"其他原因\",\n\tRevokedCodePasswordChangeSelf:  \"用户自己修改密码\",\n\tRevokedCodePasswordChangeAdmin: \"管理员修改密码\",\n}\n\n// AdminLoginLogs 登录日志表\ntype AdminLoginLogs struct {\n\tContainsDeleteBaseModel\n\tUID              uint              `json:\"uid\"`                // 用户ID（登录失败时为0）\n\tUsername         string            `json:\"username\"`           // 登录账号\n\tJwtID            string            `json:\"jwt_id\"`             // JWT唯一标识(jti claim)\n\tAccessToken      string            `json:\"access_token\"`       // 访问令牌\n\tRefreshToken     string            `json:\"refresh_token\"`      // 刷新令牌\n\tTokenHash        string            `json:\"token_hash\"`         // Token的SHA256哈希值\n\tRefreshTokenHash string            `json:\"refresh_token_hash\"` // Refresh Token的哈希值\n\tIP               string            `json:\"ip\"`                 // 登录IP(支持IPv6)\n\tUserAgent        string            `json:\"user_agent\"`         // 用户代理（浏览器/设备信息）\n\tOS               string            `json:\"os\"`                 // 操作系统\n\tBrowser          string            `json:\"browser\"`            // 浏览器\n\tExecutionTime    int               `json:\"execution_time\"`     // 登录耗时（毫秒）\n\tLoginStatus      uint8             `json:\"login_status\"`       // 登录状态：1=成功, 0=失败\n\tLoginFailReason  string            `json:\"login_fail_reason\"`  // 登录失败原因\n\tType             uint8             `json:\"type\"`               // 操作类型：1=登录操作, 2=刷新token\n\tIsRevoked        uint8             `json:\"is_revoked\"`         // 是否被撤销：0=否, 1=是\n\tRevokedCode      uint8             `json:\"revoked_code\"`       // 撤销原因码：1=用户主动登出（退出登录）, 2=系统强制登出（账号被封）, 3=系统刷新token, 4=用户禁用（针对某个设备下线操作） 5=其他原因\n\tRevokedReason    string            `json:\"revoked_reason\"`     // 撤销原因\n\tRevokedAt        *utils.FormatDate `json:\"revoked_at\"`         // 撤销时间\n\tTokenExpires     *utils.FormatDate `json:\"token_expires\"`      // Token过期时间\n\tRefreshExpires   *utils.FormatDate `json:\"refresh_expires\"`    // Refresh Token过期时间\n}\n\nfunc NewAdminLoginLogs() *AdminLoginLogs {\n\treturn BindModel(&AdminLoginLogs{})\n}\n\n// TableName 获取表名\nfunc (m *AdminLoginLogs) TableName() string {\n\treturn \"admin_login_logs\"\n}\n\n// LoginStatusMap 登录状态映射\nfunc (m *AdminLoginLogs) LoginStatusMap() string {\n\treturn LoginStatusDict.Map(m.LoginStatus)\n}\n\n// TypeMap 操作类型映射\nfunc (m *AdminLoginLogs) TypeMap() string {\n\treturn LoginTypeDict.Map(m.Type)\n}\n\n// IsRevokedMap 是否被撤销映射\nfunc (m *AdminLoginLogs) IsRevokedMap() string {\n\treturn modelDict.IsMap.Map(m.IsRevoked)\n}\n\n// RevokedCodeMap 撤销原因码映射\nfunc (m *AdminLoginLogs) RevokedCodeMap() string {\n\treturn RevokedCodeDict.Map(m.RevokedCode)\n}\n\n// Create 创建单条登录日志记录。\nfunc (m *AdminLoginLogs) Create() error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(m).Error\n}\n\n// FindByJwtId 根据 jwtId 查找登录日志。\nfunc (m *AdminLoginLogs) FindByJwtId(jwtId string) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(\"jwt_id = ? AND deleted_at = 0\", jwtId).First(m).Error\n}\n\n// UpdateRevokedStatusByJwtIds 批量更新 token 撤销状态。\nfunc (m *AdminLoginLogs) UpdateRevokedStatusByJwtIds(jwtIds []string, revokedCode uint8, revokedReason string, revokedAt utils.FormatDate) error {\n\tif len(jwtIds) == 0 {\n\t\treturn nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Model(&AdminLoginLogs{}).Where(\"jwt_id IN ? AND deleted_at = 0 AND is_revoked = ?\", jwtIds, IsRevokedNo).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"is_revoked\":     IsRevokedYes,\n\t\t\t\"revoked_code\":   revokedCode,\n\t\t\t\"revoked_reason\": revokedReason,\n\t\t\t\"revoked_at\":     revokedAt,\n\t\t}).Error\n}\n\n// FindActiveTokensByUserId 查询用户未过期的活跃 token 列表。\nfunc (m *AdminLoginLogs) FindActiveTokensByUserId(userId uint, now time.Time) ([]AdminLoginLogs, error) {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar loginLogs []AdminLoginLogs\n\terr = db.Where(\"uid = ? AND deleted_at = 0 AND is_revoked = ? AND login_status = ? AND token_expires IS NOT NULL AND token_expires > ?\",\n\t\tuserId, IsRevokedNo, LoginStatusSuccess, now).Find(&loginLogs).Error\n\treturn loginLogs, err\n}\n"
  },
  {
    "path": "internal/model/admin_user_dept_map.go",
    "content": "package model\n\n// AdminUserDeptMap 管理员用户部门关系表\ntype AdminUserDeptMap struct {\n\tBaseModel\n\tUid    uint `json:\"uid\"`     // admin_user用户ID\n\tDeptId uint `json:\"dept_id\"` // 部门ID\n}\n\nfunc NewAdminUserDeptMap() *AdminUserDeptMap {\n\treturn BindModel(&AdminUserDeptMap{})\n}\n\n// TableName 获取表名\nfunc (m *AdminUserDeptMap) TableName() string {\n\treturn \"admin_user_department_map\"\n}\n\nfunc (m *AdminUserDeptMap) CreateBatch(mappings []*AdminUserDeptMap) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(&mappings).Error\n}\n\n// DeptIdsByUid 根据用户 ID 查询其关联的部门 ID 列表。\nfunc (m *AdminUserDeptMap) DeptIdsByUid(uid uint) ([]uint, error) {\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"uid = ?\", uid).Pluck(\"dept_id\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// UidsByDeptIds 根据部门 ID 列表查询关联的用户 ID 列表。\nfunc (m *AdminUserDeptMap) UidsByDeptIds(deptIds []uint) ([]uint, error) {\n\tif len(deptIds) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"dept_id IN ?\", deptIds).Pluck(\"uid\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// UserDeptMapByUids 批量查询多个用户的部门关系，返回 map[uid][]deptId。\nfunc (m *AdminUserDeptMap) UserDeptMapByUids(uids []uint) (map[uint][]uint, []uint, error) {\n\tresult := make(map[uint][]uint, len(uids))\n\tif len(uids) == 0 {\n\t\treturn result, nil, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\ttype row struct {\n\t\tUid    uint\n\t\tDeptId uint\n\t}\n\tvar rows []row\n\tif err := db.Table(m.TableName()).Select(\"uid,dept_id\").Where(\"uid IN ?\", uids).Scan(&rows).Error; err != nil {\n\t\treturn nil, nil, err\n\t}\n\tdeptIds := make([]uint, 0, len(rows))\n\tfor _, r := range rows {\n\t\tresult[r.Uid] = append(result[r.Uid], r.DeptId)\n\t\tdeptIds = append(deptIds, r.DeptId)\n\t}\n\treturn result, deptIds, nil\n}\n\n// CountByCondition 根据条件统计数量。\nfunc (m *AdminUserDeptMap) CountByCondition(condition string, args ...any) (int64, error) {\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tvar count int64\n\tif err := db.Where(condition, args...).Count(&count).Error; err != nil {\n\t\treturn 0, err\n\t}\n\treturn count, nil\n}\n\n// CreateOne 创建单条记录。\nfunc (m *AdminUserDeptMap) CreateOne() error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(m).Error\n}\n"
  },
  {
    "path": "internal/model/admin_user_role_map.go",
    "content": "package model\n\n// AdminUserRoleMap 管理员用户角色关系表\ntype AdminUserRoleMap struct {\n\tBaseModel\n\tUid    uint `json:\"uid\"`     // admin_user用户ID\n\tRoleId uint `json:\"role_id\"` // RoleID\n}\n\nfunc NewAdminUserRoleMap() *AdminUserRoleMap {\n\treturn BindModel(&AdminUserRoleMap{})\n}\n\n// TableName 获取表名\nfunc (m *AdminUserRoleMap) TableName() string {\n\treturn \"admin_user_role_map\"\n}\n\nfunc (m *AdminUserRoleMap) CreateBatch(mappings []*AdminUserRoleMap) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(&mappings).Error\n}\n\n// RoleIdsByUid 根据用户 ID 查询其关联的角色 ID 列表。\nfunc (m *AdminUserRoleMap) RoleIdsByUid(uid uint) ([]uint, error) {\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"uid = ?\", uid).Pluck(\"role_id\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// UidsByRoleIds 根据角色 ID 列表查询关联的用户 ID 列表。\nfunc (m *AdminUserRoleMap) UidsByRoleIds(roleIds []uint) ([]uint, error) {\n\tif len(roleIds) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"role_id IN ?\", roleIds).Pluck(\"uid\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// UserRoleMapByUids 批量查询多个用户的角色关系，返回 map[uid][]roleId。\nfunc (m *AdminUserRoleMap) UserRoleMapByUids(uids []uint) (map[uint][]uint, error) {\n\tresult := make(map[uint][]uint, len(uids))\n\tif len(uids) == 0 {\n\t\treturn result, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ttype row struct {\n\t\tUid    uint\n\t\tRoleId uint\n\t}\n\tvar rows []row\n\tif err := db.Table(m.TableName()).Select(\"uid,role_id\").Where(\"uid IN ?\", uids).Scan(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, r := range rows {\n\t\tresult[r.Uid] = append(result[r.Uid], r.RoleId)\n\t}\n\treturn result, nil\n}\n\n// CountByCondition 根据条件统计数量。\nfunc (m *AdminUserRoleMap) CountByCondition(condition string, args ...any) (int64, error) {\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tvar count int64\n\tif err := db.Where(condition, args...).Count(&count).Error; err != nil {\n\t\treturn 0, err\n\t}\n\treturn count, nil\n}\n\n// CreateOne 创建单条记录。\nfunc (m *AdminUserRoleMap) CreateOne() error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(m).Error\n}\n"
  },
  {
    "path": "internal/model/admin_users.go",
    "content": "package model\n\nimport (\n\t\"fmt\"\n\n\t\"gorm.io/gorm/clause\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model/modelDict\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// 管理员状态常量\nconst (\n\tAdminUserStatusEnabled  uint8 = 1 // 启用\n\tAdminUserStatusDisabled uint8 = 0 // 禁用（数据库定义：1启用 0禁用）\n)\n\n// 管理员状态字典\nvar AdminUserStatusDict modelDict.Dict = map[uint8]string{\n\tAdminUserStatusEnabled:  \"启用\",\n\tAdminUserStatusDisabled: \"禁用\",\n}\n\nvar adminUserUniqueFieldAllowList = map[string]struct{}{\n\t\"username\":          {},\n\t\"full_phone_number\": {},\n\t\"email\":             {},\n}\n\n// AdminUser 总管理员表\ntype AdminUser struct {\n\tContainsDeleteBaseModel\n\tIsSuperAdmin    uint8              `json:\"is_super_admin\"`    // 是否是总管理员\n\tNickname        string             `json:\"nickname\"`          // 用户昵称\n\tUsername        string             `json:\"username\"`          // 用户名\n\tPassword        string             `json:\"password\"`          // 密码\n\tPhoneNumber     string             `json:\"phone_number\"`      // 手机号\n\tFullPhoneNumber string             `json:\"full_phone_number\"` // 完整手机号\n\tCountryCode     string             `json:\"country_code\"`      // 国际区号\n\tEmail           string             `json:\"email\"`             // 邮箱\n\tAvatar          string             `json:\"avatar\"`            // 头像\n\tStatus          uint8              `json:\"status\"`            // 状态 1启用 0禁用\n\tLastLogin       utils.FormatDate   `json:\"last_login\"`        // 最后登录时间\n\tLastIp          string             `json:\"last_ip\"`           // 最后登录IP\n\tDepartment      []Department       `json:\"department\" gorm:\"many2many:admin_user_department_map;foreignKey:ID;joinForeignKey:Uid;References:ID;joinReferences:DeptId\"`\n\tRoleList        []AdminUserRoleMap `json:\"role_list\" gorm:\"foreignKey:uid;references:id\"`\n}\n\nfunc NewAdminUsers() *AdminUser {\n\treturn BindModel(&AdminUser{})\n}\n\n// TableName 获取表名\nfunc (m *AdminUser) TableName() string {\n\treturn \"admin_user\"\n}\n\n// GetUserInfo 根据名称获取用户信息\nfunc (m *AdminUser) GetUserInfo(username string) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := db.Where(\"username\", username).First(m).Error; err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// IsSuperAdminMap 是否为超级管理员映射\nfunc (m *AdminUser) IsSuperAdminMap() string {\n\treturn modelDict.IsMap.Map(m.IsSuperAdmin)\n}\n\n// StatusMap 状态映射\nfunc (m *AdminUser) StatusMap() string {\n\treturn AdminUserStatusDict.Map(m.Status)\n}\n\n// SyncUserRow 权限同步时需要的用户简要信息。\ntype SyncUserRow struct {\n\tID           uint\n\tStatus       uint8\n\tIsSuperAdmin uint8\n}\n\n// SyncUserRows 根据用户 ID 列表查询未删除用户的同步信息（id, status, is_super_admin）。\nfunc (m *AdminUser) SyncUserRows(userIDs []uint) ([]SyncUserRow, error) {\n\tif len(userIDs) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar rows []SyncUserRow\n\tif err := db.Table(m.TableName()).\n\t\tSelect(\"id,status,is_super_admin\").\n\t\tWhere(\"id IN ? AND deleted_at = 0\", userIDs).\n\t\tScan(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn rows, nil\n}\n\n// AllIds 查询所有未删除用户的 ID 列表。\nfunc (m *AdminUser) AllIds() ([]uint, error) {\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"deleted_at = 0\").Pluck(\"id\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// ExistsWithLock 带行锁检查指定条件的记录是否存在。\nfunc (m *AdminUser) ExistsWithLock(condition string, args ...any) (bool, error) {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tvar exists bool\n\tif err := db.Model(m).\n\t\tSelect(\"1\").\n\t\tWhere(condition, args...).\n\t\tClauses(clause.Locking{Strength: \"UPDATE\"}).\n\t\tLimit(1).\n\t\tScan(&exists).Error; err != nil {\n\t\treturn false, err\n\t}\n\treturn exists, nil\n}\n\n// ExistsWithLockExcludeId 带行锁检查指定条件的记录是否存在（排除指定 ID）。\nfunc (m *AdminUser) ExistsWithLockExcludeId(field string, value string, excludeId uint) (bool, error) {\n\tif _, ok := adminUserUniqueFieldAllowList[field]; !ok {\n\t\treturn false, fmt.Errorf(\"field is not allowed for unique check: %s\", field)\n\t}\n\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tvar exists bool\n\tif err := db.Model(m).\n\t\tSelect(\"1\").\n\t\tWhere(clause.Eq{Column: clause.Column{Name: field}, Value: value}).\n\t\tWhere(\"id != ? AND deleted_at = 0\", excludeId).\n\t\tClauses(clause.Locking{Strength: \"UPDATE\"}).\n\t\tLimit(1).\n\t\tScan(&exists).Error; err != nil {\n\t\treturn false, err\n\t}\n\treturn exists, nil\n}\n\n// GetByIdWithPreload 根据 ID 获取用户并预加载指定关联。\nfunc (m *AdminUser) GetByIdWithPreload(id uint, relations ...string) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, relation := range relations {\n\t\tdb = db.Preload(relation)\n\t}\n\treturn db.First(m, id).Error\n}\n"
  },
  {
    "path": "internal/model/admin_users_test.go",
    "content": "package model\n\nimport (\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestExistsWithLockExcludeIdRejectsUnknownField(t *testing.T) {\n\tadminUser := NewAdminUsers()\n\t_, err := adminUser.ExistsWithLockExcludeId(\"status\", \"1\", 1)\n\tif err == nil {\n\t\tt.Fatal(\"expected unknown field to return error\")\n\t}\n}\n\nfunc TestExistsWithLockExcludeIdAllowedFieldReturnsDBErrorWhenUninitialized(t *testing.T) {\n\tadminUser := NewAdminUsers()\n\t_, err := adminUser.ExistsWithLockExcludeId(\"username\", \"tester\", 1)\n\tif !errors.Is(err, ErrDBUninitialized) {\n\t\tt.Fatalf(\"expected ErrDBUninitialized, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/model/api.go",
    "content": "package model\n\nimport (\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model/modelDict\"\n)\n\n// Api 权限路由表\ntype Api struct {\n\tContainsDeleteBaseModel\n\tCode        string `json:\"code\"`         // 权限唯一code\n\tGroupCode   string `json:\"group_code\"`   // 分组code\n\tName        string `json:\"name\"`         // 权限名称\n\tDescription string `json:\"description\"`  // 描述\n\tMethod      string `json:\"method\"`       // 接口请求方法\n\tRoute       string `json:\"route\"`        // 接口路由\n\tFunc        string `json:\"func\"`         // 接口方法\n\tFuncPath    string `json:\"func_path\"`    // 接口方法路径\n\tIsAuth      uint8  `json:\"is_auth\"`      // 接口鉴权模式 0:无需登录 1:需要登录 2:需要登录且需要API权限\n\tIsEffective uint8  `json:\"is_effective\"` // 是否有效 0:否 1:是\n\tSort        int    `json:\"sort\"`         // 排序，数字越大优先级越高\n}\n\nfunc NewApi() *Api {\n\treturn BindModel(&Api{})\n}\n\n// TableName 获取表名\nfunc (m *Api) TableName() string {\n\treturn \"api\"\n}\n\n// InitRegisters 注册接口，写入到DB\nfunc (m *Api) InitRegisters(data []map[string]any, date string) error {\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdb, err := m.GetDB(self)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Transaction(func(tx *gorm.DB) error {\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns:   []clause.Column{{Name: \"code\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"func\", \"group_code\", \"func_path\", \"is_effective\", \"updated_at\"}),\n\t\t}).Create(data).Error\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn tx.Model(self).Where(\"updated_at != ?\", date).Update(\"is_effective\", 0).Error\n\t})\n}\n\n// IsAuthMap 接口鉴权模式映射\nfunc (m *Api) IsAuthMap() string {\n\treturn global.ApiAuthMode(m.IsAuth).Label()\n}\n\n// IsEffectiveMap 是否有效映射\nfunc (m *Api) IsEffectiveMap() string {\n\treturn modelDict.IsMap.Map(m.IsEffective)\n}\n\n// FindIdsByRouteAndMethod 根据路由和方法列表查询未删除接口的 ID 列表。\nfunc (m *Api) FindIdsByRouteAndMethod(routes []string, methods []string) ([]Api, error) {\n\tif len(routes) == 0 || len(methods) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar apis []Api\n\tif err := db.Select(\"id\", \"route\", \"method\").Where(\"route IN ? AND method IN ? AND deleted_at = 0\", routes, methods).Find(&apis).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn apis, nil\n}\n\n// FindByIds 根据 ID 列表查询未删除的接口。\nfunc (m *Api) FindByIds(ids []uint) ([]Api, error) {\n\tif len(ids) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar apis []Api\n\tif err := db.Where(\"id IN ?\", ids).Find(&apis).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn apis, nil\n}\n"
  },
  {
    "path": "internal/model/base.go",
    "content": "package model\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"reflect\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/plugin/soft_delete\"\n\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// BaseModel 提供模型通用字段与基础 CRUD 能力。\ntype BaseModel struct {\n\tdbInstance *gorm.DB\n\towner      any\n\tID         uint             `gorm:\"column:id;type:int(11) unsigned AUTO_INCREMENT;not null;primarykey\" json:\"id\"`\n\tCreatedAt  utils.FormatDate `gorm:\"column:created_at;type:datetime;<-:create\" json:\"created_at\"`\n\tUpdatedAt  utils.FormatDate `gorm:\"column:updated_at;type:datetime\" json:\"updated_at\"`\n}\n\n// ErrDBUninitialized 表示数据库连接尚未初始化。\nvar ErrDBUninitialized = errors.New(\"database connection is not initialized\")\n\n// ErrModelPtrNotImplemented 表示模型尚未完成 owner 绑定。\nvar ErrModelPtrNotImplemented = errors.New(\"model owner binding is not initialized\")\n\n// ErrInvalidModelArg 表示传入 GetDB 的模型参数无效（如 typed nil 指针）。\nvar ErrInvalidModelArg = errors.New(\"invalid model argument\")\n\n// ContainsDeleteBaseModel 在 BaseModel 基础上增加软删除字段。\ntype ContainsDeleteBaseModel struct {\n\tBaseModel\n\tDeletedAt soft_delete.DeletedAt `gorm:\"column:deleted_at;type:int(11) unsigned;not null;default:0;index;\" json:\"-\"`\n}\n\n// GetDB 返回全局数据库实例，传入 model 时会附带 Model 上下文。\nfunc GetDB(model ...any) (*gorm.DB, error) {\n\tif len(model) > 0 {\n\t\tif err := validateModelArg(model[0]); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tdb := data.MysqlDB()\n\tif db == nil {\n\t\tif initErr := data.MysqlInitError(); initErr != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w: %v\", ErrDBUninitialized, initErr)\n\t\t}\n\t\treturn nil, ErrDBUninitialized\n\t}\n\tif len(model) > 0 && model[0] != nil {\n\t\treturn db.Model(model[0]), nil\n\t}\n\treturn db, nil\n}\n\nfunc validateModelArg(model any) error {\n\tif model == nil {\n\t\treturn nil\n\t}\n\tvalue := reflect.ValueOf(model)\n\tswitch value.Kind() {\n\tcase reflect.Ptr, reflect.Map, reflect.Slice, reflect.Interface, reflect.Func, reflect.Chan:\n\t\tif value.IsNil() {\n\t\t\treturn ErrInvalidModelArg\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/model/base_crud.go",
    "content": "package model\n\nimport (\n\t\"fmt\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n)\n\n// Paginate 返回 GORM 分页作用域，页码小于 1 时会自动修正为第 1 页。\nfunc (m *BaseModel) Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {\n\treturn func(db *gorm.DB) *gorm.DB {\n\t\tif page < 1 {\n\t\t\tpage = 1\n\t\t}\n\n\t\tlimit := global.PerPage\n\t\tif pageSize > 0 {\n\t\t\tlimit = pageSize\n\t\t}\n\n\t\toffset := (page - 1) * limit\n\t\treturn db.Offset(offset).Limit(limit)\n\t}\n}\n\n// Count 按条件统计当前模型记录总数。\nfunc (m *BaseModel) Count(condition string, args ...any) (count int64, err error) {\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tquery, err := m.GetDB(self)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tif condition != \"\" {\n\t\tquery = query.Where(condition, args...)\n\t}\n\terr = query.Count(&count).Error\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn\n}\n\n// GetById 根据 ID 获取当前模型信息。\nfunc (m *BaseModel) GetById(id uint) error {\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.First(self, id).Error\n}\n\n// GetAllById 根据 ID 获取当前模型及全部关联表信息。\nfunc (m *BaseModel) GetAllById(id uint) error {\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Preload(clause.Associations).First(self, id).Error\n}\n\n// GetDetail 按条件查询当前模型的单条详情记录。\nfunc (m *BaseModel) GetDetail(condition string, val ...any) error {\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(condition, val...).First(self).Error\n}\n\n// ExistsById 判断指定 ID 的记录是否存在。\nfunc (m *BaseModel) ExistsById(id uint) (bool, error) {\n\tif id == 0 {\n\t\treturn false, nil\n\t}\n\treturn m.Exists(\"id = ?\", id)\n}\n\n// Exists 判断是否存在满足条件的记录。\nfunc (m *BaseModel) Exists(condition string, args ...any) (bool, error) {\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tvar count int64\n\terr = db.Model(self).Where(condition, args...).Limit(1).Count(&count).Error\n\treturn count > 0, err\n}\n\n// UpdateById 根据 ID 更新当前模型记录。\nfunc (m *BaseModel) UpdateById(id uint, data map[string]any) error {\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Model(self).Where(\"id = ?\", id).Updates(data).Error\n}\n\n// DeleteByID 根据 ID 删除当前模型记录。\nfunc (m *BaseModel) DeleteByID(id uint) (int64, error) {\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tresult := db.Delete(self, id)\n\treturn result.RowsAffected, result.Error\n}\n\n// DeleteWhere 按条件删除当前模型记录，空条件会被拒绝以防误删全表。\nfunc (m *BaseModel) DeleteWhere(condition string, args ...any) error {\n\tif condition == \"\" {\n\t\treturn fmt.Errorf(\"delete condition is empty, operation refused to prevent full table deletion\")\n\t}\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(condition, args...).Delete(self).Error\n}\n\n// Create 使用字段映射创建一条当前模型记录。\nfunc (m *BaseModel) Create(data map[string]any) error {\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdb, err := m.GetDB(self)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(data).Error\n}\n\n// CreateBatch 使用字段映射批量创建当前模型记录。\nfunc (m *BaseModel) CreateBatch(data []map[string]any) error {\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdb, err := m.GetDB(self)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(data).Error\n}\n\n// Save 保存当前模型实例，存在主键时会执行更新。\nfunc (m *BaseModel) Save() error {\n\tself, err := m.self()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Save(self).Error\n}\n"
  },
  {
    "path": "internal/model/base_list.go",
    "content": "package model\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// BaseModelInterface 定义一个接口约束，包含所需的方法。\ntype BaseModelInterface[T any] interface {\n\tCount(condition string, args ...any) (int64, error)\n\tPaginate(page, perPage int) func(*gorm.DB) *gorm.DB\n\tTableName() string\n\t*T\n}\n\n// ListOptionalParams 定义列表查询的可选参数。\ntype ListOptionalParams struct {\n\tSelectFields  []string\n\tPreload       map[string]func(db *gorm.DB) *gorm.DB\n\tAllPreLoad    bool\n\tOrderBy       string\n\tOrderAllowMap map[string]struct{}\n\tJoins         []string\n\tDistinct      string\n\tCountDistinct string\n}\n\nvar orderByPattern = regexp.MustCompile(`(?i)^([a-z_][a-z0-9_]*)(?:\\.([a-z_][a-z0-9_]*))?(?:\\s+(asc|desc))?$`)\nvar selectFieldPattern = regexp.MustCompile(`(?i)^([a-z_][a-z0-9_]*)(?:\\.([a-z_][a-z0-9_]*))?$`)\n\nfunc normalizeOrderBy(orderBy string, allowed map[string]struct{}) (string, error) {\n\torderBy = strings.TrimSpace(orderBy)\n\tif orderBy == \"\" {\n\t\treturn \"\", nil\n\t}\n\n\tparts := strings.Split(orderBy, \",\")\n\tres := make([]string, 0, len(parts))\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tmatches := orderByPattern.FindStringSubmatch(part)\n\t\tif len(matches) == 0 {\n\t\t\treturn \"\", fmt.Errorf(\"invalid order by clause: %s\", part)\n\t\t}\n\n\t\tfield := matches[1]\n\t\tcolumn := field\n\t\tif matches[2] != \"\" {\n\t\t\tcolumn = matches[2]\n\t\t}\n\n\t\tif len(allowed) > 0 {\n\t\t\tif _, ok := allowed[column]; !ok {\n\t\t\t\treturn \"\", fmt.Errorf(\"order field not allowed: %s\", column)\n\t\t\t}\n\t\t}\n\n\t\tdirection := \"ASC\"\n\t\tif strings.EqualFold(matches[3], \"desc\") {\n\t\t\tdirection = \"DESC\"\n\t\t}\n\t\tres = append(res, fmt.Sprintf(\"%s %s\", field, direction))\n\t}\n\n\tif len(res) == 0 {\n\t\treturn \"\", nil\n\t}\n\treturn strings.Join(res, \", \"), nil\n}\n\nfunc normalizeSelectFields(fields string) (string, error) {\n\tfields = strings.TrimSpace(fields)\n\tif fields == \"\" {\n\t\treturn \"\", nil\n\t}\n\n\tparts := strings.Split(fields, \",\")\n\tres := make([]string, 0, len(parts))\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tmatches := selectFieldPattern.FindStringSubmatch(part)\n\t\tif len(matches) == 0 {\n\t\t\treturn \"\", fmt.Errorf(\"invalid select field: %s\", part)\n\t\t}\n\t\tif matches[2] != \"\" {\n\t\t\tres = append(res, fmt.Sprintf(\"%s.%s\", matches[1], matches[2]))\n\t\t\tcontinue\n\t\t}\n\t\tres = append(res, matches[1])\n\t}\n\tif len(res) == 0 {\n\t\treturn \"\", nil\n\t}\n\treturn strings.Join(res, \", \"), nil\n}\n\nfunc buildListQuery[T any, M BaseModelInterface[T]](model M, condition string, args []any, listParams ListOptionalParams) (*gorm.DB, error) {\n\tquery, err := GetDB(model)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(listParams.Joins) > 0 {\n\t\tfor _, join := range listParams.Joins {\n\t\t\tquery = query.Joins(join)\n\t\t}\n\t}\n\n\tif condition != \"\" {\n\t\tquery = query.Where(condition, args...)\n\t}\n\n\tif listParams.Distinct != \"\" {\n\t\tdistinctFields, err := normalizeSelectFields(listParams.Distinct)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif distinctFields != \"\" {\n\t\t\tquery = query.Distinct(distinctFields)\n\t\t}\n\t}\n\n\tif len(listParams.SelectFields) > 0 {\n\t\tquery = query.Select(listParams.SelectFields)\n\t}\n\n\tif listParams.OrderBy != \"\" {\n\t\tsafeOrderBy, err := normalizeOrderBy(listParams.OrderBy, listParams.OrderAllowMap)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif safeOrderBy != \"\" {\n\t\t\tquery = query.Order(safeOrderBy)\n\t\t} else {\n\t\t\tquery = query.Order(\"id desc\")\n\t\t}\n\t} else {\n\t\tquery = query.Order(\"id desc\")\n\t}\n\n\tif listParams.AllPreLoad {\n\t\tquery = query.Preload(clause.Associations)\n\t} else if len(listParams.Preload) > 0 {\n\t\tfor key, value := range listParams.Preload {\n\t\t\tif value == nil {\n\t\t\t\tquery = query.Preload(key)\n\t\t\t} else {\n\t\t\t\tquery = query.Preload(key, value)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn query, nil\n}\n\nfunc countListTotal[T any, M BaseModelInterface[T]](model M, baseQuery *gorm.DB, condition string, args []any, listParams ListOptionalParams) (int64, error) {\n\tif listParams.CountDistinct != \"\" {\n\t\tcountDistinctFields, err := normalizeSelectFields(listParams.CountDistinct)\n\t\tif err != nil {\n\t\t\treturn 0, err\n\t\t}\n\t\tif countDistinctFields == \"\" {\n\t\t\treturn 0, fmt.Errorf(\"count distinct fields are required\")\n\t\t}\n\t\tcountQuery := baseQuery\n\t\tif condition != \"\" {\n\t\t\tcountQuery = countQuery.Where(condition, args...)\n\t\t}\n\t\tvar total int64\n\t\terr = countQuery.Model(model).\n\t\t\tSelect(fmt.Sprintf(\"COUNT(DISTINCT %s)\", countDistinctFields)).\n\t\t\tScan(&total).Error\n\t\treturn total, err\n\t}\n\n\tif len(listParams.Joins) > 0 {\n\t\tcountQuery := baseQuery\n\t\tif condition != \"\" {\n\t\t\tcountQuery = countQuery.Where(condition, args...)\n\t\t}\n\t\tvar total int64\n\t\terr := countQuery.Model(model).Count(&total).Error\n\t\treturn total, err\n\t}\n\n\tif condition != \"\" {\n\t\treturn model.Count(condition, args...)\n\t}\n\treturn model.Count(\"\")\n}\n\n// ListPageE 按条件分页查询列表，并返回总数与结果集。\nfunc ListPageE[T any, M BaseModelInterface[T]](model M, page, perPage int, condition string, args []any, optional ...ListOptionalParams) (int64, []*T, error) {\n\tif condition != \"\" {\n\t\tcondition = utils.TrimPrefixAndSuffixAND(condition)\n\t}\n\n\tvar listParams ListOptionalParams\n\tif len(optional) > 0 {\n\t\tlistParams = optional[0]\n\t}\n\n\tbaseQuery, err := GetDB(model)\n\tif err != nil {\n\t\treturn 0, nil, err\n\t}\n\tif len(listParams.Joins) > 0 {\n\t\tfor _, join := range listParams.Joins {\n\t\t\tbaseQuery = baseQuery.Joins(join)\n\t\t}\n\t}\n\n\ttotal, err := countListTotal(model, baseQuery, condition, args, listParams)\n\tif err != nil || total == 0 {\n\t\treturn total, nil, err\n\t}\n\n\tquery, err := buildListQuery(model, condition, args, listParams)\n\tif err != nil {\n\t\treturn total, nil, err\n\t}\n\tquery = query.Scopes(model.Paginate(page, perPage))\n\n\tres := make([]*T, 0, perPage)\n\terr = query.Find(&res).Error\n\tif err != nil {\n\t\treturn total, nil, err\n\t}\n\treturn total, res, nil\n}\n\n// ListE 按条件查询列表，支持预加载、排序、字段选择等可选参数。\nfunc ListE[T any, M BaseModelInterface[T]](model M, condition string, args []any, optional ...ListOptionalParams) ([]*T, error) {\n\tif condition != \"\" {\n\t\tcondition = utils.TrimPrefixAndSuffixAND(condition)\n\t}\n\n\tvar listParams ListOptionalParams\n\tif len(optional) > 0 {\n\t\tlistParams = optional[0]\n\t}\n\n\tquery, err := buildListQuery(model, condition, args, listParams)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar res []*T\n\terr = query.Find(&res).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn res, nil\n}\n\n// VerifyExistingIDs 返回输入 ID 中数据库实际存在的 ID 列表。\nfunc VerifyExistingIDs[T any, M BaseModelInterface[T]](model M, ids []uint) ([]uint, error) {\n\tif len(ids) == 0 {\n\t\treturn ids, nil\n\t}\n\n\tvar existIds []uint\n\tdb, err := GetDB(model)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := db.Where(\"id IN (?)\", ids).Pluck(\"id\", &existIds).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn existIds, nil\n}\n\n// ExtractColumnsByCondition 按条件提取指定列的值列表。\nfunc ExtractColumnsByCondition[T any, M BaseModelInterface[T], R any](model M, column string, condition string, args ...any) ([]R, error) {\n\tvar columns []R\n\tif condition == \"\" {\n\t\treturn nil, fmt.Errorf(\"condition is required\")\n\t}\n\tif column == \"\" {\n\t\treturn nil, fmt.Errorf(\"column name is required\")\n\t}\n\n\tdb, err := GetDB(model)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := db.Where(condition, args...).Pluck(column, &columns).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn columns, nil\n}\n"
  },
  {
    "path": "internal/model/base_list_test.go",
    "content": "package model\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestNormalizeOrderByAcceptsValidFields(t *testing.T) {\n\torderBy, err := normalizeOrderBy(\"sort desc, id asc\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif orderBy != \"sort DESC, id ASC\" {\n\t\tt.Fatalf(\"unexpected normalized order by: %s\", orderBy)\n\t}\n}\n\nfunc TestNormalizeOrderByRejectsInjection(t *testing.T) {\n\t_, err := normalizeOrderBy(\"id desc; drop table admin_user\", nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid order by to return error\")\n\t}\n}\n\nfunc TestNormalizeOrderByChecksAllowList(t *testing.T) {\n\tallowed := map[string]struct{}{\n\t\t\"id\": {},\n\t}\n\t_, err := normalizeOrderBy(\"created_at desc\", allowed)\n\tif err == nil {\n\t\tt.Fatal(\"expected order field allow-list error\")\n\t}\n\tif !strings.Contains(err.Error(), \"not allowed\") {\n\t\tt.Fatalf(\"expected not allowed error, got %v\", err)\n\t}\n}\n\nfunc TestNormalizeSelectFieldsAcceptsValidFields(t *testing.T) {\n\tfields, err := normalizeSelectFields(\"id, created_at, admin_user.id\")\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif fields != \"id, created_at, admin_user.id\" {\n\t\tt.Fatalf(\"unexpected normalized fields: %s\", fields)\n\t}\n}\n\nfunc TestNormalizeSelectFieldsRejectsInjection(t *testing.T) {\n\t_, err := normalizeSelectFields(\"id;drop table admin_user\")\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid select fields to return error\")\n\t}\n}\n"
  },
  {
    "path": "internal/model/base_owner.go",
    "content": "package model\n\nimport \"gorm.io/gorm\"\n\ntype ownerBinder interface {\n\tbindOwner(any)\n}\n\n// SetDB 为当前模型绑定事务或指定数据库连接。\nfunc (m *BaseModel) SetDB(tx *gorm.DB) *BaseModel {\n\tm.dbInstance = tx\n\treturn m\n}\n\nfunc (m *BaseModel) bindOwner(owner any) {\n\tm.owner = owner\n}\n\n// BindModel 为嵌入 BaseModel 的模型绑定自身实例，供通用方法回写使用。\nfunc BindModel[T any](m T) T {\n\tif binder, ok := any(m).(ownerBinder); ok {\n\t\tbinder.bindOwner(m)\n\t}\n\treturn m\n}\n\nfunc (m *BaseModel) self() (any, error) {\n\tif m.owner == nil {\n\t\treturn nil, ErrModelPtrNotImplemented\n\t}\n\treturn m.owner, nil\n}\n\n// GetDB 返回当前模型可用的数据库实例，传入 model 时会附带 Model 上下文。\nfunc (m *BaseModel) GetDB(model ...any) (*gorm.DB, error) {\n\tif m.dbInstance != nil {\n\t\tif len(model) > 0 {\n\t\t\tif err := validateModelArg(model[0]); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn m.dbInstance.Model(model[0]), nil\n\t\t}\n\t\treturn m.dbInstance, nil\n\t}\n\treturn GetDB(model...)\n}\n"
  },
  {
    "path": "internal/model/base_test.go",
    "content": "package model\n\nimport (\n\t\"errors\"\n\t\"testing\"\n)\n\nfunc TestGetDBReturnsErrorWhenUninitialized(t *testing.T) {\n\t_, err := GetDB()\n\tif err == nil {\n\t\tt.Fatal(\"expected GetDB to return error when database is uninitialized\")\n\t}\n}\n\nfunc TestGetDBRejectsTypedNilModelArg(t *testing.T) {\n\tvar role *Role\n\t_, err := GetDB(role)\n\tif !errors.Is(err, ErrInvalidModelArg) {\n\t\tt.Fatalf(\"expected ErrInvalidModelArg, got %v\", err)\n\t}\n}\n\nfunc TestListEReturnsErrorWhenUninitialized(t *testing.T) {\n\t_, err := ListE(NewApi(), \"\", nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected ListE to return error when database is uninitialized\")\n\t}\n}\n\nfunc TestListPageEReturnsErrorWhenUninitialized(t *testing.T) {\n\t_, _, err := ListPageE(NewApi(), 1, 10, \"\", nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected ListPageE to return error when database is uninitialized\")\n\t}\n}\n\nfunc TestBaseModelSelfReturnsErrorWithoutBinding(t *testing.T) {\n\tvar role Role\n\t_, err := role.self()\n\tif !errors.Is(err, ErrModelPtrNotImplemented) {\n\t\tt.Fatalf(\"expected ErrModelPtrNotImplemented, got %v\", err)\n\t}\n}\n\nfunc TestNewModelBindsOwner(t *testing.T) {\n\trole := NewRole()\n\tself, err := role.self()\n\tif err != nil {\n\t\tt.Fatalf(\"expected bound owner, got error %v\", err)\n\t}\n\tif self != role {\n\t\tt.Fatalf(\"expected owner to point to role itself\")\n\t}\n}\n\nfunc TestInstanceMethodReturnsBindingErrorBeforeDBError(t *testing.T) {\n\tvar role Role\n\terr := role.GetById(1)\n\tif !errors.Is(err, ErrModelPtrNotImplemented) {\n\t\tt.Fatalf(\"expected ErrModelPtrNotImplemented, got %v\", err)\n\t}\n}\n\nfunc TestCountReturnsBindingErrorBeforeDBError(t *testing.T) {\n\tvar role Role\n\t_, err := role.Count(\"pid = ?\", 1)\n\tif !errors.Is(err, ErrModelPtrNotImplemented) {\n\t\tt.Fatalf(\"expected ErrModelPtrNotImplemented, got %v\", err)\n\t}\n}\n\nfunc TestBoundCountReturnsDBErrorWhenUninitialized(t *testing.T) {\n\trole := NewRole()\n\t_, err := role.Count(\"pid = ?\", 1)\n\tif !errors.Is(err, ErrDBUninitialized) {\n\t\tt.Fatalf(\"expected ErrDBUninitialized, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/model/base_tree.go",
    "content": "package model\n\nimport \"gorm.io/gorm\"\n\n// HasChildren 判断指定父节点是否存在子节点。\nfunc HasChildren[T any, M BaseModelInterface[T]](model M, pid uint) (bool, error) {\n\tcount, err := model.Count(\"pid = ?\", pid)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\n// UpdateChildrenNum 统计并更新父节点的 children_num 字段。\nfunc UpdateChildrenNum[T any, M BaseModelInterface[T]](model M, pid uint, tx *gorm.DB) error {\n\tif pid == 0 {\n\t\treturn nil\n\t}\n\n\tgetDB := func() (*gorm.DB, error) {\n\t\tif tx != nil {\n\t\t\treturn tx.Model(model), nil\n\t\t}\n\t\treturn GetDB(model)\n\t}\n\n\tvar count int64\n\tqueryDB, err := getDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := queryDB.Where(\"pid = ?\", pid).Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\n\tupdateDB, err := getDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn updateDB.Where(\"id = ?\", pid).Update(\"children_num\", count).Error\n}\n"
  },
  {
    "path": "internal/model/dept.go",
    "content": "package model\n\nimport (\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n)\n\n// Department 部门表\ntype Department struct {\n\tContainsDeleteBaseModel\n\tCode        string        `json:\"code\" gorm:\"column:code;type:varchar(60);not null;default:'';comment:部门业务编码\"`\n\tIsSystem    uint8         `json:\"is_system\" gorm:\"column:is_system;type:tinyint unsigned;not null;default:0;comment:是否系统保留对象\"`\n\tPid         uint          `json:\"pid\" gorm:\"column:pid;type:int unsigned;not null;default:0;comment:上级id\"`\n\tPids        string        `json:\"pids\" gorm:\"column:pids;type:varchar(255);not null;default:'';comment:所有上级id\"`\n\tName        string        `json:\"name\" gorm:\"column:name;type:varchar(60);not null;default:'';comment:部门名称\"`\n\tDescription string        `json:\"description\" gorm:\"column:description;type:varchar(255);not null;default:'';comment:描述\"`\n\tLevel       uint8         `json:\"level\" gorm:\"column:level;type:tinyint unsigned;not null;default:1;comment:层级\"`\n\tSort        uint          `json:\"sort\" gorm:\"column:sort;type:mediumint unsigned;not null;default:0;comment:排序\"`\n\tChildrenNum uint          `json:\"children_num\" gorm:\"column:children_num;type:int unsigned;not null;default:0;comment:子集数量\"`\n\tUserNumber  uint          `json:\"user_number\" gorm:\"column:user_number;type:int unsigned;not null;default:0;comment:用户数量\"`\n\tRoleList    []DeptRoleMap `json:\"role_list\" gorm:\"foreignKey:dept_id;references:id\"`\n}\n\nfunc NewDepartment() *Department {\n\treturn BindModel(&Department{})\n}\n\n// TableName 获取表名\nfunc (m *Department) TableName() string {\n\treturn \"department\"\n}\n\nfunc (m *Department) IsSystemDepartment() bool {\n\treturn m.IsSystem == global.Yes\n}\n\n// FindByCode 根据 code 查找未删除的部门，结果写入自身。\nfunc (m *Department) FindByCode(code string) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(\"code = ? AND deleted_at = 0\", code).First(m).Error\n}\n\n// UpdateUserNumberByIds 批量更新指定部门的用户数量。\nfunc (m *Department) UpdateUserNumberByIds(deptIds []uint, updateExpr string) error {\n\tif len(deptIds) == 0 {\n\t\treturn nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Model(m).\n\t\tWhere(\"id IN (?)\", deptIds).\n\t\tUpdate(\"user_number\", gorm.Expr(updateExpr)).Error\n}\n\n// UpdateChildrenPidsByParent 批量更新指定父节点下所有子部门的 pids 和 level。\nfunc (m *Department) UpdateChildrenPidsByParent(parentID uint, updateExpr string) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Model(m).\n\t\tWhere(\"FIND_IN_SET(?,pids)\", parentID).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"pids\":  gorm.Expr(updateExpr),\n\t\t\t\"level\": gorm.Expr(\"length(pids) - length(replace(pids, ',', '')) + 1\"),\n\t\t}).Error\n}\n"
  },
  {
    "path": "internal/model/dept_role_map.go",
    "content": "package model\n\n// DeptRoleMap 部门角色关联表\ntype DeptRoleMap struct {\n\tBaseModel\n\tDeptId uint `json:\"dept_id\"` // 菜单ID\n\tRoleId uint `json:\"role_id\"` // RoleID\n}\n\nfunc NewDeptRoleMap() *DeptRoleMap {\n\treturn BindModel(&DeptRoleMap{})\n}\n\n// TableName 获取表名\nfunc (m *DeptRoleMap) TableName() string {\n\treturn \"department_role_map\"\n}\n\nfunc (m *DeptRoleMap) DeleteByDeptId(deptId uint) error {\n\treturn m.DeleteWhere(\"dept_id = ?\", deptId)\n}\n\nfunc (m *DeptRoleMap) CreateBatch(mappings []*DeptRoleMap) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(&mappings).Error\n}\n\n// RoleIdsByDeptIds 根据部门 ID 列表查询关联的角色 ID 列表。\nfunc (m *DeptRoleMap) RoleIdsByDeptIds(deptIds []uint) ([]uint, error) {\n\tif len(deptIds) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"dept_id IN ?\", deptIds).Pluck(\"role_id\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// DeptIdsByRoleIds 根据角色 ID 列表查询关联的部门 ID 列表。\nfunc (m *DeptRoleMap) DeptIdsByRoleIds(roleIds []uint) ([]uint, error) {\n\tif len(roleIds) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"role_id IN ?\", roleIds).Pluck(\"dept_id\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// DeptRoleMapByDeptIds 批量查询多个部门的角色关系，返回 map[deptId][]roleId。\nfunc (m *DeptRoleMap) DeptRoleMapByDeptIds(deptIds []uint) (map[uint][]uint, error) {\n\tresult := make(map[uint][]uint, len(deptIds))\n\tif len(deptIds) == 0 {\n\t\treturn result, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ttype row struct {\n\t\tDeptId uint\n\t\tRoleId uint\n\t}\n\tvar rows []row\n\tif err := db.Table(m.TableName()).Select(\"dept_id,role_id\").Where(\"dept_id IN ?\", deptIds).Scan(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, r := range rows {\n\t\tresult[r.DeptId] = append(result[r.DeptId], r.RoleId)\n\t}\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/model/file_upload.go",
    "content": "package model\n\nimport \"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n\nconst (\n\tStorageDriverLocal     = \"local\"\n\tStorageDriverAliyunOSS = \"aliyun_oss\"\n\tStorageDriverS3        = \"s3\"\n\n\tStorageStatusStored       = \"stored\"\n\tStorageStatusDeleteFailed = \"delete_failed\"\n)\n\ntype UploadFiles struct {\n\tContainsDeleteBaseModel\n\tFileObjectID     uint             `json:\"file_object_id\"`          // 物理对象ID\n\tUID              uint             `json:\"uid\"`                     // 用户ID\n\tFolderID         uint             `json:\"folder_id\"`               // 逻辑目录ID\n\tLogicalPath      string           `json:\"logical_path\"`            // 逻辑路径快照\n\tDisplayName      string           `json:\"display_name\"`            // 展示名称\n\tOriginName       string           `json:\"origin_name\"`             // 原始文件名\n\tName             string           `json:\"name\"`                    // 存储的文件名（UUID+扩展名）\n\tPath             string           `json:\"path\"`                    // 文件相对路径（相对于storage/public或storage/private）\n\tSize             uint             `json:\"size\"`                    // 文件大小（字节）\n\tExt              string           `json:\"ext\"`                     // 文件扩展名\n\tHash             string           `json:\"hash\"`                    // 文件SHA256哈希值（用于去重）\n\tUUID             string           `json:\"uuid\"`                    // 文件UUID（用于URL访问，32位十六进制字符串，不带连字符）\n\tMimeType         string           `json:\"mime_type\"`               // MIME类型（如：image/jpeg, application/pdf）\n\tFileType         string           `json:\"file_type\"`               // 文件类型：image,pdf,word,excel,ppt,archive,text,audio,video,other\n\tIsPublic         uint8            `json:\"is_public\"`               // 是否公开访问：0否 1是\n\tStorageDriver    string           `json:\"storage_driver\"`          // 存储驱动：local,aliyun_oss,s3\n\tStorageBase      string           `json:\"storage_base\"`            // 存储基础位置\n\tBucket           string           `json:\"bucket\"`                  // 存储桶\n\tStoragePath      string           `json:\"storage_path\"`            // 实际存储路径\n\tObjectKey        string           `json:\"object_key\"`              // 对象 key\n\tETag             string           `json:\"etag\" gorm:\"column:etag\"` // 对象 ETag\n\tStorageStatus    string           `json:\"storage_status\"`          // 存储状态\n\tUploadSource     string           `json:\"upload_source\"`           // 上传来源\n\tUploadScene      string           `json:\"upload_scene\"`            // 上传场景\n\tUploadStatus     string           `json:\"upload_status\"`           // 上传状态\n\tLastAccessedAt   utils.FormatDate `json:\"last_accessed_at\"`        // 最后访问时间\n\tDeletedBy        uint             `json:\"deleted_by\"`              // 删除人\n\tDeletedReason    string           `json:\"deleted_reason\"`          // 删除原因\n\tReferenceCount   int64            `json:\"reference_count\" gorm:\"-:all\"`\n\tObjectReuseCount int64            `json:\"object_reuse_count\" gorm:\"-:all\"`\n\tObjectStatus     string           `json:\"object_status\" gorm:\"-:all\"`\n}\n\nfunc NewUploadFiles() *UploadFiles {\n\treturn BindModel(&UploadFiles{})\n}\n\n// TableName 获取表名\nfunc (m *UploadFiles) TableName() string {\n\treturn \"upload_files\"\n}\n\n// Create 创建单条文件记录。\nfunc (m *UploadFiles) Create() error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(m).Error\n}\n\nconst (\n\tUploadSourceBackend = \"backend\"\n\tUploadSourceDirect  = \"direct\"\n\tUploadSourceSystem  = \"system\"\n\n\tUploadStatusPending  = \"pending\"\n\tUploadStatusUploaded = \"uploaded\"\n\tUploadStatusFailed   = \"failed\"\n)\n\ntype UploadFileObject struct {\n\tBaseModel\n\tStorageDriver string `json:\"storage_driver\"`\n\tStorageBase   string `json:\"storage_base\"`\n\tBucket        string `json:\"bucket\"`\n\tStoragePath   string `json:\"storage_path\"`\n\tObjectKey     string `json:\"object_key\"`\n\tSize          uint   `json:\"size\"`\n\tHash          string `json:\"hash\"`\n\tMimeType      string `json:\"mime_type\"`\n\tETag          string `json:\"etag\" gorm:\"column:etag\"`\n\tStatus        string `json:\"status\"`\n}\n\nfunc NewUploadFileObject() *UploadFileObject {\n\treturn BindModel(&UploadFileObject{})\n}\n\nfunc (m *UploadFileObject) TableName() string {\n\treturn \"upload_file_objects\"\n}\n\ntype UploadFileFolder struct {\n\tContainsDeleteBaseModel\n\tParentID    uint   `json:\"parent_id\"`\n\tName        string `json:\"name\"`\n\tLogicalPath string `json:\"logical_path\"`\n\tSort        int    `json:\"sort\"`\n\tCreatedBy   uint   `json:\"created_by\"`\n\tUpdatedBy   uint   `json:\"updated_by\"`\n}\n\nfunc NewUploadFileFolder() *UploadFileFolder {\n\treturn BindModel(&UploadFileFolder{})\n}\n\nfunc (m *UploadFileFolder) TableName() string {\n\treturn \"upload_file_folders\"\n}\n\ntype UploadFileReference struct {\n\tBaseModel\n\tFileID     uint   `json:\"file_id\"`\n\tUUID       string `json:\"uuid\"`\n\tOwnerType  string `json:\"owner_type\"`\n\tOwnerID    uint   `json:\"owner_id\"`\n\tOwnerField string `json:\"owner_field\"`\n}\n\nfunc NewUploadFileReference() *UploadFileReference {\n\treturn BindModel(&UploadFileReference{})\n}\n\nfunc (m *UploadFileReference) TableName() string {\n\treturn \"upload_file_references\"\n}\n"
  },
  {
    "path": "internal/model/file_upload_test.go",
    "content": "package model\n\nimport (\n\t\"sync\"\n\t\"testing\"\n\n\t\"gorm.io/gorm/schema\"\n)\n\nfunc TestUploadFilesETagColumnName(t *testing.T) {\n\tparsed, err := schema.Parse(&UploadFiles{}, &sync.Map{}, schema.NamingStrategy{})\n\tif err != nil {\n\t\tt.Fatalf(\"parse upload files schema: %v\", err)\n\t}\n\tfield := parsed.LookUpField(\"ETag\")\n\tif field == nil {\n\t\tt.Fatal(\"expected ETag field to exist\")\n\t}\n\tif field.DBName != \"etag\" {\n\t\tt.Fatalf(\"expected ETag DB column to be etag, got %s\", field.DBName)\n\t}\n}\n"
  },
  {
    "path": "internal/model/login_security_state.go",
    "content": "package model\n\nimport (\n\t\"strings\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// LoginSecurityState 记录登录失败计数与锁定状态。\ntype LoginSecurityState struct {\n\tBaseModel\n\tUsername     string            `json:\"username\" gorm:\"column:username;type:varchar(50);not null;default:'';uniqueIndex:lss_username;comment:登录账号\"`\n\tFailCount    uint              `json:\"fail_count\" gorm:\"column:fail_count;type:int unsigned;not null;default:0;comment:连续失败次数\"`\n\tLockUntil    *utils.FormatDate `json:\"lock_until\" gorm:\"column:lock_until;type:datetime;comment:锁定截止时间\"`\n\tLastFailedAt *utils.FormatDate `json:\"last_failed_at\" gorm:\"column:last_failed_at;type:datetime;comment:最近失败时间\"`\n}\n\nfunc NewLoginSecurityState() *LoginSecurityState {\n\treturn BindModel(&LoginSecurityState{})\n}\n\nfunc (m *LoginSecurityState) TableName() string {\n\treturn \"login_security_state\"\n}\n\n// FindByUsername 查询指定账号的登录安全状态。\nfunc (m *LoginSecurityState) FindByUsername(username string) error {\n\tusername = strings.TrimSpace(username)\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(\"username = ?\", username).First(m).Error\n}\n"
  },
  {
    "path": "internal/model/menu.go",
    "content": "package model\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model/modelDict\"\n)\n\n// Menu 权限路由表\ntype Menu struct {\n\tContainsDeleteBaseModel\n\tIcon            string       `json:\"icon\"`              // 图标\n\tCode            string       `json:\"code\"`              // 前端权限标识\n\tPath            string       `json:\"path\"`              // 前端路由\n\tFullPath        string       `json:\"full_path\"`         // 完整前端路由\n\tIsShow          uint8        `json:\"is_show\"`           // 是否显示，1是 0否\n\tIsNewWindow     uint8        `json:\"is_new_window\"`     // 是否新窗口打开, 1是 0否\n\tSort            uint         `json:\"sort\"`              // 排序，数字越大，排名越靠前\n\tType            uint8        `json:\"type\"`              // 菜单类型，1目录，2菜单，3按钮\n\tPid             uint         `json:\"pid\"`               // 上级菜单id\n\tLevel           uint8        `json:\"level\"`             // 层级\n\tPids            string       `json:\"pids\"`              // 层级序列，多个用英文逗号隔开\n\tChildrenNum     uint         `json:\"children_num\"`      // 子集数量\n\tDescription     string       `json:\"description\"`       // 描述\n\tIsAuth          uint8        `json:\"is_auth\"`           // 是否鉴权 0:否 1:是\n\tIsExternalLinks uint8        `json:\"is_external_links\"` // 是否外链 0:否 1:是\n\tName            string       `json:\"name\"`              // 路由名称\n\tComponent       string       `json:\"component\"`         // 组件路径\n\tAnimateEnter    string       `json:\"animate_enter\"`     // 进入动画\n\tAnimateLeave    string       `json:\"animate_leave\"`     // 离开动画\n\tAnimateDuration float32      `json:\"animate_duration\"`  // 动画时长\n\tApiList         []MenuApiMap `json:\"api_list\" gorm:\"foreignkey:menu_id;references:id\"`\n\tStatus          uint8        `json:\"status\"`   // 状态，0禁用，1启用\n\tRedirect        string       `json:\"redirect\"` // 重定向路由名称\n}\n\nconst CATALOGUE uint8 = 1\nconst MENU uint8 = 2\nconst BUTTON uint8 = 3\n\nvar MenuType modelDict.Dict = map[uint8]string{\n\tCATALOGUE: \"目录\",\n\tMENU:      \"菜单\",\n\tBUTTON:    \"按钮\",\n}\n\nfunc (m *Menu) MenuTypeMap() string {\n\treturn MenuType.Map(m.Type)\n}\n\nfunc (m *Menu) IsExternalLinksMap() string {\n\treturn modelDict.IsMap.Map(m.IsExternalLinks)\n}\n\nfunc (m *Menu) IsAuthMap() string {\n\treturn modelDict.IsMap.Map(m.IsAuth)\n}\n\nfunc (m *Menu) IsShowMap() string {\n\treturn modelDict.IsMap.Map(m.IsShow)\n}\n\nfunc (m *Menu) IsNewWindowMap() string {\n\treturn modelDict.IsMap.Map(m.IsNewWindow)\n}\n\n// StatusMap 状态映射\nfunc (m *Menu) StatusMap() string {\n\treturn modelDict.IsMap.Map(m.Status)\n}\n\nfunc (m *Menu) GetApiIds() []uint {\n\t// 如果 ApiList 为空，直接返回空切片\n\tif len(m.ApiList) == 0 {\n\t\treturn []uint{}\n\t}\n\n\t// 预分配切片容量，避免多次内存分配\n\tapiIds := make([]uint, 0, len(m.ApiList))\n\tfor _, v := range m.ApiList {\n\t\tapiIds = append(apiIds, v.ApiId)\n\t}\n\treturn apiIds\n}\n\nfunc NewMenu() *Menu {\n\treturn BindModel(&Menu{})\n}\n\n// TableName 获取表名\nfunc (m *Menu) TableName() string {\n\treturn \"menu\"\n}\n\n// AllIds 查询所有未删除菜单的 ID 列表。\nfunc (m *Menu) AllIds() ([]uint, error) {\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"deleted_at = 0\").Pluck(\"id\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// EnabledIdsByIds 根据 ID 列表查询启用状态（status=1）且未删除的菜单 ID。\nfunc (m *Menu) EnabledIdsByIds(ids []uint) ([]uint, error) {\n\tif len(ids) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar result []uint\n\tif err := db.Where(\"id IN ? AND status = 1 AND deleted_at = 0\", ids).Pluck(\"id\", &result).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\n// ExistsExcludeId 检查指定字段值的记录是否存在（排除指定 ID）。\nfunc (m *Menu) ExistsExcludeId(field string, value string, excludeId uint) (bool, error) {\n\tif !isAllowedMenuUniqueField(field) {\n\t\treturn false, fmt.Errorf(\"unsupported menu unique field: %s\", field)\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tvar exists bool\n\tif err := db.Model(m).\n\t\tSelect(\"1\").\n\t\tWhere(field+\" = ? AND id != ? AND deleted_at = 0\", value, excludeId).\n\t\tLimit(1).\n\t\tScan(&exists).Error; err != nil {\n\t\treturn false, err\n\t}\n\treturn exists, nil\n}\n\nfunc isAllowedMenuUniqueField(field string) bool {\n\tswitch field {\n\tcase \"code\", \"name\", \"full_path\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// MenuTreeNode 菜单树节点，用于展开父级。\ntype MenuTreeNode struct {\n\tID   uint\n\tPids string\n}\n\n// FindPidsByIds 根据 ID 列表查询未删除菜单的 id 和 pids 信息。\nfunc (m *Menu) FindPidsByIds(ids []uint) ([]MenuTreeNode, error) {\n\tif len(ids) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar rows []MenuTreeNode\n\tif err := db.Table(m.TableName()).Select(\"id,pids\").Where(\"id IN ? AND deleted_at = 0\", ids).Scan(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn rows, nil\n}\n\n// FindIdsByCodes 根据代码列表查询未删除菜单的 ID 列表。\nfunc (m *Menu) FindIdsByCodes(codes []string) ([]Menu, error) {\n\tif len(codes) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar menus []Menu\n\tif err := db.Select(\"id\", \"code\").Where(\"code IN ? AND deleted_at = 0\", codes).Find(&menus).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn menus, nil\n}\n\n// FindDescendantsById 查询指定菜单 ID 的所有后代菜单（用于更新子菜单层级）。\nfunc (m *Menu) FindDescendantsById(id uint) ([]Menu, error) {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar menus []Menu\n\tif err := db.Where(\"FIND_IN_SET(?,pids)\", id).Order(\"level asc, id asc\").Find(&menus).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn menus, nil\n}\n\n// UpdateById 根据 ID 更新菜单字段。\nfunc (m *Menu) UpdateById(id uint, data map[string]any) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Model(m).Where(\"id = ?\", id).Updates(data).Error\n}\n"
  },
  {
    "path": "internal/model/menu_api_map.go",
    "content": "package model\n\nimport \"github.com/wannanbigpig/gin-layout/internal/global\"\n\n// MenuApiMap 权限路由表\ntype MenuApiMap struct {\n\tBaseModel\n\tMenuId uint `json:\"menu_id\"` // 菜单ID\n\tApiId  uint `json:\"api_id\"`  // API ID\n}\n\nfunc NewMenuApiMap() *MenuApiMap {\n\treturn BindModel(&MenuApiMap{})\n}\n\n// TableName 获取表名\nfunc (m *MenuApiMap) TableName() string {\n\treturn \"menu_api_map\"\n}\n\nfunc (m *MenuApiMap) CreateBatch(mappings []*MenuApiMap) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(&mappings).Error\n}\n\n// ApiIdsByMenuId 根据菜单 ID 查询关联的 API ID 列表。\nfunc (m *MenuApiMap) ApiIdsByMenuId(menuId uint) ([]uint, error) {\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"menu_id = ?\", menuId).Pluck(\"api_id\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// MenuIdsByApiIds 根据 API ID 列表查询关联的菜单 ID 列表。\nfunc (m *MenuApiMap) MenuIdsByApiIds(apiIds []uint) ([]uint, error) {\n\tif len(apiIds) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"api_id IN ?\", apiIds).Pluck(\"menu_id\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// ApiPermission 接口权限信息（路由+方法）。\ntype ApiPermission struct {\n\tRoute  string\n\tMethod string\n}\n\n// ApiPermissionsByMenuIds 根据菜单 ID 列表查询去重后的接口权限（JOIN api 表）。\nfunc (m *MenuApiMap) ApiPermissionsByMenuIds(menuIds []uint) ([]ApiPermission, error) {\n\tif len(menuIds) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar permissions []ApiPermission\n\terr = db.Table(m.TableName()+\" m\").\n\t\tSelect(\"DISTINCT a.route, a.method\").\n\t\tJoins(\"JOIN api a ON a.id = m.api_id\").\n\t\tWhere(\"m.menu_id IN ? AND a.deleted_at = 0 AND a.is_auth = ? AND a.is_effective = 1\", menuIds, global.ApiAuthModeAuth).\n\t\tFind(&permissions).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn permissions, nil\n}\n\n// MenuApiPermission 按菜单分组的接口权限信息。\ntype MenuApiPermission struct {\n\tMenuId uint\n\tRoute  string\n\tMethod string\n}\n\n// MenuApiPermissionsByMenuIds 根据菜单 ID 列表查询按菜单分组的接口权限（JOIN api 表）。\nfunc (m *MenuApiMap) MenuApiPermissionsByMenuIds(menuIds []uint) ([]MenuApiPermission, error) {\n\tif len(menuIds) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar rows []MenuApiPermission\n\terr = db.Table(m.TableName()+\" m\").\n\t\tSelect(\"m.menu_id, a.route, a.method\").\n\t\tJoins(\"JOIN api a ON a.id = m.api_id\").\n\t\tWhere(\"m.menu_id IN ? AND a.deleted_at = 0 AND a.is_auth = ? AND a.is_effective = 1\", menuIds, global.ApiAuthModeAuth).\n\t\tScan(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn rows, nil\n}\n"
  },
  {
    "path": "internal/model/menu_i18n.go",
    "content": "package model\n\nimport (\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n)\n\n// MenuI18n 菜单标题多语言表。\ntype MenuI18n struct {\n\tBaseModel\n\tMenuID uint   `json:\"menu_id\" gorm:\"column:menu_id;type:int unsigned;not null;default:0;index:uniq_menu_id_locale,unique;index:idx_locale_menu_id\"`\n\tLocale string `json:\"locale\" gorm:\"column:locale;type:varchar(10);not null;default:'';index:uniq_menu_id_locale,unique;index:idx_locale_menu_id\"`\n\tTitle  string `json:\"title\" gorm:\"column:title;type:varchar(60);not null;default:''\"`\n}\n\nfunc NewMenuI18n() *MenuI18n {\n\treturn BindModel(&MenuI18n{})\n}\n\n// TableName 获取表名。\nfunc (m *MenuI18n) TableName() string {\n\treturn \"menu_i18n\"\n}\n\n// UpsertMenuTitles 按 menu_id + locale 幂等写入标题翻译。\nfunc (m *MenuI18n) UpsertMenuTitles(menuID uint, localeTitles map[string]string, tx ...*gorm.DB) error {\n\tif menuID == 0 || len(localeTitles) == 0 {\n\t\treturn nil\n\t}\n\tif len(tx) > 0 && tx[0] != nil {\n\t\tm.SetDB(tx[0])\n\t}\n\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trows := make([]MenuI18n, 0, len(localeTitles))\n\tfor locale, title := range localeTitles {\n\t\ttrimmedLocale := strings.TrimSpace(locale)\n\t\ttrimmedTitle := strings.TrimSpace(title)\n\t\tif trimmedLocale == \"\" || trimmedTitle == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\trows = append(rows, MenuI18n{\n\t\t\tMenuID: menuID,\n\t\t\tLocale: trimmedLocale,\n\t\t\tTitle:  trimmedTitle,\n\t\t})\n\t}\n\tif len(rows) == 0 {\n\t\treturn nil\n\t}\n\n\treturn db.Clauses(clause.OnConflict{\n\t\tColumns:   []clause.Column{{Name: \"menu_id\"}, {Name: \"locale\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"title\", \"updated_at\"}),\n\t}).Create(&rows).Error\n}\n\n// LocalizedTitleMapByMenuIDs 按语言优先级批量查询菜单标题。\nfunc (m *MenuI18n) LocalizedTitleMapByMenuIDs(menuIDs []uint, localePriority []string) (map[uint]string, error) {\n\tresult := make(map[uint]string, len(menuIDs))\n\tif len(menuIDs) == 0 {\n\t\treturn result, nil\n\t}\n\n\tpriorities := make([]string, 0, len(localePriority))\n\tseen := make(map[string]struct{}, len(localePriority))\n\tfor _, locale := range localePriority {\n\t\ttrimmedLocale := strings.TrimSpace(locale)\n\t\tif trimmedLocale == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[trimmedLocale]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[trimmedLocale] = struct{}{}\n\t\tpriorities = append(priorities, trimmedLocale)\n\t}\n\tif len(priorities) == 0 {\n\t\treturn result, nil\n\t}\n\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar rows []MenuI18n\n\tif err := db.Where(\"menu_id IN ? AND locale IN ?\", menuIDs, priorities).Find(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\tgrouped := make(map[uint]map[string]string, len(menuIDs))\n\tfor _, row := range rows {\n\t\tif _, ok := grouped[row.MenuID]; !ok {\n\t\t\tgrouped[row.MenuID] = make(map[string]string)\n\t\t}\n\t\tgrouped[row.MenuID][strings.TrimSpace(row.Locale)] = strings.TrimSpace(row.Title)\n\t}\n\n\tfor _, menuID := range menuIDs {\n\t\tlocalizedMap := grouped[menuID]\n\t\tif len(localizedMap) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, locale := range priorities {\n\t\t\tif title := strings.TrimSpace(localizedMap[locale]); title != \"\" {\n\t\t\t\tresult[menuID] = title\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\treturn result, nil\n}\n\n// LocaleTitleMapByMenuID 查询指定菜单的全部翻译。\nfunc (m *MenuI18n) LocaleTitleMapByMenuID(menuID uint) (map[string]string, error) {\n\tresult := make(map[string]string)\n\tif menuID == 0 {\n\t\treturn result, nil\n\t}\n\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar rows []MenuI18n\n\tif err := db.Where(\"menu_id = ?\", menuID).Find(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, row := range rows {\n\t\ttrimmedLocale := strings.TrimSpace(row.Locale)\n\t\ttrimmedTitle := strings.TrimSpace(row.Title)\n\t\tif trimmedLocale == \"\" || trimmedTitle == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tresult[trimmedLocale] = trimmedTitle\n\t}\n\treturn result, nil\n}\n\n// DeleteByMenuIDs 删除菜单对应的翻译数据。\nfunc (m *MenuI18n) DeleteByMenuIDs(menuIDs []uint, tx ...*gorm.DB) error {\n\tif len(menuIDs) == 0 {\n\t\treturn nil\n\t}\n\tif len(tx) > 0 && tx[0] != nil {\n\t\tm.SetDB(tx[0])\n\t}\n\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(\"menu_id IN ?\", menuIDs).Delete(&MenuI18n{}).Error\n}\n"
  },
  {
    "path": "internal/model/modelDict/base.go",
    "content": "package modelDict\n\nimport \"github.com/wannanbigpig/gin-layout/internal/global\"\n\ntype Dict map[uint8]string\n\nfunc (d Dict) Map(k uint8) string {\n\t// 先判断 d 是否为 nil，防止 nil 指针解引用\n\tif d == nil {\n\t\treturn \"-\"\n\t}\n\n\tif v, ok := d[k]; ok {\n\t\treturn v\n\t}\n\n\treturn \"-\"\n}\n\nvar IsMap Dict = map[uint8]string{\n\tglobal.No:  \"否\",\n\tglobal.Yes: \"是\",\n}\n"
  },
  {
    "path": "internal/model/request_logs.go",
    "content": "package model\n\n// RequestLogs 请求日志表\ntype RequestLogs struct {\n\tBaseModel\n\tRequestID       string  `json:\"request_id\"`       // 请求唯一标识\n\tJwtID           string  `json:\"jwt_id\"`           // 请求授权的jwtId\n\tOperatorID      uint    `json:\"operator_id\"`      // 操作ID（用户ID）\n\tIP              string  `json:\"ip\"`               // 客户端IP地址\n\tUserAgent       string  `json:\"user_agent\"`       // 用户代理（浏览器/设备信息）\n\tOS              string  `json:\"os\"`               // 操作系统\n\tBrowser         string  `json:\"browser\"`          // 浏览器\n\tMethod          string  `json:\"method\"`           // HTTP请求方法（GET/POST等）\n\tBaseURL         string  `json:\"base_url\"`         // 请求基础URL\n\tOperationName   string  `json:\"operation_name\"`   // 操作名称\n\tOperationStatus int     `json:\"operation_status\"` // 操作状态码（响应返回的code，0=成功，其他=失败）\n\tIsHighRisk      uint8   `json:\"is_high_risk\"`     // 是否高危操作 1是 0否\n\tOperatorAccount string  `json:\"operator_account\"` // 操作账号\n\tOperatorName    string  `json:\"operator_name\"`    // 操作人员\n\tRequestHeaders  string  `json:\"request_headers\"`  // 请求头（JSON格式）\n\tRequestQuery    string  `json:\"request_query\"`    // 请求参数\n\tRequestBody     string  `json:\"request_body\"`     // 请求体\n\tChangeDiff      string  `json:\"change_diff\"`      // 关键变更前后差异（JSON）\n\tResponseStatus  int     `json:\"response_status\"`  // 响应状态码\n\tResponseBody    string  `json:\"response_body\"`    // 响应体\n\tResponseHeader  string  `json:\"response_header\"`  // 响应头\n\tExecutionTime   float64 `json:\"execution_time\"`   // 执行时间（毫秒，支持小数，最多4位）\n}\n\nfunc NewRequestLogs() *RequestLogs {\n\treturn BindModel(&RequestLogs{})\n}\n\n// TableName 获取表名\nfunc (m *RequestLogs) TableName() string {\n\treturn \"request_logs\"\n}\n\n// Create 创建单条请求日志记录。\nfunc (m *RequestLogs) Create() error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(m).Error\n}\n"
  },
  {
    "path": "internal/model/role.go",
    "content": "package model\n\nimport (\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model/modelDict\"\n)\n\n// 角色状态字典\nvar RoleStatusDict modelDict.Dict = map[uint8]string{\n\t1: \"启用\",\n\t0: \"禁用\",\n}\n\n// Role 角色表\ntype Role struct {\n\tContainsDeleteBaseModel\n\tCode        string        `json:\"code\" gorm:\"column:code;type:varchar(60);not null;default:'';comment:角色业务编码\"`\n\tIsSystem    uint8         `json:\"is_system\" gorm:\"column:is_system;type:tinyint unsigned;not null;default:0;comment:是否系统保留对象\"`\n\tPid         uint          `json:\"pid\" gorm:\"column:pid;type:int unsigned;not null;default:0;comment:上级id\"`\n\tPids        string        `json:\"pids\" gorm:\"column:pids;type:varchar(255);not null;default:'';comment:所有上级id\"`\n\tName        string        `json:\"name\" gorm:\"column:name;type:varchar(60);not null;default:'';comment:角色名称\"`\n\tDescription string        `json:\"description\" gorm:\"column:description;type:varchar(255);not null;default:'';comment:描述\"`\n\tLevel       uint8         `json:\"level\" gorm:\"column:level;type:tinyint unsigned;not null;default:1;comment:层级\"`\n\tSort        uint          `json:\"sort\" gorm:\"column:sort;type:mediumint unsigned;not null;default:0;comment:排序\"`\n\tChildrenNum uint          `json:\"children_num\" gorm:\"column:children_num;type:int unsigned;not null;default:0;comment:子集数量\"`\n\tMenuList    []RoleMenuMap `json:\"menu_list,omitempty\" gorm:\"foreignkey:role_id;references:id;comment:菜单列表\"`\n\tStatus      uint8         `json:\"status\" gorm:\"column:status;type:tinyint unsigned;not null;default:1;comment:是否启用状态,1启用，0禁用\"`\n}\n\nfunc NewRole() *Role {\n\treturn BindModel(&Role{})\n}\n\n// TableName 获取表名\nfunc (m *Role) TableName() string {\n\treturn \"role\"\n}\n\n// StatusMap 状态映射\nfunc (m *Role) StatusMap() string {\n\treturn RoleStatusDict.Map(m.Status)\n}\n\nfunc (m *Role) IsSystemRole() bool {\n\treturn m.IsSystem == global.Yes\n}\n\n// RoleStatusInfo 角色状态简要信息。\ntype RoleStatusInfo struct {\n\tID     uint\n\tPids   string\n\tStatus uint8\n}\n\n// AllRoleStatusInfos 查询所有未删除角色的 id、pids、status 信息。\nfunc (m *Role) AllRoleStatusInfos() ([]RoleStatusInfo, error) {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar rows []RoleStatusInfo\n\tif err := db.Table(m.TableName()).Select(\"id,pids,status\").Where(\"deleted_at = 0\").Scan(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn rows, nil\n}\n\n// RoleTreeNode 角色树节点，用于展开子树。\ntype RoleTreeNode struct {\n\tID   uint\n\tPids string\n}\n\n// AllTreeNodes 查询所有未删除角色的 id、pids，用于角色子树展开。\nfunc (m *Role) AllTreeNodes() ([]RoleTreeNode, error) {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar rows []RoleTreeNode\n\tif err := db.Table(m.TableName()).Select(\"id,pids\").Where(\"deleted_at = 0\").Scan(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn rows, nil\n}\n\n// EnabledIdsByIds 根据 ID 列表查询启用状态（status=1）且未删除的角色 ID。\nfunc (m *Role) EnabledIdsByIds(ids []uint) ([]uint, error) {\n\tif len(ids) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar result []uint\n\tif err := db.Where(\"id IN ? AND status = 1 AND deleted_at = 0\", ids).Pluck(\"id\", &result).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\n// FindByCode 根据 code 查找未删除的角色，结果写入自身。\nfunc (m *Role) FindByCode(code string) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(\"code = ? AND deleted_at = 0\", code).First(m).Error\n}\n\n// FindPidsByIds 根据 ID 列表查询未删除角色的 id 和 pids 信息。\nfunc (m *Role) FindPidsByIds(ids []uint) ([]RoleTreeNode, error) {\n\tif len(ids) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar rows []RoleTreeNode\n\tif err := db.Table(m.TableName()).Select(\"id,pids\").Where(\"id IN ? AND deleted_at = 0\", ids).Scan(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn rows, nil\n}\n\n// SubtreeIdsByRootIds 查询指定角色及其全部后代角色 ID。\nfunc (m *Role) SubtreeIdsByRootIds(rootIDs []uint) ([]uint, error) {\n\tif len(rootIDs) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tquery := db.Table(m.TableName()).Where(\"deleted_at = 0\").Where(\"id IN ?\", rootIDs)\n\tfor _, rootID := range rootIDs {\n\t\tquery = query.Or(\"deleted_at = 0 AND FIND_IN_SET(?, pids)\", rootID)\n\t}\n\n\tvar ids []uint\n\tif err := query.Pluck(\"id\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// UpdateChildrenPidsByParent 批量更新指定父节点下所有子角色的 pids 和 level。\nfunc (m *Role) UpdateChildrenPidsByParent(parentID uint, updateExpr string) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Model(m).\n\t\tWhere(\"FIND_IN_SET(?,pids)\", parentID).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"pids\":  gorm.Expr(updateExpr),\n\t\t\t\"level\": gorm.Expr(\"length(pids) - length(replace(pids, ',', '')) + 1\"),\n\t\t}).Error\n}\n"
  },
  {
    "path": "internal/model/role_menu_map.go",
    "content": "package model\n\n// RoleMenuMap 角色菜单关联表\ntype RoleMenuMap struct {\n\tBaseModel\n\tMenuId uint `json:\"menu_id\"` // 菜单ID\n\tRoleId uint `json:\"role_id\"` // RoleID\n}\n\nfunc NewRoleMenuMap() *RoleMenuMap {\n\treturn BindModel(&RoleMenuMap{})\n}\n\n// TableName 获取表名\nfunc (m *RoleMenuMap) TableName() string {\n\treturn \"role_menu_map\"\n}\n\nfunc (m *RoleMenuMap) CreateBatch(mappings []*RoleMenuMap) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Create(&mappings).Error\n}\n\n// MenuIdsByRoleIds 根据角色 ID 列表查询关联的菜单 ID 列表。\nfunc (m *RoleMenuMap) MenuIdsByRoleIds(roleIds []uint) ([]uint, error) {\n\tif len(roleIds) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"role_id IN ?\", roleIds).Pluck(\"menu_id\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// RoleIdsByMenuIds 根据菜单 ID 列表查询关联的角色 ID 列表。\nfunc (m *RoleMenuMap) RoleIdsByMenuIds(menuIds []uint) ([]uint, error) {\n\tif len(menuIds) == 0 {\n\t\treturn nil, nil\n\t}\n\tdb, err := m.GetDB(m)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ids []uint\n\tif err := db.Where(\"menu_id IN ?\", menuIds).Pluck(\"role_id\", &ids).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\n// RoleMenuMapByRoleIds 批量查询多个角色的菜单关系，返回 map[roleId][]menuId。\nfunc (m *RoleMenuMap) RoleMenuMapByRoleIds(roleIds []uint) (map[uint][]uint, error) {\n\tresult := make(map[uint][]uint)\n\tif len(roleIds) == 0 {\n\t\treturn result, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ttype row struct {\n\t\tRoleId uint\n\t\tMenuId uint\n\t}\n\tvar rows []row\n\tif err := db.Table(m.TableName()).Select(\"role_id,menu_id\").Where(\"role_id IN ?\", roleIds).Scan(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, r := range rows {\n\t\tresult[r.RoleId] = append(result[r.RoleId], r.MenuId)\n\t}\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/model/sys_config.go",
    "content": "package model\n\nimport (\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n)\n\nconst (\n\tSysConfigValueTypeString = \"string\"\n\tSysConfigValueTypeNumber = \"number\"\n\tSysConfigValueTypeBool   = \"bool\"\n\tSysConfigValueTypeJSON   = \"json\"\n)\n\n// SysConfig 系统参数表。\ntype SysConfig struct {\n\tContainsDeleteBaseModel\n\tConfigKey      string            `json:\"config_key\" gorm:\"column:config_key;type:varchar(100);not null;default:'';comment:参数键名\"`\n\tConfigName     string            `json:\"config_name\" gorm:\"-:all\"`\n\tConfigNameI18n map[string]string `json:\"config_name_i18n\" gorm:\"-:all\"`\n\tConfigValue    string            `json:\"config_value\" gorm:\"column:config_value;type:text;comment:参数值\"`\n\tValueType      string            `json:\"value_type\" gorm:\"column:value_type;type:varchar(20);not null;default:'string';comment:值类型\"`\n\tGroupCode      string            `json:\"group_code\" gorm:\"column:group_code;type:varchar(60);not null;default:'default';comment:参数分组\"`\n\tIsSystem       uint8             `json:\"is_system\" gorm:\"column:is_system;type:tinyint unsigned;not null;default:0;comment:是否系统内置\"`\n\tIsSensitive    uint8             `json:\"is_sensitive\" gorm:\"column:is_sensitive;type:tinyint unsigned;not null;default:0;comment:是否敏感配置\"`\n\tIsVisible      uint8             `json:\"is_visible\" gorm:\"column:is_visible;type:tinyint unsigned;not null;default:1;comment:是否在系统参数页展示\"`\n\tManageTab      string            `json:\"manage_tab\" gorm:\"column:manage_tab;type:varchar(60);not null;default:'';comment:专属配置Tab\"`\n\tStatus         uint8             `json:\"status\" gorm:\"column:status;type:tinyint unsigned;not null;default:1;comment:状态\"`\n\tSort           uint              `json:\"sort\" gorm:\"column:sort;type:int unsigned;not null;default:0;comment:排序\"`\n\tRemark         string            `json:\"remark\" gorm:\"column:remark;type:varchar(255);not null;default:'';comment:备注\"`\n}\n\nfunc NewSysConfig() *SysConfig {\n\treturn BindModel(&SysConfig{})\n}\n\nfunc (m *SysConfig) TableName() string {\n\treturn \"sys_config\"\n}\n\n// IsProtected 判断参数是否为系统内置保护项。\nfunc (m *SysConfig) IsProtected() bool {\n\treturn m != nil && m.IsSystem == 1\n}\n\n// NormalizeValueType 归一化参数值类型。\nfunc NormalizeValueType(valueType string) string {\n\tvalueType = strings.TrimSpace(strings.ToLower(valueType))\n\tif valueType == \"\" {\n\t\treturn SysConfigValueTypeString\n\t}\n\treturn valueType\n}\n\n// FindByKey 根据参数键查询未删除参数。\nfunc (m *SysConfig) FindByKey(key string) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(\"config_key = ? AND deleted_at = 0\", key).First(m).Error\n}\n\n// ExistsByKeyExcludeID 检查参数键是否已被其他记录占用。\nfunc (m *SysConfig) ExistsByKeyExcludeID(key string, excludeID uint) (bool, error) {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tvar count int64\n\tquery := db.Model(m).Where(\"config_key = ? AND deleted_at = 0\", key)\n\tif excludeID > 0 {\n\t\tquery = query.Where(\"id <> ?\", excludeID)\n\t}\n\tif err := query.Count(&count).Error; err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\n// EnabledConfigs 查询所有启用参数，用于刷新缓存。\nfunc (m *SysConfig) EnabledConfigs(tx ...*gorm.DB) ([]SysConfig, error) {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(tx) > 0 && tx[0] != nil {\n\t\tdb = tx[0]\n\t}\n\tvar configs []SysConfig\n\terr = db.Where(\"status = 1 AND deleted_at = 0\").Order(\"sort desc, id desc\").Find(&configs).Error\n\treturn configs, err\n}\n"
  },
  {
    "path": "internal/model/sys_dict.go",
    "content": "package model\n\n// SysDictType 系统字典类型表。\ntype SysDictType struct {\n\tContainsDeleteBaseModel\n\tTypeCode     string            `json:\"type_code\" gorm:\"column:type_code;type:varchar(100);not null;default:'';comment:字典类型编码\"`\n\tTypeName     string            `json:\"type_name\" gorm:\"-:all\"`\n\tTypeNameI18n map[string]string `json:\"type_name_i18n\" gorm:\"-:all\"`\n\tIsSystem     uint8             `json:\"is_system\" gorm:\"column:is_system;type:tinyint unsigned;not null;default:0;comment:是否系统内置\"`\n\tStatus       uint8             `json:\"status\" gorm:\"column:status;type:tinyint unsigned;not null;default:1;comment:状态\"`\n\tSort         uint              `json:\"sort\" gorm:\"column:sort;type:int unsigned;not null;default:0;comment:排序\"`\n\tRemark       string            `json:\"remark\" gorm:\"column:remark;type:varchar(255);not null;default:'';comment:备注\"`\n}\n\n// SysDictItem 系统字典项表。\ntype SysDictItem struct {\n\tContainsDeleteBaseModel\n\tTypeCode  string            `json:\"type_code\" gorm:\"column:type_code;type:varchar(100);not null;default:'';comment:字典类型编码\"`\n\tLabel     string            `json:\"label\" gorm:\"-:all\"`\n\tLabelI18n map[string]string `json:\"label_i18n\" gorm:\"-:all\"`\n\tValue     string            `json:\"value\" gorm:\"column:value;type:varchar(100);not null;default:'';comment:字典值\"`\n\tColor     string            `json:\"color\" gorm:\"column:color;type:varchar(30);not null;default:'';comment:展示颜色\"`\n\tTagType   string            `json:\"tag_type\" gorm:\"column:tag_type;type:varchar(30);not null;default:'';comment:前端标签类型\"`\n\tIsDefault uint8             `json:\"is_default\" gorm:\"column:is_default;type:tinyint unsigned;not null;default:0;comment:是否默认项\"`\n\tIsSystem  uint8             `json:\"is_system\" gorm:\"column:is_system;type:tinyint unsigned;not null;default:0;comment:是否系统内置\"`\n\tStatus    uint8             `json:\"status\" gorm:\"column:status;type:tinyint unsigned;not null;default:1;comment:状态\"`\n\tSort      uint              `json:\"sort\" gorm:\"column:sort;type:int unsigned;not null;default:0;comment:排序\"`\n\tRemark    string            `json:\"remark\" gorm:\"column:remark;type:varchar(255);not null;default:'';comment:备注\"`\n}\n\nfunc NewSysDictType() *SysDictType {\n\treturn BindModel(&SysDictType{})\n}\n\nfunc NewSysDictItem() *SysDictItem {\n\treturn BindModel(&SysDictItem{})\n}\n\nfunc (m *SysDictType) TableName() string {\n\treturn \"sys_dict_type\"\n}\n\nfunc (m *SysDictItem) TableName() string {\n\treturn \"sys_dict_item\"\n}\n\n// IsProtected 判断字典类型是否为系统内置保护项。\nfunc (m *SysDictType) IsProtected() bool {\n\treturn m != nil && m.IsSystem == 1\n}\n\n// IsProtected 判断字典项是否为系统内置保护项。\nfunc (m *SysDictItem) IsProtected() bool {\n\treturn m != nil && m.IsSystem == 1\n}\n\n// FindByTypeCode 根据类型编码查询未删除字典类型。\nfunc (m *SysDictType) FindByTypeCode(typeCode string) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(\"type_code = ? AND deleted_at = 0\", typeCode).First(m).Error\n}\n\n// ExistsByTypeCodeExcludeID 检查类型编码是否已被其他记录占用。\nfunc (m *SysDictType) ExistsByTypeCodeExcludeID(typeCode string, excludeID uint) (bool, error) {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tvar count int64\n\tquery := db.Model(m).Where(\"type_code = ? AND deleted_at = 0\", typeCode)\n\tif excludeID > 0 {\n\t\tquery = query.Where(\"id <> ?\", excludeID)\n\t}\n\tif err := query.Count(&count).Error; err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\n// ExistsByValueExcludeID 检查同类型下字典值是否已被其他记录占用。\nfunc (m *SysDictItem) ExistsByValueExcludeID(typeCode, value string, excludeID uint) (bool, error) {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tvar count int64\n\tquery := db.Model(m).Where(\"type_code = ? AND value = ? AND deleted_at = 0\", typeCode, value)\n\tif excludeID > 0 {\n\t\tquery = query.Where(\"id <> ?\", excludeID)\n\t}\n\tif err := query.Count(&count).Error; err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\n// FindByTypeCodeAndValue 根据类型编码和字典值查询未删除字典项。\nfunc (m *SysDictItem) FindByTypeCodeAndValue(typeCode, value string) error {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(\"type_code = ? AND value = ? AND deleted_at = 0\", typeCode, value).First(m).Error\n}\n\n// EnabledItemsByTypeCode 查询指定类型下启用字典项。\nfunc (m *SysDictItem) EnabledItemsByTypeCode(typeCode string) ([]SysDictItem, error) {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar items []SysDictItem\n\terr = db.Where(\"type_code = ? AND status = 1 AND deleted_at = 0\", typeCode).\n\t\tOrder(\"sort desc, id asc\").\n\t\tFind(&items).Error\n\treturn items, err\n}\n\n// CountByTypeCode 统计指定类型下未删除字典项数量。\nfunc (m *SysDictItem) CountByTypeCode(typeCode string) (int64, error) {\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tvar count int64\n\terr = db.Model(m).Where(\"type_code = ? AND deleted_at = 0\", typeCode).Count(&count).Error\n\treturn count, err\n}\n"
  },
  {
    "path": "internal/model/sys_i18n.go",
    "content": "package model\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n)\n\ntype SysConfigI18n struct {\n\tBaseModel\n\tConfigID   uint   `json:\"config_id\" gorm:\"column:config_id;type:int unsigned;not null;default:0;index:uniq_config_id_locale,unique\"`\n\tLocale     string `json:\"locale\" gorm:\"column:locale;type:varchar(20);not null;default:'';index:uniq_config_id_locale,unique;index:idx_locale_config_name\"`\n\tConfigName string `json:\"config_name\" gorm:\"column:config_name;type:varchar(100);not null;default:''\"`\n}\n\ntype SysDictTypeI18n struct {\n\tBaseModel\n\tDictTypeID uint   `json:\"dict_type_id\" gorm:\"column:dict_type_id;type:int unsigned;not null;default:0;index:uniq_dict_type_id_locale,unique\"`\n\tLocale     string `json:\"locale\" gorm:\"column:locale;type:varchar(20);not null;default:'';index:uniq_dict_type_id_locale,unique;index:idx_locale_type_name\"`\n\tTypeName   string `json:\"type_name\" gorm:\"column:type_name;type:varchar(100);not null;default:''\"`\n}\n\ntype SysDictItemI18n struct {\n\tBaseModel\n\tDictItemID uint   `json:\"dict_item_id\" gorm:\"column:dict_item_id;type:int unsigned;not null;default:0;index:uniq_dict_item_id_locale,unique\"`\n\tLocale     string `json:\"locale\" gorm:\"column:locale;type:varchar(20);not null;default:'';index:uniq_dict_item_id_locale,unique;index:idx_locale_label\"`\n\tLabel      string `json:\"label\" gorm:\"column:label;type:varchar(100);not null;default:''\"`\n}\n\nfunc NewSysConfigI18n() *SysConfigI18n {\n\treturn BindModel(&SysConfigI18n{})\n}\n\nfunc NewSysDictTypeI18n() *SysDictTypeI18n {\n\treturn BindModel(&SysDictTypeI18n{})\n}\n\nfunc NewSysDictItemI18n() *SysDictItemI18n {\n\treturn BindModel(&SysDictItemI18n{})\n}\n\nfunc (m *SysConfigI18n) TableName() string {\n\treturn \"sys_config_i18n\"\n}\n\nfunc (m *SysDictTypeI18n) TableName() string {\n\treturn \"sys_dict_type_i18n\"\n}\n\nfunc (m *SysDictItemI18n) TableName() string {\n\treturn \"sys_dict_item_i18n\"\n}\n\nfunc (m *SysConfigI18n) UpsertConfigNames(configID uint, localeNames map[string]string, tx ...*gorm.DB) error {\n\tif configID == 0 || len(localeNames) == 0 {\n\t\treturn nil\n\t}\n\tif len(tx) > 0 && tx[0] != nil {\n\t\tm.SetDB(tx[0])\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\trows := make([]SysConfigI18n, 0, len(localeNames))\n\tfor locale, name := range localeNames {\n\t\ttrimmedLocale := strings.TrimSpace(locale)\n\t\ttrimmedName := strings.TrimSpace(name)\n\t\tif trimmedLocale == \"\" || trimmedName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\trows = append(rows, SysConfigI18n{\n\t\t\tConfigID:   configID,\n\t\t\tLocale:     trimmedLocale,\n\t\t\tConfigName: trimmedName,\n\t\t})\n\t}\n\tif len(rows) == 0 {\n\t\treturn nil\n\t}\n\treturn db.Clauses(clause.OnConflict{\n\t\tColumns:   []clause.Column{{Name: \"config_id\"}, {Name: \"locale\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"config_name\", \"updated_at\"}),\n\t}).Create(&rows).Error\n}\n\nfunc (m *SysDictTypeI18n) UpsertTypeNames(dictTypeID uint, localeNames map[string]string, tx ...*gorm.DB) error {\n\tif dictTypeID == 0 || len(localeNames) == 0 {\n\t\treturn nil\n\t}\n\tif len(tx) > 0 && tx[0] != nil {\n\t\tm.SetDB(tx[0])\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\trows := make([]SysDictTypeI18n, 0, len(localeNames))\n\tfor locale, name := range localeNames {\n\t\ttrimmedLocale := strings.TrimSpace(locale)\n\t\ttrimmedName := strings.TrimSpace(name)\n\t\tif trimmedLocale == \"\" || trimmedName == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\trows = append(rows, SysDictTypeI18n{\n\t\t\tDictTypeID: dictTypeID,\n\t\t\tLocale:     trimmedLocale,\n\t\t\tTypeName:   trimmedName,\n\t\t})\n\t}\n\tif len(rows) == 0 {\n\t\treturn nil\n\t}\n\treturn db.Clauses(clause.OnConflict{\n\t\tColumns:   []clause.Column{{Name: \"dict_type_id\"}, {Name: \"locale\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"type_name\", \"updated_at\"}),\n\t}).Create(&rows).Error\n}\n\nfunc (m *SysDictItemI18n) UpsertLabels(dictItemID uint, localeLabels map[string]string, tx ...*gorm.DB) error {\n\tif dictItemID == 0 || len(localeLabels) == 0 {\n\t\treturn nil\n\t}\n\tif len(tx) > 0 && tx[0] != nil {\n\t\tm.SetDB(tx[0])\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\trows := make([]SysDictItemI18n, 0, len(localeLabels))\n\tfor locale, label := range localeLabels {\n\t\ttrimmedLocale := strings.TrimSpace(locale)\n\t\ttrimmedLabel := strings.TrimSpace(label)\n\t\tif trimmedLocale == \"\" || trimmedLabel == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\trows = append(rows, SysDictItemI18n{\n\t\t\tDictItemID: dictItemID,\n\t\t\tLocale:     trimmedLocale,\n\t\t\tLabel:      trimmedLabel,\n\t\t})\n\t}\n\tif len(rows) == 0 {\n\t\treturn nil\n\t}\n\treturn db.Clauses(clause.OnConflict{\n\t\tColumns:   []clause.Column{{Name: \"dict_item_id\"}, {Name: \"locale\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"label\", \"updated_at\"}),\n\t}).Create(&rows).Error\n}\n\nfunc (m *SysConfigI18n) LocalizedNameMapByConfigIDs(configIDs []uint, localePriority []string) (map[uint]string, error) {\n\tresult := make(map[uint]string, len(configIDs))\n\trows, priorities, err := m.listRowsByConfigIDs(configIDs, localePriority)\n\tif err != nil || len(rows) == 0 {\n\t\treturn result, err\n\t}\n\n\tgrouped := make(map[uint]map[string]string, len(configIDs))\n\tfor _, row := range rows {\n\t\tif _, ok := grouped[row.ConfigID]; !ok {\n\t\t\tgrouped[row.ConfigID] = make(map[string]string)\n\t\t}\n\t\tgrouped[row.ConfigID][strings.TrimSpace(row.Locale)] = strings.TrimSpace(row.ConfigName)\n\t}\n\tfor _, id := range configIDs {\n\t\tresult[id] = pickLocalizedText(grouped[id], priorities)\n\t}\n\treturn result, nil\n}\n\nfunc (m *SysConfigI18n) LocaleNameMapByConfigID(configID uint) (map[string]string, error) {\n\tresult := make(map[string]string)\n\tif configID == 0 {\n\t\treturn result, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar rows []SysConfigI18n\n\tif err := db.Where(\"config_id = ?\", configID).Find(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, row := range rows {\n\t\tlocale := strings.TrimSpace(row.Locale)\n\t\tname := strings.TrimSpace(row.ConfigName)\n\t\tif locale == \"\" || name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tresult[locale] = name\n\t}\n\treturn result, nil\n}\n\nfunc (m *SysConfigI18n) DeleteByConfigIDs(configIDs []uint, tx ...*gorm.DB) error {\n\tif len(configIDs) == 0 {\n\t\treturn nil\n\t}\n\tif len(tx) > 0 && tx[0] != nil {\n\t\tm.SetDB(tx[0])\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(\"config_id IN ?\", configIDs).Delete(&SysConfigI18n{}).Error\n}\n\nfunc (m *SysDictTypeI18n) LocalizedNameMapByTypeIDs(typeIDs []uint, localePriority []string) (map[uint]string, error) {\n\tresult := make(map[uint]string, len(typeIDs))\n\trows, priorities, err := m.listRowsByTypeIDs(typeIDs, localePriority)\n\tif err != nil || len(rows) == 0 {\n\t\treturn result, err\n\t}\n\n\tgrouped := make(map[uint]map[string]string, len(typeIDs))\n\tfor _, row := range rows {\n\t\tif _, ok := grouped[row.DictTypeID]; !ok {\n\t\t\tgrouped[row.DictTypeID] = make(map[string]string)\n\t\t}\n\t\tgrouped[row.DictTypeID][strings.TrimSpace(row.Locale)] = strings.TrimSpace(row.TypeName)\n\t}\n\tfor _, id := range typeIDs {\n\t\tresult[id] = pickLocalizedText(grouped[id], priorities)\n\t}\n\treturn result, nil\n}\n\nfunc (m *SysDictTypeI18n) LocaleNameMapByTypeID(dictTypeID uint) (map[string]string, error) {\n\tresult := make(map[string]string)\n\tif dictTypeID == 0 {\n\t\treturn result, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar rows []SysDictTypeI18n\n\tif err := db.Where(\"dict_type_id = ?\", dictTypeID).Find(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, row := range rows {\n\t\tlocale := strings.TrimSpace(row.Locale)\n\t\tname := strings.TrimSpace(row.TypeName)\n\t\tif locale == \"\" || name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tresult[locale] = name\n\t}\n\treturn result, nil\n}\n\nfunc (m *SysDictTypeI18n) DeleteByTypeIDs(dictTypeIDs []uint, tx ...*gorm.DB) error {\n\tif len(dictTypeIDs) == 0 {\n\t\treturn nil\n\t}\n\tif len(tx) > 0 && tx[0] != nil {\n\t\tm.SetDB(tx[0])\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(\"dict_type_id IN ?\", dictTypeIDs).Delete(&SysDictTypeI18n{}).Error\n}\n\nfunc (m *SysDictItemI18n) LocalizedLabelMapByItemIDs(itemIDs []uint, localePriority []string) (map[uint]string, error) {\n\tresult := make(map[uint]string, len(itemIDs))\n\trows, priorities, err := m.listRowsByItemIDs(itemIDs, localePriority)\n\tif err != nil || len(rows) == 0 {\n\t\treturn result, err\n\t}\n\n\tgrouped := make(map[uint]map[string]string, len(itemIDs))\n\tfor _, row := range rows {\n\t\tif _, ok := grouped[row.DictItemID]; !ok {\n\t\t\tgrouped[row.DictItemID] = make(map[string]string)\n\t\t}\n\t\tgrouped[row.DictItemID][strings.TrimSpace(row.Locale)] = strings.TrimSpace(row.Label)\n\t}\n\tfor _, id := range itemIDs {\n\t\tresult[id] = pickLocalizedText(grouped[id], priorities)\n\t}\n\treturn result, nil\n}\n\nfunc (m *SysDictItemI18n) LocaleLabelMapByItemID(dictItemID uint) (map[string]string, error) {\n\tresult := make(map[string]string)\n\tif dictItemID == 0 {\n\t\treturn result, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar rows []SysDictItemI18n\n\tif err := db.Where(\"dict_item_id = ?\", dictItemID).Find(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, row := range rows {\n\t\tlocale := strings.TrimSpace(row.Locale)\n\t\tlabel := strings.TrimSpace(row.Label)\n\t\tif locale == \"\" || label == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tresult[locale] = label\n\t}\n\treturn result, nil\n}\n\nfunc (m *SysDictItemI18n) DeleteByItemIDs(dictItemIDs []uint, tx ...*gorm.DB) error {\n\tif len(dictItemIDs) == 0 {\n\t\treturn nil\n\t}\n\tif len(tx) > 0 && tx[0] != nil {\n\t\tm.SetDB(tx[0])\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Where(\"dict_item_id IN ?\", dictItemIDs).Delete(&SysDictItemI18n{}).Error\n}\n\nfunc (m *SysConfigI18n) listRowsByConfigIDs(configIDs []uint, localePriority []string) ([]SysConfigI18n, []string, error) {\n\tpriorities := normalizeLocalePriority(localePriority)\n\tif len(configIDs) == 0 || len(priorities) == 0 {\n\t\treturn nil, priorities, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, priorities, err\n\t}\n\tvar rows []SysConfigI18n\n\tif err := db.Where(\"config_id IN ? AND locale IN ?\", configIDs, priorities).Find(&rows).Error; err != nil {\n\t\treturn nil, priorities, err\n\t}\n\treturn rows, priorities, nil\n}\n\nfunc (m *SysDictTypeI18n) listRowsByTypeIDs(typeIDs []uint, localePriority []string) ([]SysDictTypeI18n, []string, error) {\n\tpriorities := normalizeLocalePriority(localePriority)\n\tif len(typeIDs) == 0 || len(priorities) == 0 {\n\t\treturn nil, priorities, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, priorities, err\n\t}\n\tvar rows []SysDictTypeI18n\n\tif err := db.Where(\"dict_type_id IN ? AND locale IN ?\", typeIDs, priorities).Find(&rows).Error; err != nil {\n\t\treturn nil, priorities, err\n\t}\n\treturn rows, priorities, nil\n}\n\nfunc (m *SysDictItemI18n) listRowsByItemIDs(itemIDs []uint, localePriority []string) ([]SysDictItemI18n, []string, error) {\n\tpriorities := normalizeLocalePriority(localePriority)\n\tif len(itemIDs) == 0 || len(priorities) == 0 {\n\t\treturn nil, priorities, nil\n\t}\n\tdb, err := m.GetDB()\n\tif err != nil {\n\t\treturn nil, priorities, err\n\t}\n\tvar rows []SysDictItemI18n\n\tif err := db.Where(\"dict_item_id IN ? AND locale IN ?\", itemIDs, priorities).Find(&rows).Error; err != nil {\n\t\treturn nil, priorities, err\n\t}\n\treturn rows, priorities, nil\n}\n\nfunc normalizeLocalePriority(localePriority []string) []string {\n\tpriorities := make([]string, 0, len(localePriority))\n\tseen := make(map[string]struct{}, len(localePriority))\n\tfor _, locale := range localePriority {\n\t\ttrimmedLocale := strings.TrimSpace(locale)\n\t\tif trimmedLocale == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[trimmedLocale]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[trimmedLocale] = struct{}{}\n\t\tpriorities = append(priorities, trimmedLocale)\n\t}\n\treturn priorities\n}\n\nfunc pickLocalizedText(localeText map[string]string, priorities []string) string {\n\tif len(localeText) == 0 {\n\t\treturn \"\"\n\t}\n\tfor _, locale := range priorities {\n\t\tif text := strings.TrimSpace(localeText[locale]); text != \"\" {\n\t\t\treturn text\n\t\t}\n\t}\n\n\tkeys := make([]string, 0, len(localeText))\n\tfor key := range localeText {\n\t\tkeys = append(keys, key)\n\t}\n\tsort.Strings(keys)\n\tfor _, key := range keys {\n\t\tif text := strings.TrimSpace(localeText[key]); text != \"\" {\n\t\t\treturn text\n\t\t}\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/model/task_center.go",
    "content": "package model\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\nconst (\n\tTaskKindAsync = \"async\"\n\tTaskKindCron  = \"cron\"\n\n\tTaskStatusEnabled  uint8 = 1\n\tTaskStatusDisabled uint8 = 0\n\n\tTaskManualAllowed    uint8 = 1\n\tTaskManualNotAllowed uint8 = 0\n\n\tTaskRetryAllowed    uint8 = 1\n\tTaskRetryNotAllowed uint8 = 0\n\n\tTaskHighRisk    uint8 = 1\n\tTaskNotHighRisk uint8 = 0\n\n\tTaskSourceQueue  = \"queue\"\n\tTaskSourceCron   = \"cron\"\n\tTaskSourceManual = \"manual\"\n\n\tTaskRunStatusPending  = \"pending\"\n\tTaskRunStatusRunning  = \"running\"\n\tTaskRunStatusSuccess  = \"success\"\n\tTaskRunStatusFailed   = \"failed\"\n\tTaskRunStatusCanceled = \"canceled\"\n\tTaskRunStatusRetrying = \"retrying\"\n\n\tTaskEventEnqueue = \"enqueue\"\n\tTaskEventStart   = \"start\"\n\tTaskEventRetry   = \"retry\"\n\tTaskEventFail    = \"fail\"\n\tTaskEventSuccess = \"success\"\n\tTaskEventCancel  = \"cancel\"\n)\n\n// TaskDefinition 描述一个可被后台管理识别的任务。\ntype TaskDefinition struct {\n\tContainsDeleteBaseModel\n\tCode        string `json:\"code\"`         // 任务唯一编码\n\tName        string `json:\"name\"`         // 任务名称\n\tKind        string `json:\"kind\"`         // async/cron\n\tQueue       string `json:\"queue\"`        // 队列名称\n\tCronSpec    string `json:\"cron_spec\"`    // Cron 表达式\n\tHandler     string `json:\"handler\"`      // 处理器标识\n\tStatus      uint8  `json:\"status\"`       // 状态 1启用 0停用\n\tAllowManual uint8  `json:\"allow_manual\"` // 是否允许手动触发\n\tAllowRetry  uint8  `json:\"allow_retry\"`  // 是否允许手动重试\n\tIsHighRisk  uint8  `json:\"is_high_risk\"` // 是否高危任务\n\tRemark      string `json:\"remark\"`       // 备注\n}\n\nfunc NewTaskDefinition() *TaskDefinition {\n\treturn BindModel(&TaskDefinition{})\n}\n\nfunc (m *TaskDefinition) TableName() string {\n\treturn \"task_definitions\"\n}\n\n// TaskRun 表示一次任务执行记录。\ntype TaskRun struct {\n\tBaseModel\n\tTaskCode       string            `json:\"task_code\"`       // 任务唯一编码\n\tKind           string            `json:\"kind\"`            // async/cron\n\tSource         string            `json:\"source\"`          // queue/cron/manual\n\tSourceID       string            `json:\"source_id\"`       // 来源任务ID\n\tQueue          string            `json:\"queue\"`           // 队列名称\n\tTriggerUserID  uint              `json:\"trigger_user_id\"` // 触发人ID\n\tTriggerAccount string            `json:\"trigger_account\"` // 触发人账号\n\tStatus         string            `json:\"status\"`          // 执行状态\n\tAttempt        int               `json:\"attempt\"`         // 当前尝试次数\n\tMaxRetry       int               `json:\"max_retry\"`       // 最大重试次数\n\tPayload        string            `json:\"payload\"`         // 任务 payload\n\tErrorMessage   string            `json:\"error_message\"`   // 失败原因\n\tStartedAt      *utils.FormatDate `json:\"started_at\"`      // 开始时间\n\tFinishedAt     *utils.FormatDate `json:\"finished_at\"`     // 结束时间\n\tDurationMS     float64           `json:\"duration_ms\"`     // 执行耗时毫秒\n}\n\nfunc NewTaskRun() *TaskRun {\n\treturn BindModel(&TaskRun{})\n}\n\nfunc (m *TaskRun) TableName() string {\n\treturn \"task_runs\"\n}\n\n// TaskRunEvent 表示任务执行过程中的状态事件。\ntype TaskRunEvent struct {\n\tBaseModel\n\tRunID     uint   `json:\"run_id\"`     // 任务执行记录ID\n\tEventType string `json:\"event_type\"` // 事件类型\n\tMessage   string `json:\"message\"`    // 事件说明\n\tMeta      string `json:\"meta\"`       // 事件元数据 JSON\n}\n\nfunc NewTaskRunEvent() *TaskRunEvent {\n\treturn BindModel(&TaskRunEvent{})\n}\n\nfunc (m *TaskRunEvent) TableName() string {\n\treturn \"task_run_events\"\n}\n\n// CronTaskState 保存定时任务最近一次执行状态。\ntype CronTaskState struct {\n\tBaseModel\n\tTaskCode       string            `json:\"task_code\"`        // 任务唯一编码\n\tCronSpec       string            `json:\"cron_spec\"`        // Cron 表达式\n\tLastRunID      uint              `json:\"last_run_id\"`      // 最近执行记录ID\n\tLastStatus     string            `json:\"last_status\"`      // 最近执行状态\n\tLastStartedAt  *utils.FormatDate `json:\"last_started_at\"`  // 最近开始时间\n\tLastFinishedAt *utils.FormatDate `json:\"last_finished_at\"` // 最近结束时间\n\tNextRunAt      *utils.FormatDate `json:\"next_run_at\"`      // 下次执行时间\n\tLastError      string            `json:\"last_error\"`       // 最近失败原因\n}\n\nfunc NewCronTaskState() *CronTaskState {\n\treturn BindModel(&CronTaskState{})\n}\n\nfunc (m *CronTaskState) TableName() string {\n\treturn \"cron_task_states\"\n}\n"
  },
  {
    "path": "internal/pkg/auditdiff/diff.go",
    "content": "package auditdiff\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// ChangeDiffItem 表示单个字段变更。\ntype ChangeDiffItem struct {\n\tField         string `json:\"field\"`\n\tLabel         string `json:\"label,omitempty\"`\n\tBefore        any    `json:\"before,omitempty\"`\n\tAfter         any    `json:\"after,omitempty\"`\n\tBeforeDisplay string `json:\"before_display,omitempty\"`\n\tAfterDisplay  string `json:\"after_display,omitempty\"`\n}\n\n// FieldRule 描述字段 diff 规则。\ntype FieldRule struct {\n\tField       string\n\tLabel       string\n\tValueLabels map[string]string\n\tFormatter   func(value any) string\n}\n\n// BuildFieldDiff 按字段规则构建 before/after 差异。\nfunc BuildFieldDiff(before, after map[string]any, rules []FieldRule) []ChangeDiffItem {\n\tif len(rules) == 0 {\n\t\treturn nil\n\t}\n\tresult := make([]ChangeDiffItem, 0, len(rules))\n\tfor _, rule := range rules {\n\t\tif strings.TrimSpace(rule.Field) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tbeforeValue, hasBefore := before[rule.Field]\n\t\tafterValue, hasAfter := after[rule.Field]\n\t\tif !hasBefore && !hasAfter {\n\t\t\tcontinue\n\t\t}\n\t\tif valuesEqual(beforeValue, afterValue) {\n\t\t\tcontinue\n\t\t}\n\n\t\titem := ChangeDiffItem{\n\t\t\tField: rule.Field,\n\t\t\tLabel: strings.TrimSpace(rule.Label),\n\t\t}\n\t\tif item.Label == \"\" {\n\t\t\titem.Label = rule.Field\n\t\t}\n\t\tif hasBefore {\n\t\t\titem.Before = beforeValue\n\t\t\titem.BeforeDisplay = formatDisplayValue(rule, beforeValue)\n\t\t}\n\t\tif hasAfter {\n\t\t\titem.After = afterValue\n\t\t\titem.AfterDisplay = formatDisplayValue(rule, afterValue)\n\t\t}\n\t\tresult = append(result, item)\n\t}\n\treturn result\n}\n\n// Marshal 将 diff 项编码为 JSON 字符串；空 diff 返回 []。\nfunc Marshal(items []ChangeDiffItem) string {\n\tif len(items) == 0 {\n\t\treturn \"[]\"\n\t}\n\traw, err := json.Marshal(items)\n\tif err != nil {\n\t\treturn \"[]\"\n\t}\n\treturn string(raw)\n}\n\nfunc formatDisplayValue(rule FieldRule, value any) string {\n\tif rule.Formatter != nil {\n\t\treturn strings.TrimSpace(rule.Formatter(value))\n\t}\n\tif len(rule.ValueLabels) == 0 {\n\t\treturn \"\"\n\t}\n\tkey := valueLabelKey(value)\n\tif key == \"\" {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(rule.ValueLabels[key])\n}\n\nfunc valueLabelKey(value any) string {\n\tswitch v := value.(type) {\n\tcase nil:\n\t\treturn \"\"\n\tcase string:\n\t\treturn strings.TrimSpace(v)\n\tcase bool:\n\t\treturn strconv.FormatBool(v)\n\tcase int:\n\t\treturn strconv.Itoa(v)\n\tcase int8:\n\t\treturn strconv.FormatInt(int64(v), 10)\n\tcase int16:\n\t\treturn strconv.FormatInt(int64(v), 10)\n\tcase int32:\n\t\treturn strconv.FormatInt(int64(v), 10)\n\tcase int64:\n\t\treturn strconv.FormatInt(v, 10)\n\tcase uint:\n\t\treturn strconv.FormatUint(uint64(v), 10)\n\tcase uint8:\n\t\treturn strconv.FormatUint(uint64(v), 10)\n\tcase uint16:\n\t\treturn strconv.FormatUint(uint64(v), 10)\n\tcase uint32:\n\t\treturn strconv.FormatUint(uint64(v), 10)\n\tcase uint64:\n\t\treturn strconv.FormatUint(v, 10)\n\tcase float32:\n\t\treturn strconv.FormatFloat(float64(v), 'f', -1, 32)\n\tcase float64:\n\t\treturn strconv.FormatFloat(v, 'f', -1, 64)\n\tdefault:\n\t\treturn strings.TrimSpace(fmt.Sprintf(\"%v\", value))\n\t}\n}\n\nfunc valuesEqual(before, after any) bool {\n\tif reflect.DeepEqual(before, after) {\n\t\treturn true\n\t}\n\tbeforeKey := valueLabelKey(before)\n\tafterKey := valueLabelKey(after)\n\tif beforeKey != \"\" || afterKey != \"\" {\n\t\treturn beforeKey == afterKey\n\t}\n\tbeforeRaw, beforeErr := json.Marshal(before)\n\tafterRaw, afterErr := json.Marshal(after)\n\tif beforeErr != nil || afterErr != nil {\n\t\treturn false\n\t}\n\treturn string(beforeRaw) == string(afterRaw)\n}\n"
  },
  {
    "path": "internal/pkg/auditdiff/diff_test.go",
    "content": "package auditdiff\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestBuildFieldDiffMapsStatusLabel(t *testing.T) {\n\titems := BuildFieldDiff(\n\t\tmap[string]any{\"status\": uint8(0), \"remark\": \"old\"},\n\t\tmap[string]any{\"status\": uint8(1), \"remark\": \"old\"},\n\t\t[]FieldRule{\n\t\t\t{\n\t\t\t\tField: \"status\",\n\t\t\t\tLabel: \"状态\",\n\t\t\t\tValueLabels: map[string]string{\n\t\t\t\t\t\"0\": \"禁用\",\n\t\t\t\t\t\"1\": \"启用\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t{Field: \"remark\", Label: \"备注\"},\n\t\t},\n\t)\n\tif len(items) != 1 {\n\t\tt.Fatalf(\"expected 1 diff item, got %d\", len(items))\n\t}\n\tif items[0].Field != \"status\" {\n\t\tt.Fatalf(\"expected field status, got %s\", items[0].Field)\n\t}\n\tif items[0].BeforeDisplay != \"禁用\" || items[0].AfterDisplay != \"启用\" {\n\t\tt.Fatalf(\"unexpected display mapping: before=%s after=%s\", items[0].BeforeDisplay, items[0].AfterDisplay)\n\t}\n}\n\nfunc TestMarshalReturnsJSONString(t *testing.T) {\n\traw := Marshal([]ChangeDiffItem{{\n\t\tField:  \"status\",\n\t\tLabel:  \"状态\",\n\t\tBefore: 0,\n\t\tAfter:  1,\n\t}})\n\tif raw == \"\" {\n\t\tt.Fatal(\"expected non-empty json\")\n\t}\n\tvar decoded []map[string]any\n\tif err := json.Unmarshal([]byte(raw), &decoded); err != nil {\n\t\tt.Fatalf(\"expected valid json, got %v\", err)\n\t}\n\tif len(decoded) != 1 {\n\t\tt.Fatalf(\"expected 1 decoded item, got %d\", len(decoded))\n\t}\n}\n\nfunc TestMarshalReturnsEmptyArrayWhenNoDiff(t *testing.T) {\n\tif got := Marshal(nil); got != \"[]\" {\n\t\tt.Fatalf(\"expected [] for nil diff, got %s\", got)\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/errors/code.go",
    "content": "package errors\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\nconst (\n\tSUCCESS                   = 0\n\tFAILURE                   = 1\n\tAuthorizationErr          = 403\n\tNotFound                  = 404\n\tCaptchaErr                = 400\n\tNotLogin                  = 401\n\tServerErr                 = 500\n\tInvalidParameter          = 10000\n\tUserDoesNotExist          = 10001\n\tUserDisable               = 10002\n\tServiceDependencyNotReady = 10003\n\tTooManyRequests           = 10102\n\n\t// 文件相关错误码 11000-11999\n\tFileIdentifierInvalid = 11001\n\tFilePrivateAuthNeeded = 11002\n\tFileAccessDenied      = 11003\n\tFileUploadPartialFail = 11004\n\tFileReferenced        = 11005\n\n\t// 业务错误码 20000-29999\n\tUserPasswordWrong       = 20001\n\tUserExists              = 20002\n\tPhoneNumberExists       = 20003\n\tEmailExists             = 20004\n\tUsernameRequired        = 20005\n\tNicknameRequired        = 20006\n\tPasswordProcessFailed   = 20007\n\tSuperAdminCannotModify  = 20008\n\tSuperAdminCannotDisable = 20009\n\tSuperAdminCannotDelete  = 20010\n\tSamePassword            = 20011\n\tRoleNotFound            = 20012\n\tRoleExists              = 20013\n\tRoleHasChildren         = 20014\n\tRoleCannotDelete        = 20015\n\tParentRoleNotExists     = 20016\n\tParentRoleInvalid       = 20017\n\tMaxRoleDepth            = 20018\n\tMaxChildRoles           = 20019\n\tMenuNotFound            = 20020\n\tMenuExists              = 20021\n\tMenuHasChildren         = 20022\n\tMenuCannotDelete        = 20023\n\tDepartmentNotFound      = 20024\n\tDepartmentExists        = 20025\n\tDepartmentHasChildren   = 20026\n\tDepartmentCannotDelete  = 20027\n\tParentDeptNotExists     = 20028\n\tParentDeptInvalid       = 20029\n\tMaxDeptDepth            = 20030\n\tCasbinInitFailed        = 20031\n\tTokenGenerateFailed     = 20032\n\tLoginFailed             = 20033\n\tCreateUserFailed        = 20034\n\tUpdateUserFailed        = 20035\n\tDeleteUserFailed        = 20036\n\tQueryUserDeptFailed     = 20037\n\tSuperAdminMustKeepRole  = 20038\n\tMaxMenuDepth            = 20039\n\tParentMenuNotExists     = 20040\n\tParentMenuTypeInvalid   = 20041\n\tParentMenuInvalid       = 20042\n\tMenuCodeExists          = 20043\n\tMenuRouteNameExists     = 20044\n\tMenuPathExists          = 20045\n\tLoginAccountLocked      = 20046\n\tPasswordRequired        = 20047\n)\n\nconst (\n\tMsgKeyAuthSessionExpired        = \"auth.session.expired\"\n\tMsgKeyAuthPermissionInitFailed  = \"auth.permission.init_failed\"\n\tMsgKeyAuthPermissionCheckFailed = \"auth.permission.check_failed\"\n\tMsgKeyAuthAPIOperationDenied    = \"auth.api.operation_denied\"\n\tMsgKeyAuthAccountLocked         = \"auth.account.locked\"\n)\n\n// ErrorText 根据语言返回业务错误文案。\ntype ErrorText struct {\n\tLanguage string\n}\n\n// NewErrorText 创建错误文案解析器。\nfunc NewErrorText(language string) *ErrorText {\n\treturn &ErrorText{\n\t\tLanguage: language,\n\t}\n}\n\n// Text 按错误码和语言返回错误消息。\nfunc (et *ErrorText) Text(code int) (str string) {\n\tvar ok bool\n\tswitch et.Language {\n\tcase \"zh_CN\":\n\t\tstr, ok = zhCNText[code]\n\tcase \"en\":\n\t\tstr, ok = enUSText[code]\n\tdefault:\n\t\tstr, ok = zhCNText[code]\n\t}\n\tif !ok {\n\t\treturn \"unknown error\"\n\t}\n\treturn\n}\n\n// TextByKey 按语言和文案 key 返回错误消息。\nfunc (et *ErrorText) TextByKey(key string, args ...any) (string, bool) {\n\tkey = strings.TrimSpace(key)\n\tif key == \"\" {\n\t\treturn \"\", false\n\t}\n\n\tvar (\n\t\ttemplate string\n\t\tok       bool\n\t)\n\tswitch et.Language {\n\tcase \"zh_CN\":\n\t\ttemplate, ok = zhCNTextKey[key]\n\tcase \"en\":\n\t\ttemplate, ok = enUSTextKey[key]\n\tdefault:\n\t\ttemplate, ok = zhCNTextKey[key]\n\t}\n\tif !ok {\n\t\treturn \"\", false\n\t}\n\tif len(args) == 0 {\n\t\treturn template, true\n\t}\n\treturn fmt.Sprintf(template, args...), true\n}\n"
  },
  {
    "path": "internal/pkg/errors/code_test.go",
    "content": "package errors\n\nimport (\n\t\"testing\"\n)\n\nfunc TestText(t *testing.T) {\n\tvar errorText = NewErrorText(\"zh_CN\")\n\tif \"OK\" != errorText.Text(0) {\n\t\tt.Error(\"text 返回 msg 不是预期的\")\n\t}\n\tif \"文件存在引用，不能删除\" != errorText.Text(FileReferenced) {\n\t\tt.Error(\"文件引用错误码文案不是预期的\")\n\t}\n\n\tif \"unknown error\" != errorText.Text(1202389) {\n\t\tt.Error(\"text 返回 msg 不是预期的\")\n\t}\n}\n\nfunc TestTextByKey(t *testing.T) {\n\terrorText := NewErrorText(\"zh_CN\")\n\tmsg, ok := errorText.TextByKey(MsgKeyAuthPermissionInitFailed)\n\tif !ok {\n\t\tt.Fatal(\"expected key exists\")\n\t}\n\tif msg != \"权限验证初始化失败\" {\n\t\tt.Fatalf(\"unexpected msg: %s\", msg)\n\t}\n\n\tif _, ok := errorText.TextByKey(\"not.exists\"); ok {\n\t\tt.Fatal(\"expected missing key\")\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/errors/en-us.go",
    "content": "package errors\n\nvar enUSText = map[int]string{\n\tSUCCESS:                   \"OK\",\n\tFAILURE:                   \"FAIL\",\n\tNotFound:                  \"resources not found\",\n\tServerErr:                 \"Internal server error\",\n\tTooManyRequests:           \"Too many requests\",\n\tInvalidParameter:          \"Parameter error\",\n\tUserDoesNotExist:          \"user does not exist\",\n\tUserDisable:               \"User is disabled\",\n\tServiceDependencyNotReady: \"service database is not configured, please contact the administrator\",\n\tAuthorizationErr:          \"You have no permission\",\n\tNotLogin:                  \"Please login first\",\n\tCaptchaErr:                \"Captcha error\",\n\n\t// File-related error messages 11000-11999\n\tFileIdentifierInvalid: \"invalid file identifier\",\n\tFilePrivateAuthNeeded: \"login required for private file access\",\n\tFileAccessDenied:      \"no permission to access this file\",\n\tFileUploadPartialFail: \"partial image upload failure\",\n\tFileReferenced:        \"file is referenced and cannot be deleted\",\n\n\t// Business error messages 20000-29999\n\tUserPasswordWrong:       \"Incorrect password\",\n\tUserExists:              \"User already exists\",\n\tPhoneNumberExists:       \"Phone number already exists\",\n\tEmailExists:             \"Email already exists\",\n\tUsernameRequired:        \"Username is required\",\n\tNicknameRequired:        \"Nickname is required\",\n\tPasswordRequired:        \"Password is required\",\n\tPasswordProcessFailed:   \"Password processing failed\",\n\tSuperAdminCannotModify:  \"Cannot modify default super admin password\",\n\tSuperAdminCannotDisable: \"Cannot disable default super admin\",\n\tSuperAdminCannotDelete:  \"Cannot delete default super admin\",\n\tSamePassword:            \"New password cannot be the same as current password\",\n\tRoleNotFound:            \"Role not found\",\n\tRoleExists:              \"Role already exists\",\n\tRoleHasChildren:         \"Cannot delete role with child roles\",\n\tRoleCannotDelete:        \"Failed to delete role\",\n\tParentRoleNotExists:     \"Parent role not exists\",\n\tParentRoleInvalid:       \"Parent role cannot be itself or its child role\",\n\tMaxRoleDepth:            \"Can only create up to 2 levels of roles\",\n\tMaxChildRoles:           \"Each top-level role can have up to 5 child roles\",\n\tMenuNotFound:            \"Menu not found\",\n\tMenuExists:              \"Menu already exists\",\n\tMenuHasChildren:         \"Cannot delete menu with child menus\",\n\tMenuCannotDelete:        \"Failed to delete menu\",\n\tDepartmentNotFound:      \"Department not found\",\n\tDepartmentExists:        \"Department already exists\",\n\tDepartmentHasChildren:   \"Cannot delete department with child departments\",\n\tDepartmentCannotDelete:  \"Failed to delete department\",\n\tParentDeptNotExists:     \"Parent department not exists\",\n\tParentDeptInvalid:       \"Parent department cannot be itself or its child department\",\n\tMaxDeptDepth:            \"Department level exceeds limit\",\n\tCasbinInitFailed:        \"Permission initialization failed\",\n\tTokenGenerateFailed:     \"Failed to generate token\",\n\tLoginFailed:             \"Login failed, please try again later\",\n\tCreateUserFailed:        \"Failed to create user, please try again\",\n\tUpdateUserFailed:        \"Failed to update user, please try again\",\n\tDeleteUserFailed:        \"Failed to delete user, please try again\",\n\tQueryUserDeptFailed:     \"Failed to query user department association\",\n\tSuperAdminMustKeepRole:  \"Default super admin must keep super admin role\",\n\tMaxMenuDepth:            \"Can only create up to 4 levels of menu\",\n\tParentMenuNotExists:     \"Parent menu not exists\",\n\tParentMenuTypeInvalid:   \"Parent menu cannot be a button\",\n\tParentMenuInvalid:       \"Parent menu cannot be itself or its child menu\",\n\tMenuCodeExists:          \"Permission code already exists\",\n\tMenuRouteNameExists:     \"Route name already exists\",\n\tMenuPathExists:          \"Route already exists\",\n\tLoginAccountLocked:      \"Account is locked, please try again later\",\n}\n\nvar enUSTextKey = map[string]string{\n\tMsgKeyAuthSessionExpired:        \"Session expired, please log in again\",\n\tMsgKeyAuthPermissionInitFailed:  \"Permission engine initialization failed\",\n\tMsgKeyAuthPermissionCheckFailed: \"Permission check failed\",\n\tMsgKeyAuthAPIOperationDenied:    \"No permission to operate this API\",\n\tMsgKeyAuthAccountLocked:         \"Account is locked, please try again in %d minutes\",\n}\n"
  },
  {
    "path": "internal/pkg/errors/error.go",
    "content": "package errors\n\nimport (\n\tstderrors \"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\n// BusinessError 表示带业务码的可控错误。\ntype BusinessError struct {\n\tcode            int\n\tmessage         string\n\tmessageKey      string\n\tmessageArgs     []any\n\texplicitMessage bool\n\tcontextErrs     []error\n}\n\n// Error 实现 error 接口。\nfunc (e *BusinessError) Error() string {\n\tif len(e.contextErrs) == 0 {\n\t\treturn fmt.Sprintf(\"[Code]:%d [Msg]:%s\", e.code, e.message)\n\t}\n\tmsgs := make([]string, 0, len(e.contextErrs))\n\tfor _, err := range e.contextErrs {\n\t\tmsgs = append(msgs, err.Error())\n\t}\n\treturn fmt.Sprintf(\"[Code]:%d [Msg]:%s, [context error] %s\", e.code, e.message, strings.Join(msgs, \"; \"))\n}\n\n// GetCode 返回业务错误码。\nfunc (e *BusinessError) GetCode() int {\n\treturn e.code\n}\n\n// GetMessage 返回业务错误消息。\nfunc (e *BusinessError) GetMessage() string {\n\treturn e.message\n}\n\n// GetMessageKey 返回业务错误文案 key。\nfunc (e *BusinessError) GetMessageKey() string {\n\treturn e.messageKey\n}\n\n// GetMessageArgs 返回业务错误文案参数。\nfunc (e *BusinessError) GetMessageArgs() []any {\n\treturn e.messageArgs\n}\n\n// HasExplicitMessage 返回是否为业务代码调用方显式提供的消息文本。\nfunc (e *BusinessError) HasExplicitMessage() bool {\n\treturn e.explicitMessage\n}\n\n// HasMessageKey 返回是否携带文案 key。\nfunc (e *BusinessError) HasMessageKey() bool {\n\treturn strings.TrimSpace(e.messageKey) != \"\"\n}\n\n// SetCode 设置业务错误码。\nfunc (e *BusinessError) SetCode(code int) {\n\te.code = code\n}\n\n// SetMessage 设置业务错误消息。\nfunc (e *BusinessError) SetMessage(message string) {\n\te.message = message\n\te.messageKey = \"\"\n\te.messageArgs = nil\n\te.explicitMessage = strings.TrimSpace(message) != \"\"\n}\n\n// AppendContextErr 追加底层上下文错误。\nfunc (e *BusinessError) AppendContextErr(err error) {\n\te.contextErrs = append(e.contextErrs, err)\n}\n\n// GetContextErr 返回附带的上下文错误列表。\nfunc (e *BusinessError) GetContextErr() []error {\n\treturn e.contextErrs\n}\n\n// NewBusinessError 创建业务错误。\nfunc NewBusinessError(code int, message ...string) *BusinessError {\n\tmsg := \"\"\n\texplicitMessage := false\n\tif len(message) > 0 && strings.TrimSpace(message[0]) != \"\" {\n\t\tmsg = message[0]\n\t\texplicitMessage = true\n\t} else {\n\t\tmsg = NewErrorText(c.GetConfig().Language).Text(code)\n\t}\n\treturn &BusinessError{\n\t\tcode:            code,\n\t\tmessage:         msg,\n\t\texplicitMessage: explicitMessage,\n\t}\n}\n\n// NewBusinessErrorWithKey 创建带文案 key 的业务错误。\nfunc NewBusinessErrorWithKey(code int, messageKey string, messageArgs ...any) *BusinessError {\n\tmsg := \"\"\n\tif key := strings.TrimSpace(messageKey); key != \"\" {\n\t\tif translated, ok := NewErrorText(c.GetConfig().Language).TextByKey(key, messageArgs...); ok {\n\t\t\tmsg = translated\n\t\t}\n\t}\n\tif msg == \"\" {\n\t\tmsg = NewErrorText(c.GetConfig().Language).Text(code)\n\t}\n\n\treturn &BusinessError{\n\t\tcode:            code,\n\t\tmessage:         msg,\n\t\tmessageKey:      strings.TrimSpace(messageKey),\n\t\tmessageArgs:     append([]any(nil), messageArgs...),\n\t\texplicitMessage: false,\n\t}\n}\n\n// Error 提供错误转换辅助方法。\ntype Error struct{}\n\n// AsBusinessError 尝试把任意错误转换为 BusinessError。\nfunc (e *Error) AsBusinessError(err error) (*BusinessError, error) {\n\tvar be *BusinessError\n\tif stderrors.As(err, &be) {\n\t\treturn be, nil\n\t}\n\treturn nil, err\n}\n\n// NewDependencyNotReadyError 返回统一的依赖未就绪业务错误。\nfunc NewDependencyNotReadyError(message ...string) *BusinessError {\n\treturn NewBusinessError(ServiceDependencyNotReady, message...)\n}\n\n// IsDependencyNotReady 判断错误是否表示底层依赖尚未就绪。\nfunc IsDependencyNotReady(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif stderrors.Is(err, model.ErrDBUninitialized) {\n\t\treturn true\n\t}\n\treturn strings.Contains(strings.ToLower(err.Error()), \"mysql not initialized\")\n}\n"
  },
  {
    "path": "internal/pkg/errors/zh-cn.go",
    "content": "package errors\n\nvar zhCNText = map[int]string{\n\tSUCCESS:                   \"OK\",\n\tFAILURE:                   \"FAIL\",\n\tNotFound:                  \"资源不存在\",\n\tInvalidParameter:          \"参数错误\",\n\tServerErr:                 \"服务器内部错误\",\n\tTooManyRequests:           \"请求过多\",\n\tUserDoesNotExist:          \"用户不存在\",\n\tUserDisable:               \"用户已被禁用\",\n\tServiceDependencyNotReady: \"服务暂未配置数据库，请联系管理员\",\n\tAuthorizationErr:          \"暂无权限\",\n\tNotLogin:                  \"请先登录\",\n\tCaptchaErr:                \"验证码错误\",\n\n\t// 文件相关错误消息 11000-11999\n\tFileIdentifierInvalid: \"文件标识错误\",\n\tFilePrivateAuthNeeded: \"访问私有文件需要登录认证\",\n\tFileAccessDenied:      \"无权访问该文件\",\n\tFileUploadPartialFail: \"部分图片上传失败\",\n\tFileReferenced:        \"文件存在引用，不能删除\",\n\n\t// 业务错误消息 20000-29999\n\tUserPasswordWrong:       \"用户密码错误\",\n\tUserExists:              \"用户已存在\",\n\tPhoneNumberExists:       \"手机号已存在\",\n\tEmailExists:             \"邮箱已存在\",\n\tUsernameRequired:        \"用户名必填\",\n\tNicknameRequired:        \"昵称必填\",\n\tPasswordRequired:        \"密码必填\",\n\tPasswordProcessFailed:   \"密码处理失败\",\n\tSuperAdminCannotModify:  \"系统默认超级管理员不允许修改密码\",\n\tSuperAdminCannotDisable: \"系统默认超级管理员不允许被禁用\",\n\tSuperAdminCannotDelete:  \"系统默认超级管理员不允许删除\",\n\tSamePassword:            \"新密码不能与当前密码相同\",\n\tRoleNotFound:            \"角色不存在\",\n\tRoleExists:              \"角色已存在\",\n\tRoleHasChildren:         \"该角色有子角色，无法删除\",\n\tRoleCannotDelete:        \"删除角色失败\",\n\tParentRoleNotExists:     \"上级角色不存在\",\n\tParentRoleInvalid:       \"上级角色不能是当前角色自身或其子角色\",\n\tMaxRoleDepth:            \"最多只能创建2层角色\",\n\tMaxChildRoles:           \"每个顶级角色下最多只能创建5个子角色\",\n\tMenuNotFound:            \"菜单不存在\",\n\tMenuExists:              \"菜单已存在\",\n\tMenuHasChildren:         \"该菜单有子菜单，无法删除\",\n\tMenuCannotDelete:        \"删除菜单失败\",\n\tDepartmentNotFound:      \"部门不存在\",\n\tDepartmentExists:        \"部门已存在\",\n\tDepartmentHasChildren:   \"该部门有子部门，无法删除\",\n\tDepartmentCannotDelete:  \"删除部门失败\",\n\tParentDeptNotExists:     \"上级部门不存在\",\n\tParentDeptInvalid:       \"上级部门不能是当前部门自身或其子部门\",\n\tMaxDeptDepth:            \"部门层级超出限制\",\n\tCasbinInitFailed:        \"权限验证初始化失败\",\n\tTokenGenerateFailed:     \"生成Token失败\",\n\tLoginFailed:             \"登录失败，请稍后重试\",\n\tCreateUserFailed:        \"创建用户失败，请重试\",\n\tUpdateUserFailed:        \"更新用户失败，请重试\",\n\tDeleteUserFailed:        \"删除用户失败，请重试\",\n\tQueryUserDeptFailed:     \"查询用户部门关联失败\",\n\tSuperAdminMustKeepRole:  \"系统默认超级管理员必须保留超级管理员角色\",\n\tMaxMenuDepth:            \"最多只能创建 4 层菜单\",\n\tParentMenuNotExists:     \"上级菜单不存在\",\n\tParentMenuTypeInvalid:   \"上级菜单不能是按钮类型\",\n\tParentMenuInvalid:       \"上级菜单不能是当前菜单自身或其子菜单\",\n\tMenuCodeExists:          \"权限标识已存在\",\n\tMenuRouteNameExists:     \"路由名称已存在\",\n\tMenuPathExists:          \"路由已存在\",\n\tLoginAccountLocked:      \"账号已被锁定，请稍后重试\",\n}\n\nvar zhCNTextKey = map[string]string{\n\tMsgKeyAuthSessionExpired:        \"登录已失效，请重新登录\",\n\tMsgKeyAuthPermissionInitFailed:  \"权限验证初始化失败\",\n\tMsgKeyAuthPermissionCheckFailed: \"权限验证失败\",\n\tMsgKeyAuthAPIOperationDenied:    \"暂无接口操作权限\",\n\tMsgKeyAuthAccountLocked:         \"账号已被锁定，请在 %d 分钟后重试\",\n}\n"
  },
  {
    "path": "internal/pkg/func_make/func_make.go",
    "content": "package func_make\n\nimport (\n\t\"errors\"\n\t\"reflect\"\n)\n\n// FuncMap 保存可按名称调用的函数映射。\ntype FuncMap map[string]reflect.Value\n\n// New 创建一个空的函数映射表。\nfunc New() FuncMap {\n\treturn make(FuncMap, 2)\n}\n\n// Register 注册单个函数。\nfunc (f FuncMap) Register(name string, fn any) error {\n\tv := reflect.ValueOf(fn)\n\tif v.Kind() != reflect.Func {\n\t\treturn errors.New(name + \" is not a function type.\")\n\t}\n\tf[name] = v\n\treturn nil\n}\n\n// Registers 批量注册函数。\nfunc (f FuncMap) Registers(funcMap map[string]any) (err error) {\n\tfor k, v := range funcMap {\n\t\terr = f.Register(k, v)\n\t\tif err != nil {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn\n}\n\n// Call 按名称调用已注册函数。\nfunc (f FuncMap) Call(name string, params ...any) (result []reflect.Value, err error) {\n\tif _, ok := f[name]; !ok {\n\t\terr = errors.New(name + \" method does not exist.\")\n\t\treturn\n\t}\n\tin := make([]reflect.Value, len(params))\n\tfor k, param := range params {\n\t\tin[k] = reflect.ValueOf(param)\n\t}\n\n\tdefer func() {\n\t\tif e := recover(); e != nil {\n\t\t\terr = errors.New(\"call \" + name + \" method fail. \" + e.(string))\n\t\t}\n\t}()\n\n\tresult = f[name].Call(in)\n\treturn\n}\n"
  },
  {
    "path": "internal/pkg/func_make/func_make_test.go",
    "content": "package func_make\n\nimport (\n\t\"testing\"\n)\n\nvar (\n\tfuncMap = map[string]interface{}{\n\t\t\"test\": func(str string) string {\n\t\t\treturn str\n\t\t},\n\t}\n\tfuncMake = New()\n)\n\nfunc TestRegisters(t *testing.T) {\n\terr := funcMake.Registers(funcMap)\n\tif err != nil {\n\t\tt.Errorf(\"绑定失败\")\n\t}\n}\n\nfunc TestRegister(t *testing.T) {\n\terr := funcMake.Register(\"test1\", func(str ...string) string {\n\t\tvar res string\n\t\tfor _, v := range str {\n\t\t\tres += v\n\t\t}\n\t\treturn res\n\t})\n\tif err != nil {\n\t\tt.Errorf(\"绑定失败\")\n\t}\n}\n\nfunc TestCall(t *testing.T) {\n\tTestRegisters(t)\n\tTestRegister(t)\n\tif _, err := funcMake.Call(\"test\", \"1\"); err != nil {\n\t\tt.Errorf(\"请求test方法失败:%s\", err)\n\t}\n\tif _, err := funcMake.Call(\"test1\", \"2323\", \"ddd\"); err != nil {\n\t\tt.Errorf(\"请求test1方法失败:%s\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/i18n/locale.go",
    "content": "package i18n\n\nimport (\n\t\"encoding/json\"\n\t\"sort\"\n\t\"strings\"\n)\n\nconst (\n\tLocaleZhCN    = \"zh-CN\"\n\tLocaleEnUS    = \"en-US\"\n\tDefaultLocale = LocaleZhCN\n)\n\n// NormalizeLocale 归一化语言标签，仅支持项目当前定义的语言集合。\nfunc NormalizeLocale(locale string) string {\n\tnormalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(locale), \"_\", \"-\"))\n\tif normalized == \"\" {\n\t\treturn DefaultLocale\n\t}\n\n\tswitch {\n\tcase strings.HasPrefix(normalized, \"zh\"):\n\t\treturn LocaleZhCN\n\tcase strings.HasPrefix(normalized, \"en\"):\n\t\treturn LocaleEnUS\n\tdefault:\n\t\treturn DefaultLocale\n\t}\n}\n\n// ParseAcceptLanguage 从 Accept-Language 请求头中解析语言。\nfunc ParseAcceptLanguage(headerValue string) string {\n\tif strings.TrimSpace(headerValue) == \"\" {\n\t\treturn DefaultLocale\n\t}\n\n\titems := strings.Split(headerValue, \",\")\n\tfor _, item := range items {\n\t\tsegment := strings.TrimSpace(item)\n\t\tif segment == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\ttag := strings.TrimSpace(strings.Split(segment, \";\")[0])\n\t\tif tag == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treturn NormalizeLocale(tag)\n\t}\n\n\treturn DefaultLocale\n}\n\n// ParseLocaleMap 将 title_i18n 的 JSON 字符串解析为归一化 map。\nfunc ParseLocaleMap(raw string) map[string]string {\n\tresult := make(map[string]string)\n\tif strings.TrimSpace(raw) == \"\" {\n\t\treturn result\n\t}\n\n\tparsed := make(map[string]string)\n\tif err := json.Unmarshal([]byte(raw), &parsed); err != nil {\n\t\treturn result\n\t}\n\n\tfor key, value := range parsed {\n\t\ttrimmedValue := strings.TrimSpace(value)\n\t\tif trimmedValue == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tresult[NormalizeLocale(key)] = trimmedValue\n\t}\n\treturn result\n}\n\n// MarshalLocaleMap 将多语言 map 序列化为 JSON 字符串。\nfunc MarshalLocaleMap(data map[string]string) string {\n\tif len(data) == 0 {\n\t\treturn \"\"\n\t}\n\n\tnormalized := make(map[string]string, len(data))\n\tfor key, value := range data {\n\t\ttrimmedValue := strings.TrimSpace(value)\n\t\tif trimmedValue == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tnormalized[NormalizeLocale(key)] = trimmedValue\n\t}\n\tif len(normalized) == 0 {\n\t\treturn \"\"\n\t}\n\n\tencoded, err := json.Marshal(normalized)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn string(encoded)\n}\n\n// ResolveLocalizedText 根据请求语言从多语言文案中解析最终展示文本。\nfunc ResolveLocalizedText(defaultText string, i18nRaw string, locale string) string {\n\ttranslations := ParseLocaleMap(i18nRaw)\n\tif len(translations) > 0 {\n\t\tif text := strings.TrimSpace(translations[NormalizeLocale(locale)]); text != \"\" {\n\t\t\treturn text\n\t\t}\n\t\tif text := strings.TrimSpace(translations[LocaleZhCN]); text != \"\" {\n\t\t\treturn text\n\t\t}\n\t\tif text := strings.TrimSpace(translations[LocaleEnUS]); text != \"\" {\n\t\t\treturn text\n\t\t}\n\n\t\tkeys := make([]string, 0, len(translations))\n\t\tfor key := range translations {\n\t\t\tkeys = append(keys, key)\n\t\t}\n\t\tsort.Strings(keys)\n\t\tfor _, key := range keys {\n\t\t\tif text := strings.TrimSpace(translations[key]); text != \"\" {\n\t\t\t\treturn text\n\t\t\t}\n\t\t}\n\t}\n\n\treturn strings.TrimSpace(defaultText)\n}\n\n// MergeLocaleJSON 合并历史与本次提交的多语言文案，并返回持久化 JSON 及默认标题字段值。\nfunc MergeLocaleJSON(existingRaw string, incoming map[string]string, locale string, fallbackTitle string) (string, string) {\n\tnext := ParseLocaleMap(existingRaw)\n\tfor key, value := range incoming {\n\t\ttrimmedValue := strings.TrimSpace(value)\n\t\tif trimmedValue == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tnext[NormalizeLocale(key)] = trimmedValue\n\t}\n\n\tnormalizedLocale := NormalizeLocale(locale)\n\ttrimmedFallback := strings.TrimSpace(fallbackTitle)\n\tif trimmedFallback != \"\" {\n\t\tif _, exists := next[normalizedLocale]; !exists {\n\t\t\tnext[normalizedLocale] = trimmedFallback\n\t\t}\n\t}\n\n\tdefaultTitle := strings.TrimSpace(next[LocaleZhCN])\n\tif defaultTitle == \"\" {\n\t\tdefaultTitle = strings.TrimSpace(trimmedFallback)\n\t}\n\tif defaultTitle == \"\" {\n\t\tdefaultTitle = ResolveLocalizedText(\"\", MarshalLocaleMap(next), normalizedLocale)\n\t}\n\n\treturn MarshalLocaleMap(next), defaultTitle\n}\n\n// ToErrorLanguage 将请求语言转换为错误文案模块使用的语言代码。\nfunc ToErrorLanguage(locale string) string {\n\tif NormalizeLocale(locale) == LocaleEnUS {\n\t\treturn \"en\"\n\t}\n\treturn \"zh_CN\"\n}\n"
  },
  {
    "path": "internal/pkg/i18n/locale_test.go",
    "content": "package i18n\n\nimport \"testing\"\n\nfunc TestParseAcceptLanguage(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\theader string\n\t\twant   string\n\t}{\n\t\t{name: \"empty\", header: \"\", want: LocaleZhCN},\n\t\t{name: \"english list\", header: \"en-US,en;q=0.9,zh;q=0.8\", want: LocaleEnUS},\n\t\t{name: \"zh underscore\", header: \"zh_CN\", want: LocaleZhCN},\n\t\t{name: \"unsupported\", header: \"fr-FR,fr;q=0.8\", want: LocaleZhCN},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := ParseAcceptLanguage(tt.header); got != tt.want {\n\t\t\t\tt.Fatalf(\"ParseAcceptLanguage() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestResolveLocalizedText(t *testing.T) {\n\traw := `{\"zh-CN\":\"菜单\",\"en-US\":\"Menu\"}`\n\tif got := ResolveLocalizedText(\"默认\", raw, LocaleEnUS); got != \"Menu\" {\n\t\tt.Fatalf(\"expected english text, got %q\", got)\n\t}\n\tif got := ResolveLocalizedText(\"默认\", raw, \"zh_CN\"); got != \"菜单\" {\n\t\tt.Fatalf(\"expected chinese text, got %q\", got)\n\t}\n\tif got := ResolveLocalizedText(\"默认\", raw, \"fr-FR\"); got != \"菜单\" {\n\t\tt.Fatalf(\"expected fallback chinese text, got %q\", got)\n\t}\n}\n\nfunc TestMergeLocaleJSON(t *testing.T) {\n\texisting := `{\"zh-CN\":\"菜单\"}`\n\tincoming := map[string]string{\"en-US\": \"Menu\"}\n\n\traw, title := MergeLocaleJSON(existing, incoming, LocaleEnUS, \"Menu\")\n\tif title != \"菜单\" {\n\t\tt.Fatalf(\"default title should prefer zh-CN, got %q\", title)\n\t}\n\n\tlocalized := ResolveLocalizedText(\"\", raw, LocaleEnUS)\n\tif localized != \"Menu\" {\n\t\tt.Fatalf(\"expected merged english text, got %q\", localized)\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/logger/logger.go",
    "content": "package logger\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\trotatelogs \"github.com/lestrrat-go/file-rotatelogs\"\n\t\"go.uber.org/zap\"\n\t\"go.uber.org/zap/zapcore\"\n\t\"gopkg.in/natefinch/lumberjack.v2\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n)\n\nvar (\n\tnopLogger = zap.NewNop()\n\tLogger    = nopLogger\n\n\tloggerOnce sync.Once\n\tloggerVal  atomic.Value\n\tloggerMu   sync.Mutex\n\tinitErr    error\n)\n\n// InitLogger 初始化全局日志实例。\nfunc InitLogger() error {\n\tloggerOnce.Do(func() {\n\t\tlogger, err := buildLogger(config.GetConfig())\n\t\tif err != nil {\n\t\t\tinitErr = fmt.Errorf(\"创建zap日志包失败: %w\", err)\n\t\t\treturn\n\t\t}\n\t\tsetLogger(logger)\n\t})\n\treturn initErr\n}\n\n// ReloadLogger 根据新配置重建全局日志实例。\nfunc ReloadLogger(cfg *config.Conf) error {\n\tloggerMu.Lock()\n\tdefer loggerMu.Unlock()\n\n\tnext, err := buildLogger(cfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\told := current()\n\tsetLogger(next)\n\tif old != nil {\n\t\t_ = old.Sync()\n\t}\n\treturn nil\n}\n\nfunc current() *zap.Logger {\n\tif logger, ok := loggerVal.Load().(*zap.Logger); ok && logger != nil {\n\t\treturn logger\n\t}\n\tif Logger != nil {\n\t\treturn Logger\n\t}\n\treturn nopLogger\n}\n\nfunc setLogger(logger *zap.Logger) {\n\tif logger == nil {\n\t\tlogger = nopLogger\n\t}\n\tLogger = logger\n\tloggerVal.Store(logger)\n}\n\n// ReplaceLoggerForTesting 临时替换全局 logger，并返回恢复函数。\nfunc ReplaceLoggerForTesting(logger *zap.Logger) func() {\n\tloggerMu.Lock()\n\tprevious := current()\n\tsetLogger(logger)\n\tloggerMu.Unlock()\n\n\treturn func() {\n\t\tloggerMu.Lock()\n\t\tsetLogger(previous)\n\t\tloggerMu.Unlock()\n\t}\n}\n\n// Info 使用当前全局 logger 记录 info 日志。\nfunc Info(msg string, fields ...zap.Field) {\n\tcurrent().Info(msg, fields...)\n}\n\n// Error 使用当前全局 logger 记录 error 日志。\nfunc Error(msg string, fields ...zap.Field) {\n\tcurrent().Error(msg, fields...)\n}\n\n// Warn 使用当前全局 logger 记录 warn 日志。\nfunc Warn(msg string, fields ...zap.Field) {\n\tcurrent().Warn(msg, fields...)\n}\n\n// buildLogger 初始化 zap 日志\nfunc buildLogger(cfg *config.Conf) (*zap.Logger, error) {\n\tif cfg == nil {\n\t\tcfg = config.GetConfig()\n\t}\n\n\tif cfg.Logger.Output == \"stderr\" {\n\t\treturn zap.NewDevelopment()\n\t}\n\n\tencoderConfig := zap.NewProductionEncoderConfig()\n\tencoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {\n\t\tenc.AppendString(t.Format(\"2006-01-02 15:04:05.000\"))\n\t}\n\tencoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder\n\tencoder := zapcore.NewConsoleEncoder(encoderConfig)\n\tfilename := filepath.Join(cfg.BasePath, \"logs\", cfg.Logger.Filename)\n\n\tvar writer zapcore.WriteSyncer\n\tif cfg.Logger.DefaultDivision == \"size\" {\n\t\twriter = zapcore.AddSync(getLumberJackWriter(cfg, filename))\n\t} else {\n\t\trotateWriter, err := getRotateWriter(cfg, filename)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\twriter = zapcore.AddSync(rotateWriter)\n\t}\n\n\tzapCore := zapcore.NewCore(encoder, writer, zap.InfoLevel)\n\treturn zap.New(zapCore, zap.AddCaller()), nil\n}\n\n// getRotateWriter 按日期切割日志\nfunc getRotateWriter(cfg *config.Conf, filename string) (io.Writer, error) {\n\tmaxAge := time.Duration(cfg.Logger.DivisionTime.MaxAge)\n\trotationTime := time.Duration(cfg.Logger.DivisionTime.RotationTime)\n\thook, err := rotatelogs.New(\n\t\tfilename+\".%Y%m%d\",\n\t\trotatelogs.WithLinkName(filename),\n\t\trotatelogs.WithMaxAge(time.Hour*24*maxAge),\n\t\trotatelogs.WithRotationTime(time.Hour*rotationTime),\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn hook, nil\n}\n\n// getLumberJackWriter 按文件切割日志\nfunc getLumberJackWriter(cfg *config.Conf, filename string) io.Writer {\n\treturn &lumberjack.Logger{\n\t\tFilename:   filename,\n\t\tMaxSize:    cfg.Logger.DivisionSize.MaxSize,\n\t\tMaxBackups: cfg.Logger.DivisionSize.MaxBackups,\n\t\tMaxAge:     cfg.Logger.DivisionSize.MaxAge,\n\t\tCompress:   cfg.Logger.DivisionSize.Compress,\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/logger/logger_test.go",
    "content": "package logger\n\nimport \"testing\"\n\nfunc TestLoggerDefaultIsNotNil(t *testing.T) {\n\tif Logger == nil {\n\t\tt.Fatal(\"expected default logger to be non-nil\")\n\t}\n}\n\nfunc TestLoggerWrappersDoNotPanic(t *testing.T) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\tt.Fatalf(\"expected wrappers not to panic, got %v\", r)\n\t\t}\n\t}()\n\n\tInfo(\"info test\")\n\tWarn(\"warn test\")\n\tError(\"error test\")\n}\n"
  },
  {
    "path": "internal/pkg/query_builder/query_builder.go",
    "content": "package query_builder\n\nimport \"strings\"\n\ntype QueryBuilder struct {\n\tconditions []string\n\targs       []any\n}\n\nfunc New() *QueryBuilder {\n\treturn &QueryBuilder{\n\t\tconditions: make([]string, 0),\n\t\targs:       make([]any, 0),\n\t}\n}\n\nfunc (qb *QueryBuilder) AddCondition(cond string, args ...any) *QueryBuilder {\n\tif cond != \"\" {\n\t\tqb.conditions = append(qb.conditions, cond)\n\t\tqb.args = append(qb.args, args...)\n\t}\n\treturn qb\n}\n\nfunc (qb *QueryBuilder) AddLike(field, value string) *QueryBuilder {\n\tif value != \"\" {\n\t\tqb.conditions = append(qb.conditions, field+\" like ?\")\n\t\tqb.args = append(qb.args, \"%\"+value+\"%\")\n\t}\n\treturn qb\n}\n\nfunc (qb *QueryBuilder) AddEq(field string, value any) *QueryBuilder {\n\tif hasValue(value) {\n\t\tqb.conditions = append(qb.conditions, field+\" = ?\")\n\t\tqb.args = append(qb.args, value)\n\t}\n\treturn qb\n}\n\nfunc (qb *QueryBuilder) AddIn(field string, values []uint) *QueryBuilder {\n\tif len(values) > 0 {\n\t\tqb.conditions = append(qb.conditions, field+\" IN (?)\")\n\t\tqb.args = append(qb.args, values)\n\t}\n\treturn qb\n}\n\nfunc (qb *QueryBuilder) AddExists(subQuery string) *QueryBuilder {\n\tif subQuery != \"\" {\n\t\tqb.conditions = append(qb.conditions, \"EXISTS (\"+subQuery+\")\")\n\t}\n\treturn qb\n}\n\nfunc (qb *QueryBuilder) AddConditionf(cond string, args ...any) *QueryBuilder {\n\treturn qb.AddCondition(cond, args...)\n}\n\nfunc (qb *QueryBuilder) AddKeywordLike(keyword string, fields ...string) *QueryBuilder {\n\tif keyword == \"\" || len(fields) == 0 {\n\t\treturn qb\n\t}\n\n\tclauses := make([]string, 0, len(fields))\n\tfor range fields {\n\t\tqb.args = append(qb.args, \"%\"+keyword+\"%\")\n\t}\n\tfor _, field := range fields {\n\t\tclauses = append(clauses, field+\" like ?\")\n\t}\n\tqb.conditions = append(qb.conditions, \"(\"+strings.Join(clauses, \" OR \")+\")\")\n\treturn qb\n}\n\nfunc (qb *QueryBuilder) Build() (string, []any) {\n\tif len(qb.conditions) == 0 {\n\t\treturn \"\", nil\n\t}\n\tcond := qb.conditions[0]\n\tfor i := 1; i < len(qb.conditions); i++ {\n\t\tcond += \" AND \" + qb.conditions[i]\n\t}\n\treturn cond, qb.args\n}\n\nfunc hasValue(value any) bool {\n\tif value == nil {\n\t\treturn false\n\t}\n\n\tswitch typed := value.(type) {\n\tcase string:\n\t\treturn typed != \"\"\n\tcase *string:\n\t\treturn typed != nil && *typed != \"\"\n\tcase *int8:\n\t\treturn typed != nil\n\tcase *uint8:\n\t\treturn typed != nil\n\tcase *uint:\n\t\treturn typed != nil\n\tcase *int:\n\t\treturn typed != nil\n\tdefault:\n\t\treturn true\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/query_builder/query_builder_test.go",
    "content": "package query_builder\n\nimport \"testing\"\n\nfunc TestQueryBuilderBuildsExpectedCondition(t *testing.T) {\n\tstatus := int8(1)\n\tpid := uint(3)\n\n\tcondition, args := New().\n\t\tAddKeywordLike(\"dashboard\", \"title\", \"path\").\n\t\tAddEq(\"status\", &status).\n\t\tAddEq(\"pid\", &pid).\n\t\tBuild()\n\n\texpected := \"(title like ? OR path like ?) AND status = ? AND pid = ?\"\n\tif condition != expected {\n\t\tt.Fatalf(\"unexpected condition: %s\", condition)\n\t}\n\tif len(args) != 4 {\n\t\tt.Fatalf(\"unexpected args len: %d\", len(args))\n\t}\n}\n\nfunc TestQueryBuilderSkipsEmptyValues(t *testing.T) {\n\tempty := \"\"\n\n\tcondition, args := New().\n\t\tAddLike(\"name\", \"\").\n\t\tAddEq(\"code\", &empty).\n\t\tBuild()\n\n\tif condition != \"\" {\n\t\tt.Fatalf(\"expected empty condition, got %s\", condition)\n\t}\n\tif args != nil {\n\t\tt.Fatalf(\"expected nil args, got %#v\", args)\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/request/request.go",
    "content": "package request\n\nimport (\n\t\"errors\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token\"\n)\n\n// GetQueryParams 提取当前请求的查询参数。\n// 说明：\n//   - 不做字段白名单过滤，调用方需自行约束参数使用范围；\n//   - 仅保留每个 key 的首个值（与 c.Query 行为一致）。\nfunc GetQueryParams(c *gin.Context) map[string]any {\n\tif c == nil {\n\t\treturn map[string]any{}\n\t}\n\tquery := c.Request.URL.Query()\n\tvar queryMap = make(map[string]any, len(query))\n\tfor k := range query {\n\t\tqueryMap[k] = c.Query(k)\n\t}\n\treturn queryMap\n}\n\n// GetAccessToken 从 Authorization 请求头提取 access token。\nfunc GetAccessToken(c *gin.Context) (string, error) {\n\tif c == nil {\n\t\treturn \"\", errors.New(\"gin context is nil\")\n\t}\n\tauthorization := c.GetHeader(\"Authorization\")\n\treturn token.GetAccessToken(authorization)\n}\n"
  },
  {
    "path": "internal/pkg/request/request_test.go",
    "content": "package request\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc TestGetAccessTokenSuccess(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = httptest.NewRequest(http.MethodGet, \"/test\", nil)\n\tctx.Request.Header.Set(\"Authorization\", \"Bearer token-value\")\n\n\ttokenValue, err := GetAccessToken(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif tokenValue != \"token-value\" {\n\t\tt.Fatalf(\"unexpected token value %q\", tokenValue)\n\t}\n}\n\nfunc TestGetAccessTokenNilContext(t *testing.T) {\n\t_, err := GetAccessToken(nil)\n\tif err == nil {\n\t\tt.Fatal(\"expected nil context to return error\")\n\t}\n}\n\nfunc TestGetQueryParamsNilContext(t *testing.T) {\n\tparams := GetQueryParams(nil)\n\tif len(params) != 0 {\n\t\tt.Fatalf(\"expected empty map, got %#v\", params)\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/response/response.go",
    "content": "package response\n\nimport (\n\t\"net/http\"\n\t\"reflect\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/i18n\"\n)\n\n// Result API响应结果结构\ntype Result struct {\n\tCode      int    `json:\"code\"`\n\tMsg       string `json:\"msg\"`\n\tData      any    `json:\"data\"`\n\tCost      string `json:\"cost\"`\n\tRequestId string `json:\"request_id\"`\n}\n\n// NewResult 创建新的响应结果\nfunc NewResult() *Result {\n\treturn &Result{\n\t\tCode:      0,\n\t\tMsg:       \"\",\n\t\tData:      emptyObject(),\n\t\tCost:      \"\",\n\t\tRequestId: \"\",\n\t}\n}\n\n// Response 响应处理器\ntype Response struct {\n\thttpCode int\n\tresult   *Result\n\tmsgKey   string\n\tmsgArgs  []any\n}\n\n// Resp 创建响应处理器实例\nfunc Resp() *Response {\n\treturn &Response{\n\t\thttpCode: http.StatusOK,\n\t\tresult:   NewResult(),\n\t}\n}\n\n// Fail 错误返回\nfunc (r *Response) Fail(c *gin.Context, code int, msg string, data ...any) {\n\tr.SetCode(code)\n\tr.SetMessage(msg)\n\tif len(data) > 0 && data[0] != nil {\n\t\tr.WithData(data[0])\n\t}\n\tr.json(c)\n}\n\n// FailCode 自定义错误码返回\nfunc (r *Response) FailCode(c *gin.Context, code int, msg ...string) {\n\tr.SetCode(code)\n\tif len(msg) > 0 && msg[0] != \"\" {\n\t\tr.SetMessage(msg[0])\n\t}\n\tr.json(c)\n}\n\n// FailCodeByKey 自定义错误码返回（按文案 key 国际化）。\nfunc (r *Response) FailCodeByKey(c *gin.Context, code int, key string, args ...any) {\n\tr.SetCode(code)\n\tr.SetMessageKey(key, args...)\n\tr.json(c)\n}\n\n// Success 正确返回\nfunc (r *Response) Success(c *gin.Context) {\n\tr.SetCode(errors.SUCCESS)\n\tr.json(c)\n}\n\n// WithDataSuccess 成功后需要返回值\nfunc (r *Response) WithDataSuccess(c *gin.Context, data interface{}) {\n\tr.SetCode(errors.SUCCESS)\n\tr.WithData(data)\n\tr.json(c)\n}\n\n// SetCode 设置返回code码\nfunc (r *Response) SetCode(code int) *Response {\n\tr.result.Code = code\n\treturn r\n}\n\n// SetHttpCode 设置http状态码\nfunc (r *Response) SetHttpCode(code int) *Response {\n\tr.httpCode = code\n\treturn r\n}\n\n// defaultRes 默认响应数据结构\ntype defaultRes struct {\n\tResult any `json:\"result\"`\n}\n\n// WithData 设置返回data数据\nfunc (r *Response) WithData(data any) *Response {\n\tif isNilData(data) {\n\t\tr.result.Data = emptyObject()\n\t\treturn r\n\t}\n\tif !isObjectData(data) {\n\t\tr.result.Data = &defaultRes{Result: data}\n\t\treturn r\n\t}\n\tr.result.Data = data\n\treturn r\n}\n\n// SetMessage 设置返回自定义错误消息\nfunc (r *Response) SetMessage(message string) *Response {\n\tr.result.Msg = message\n\tr.msgKey = \"\"\n\tr.msgArgs = nil\n\treturn r\n}\n\n// SetMessageKey 设置返回错误文案 key（供国际化解析）。\nfunc (r *Response) SetMessageKey(key string, args ...any) *Response {\n\tr.msgKey = key\n\tr.msgArgs = append([]any(nil), args...)\n\treturn r\n}\n\n// json 返回 gin 框架的 JSON 响应\nfunc (r *Response) json(c *gin.Context) {\n\t// 如果消息为空，使用错误码对应的默认消息\n\tif r.result.Msg == \"\" {\n\t\tlanguage := config.GetConfig().Language\n\t\tif c != nil {\n\t\t\tif locale, exists := c.Get(global.ContextKeyLocale); exists {\n\t\t\t\tif localeText, ok := locale.(string); ok {\n\t\t\t\t\tlanguage = i18n.ToErrorLanguage(localeText)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\terrorText := errors.NewErrorText(language)\n\t\tif r.msgKey != \"\" {\n\t\t\tif msg, ok := errorText.TextByKey(r.msgKey, r.msgArgs...); ok && msg != \"\" {\n\t\t\t\tr.result.Msg = msg\n\t\t\t}\n\t\t}\n\t\tif r.result.Msg == \"\" {\n\t\t\tr.result.Msg = errorText.Text(r.result.Code)\n\t\t}\n\t}\n\n\t// 计算请求耗时\n\tr.result.Cost = time.Since(c.GetTime(global.ContextKeyRequestStartTime)).String()\n\tr.result.RequestId = c.GetString(global.ContextKeyRequestID)\n\tc.AbortWithStatusJSON(r.httpCode, r.result)\n}\n\n// Success 业务成功响应（便捷方法）\nfunc Success(c *gin.Context, data ...any) {\n\tif len(data) > 0 && data[0] != nil {\n\t\tResp().WithDataSuccess(c, data[0])\n\t\treturn\n\t}\n\tResp().Success(c)\n}\n\n// FailCode 业务失败响应（便捷方法）\nfunc FailCode(c *gin.Context, code int, data ...any) {\n\tif len(data) > 0 && data[0] != nil {\n\t\tResp().WithData(data[0]).FailCode(c, code)\n\t\treturn\n\t}\n\tResp().FailCode(c, code)\n}\n\n// FailCodeByKey 业务失败响应（按 key 解析多语言文案）。\nfunc FailCodeByKey(c *gin.Context, code int, key string, args ...any) {\n\tResp().FailCodeByKey(c, code, key, args...)\n}\n\n// Fail 业务失败响应（便捷方法）\nfunc Fail(c *gin.Context, code int, message string, data ...any) {\n\tif len(data) > 0 && data[0] != nil {\n\t\tResp().WithData(data[0]).Fail(c, code, message)\n\t\treturn\n\t}\n\tResp().Fail(c, code, message)\n}\n\nfunc emptyObject() map[string]any {\n\treturn map[string]any{}\n}\n\nfunc isNilData(data any) bool {\n\tif data == nil {\n\t\treturn true\n\t}\n\n\tvalue := reflect.ValueOf(data)\n\tswitch value.Kind() {\n\tcase reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:\n\t\treturn value.IsNil()\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc isObjectData(data any) bool {\n\tvalue := reflect.ValueOf(data)\n\n\t// 解引用接口和指针，判断底层真实类型是否为对象形态。\n\tfor value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer {\n\t\tif value.IsNil() {\n\t\t\treturn false\n\t\t}\n\t\tvalue = value.Elem()\n\t}\n\n\tswitch value.Kind() {\n\tcase reflect.Struct, reflect.Map:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/response/response_test.go",
    "content": "package response\n\nimport (\n\t\"encoding/json\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\nfunc TestSuccessDefaultsDataToEmptyObject(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-1\")\n\n\tResp().Success(ctx)\n\n\tvar result Result\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal response failed: %v\", err)\n\t}\n\n\tdata, ok := result.Data.(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"expected object data, got %#v\", result.Data)\n\t}\n\tif len(data) != 0 {\n\t\tt.Fatalf(\"expected empty object, got %#v\", data)\n\t}\n}\n\nfunc TestWithNilDataReturnsEmptyObject(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-2\")\n\n\tResp().WithDataSuccess(ctx, nil)\n\n\tvar result Result\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal response failed: %v\", err)\n\t}\n\n\tdata, ok := result.Data.(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"expected object data, got %#v\", result.Data)\n\t}\n\tif len(data) != 0 {\n\t\tt.Fatalf(\"expected empty object, got %#v\", data)\n\t}\n}\n\nfunc TestScalarDataStillWrapped(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-3\")\n\n\tResp().WithDataSuccess(ctx, true)\n\n\tvar result struct {\n\t\tData struct {\n\t\t\tResult bool `json:\"result\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal response failed: %v\", err)\n\t}\n\tif !result.Data.Result {\n\t\tt.Fatalf(\"expected wrapped scalar result=true\")\n\t}\n}\n\nfunc TestInt64DataStillWrapped(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-4\")\n\n\tResp().WithDataSuccess(ctx, int64(42))\n\n\tvar result struct {\n\t\tData struct {\n\t\t\tResult int64 `json:\"result\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal response failed: %v\", err)\n\t}\n\tif result.Data.Result != 42 {\n\t\tt.Fatalf(\"expected wrapped scalar result=42, got %d\", result.Data.Result)\n\t}\n}\n\nfunc TestTypedNilPointerReturnsEmptyObject(t *testing.T) {\n\ttype payload struct {\n\t\tName string `json:\"name\"`\n\t}\n\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-5\")\n\n\tvar nilPayload *payload\n\tResp().WithDataSuccess(ctx, nilPayload)\n\n\tvar result Result\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal response failed: %v\", err)\n\t}\n\n\tdata, ok := result.Data.(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"expected object data, got %#v\", result.Data)\n\t}\n\tif len(data) != 0 {\n\t\tt.Fatalf(\"expected empty object, got %#v\", data)\n\t}\n}\n\nfunc TestNilSliceReturnsEmptyObject(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-6\")\n\n\tvar list []int\n\tResp().WithDataSuccess(ctx, list)\n\n\tvar result Result\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal response failed: %v\", err)\n\t}\n\n\tdata, ok := result.Data.(map[string]any)\n\tif !ok {\n\t\tt.Fatalf(\"expected object data, got %#v\", result.Data)\n\t}\n\tif len(data) != 0 {\n\t\tt.Fatalf(\"expected empty object, got %#v\", data)\n\t}\n}\n\nfunc TestSliceDataWrappedAsObject(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-7\")\n\n\tResp().WithDataSuccess(ctx, []int{1, 2, 3})\n\n\tvar result struct {\n\t\tData struct {\n\t\t\tResult []int `json:\"result\"`\n\t\t} `json:\"data\"`\n\t}\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal response failed: %v\", err)\n\t}\n\tif len(result.Data.Result) != 3 {\n\t\tt.Fatalf(\"expected wrapped slice length=3, got %d\", len(result.Data.Result))\n\t}\n}\n\nfunc TestFailCodeByKeyResolvesMessage(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"req-key\")\n\tctx.Set(global.ContextKeyLocale, \"zh-CN\")\n\n\tResp().FailCodeByKey(ctx, errors.ServerErr, errors.MsgKeyAuthPermissionInitFailed)\n\n\tvar result Result\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal response failed: %v\", err)\n\t}\n\tif result.Code != errors.ServerErr {\n\t\tt.Fatalf(\"expected code %d, got %d\", errors.ServerErr, result.Code)\n\t}\n\tif result.Msg != \"权限验证初始化失败\" {\n\t\tt.Fatalf(\"expected translated key message, got %q\", result.Msg)\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/testkit/secret.go",
    "content": "package testkit\n\nimport \"strings\"\n\n// SecretKey 返回测试专用密钥，统一管理避免散落硬编码。\nfunc SecretKey(scope string) string {\n\tscope = strings.TrimSpace(scope)\n\tif scope == \"\" {\n\t\tscope = \"default\"\n\t}\n\t// 保持长度充足以满足生产级最小长度校验场景。\n\treturn \"unit-test-secret-key-\" + scope + \"-0123456789abcdef\"\n}\n"
  },
  {
    "path": "internal/pkg/testkit/secret_test.go",
    "content": "package testkit\n\nimport \"testing\"\n\nfunc TestSecretKey(t *testing.T) {\n\tsecret := SecretKey(\"auth\")\n\tif secret == \"\" {\n\t\tt.Fatal(\"expected non-empty secret\")\n\t}\n\tif len(secret) < 16 {\n\t\tt.Fatalf(\"expected secret length >=16, got %d\", len(secret))\n\t}\n\n\tfallback := SecretKey(\"\")\n\tif fallback == \"\" {\n\t\tt.Fatal(\"expected fallback secret to be non-empty\")\n\t}\n\tif fallback == secret {\n\t\tt.Fatal(\"expected scoped and fallback secrets to differ\")\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/utils/desensitize.go",
    "content": "package utils\n\nimport (\n\t\"strings\"\n\t\"unicode/utf8\"\n)\n\n// DesensitizeRule 描述字符串脱敏策略。\ntype DesensitizeRule struct {\n\tKeepPrefixLen   int  // 保留前缀长度\n\tKeepSuffixLen   int  // 保留后缀长度\n\tMaskChar        rune // 脱敏字符\n\tSeparator       rune // 特殊分隔符(如邮箱的@)\n\tFixedMaskLength int  // 固定脱敏长度(0表示不固定)\n}\n\n// NewPhoneRule 构建手机号码脱敏规则\nfunc NewPhoneRule() *DesensitizeRule {\n\treturn &DesensitizeRule{KeepPrefixLen: 3, KeepSuffixLen: 4, MaskChar: '*', FixedMaskLength: 4}\n}\n\n// NewEmailRule 构建邮箱脱敏规则\nfunc NewEmailRule() *DesensitizeRule {\n\treturn &DesensitizeRule{KeepPrefixLen: 2, KeepSuffixLen: 0, MaskChar: '*', Separator: '@', FixedMaskLength: 3}\n}\n\n// Apply 按当前规则对输入字符串做脱敏。\nfunc (r *DesensitizeRule) Apply(s string) string {\n\tif utf8.RuneCountInString(s) == 0 {\n\t\treturn s\n\t}\n\n\t// 处理带分隔符的情况(如邮箱)\n\tif r.Separator != 0 {\n\t\tparts := strings.Split(s, string(r.Separator))\n\t\tif len(parts) == 2 {\n\t\t\tlocalPart := r.applyToPart(parts[0])\n\t\t\treturn localPart + string(r.Separator) + parts[1]\n\t\t}\n\t}\n\n\treturn r.applyToPart(s)\n}\n\nfunc (r *DesensitizeRule) applyToPart(s string) string {\n\trunes := []rune(s)\n\tlength := len(runes)\n\n\t// 计算需要保留的前后部分\n\tkeepPrefix := r.min(r.KeepPrefixLen, length)\n\tkeepSuffix := r.min(r.KeepSuffixLen, length-keepPrefix)\n\n\t// 计算脱敏部分长度\n\tvar maskLength int\n\tif r.FixedMaskLength > 0 {\n\t\tmaskLength = r.FixedMaskLength // 使用固定长度\n\t} else {\n\t\tmaskLength = length - keepPrefix - keepSuffix // 使用可变长度\n\t}\n\n\t// 构建结果\n\tvar result strings.Builder\n\tif keepPrefix > 0 {\n\t\tresult.WriteString(string(runes[:keepPrefix]))\n\t}\n\tif maskLength > 0 {\n\t\tresult.WriteString(strings.Repeat(string(r.MaskChar), maskLength))\n\t}\n\tif keepSuffix > 0 {\n\t\tresult.WriteString(string(runes[length-keepSuffix:]))\n\t}\n\n\treturn result.String()\n}\n\nfunc (r *DesensitizeRule) min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\treturn b\n}\n"
  },
  {
    "path": "internal/pkg/utils/format_time.go",
    "content": "package utils\n\nimport (\n\t\"database/sql/driver\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n)\n\n// FormatDate 为时间字段提供统一的 JSON/SQL 编解码行为。\ntype FormatDate struct {\n\ttime.Time\n}\n\nconst (\n\ttimeFormat = \"2006-01-02 15:04:05\"\n)\n\n// MarshalJSON 以固定格式输出 JSON 时间字符串。\nfunc (t FormatDate) MarshalJSON() ([]byte, error) {\n\tif &t == nil || t.IsZero() {\n\t\treturn []byte(\"null\"), nil\n\t}\n\treturn []byte(fmt.Sprintf(\"\\\"%s\\\"\", t.Format(timeFormat))), nil\n}\n\n// Value 实现 driver.Valuer 接口。\nfunc (t FormatDate) Value() (driver.Value, error) {\n\tvar zeroTime time.Time\n\tif t.Time.UnixNano() == zeroTime.UnixNano() {\n\t\treturn nil, nil\n\t}\n\treturn t.Time, nil\n}\n\n// Scan 实现 sql.Scanner 接口。\nfunc (t *FormatDate) Scan(v interface{}) error {\n\tif value, ok := v.(time.Time); ok {\n\t\t*t = FormatDate{value}\n\t\treturn nil\n\t}\n\treturn fmt.Errorf(\"can not convert %v to timestamp\", v)\n}\n\n// String 返回可读的时间字符串。\nfunc (t *FormatDate) String() string {\n\tif t == nil || t.IsZero() {\n\t\treturn \"\"\n\t}\n\treturn fmt.Sprintf(\"%s\", t.Time.Format(timeFormat))\n}\n\n// UnmarshalJSON 解析固定格式的 JSON 时间字符串。\nfunc (t *FormatDate) UnmarshalJSON(data []byte) error {\n\tstr := string(data)\n\tif str == \"null\" {\n\t\treturn nil\n\t}\n\tt1, err := time.ParseInLocation(timeFormat, strings.Trim(str, \"\\\"\"), time.Local)\n\t*t = FormatDate{t1}\n\treturn err\n}\n"
  },
  {
    "path": "internal/pkg/utils/sensitive/fields.go",
    "content": "package sensitive\n\nimport (\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n)\n\nconst (\n\tmaskTokenPrefixLen    = 6\n\tmaskTokenSuffixLen    = 6\n\tmaskPhonePrefixLen    = 3\n\tmaskPhoneSuffixLen    = 4\n\tmaskEmailPrefixLen    = 2\n\tmaskIdCardPrefixLen   = 6\n\tmaskIdCardSuffixLen   = 4\n\tmaskBankCardPrefixLen = 4\n\tmaskBankCardSuffixLen = 4\n\tmaskDefaultPrefixLen  = 1\n\tmaskDefaultSuffixLen  = 1\n)\n\n// SensitiveFieldsConfig 敏感字段配置结构（未来可通过配置文件加载）\ntype SensitiveFieldsConfig struct {\n\tCommon         []string `json:\"common\"`\n\tRequestHeader  []string `json:\"request_header\"`\n\tRequestBody    []string `json:\"request_body\"`\n\tResponseHeader []string `json:\"response_header\"`\n\tResponseBody   []string `json:\"response_body\"`\n}\n\ntype sensitiveFieldsManager struct {\n\tcommonFields         map[string]bool\n\trequestHeaderFields  map[string]bool\n\trequestBodyFields    map[string]bool\n\tresponseHeaderFields map[string]bool\n\tresponseBodyFields   map[string]bool\n\tmu                   sync.RWMutex\n}\n\nvar (\n\tdefaultFieldsManagerOnce sync.Once\n\tdefaultFieldsManagerVal  atomic.Pointer[sensitiveFieldsManager]\n\n\tphoneRegex    = regexp.MustCompile(`1[3-9]\\d{9}`)\n\temailRegex    = regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}`)\n\tidCardRegex   = regexp.MustCompile(`\\d{15}|\\d{17}[\\dXx]`)\n\tbankCardRegex = regexp.MustCompile(`\\d{16,19}`)\n)\n\nfunc currentFieldsManager() *sensitiveFieldsManager {\n\tdefaultFieldsManagerOnce.Do(func() {\n\t\tdefaultFieldsManagerVal.Store(newSensitiveFieldsManager(defaultSensitiveFieldsConfig()))\n\t})\n\n\tmanager := defaultFieldsManagerVal.Load()\n\tif manager != nil {\n\t\treturn manager\n\t}\n\n\t// 防御性兜底，确保任何情况下都返回可用 manager。\n\tmanager = newSensitiveFieldsManager(defaultSensitiveFieldsConfig())\n\tdefaultFieldsManagerVal.Store(manager)\n\treturn manager\n}\n\nfunc newSensitiveFieldsManager(config SensitiveFieldsConfig) *sensitiveFieldsManager {\n\tmanager := &sensitiveFieldsManager{\n\t\tcommonFields:         make(map[string]bool),\n\t\trequestHeaderFields:  make(map[string]bool),\n\t\trequestBodyFields:    make(map[string]bool),\n\t\tresponseHeaderFields: make(map[string]bool),\n\t\tresponseBodyFields:   make(map[string]bool),\n\t}\n\tmanager.applyConfig(config)\n\treturn manager\n}\n\nfunc (m *sensitiveFieldsManager) applyConfig(config SensitiveFieldsConfig) {\n\tm.commonFields = sliceToMap(config.Common)\n\tm.requestHeaderFields = sliceToMap(config.RequestHeader)\n\tm.requestBodyFields = sliceToMap(config.RequestBody)\n\tm.responseHeaderFields = sliceToMap(config.ResponseHeader)\n\tm.responseBodyFields = sliceToMap(config.ResponseBody)\n}\n\nfunc defaultSensitiveFieldsConfig() SensitiveFieldsConfig {\n\treturn SensitiveFieldsConfig{\n\t\tCommon: []string{\n\t\t\t\"password\", \"pwd\", \"passwd\", \"pass\", \"secret\",\n\t\t\t\"token\", \"access_token\", \"refresh_token\",\n\t\t\t\"api_key\", \"apikey\", \"apiKey\",\n\t\t\t\"pin\", \"cvv\", \"cvc\", \"cvv2\", \"security_code\",\n\t\t},\n\t\tRequestHeader: []string{\n\t\t\t\"authorization\", \"auth\",\n\t\t\t\"cookie\",\n\t\t\t\"x-api-key\", \"x-access-token\", \"x-auth-token\", \"x-token\",\n\t\t},\n\t\tRequestBody: []string{\n\t\t\t\"password\", \"pwd\", \"passwd\", \"pass\", \"secret\",\n\t\t\t\"token\", \"access_token\", \"refresh_token\",\n\t\t\t\"api_key\", \"apikey\", \"apiKey\",\n\t\t\t\"phone\", \"mobile\", \"tel\", \"telephone\",\n\t\t\t\"phone_number\", \"mobile_number\",\n\t\t\t\"email\", \"mail\",\n\t\t\t\"id_card\", \"idcard\", \"identity\", \"id_number\",\n\t\t\t\"bank_card\", \"bankcard\", \"card_number\", \"card_no\",\n\t\t\t\"cvv\", \"cvc\", \"cvv2\", \"security_code\",\n\t\t\t\"pin\", \"ssn\", \"social_security\",\n\t\t\t\"real_name\", \"realname\", \"name\",\n\t\t},\n\t\tResponseHeader: []string{\n\t\t\t\"set-cookie\",\n\t\t\t\"authorization\", \"auth\",\n\t\t\t\"x-api-key\", \"x-access-token\", \"x-auth-token\", \"x-token\", \"x-refresh-token\",\n\t\t\t\"refresh-access-token\", \"refresh-exp\",\n\t\t\t\"cookie\",\n\t\t},\n\t\tResponseBody: []string{\n\t\t\t\"password\", \"pwd\", \"passwd\", \"pass\", \"secret\",\n\t\t\t\"token\", \"access_token\", \"refresh_token\",\n\t\t\t\"api_key\", \"apikey\", \"apiKey\",\n\t\t\t\"phone\", \"mobile\", \"tel\", \"telephone\",\n\t\t\t\"phone_number\", \"mobile_number\",\n\t\t\t\"email\", \"mail\",\n\t\t\t\"id_card\", \"idcard\", \"identity\", \"id_number\",\n\t\t\t\"bank_card\", \"bankcard\", \"card_number\", \"card_no\",\n\t\t\t\"cvv\", \"cvc\", \"cvv2\", \"security_code\",\n\t\t\t\"pin\", \"ssn\", \"social_security\",\n\t\t},\n\t}\n}\n\n// DefaultSensitiveFieldsConfig 返回默认敏感字段配置副本。\nfunc DefaultSensitiveFieldsConfig() SensitiveFieldsConfig {\n\treturn defaultSensitiveFieldsConfig()\n}\n\n// LoadSensitiveFieldsConfig 加载敏感字段配置（未来可从配置文件调用）\nfunc LoadSensitiveFieldsConfig(config SensitiveFieldsConfig) {\n\tmanager := currentFieldsManager()\n\tmanager.mu.Lock()\n\tdefer manager.mu.Unlock()\n\tmanager.applyConfig(config)\n}\n\n// GetSensitiveFieldsConfig 返回当前生效的敏感字段配置快照。\nfunc GetSensitiveFieldsConfig() SensitiveFieldsConfig {\n\tmanager := currentFieldsManager()\n\tmanager.mu.RLock()\n\tdefer manager.mu.RUnlock()\n\n\treturn SensitiveFieldsConfig{\n\t\tCommon:         mapKeys(manager.commonFields),\n\t\tRequestHeader:  mapKeys(manager.requestHeaderFields),\n\t\tRequestBody:    mapKeys(manager.requestBodyFields),\n\t\tResponseHeader: mapKeys(manager.responseHeaderFields),\n\t\tResponseBody:   mapKeys(manager.responseBodyFields),\n\t}\n}\n\nfunc sliceToMap(slice []string) map[string]bool {\n\tif len(slice) == 0 {\n\t\treturn make(map[string]bool)\n\t}\n\tresult := make(map[string]bool, len(slice))\n\tfor _, s := range slice {\n\t\tif s != \"\" {\n\t\t\tresult[strings.ToLower(s)] = true\n\t\t}\n\t}\n\treturn result\n}\n\nfunc getCommonFields() map[string]bool {\n\tmanager := currentFieldsManager()\n\treturn manager.cloneFieldSet(manager.commonFields)\n}\n\nfunc getRequestHeaderFields() map[string]bool {\n\tmanager := currentFieldsManager()\n\treturn manager.cloneFieldSet(manager.requestHeaderFields)\n}\n\nfunc getRequestBodyFields() map[string]bool {\n\tmanager := currentFieldsManager()\n\treturn manager.cloneFieldSet(manager.requestBodyFields)\n}\n\nfunc getResponseHeaderFields() map[string]bool {\n\tmanager := currentFieldsManager()\n\treturn manager.cloneFieldSet(manager.responseHeaderFields)\n}\n\nfunc getResponseBodyFields() map[string]bool {\n\tmanager := currentFieldsManager()\n\treturn manager.cloneFieldSet(manager.responseBodyFields)\n}\n\nfunc (m *sensitiveFieldsManager) cloneFieldSet(source map[string]bool) map[string]bool {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tresult := make(map[string]bool, len(source))\n\tfor k, v := range source {\n\t\tresult[k] = v\n\t}\n\treturn result\n}\n\nfunc mapKeys(source map[string]bool) []string {\n\tif len(source) == 0 {\n\t\treturn []string{}\n\t}\n\tresult := make([]string, 0, len(source))\n\tfor key := range source {\n\t\tresult = append(result, key)\n\t}\n\tsort.Strings(result)\n\treturn result\n}\n"
  },
  {
    "path": "internal/pkg/utils/sensitive/http_mask.go",
    "content": "package sensitive\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"unicode/utf8\"\n)\n\nfunc maskHeaders(headers http.Header, sensitiveFields map[string]bool) string {\n\tif len(headers) == 0 {\n\t\treturn \"{}\"\n\t}\n\n\tmaskedHeaders := make(http.Header, len(headers))\n\tfor k, v := range headers {\n\t\tif isSensitiveField(strings.ToLower(k), sensitiveFields) {\n\t\t\tmaskedHeaders[k] = maskStringSlice(v, maskSensitiveString)\n\t\t\tcontinue\n\t\t}\n\t\tmaskedHeaders[k] = v\n\t}\n\n\tbytes, err := json.Marshal(maskedHeaders)\n\tif err != nil {\n\t\treturn \"{}\"\n\t}\n\treturn string(bytes)\n}\n\n// GetMaskedRequestHeaders 获取脱敏后的请求头\nfunc GetMaskedRequestHeaders(headers http.Header) string {\n\treturn maskHeaders(headers, getRequestHeaderFields())\n}\n\n// GetMaskedResponseHeaders 获取脱敏后的响应头\nfunc GetMaskedResponseHeaders(headers http.Header) string {\n\treturn maskHeaders(headers, getResponseHeaderFields())\n}\n\n// GetMaskedRequestBody 获取脱敏后的请求体\nfunc GetMaskedRequestBody(bodyBytes []byte, contentType string) string {\n\tif len(bodyBytes) == 0 {\n\t\treturn \"\"\n\t}\n\n\tcontentTypeLower := strings.ToLower(contentType)\n\tswitch {\n\tcase strings.Contains(contentTypeLower, \"multipart/form-data\"):\n\t\treturn \"[multipart/form-data: file upload, body not logged]\"\n\tcase !isValidUTF8(bodyBytes):\n\t\treturn \"[binary data: non-text content, body not logged]\"\n\tcase !strings.Contains(contentTypeLower, \"application/json\"):\n\t\treturn maskString(string(bodyBytes))\n\tdefault:\n\t\treturn maskJSONBytes(bodyBytes, getRequestBodyFields())\n\t}\n}\n\n// GetMaskedResponseBody 获取脱敏后的响应体\nfunc GetMaskedResponseBody(bodyBytes []byte) string {\n\tif len(bodyBytes) == 0 {\n\t\treturn \"\"\n\t}\n\tif !isValidUTF8(bodyBytes) {\n\t\treturn \"[binary data: non-text content, body not logged]\"\n\t}\n\treturn maskJSONBytes(bodyBytes, getResponseBodyFields())\n}\n\n// MaskQueryString 对查询字符串进行脱敏\nfunc MaskQueryString(queryString string) string {\n\tif queryString == \"\" {\n\t\treturn queryString\n\t}\n\n\tvalues, err := url.ParseQuery(queryString)\n\tif err != nil {\n\t\treturn maskString(queryString)\n\t}\n\n\trequestBodyFields := getRequestBodyFields()\n\tmaskedValues := make(url.Values, len(values))\n\tfor key, values := range values {\n\t\tmaskValueFn := maskString\n\t\tif isSensitiveField(strings.ToLower(key), requestBodyFields) {\n\t\t\tmaskValueFn = maskSensitiveString\n\t\t}\n\t\tmaskedValues[key] = maskStringSlice(values, maskValueFn)\n\t}\n\treturn maskedValues.Encode()\n}\n\nfunc maskJSONBytes(bodyBytes []byte, sensitiveFields map[string]bool) string {\n\tvar data interface{}\n\tif err := json.Unmarshal(bodyBytes, &data); err != nil {\n\t\treturn maskString(string(bodyBytes))\n\t}\n\n\tmaskedData := maskSensitiveDataWithFields(data, sensitiveFields)\n\tmaskedBytes, err := json.Marshal(maskedData)\n\tif err != nil {\n\t\treturn maskString(string(bodyBytes))\n\t}\n\treturn string(maskedBytes)\n}\n\nfunc maskStringSlice(values []string, fn func(string) string) []string {\n\tmasked := make([]string, len(values))\n\tfor i, value := range values {\n\t\tmasked[i] = fn(value)\n\t}\n\treturn masked\n}\n\nfunc isValidUTF8(data []byte) bool {\n\treturn len(data) == 0 || len(string(data)) == len(data) && utf8.Valid(data)\n}\n"
  },
  {
    "path": "internal/pkg/utils/sensitive/mask.go",
    "content": "package sensitive\n\nimport \"strings\"\n\n// maskMap 对 map 进行递归脱敏（使用指定的敏感字段列表）\nfunc maskMap(m map[string]interface{}, sensitiveFields map[string]bool) map[string]interface{} {\n\tif len(m) == 0 {\n\t\treturn m\n\t}\n\tresult := make(map[string]interface{}, len(m))\n\tfor k, v := range m {\n\t\tkeyLower := strings.ToLower(k)\n\t\tif isSensitiveField(keyLower, sensitiveFields) {\n\t\t\tresult[k] = maskValue(v)\n\t\t} else {\n\t\t\tresult[k] = maskSensitiveDataWithFields(v, sensitiveFields)\n\t\t}\n\t}\n\treturn result\n}\n\n// maskSensitiveDataWithFields 对敏感数据进行脱敏处理（使用指定的敏感字段列表）\nfunc maskSensitiveDataWithFields(data interface{}, sensitiveFields map[string]bool) interface{} {\n\tswitch v := data.(type) {\n\tcase map[string]interface{}:\n\t\treturn maskMap(v, sensitiveFields)\n\tcase []interface{}:\n\t\treturn maskArrayWithFields(v, sensitiveFields)\n\tcase string:\n\t\treturn maskString(v)\n\tdefault:\n\t\treturn data\n\t}\n}\n\n// maskArrayWithFields 对数组进行递归脱敏（使用指定的敏感字段列表）\nfunc maskArrayWithFields(arr []interface{}, sensitiveFields map[string]bool) []interface{} {\n\tif len(arr) == 0 {\n\t\treturn arr\n\t}\n\tresult := make([]interface{}, len(arr))\n\tfor i, v := range arr {\n\t\tresult[i] = maskSensitiveDataWithFields(v, sensitiveFields)\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "internal/pkg/utils/sensitive/string_mask.go",
    "content": "package sensitive\n\nimport \"strings\"\n\nfunc maskString(s string) string {\n\tif s == \"\" {\n\t\treturn s\n\t}\n\treturn applyMatchers(s, maskBankCard, maskIdCard, maskPhone, maskEmail)\n}\n\nfunc maskValue(v interface{}) interface{} {\n\tswitch val := v.(type) {\n\tcase string:\n\t\tif val == \"\" {\n\t\t\treturn val\n\t\t}\n\t\treturn maskSensitiveString(val)\n\tcase map[string]interface{}:\n\t\treturn maskMap(val, getCommonFields())\n\tcase []interface{}:\n\t\treturn maskArrayWithFields(val, getCommonFields())\n\tdefault:\n\t\treturn v\n\t}\n}\n\nfunc maskSensitiveString(s string) string {\n\tif s == \"\" {\n\t\treturn s\n\t}\n\n\tif prefix, ok := authPrefix(s); ok {\n\t\treturn maskAuthToken(s, prefix)\n\t}\n\n\tmasked := applyMatchers(s, maskBankCard, maskIdCard, maskPhone, maskEmail)\n\tif masked != s {\n\t\treturn masked\n\t}\n\treturn maskDefault(s)\n}\n\nfunc authPrefix(s string) (string, bool) {\n\tswitch sLower := strings.ToLower(s); {\n\tcase strings.HasPrefix(s, \"eyJ\"):\n\t\treturn \"\", false\n\tcase strings.HasPrefix(sLower, \"bearer \"):\n\t\treturn \"bearer \", true\n\tcase strings.HasPrefix(sLower, \"basic \"):\n\t\treturn \"basic \", true\n\tdefault:\n\t\treturn \"\", false\n\t}\n}\n\nfunc applyMatchers(s string, replacers ...func(string) string) string {\n\tresult := s\n\tif bankCardRegex.MatchString(result) {\n\t\tresult = bankCardRegex.ReplaceAllStringFunc(result, replacers[0])\n\t}\n\tif idCardRegex.MatchString(result) {\n\t\tresult = idCardRegex.ReplaceAllStringFunc(result, replacers[1])\n\t}\n\tif phoneRegex.MatchString(result) {\n\t\tresult = phoneRegex.ReplaceAllStringFunc(result, replacers[2])\n\t}\n\tif emailRegex.MatchString(result) {\n\t\tresult = emailRegex.ReplaceAllStringFunc(result, replacers[3])\n\t}\n\treturn result\n}\n\nfunc maskAuthToken(s, prefix string) string {\n\tif strings.HasPrefix(strings.ToLower(s), prefix) {\n\t\ttokenPart := s[len(prefix):]\n\t\treturn prefix + maskToken(tokenPart)\n\t}\n\tparts := strings.SplitN(s, \" \", 2)\n\tif len(parts) == 2 {\n\t\treturn parts[0] + \" \" + maskToken(parts[1])\n\t}\n\treturn maskDefault(s)\n}\n\nfunc isSensitiveField(fieldName string, sensitiveFields map[string]bool) bool {\n\tif sensitiveFields[fieldName] {\n\t\treturn true\n\t}\n\tif len(fieldName) < 3 {\n\t\treturn false\n\t}\n\tfor keyword := range sensitiveFields {\n\t\tif len(keyword) <= len(fieldName) && strings.Contains(fieldName, keyword) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc maskToken(token string) string {\n\tlength := len(token)\n\tif length <= maskTokenPrefixLen+maskTokenSuffixLen {\n\t\treturn strings.Repeat(\"*\", length)\n\t}\n\treturn token[:maskTokenPrefixLen] + \"***\" + token[length-maskTokenSuffixLen:]\n}\n\nfunc maskPhone(phone string) string {\n\tif len(phone) != 11 {\n\t\treturn maskDefault(phone)\n\t}\n\treturn phone[:maskPhonePrefixLen] + \"****\" + phone[11-maskPhoneSuffixLen:]\n}\n\nfunc maskEmail(email string) string {\n\tidx := strings.IndexByte(email, '@')\n\tif idx == -1 || idx == 0 {\n\t\treturn maskDefault(email)\n\t}\n\n\tlocalPart := email[:idx]\n\tdomain := email[idx:]\n\tif len(localPart) <= maskEmailPrefixLen {\n\t\treturn strings.Repeat(\"*\", len(localPart)) + domain\n\t}\n\treturn localPart[:maskEmailPrefixLen] + \"***\" + domain\n}\n\nfunc maskIdCard(idCard string) string {\n\tswitch len(idCard) {\n\tcase 15:\n\t\treturn idCard[:maskIdCardPrefixLen] + \"******\" + idCard[15-maskIdCardSuffixLen:]\n\tcase 18:\n\t\treturn idCard[:maskIdCardPrefixLen] + \"********\" + idCard[18-maskIdCardSuffixLen:]\n\tdefault:\n\t\treturn maskDefault(idCard)\n\t}\n}\n\nfunc maskBankCard(cardNo string) string {\n\tlength := len(cardNo)\n\tif length < maskBankCardPrefixLen+maskBankCardSuffixLen {\n\t\treturn maskDefault(cardNo)\n\t}\n\tmaskLen := length - maskBankCardPrefixLen - maskBankCardSuffixLen\n\treturn cardNo[:maskBankCardPrefixLen] + strings.Repeat(\"*\", maskLen) + cardNo[length-maskBankCardSuffixLen:]\n}\n\nfunc maskDefault(s string) string {\n\tlength := len(s)\n\tif length <= maskDefaultPrefixLen+maskDefaultSuffixLen {\n\t\treturn strings.Repeat(\"*\", length)\n\t}\n\tmaskLen := length - maskDefaultPrefixLen - maskDefaultSuffixLen\n\treturn s[:maskDefaultPrefixLen] + strings.Repeat(\"*\", maskLen) + s[length-maskDefaultSuffixLen:]\n}\n"
  },
  {
    "path": "internal/pkg/utils/token/jwt.go",
    "content": "package token\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/google/uuid\"\n\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\n// AdminUserInfo 是写入 JWT 的管理员基础信息。\ntype AdminUserInfo struct {\n\t// 可根据需要自行添加字段\n\tUserID          uint   `json:\"user_id\"`\n\tUsername        string `json:\"username\"`\n\tFullPhoneNumber string `json:\"full_phone_number\"`\n\tEmail           string `json:\"email\"`\n\tNickname        string `json:\"nickname\"`\n\tPhoneNumber     string `json:\"phone_number\"`\n\tCountryCode     string `json:\"country_code\"`\n\tIsSuperAdmin    uint8  `json:\"is_super_admin\"`\n}\n\n// Generate 生成JWT Token\nfunc Generate(claims jwt.Claims) (string, error) {\n\ttoken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)\n\tcfg := c.GetConfig()\n\n\t// 生成签名字符串\n\ttokenStr, err := token.SignedString([]byte(cfg.Jwt.SecretKey))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn tokenStr, nil\n}\n\n// Refresh 刷新JWT Token\nfunc Refresh(claims jwt.Claims) (string, error) {\n\treturn Generate(claims)\n}\n\n// Parse 解析token\nfunc Parse(accessToken string, claims jwt.Claims, options ...jwt.ParserOption) error {\n\tcfg := c.GetConfig()\n\ttoken, err := jwt.ParseWithClaims(accessToken, claims, func(token *jwt.Token) (i interface{}, err error) {\n\t\tif _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {\n\t\t\treturn nil, fmt.Errorf(\"unexpected signing method: %v\", token.Header[\"alg\"])\n\t\t}\n\t\treturn []byte(cfg.Jwt.SecretKey), nil\n\t}, options...)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif !token.Valid {\n\t\treturn e.NewBusinessError(e.NotLogin)\n\t}\n\n\treturn nil\n}\n\n// GetAccessToken 获取jwt的Token\nfunc GetAccessToken(authorization string) (accessToken string, err error) {\n\tif authorization == \"\" {\n\t\treturn \"\", errors.New(\"authorization header is missing\")\n\t}\n\n\t// 检查 Authorization 头的格式\n\tif !strings.HasPrefix(authorization, \"Bearer \") {\n\t\treturn \"\", errors.New(\"invalid Authorization header format\")\n\t}\n\n\t// 提取 Token 的值\n\taccessToken = strings.TrimPrefix(authorization, \"Bearer \")\n\treturn\n}\n\n// AdminCustomClaims 自定义格式内容\ntype AdminCustomClaims struct {\n\tAdminUserInfo\n\tjwt.RegisteredClaims // 内嵌标准的声明\n}\n\n// NewAdminCustomClaims 初始化AdminCustomClaims\nfunc NewAdminCustomClaims(user *model.AdminUser) AdminCustomClaims {\n\tcfg := c.GetConfig()\n\tnow := time.Now().UTC()\n\texpiresAt := now.Add(time.Second * cfg.Jwt.TTL)\n\t// phoneRule := &utils.DesensitizeRule{KeepPrefixLen: 3, KeepSuffixLen: 4, MaskChar: '*'}\n\t// emailRule := &utils.DesensitizeRule{KeepPrefixLen: 2, KeepSuffixLen: 0, MaskChar: '*', Separator: '@', FixedMaskLength: 3}\n\treturn AdminCustomClaims{\n\t\tAdminUserInfo: AdminUserInfo{\n\t\t\tUserID:          user.ID,\n\t\t\tUsername:        user.Username,\n\t\t\tFullPhoneNumber: user.FullPhoneNumber, // phoneRule.Apply(user.Mobile),\n\t\t\tPhoneNumber:     user.PhoneNumber,     // phoneRule.Apply(user.Mobile),\n\t\t\tCountryCode:     user.CountryCode,     // phoneRule.Apply(user.Mobile),\n\t\t\tEmail:           user.Email,           // emailRule.Apply(user.Email),\n\t\t\tNickname:        user.Nickname,\n\t\t\tIsSuperAdmin:    user.IsSuperAdmin,\n\t\t},\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tExpiresAt: jwt.NewNumericDate(expiresAt), // 定义过期时间\n\t\t\tIssuer:    global.Issuer,                 // 签发人\n\t\t\tIssuedAt:  jwt.NewNumericDate(now),       // 签发时间\n\t\t\tSubject:   global.PcAdminSubject,         // 签发主题\n\t\t\tNotBefore: jwt.NewNumericDate(now),       // 生效时间\n\t\t\tID:        uuid.New().String(),           // 唯一标识\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/utils/token/jwt_test.go",
    "content": "package token\n\nimport (\n\t\"testing\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n)\n\nfunc TestGenerate(t *testing.T) {\n\tclaims := jwt.MapClaims{\n\t\t\"Id\": 1,\n\t}\n\t_, err := Generate(claims)\n\tif err != nil {\n\t\tt.Error(\"生成Token失败\")\n\t}\n}\n\nfunc TestParse(t *testing.T) {\n\ttokenString := \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJZCI6MX0.JGVOAsonk7CoOaTS-b6dW86LLEOt8Z6kHhsFxIvqaCE\"\n\tclaims := jwt.MapClaims{}\n\terr := Parse(tokenString, claims)\n\tif err != nil {\n\t\tt.Error(\"解析Token失败\")\n\t}\n}\n\nfunc TestGetAccessToken(t *testing.T) {\n\tauthorization := \"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJZCI6MX0.JGVOAsonk7CoOaTS-b6dW86LLEOt8Z6kHhsFxIvqaCE\"\n\n\t_, err := GetAccessToken(authorization)\n\tif err != nil {\n\t\tt.Error(\"获取Token失败\")\n\t}\n}\n"
  },
  {
    "path": "internal/pkg/utils/utils.go",
    "content": "package utils\n\nimport (\n\t\"math/rand\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/samber/lo\"\n)\n\n// CalculateChanges 计算差集 （一次性获取删除、新增和剩余列表）\n// 计算交集\n// 合并差集和交集\n// 示例：\n//\n//\texistingIds := []int{1, 2, 3, 4, 5}\n//\tids := []int{2, 3, 6, 7}\n//\ttoDelete, toAdd, remainingList := CalculateChanges(existingIds, ids)\n//\tfmt.Println(\"toDelete:\", toDelete)\n//\tfmt.Println(\"toAdd:\", toAdd)\n//\tfmt.Println(\"remainingList:\", remainingList)\n//\n// 输出：\n// toDelete: [1 4 5]\n// toAdd: [6 7]\n// remainingList: [2 3 6 7]\nfunc CalculateChanges[T comparable](existingIds, ids []T) (toDelete, toAdd, remainingList []T) {\n\t// 2. 计算差集（一次性获取删除和新增列表）\n\ttoDelete, toAdd = lo.Difference(existingIds, lo.Uniq(ids))\n\n\t// 2. 计算交集\n\tintersection := lo.Intersect(ids, existingIds)\n\n\t// 3. 合并差集和交集\n\tremainingList = lo.Union(intersection, toAdd)\n\treturn\n}\n\n// RandString 生成随机字符串\nfunc RandString(n int) string {\n\tletterBytes := []byte(\"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\")\n\tvar src = rand.NewSource(time.Now().UnixNano())\n\n\tconst (\n\t\tletterIdxBits = 6\n\t\tletterIdxMask = 1<<letterIdxBits - 1\n\t\tletterIdxMax  = 63 / letterIdxBits\n\t)\n\tb := make([]byte, n)\n\tfor i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {\n\t\tif remain == 0 {\n\t\t\tcache, remain = src.Int63(), letterIdxMax\n\t\t}\n\t\tif idx := int(cache & letterIdxMask); idx < len(letterBytes) {\n\t\t\tb[i] = letterBytes[idx]\n\t\t\ti--\n\t\t}\n\t\tcache >>= letterIdxBits\n\t\tremain--\n\t}\n\treturn string(b)\n}\n\n// TrimPrefixAndSuffixAND 去除字符串前后的 AND（不区分大小写，忽略多余空白）\nfunc TrimPrefixAndSuffixAND(s string) string {\n\ts = strings.TrimSpace(s)\n\n\t// 正则匹配开头或结尾的 AND（忽略大小写和空白）\n\tre := regexp.MustCompile(`(?i)^(AND\\s+)|(\\s+AND)$`)\n\tfor {\n\t\ttrimmed := re.ReplaceAllString(s, \"\")\n\t\tif trimmed == s {\n\t\t\tbreak\n\t\t}\n\t\ts = strings.TrimSpace(trimmed)\n\t}\n\n\treturn s\n}\n"
  },
  {
    "path": "internal/pkg/utils/utils_test.go",
    "content": "package utils\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc TestCalculateChanges(t *testing.T) {\n\texistingIds := []int{1, 2, 3, 4, 5}\n\tids := []int{2, 3, 6, 7}\n\ttoDelete, toAdd, remainingList := CalculateChanges(existingIds, ids)\n\tfmt.Println(\"toDelete:\", toDelete)\n\tfmt.Println(\"toAdd:\", toAdd)\n\tfmt.Println(\"remainingList:\", remainingList)\n}\n\nfunc TestRandString(t *testing.T) {\n\ts := RandString(12)\n\tif s == \"\" {\n\t\tt.Error(\"获取运行路径失败\")\n\t}\n}\n\nfunc BenchmarkRandString(b *testing.B) {\n\t// 基准函数会运行目标代码b.N次。\n\tfor i := 0; i < b.N; i++ {\n\t\tRandString(12)\n\t}\n}\n\nfunc TestDesensitizeRule(b *testing.T) {\n\t// 手机号脱敏\n\tphoneRule := &DesensitizeRule{KeepPrefixLen: 3, KeepSuffixLen: 4, MaskChar: '*'}\n\tif phoneRule.Apply(\"13812345678\") != \"138****5678\" {\n\t\tb.Error(\"手机号码脱敏失败\")\n\t}\n\n\t// 邮箱脱敏\n\temailRule := &DesensitizeRule{KeepPrefixLen: 2, KeepSuffixLen: 0, MaskChar: '*', Separator: '@', FixedMaskLength: 3}\n\tif emailRule.Apply(\"test@example.com\") != \"te***@example.com\" {\n\t\tb.Error(\"邮箱脱敏失败\")\n\t}\n}\nfunc BenchmarkTrimPrefixAndSuffixAND(b *testing.B) {\n\tinput := \"   AND AND name = 'Tom' AND age = 18 AND  \"\n\tfor i := 0; i < b.N; i++ {\n\t\t_ = TrimPrefixAndSuffixAND(input)\n\t}\n}\n"
  },
  {
    "path": "internal/queue/asynqx/asynq.go",
    "content": "package asynqx\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/hibiken/asynq\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/taskcenter\"\n)\n\nfunc init() {\n\tqueue.RegisterPublisherFactory(NewPublisher)\n\tqueue.RegisterInspectorFactory(NewInspector)\n}\n\ntype publisher struct {\n\tclient    *asynq.Client\n\tnamespace string\n}\n\ntype inspector struct {\n\traw       *asynq.Inspector\n\tnamespace string\n}\n\n// NewPublisher 创建 Asynq publisher。\nfunc NewPublisher(cfg *config.Conf) (queue.Publisher, error) {\n\tif cfg == nil {\n\t\tcfg = config.GetConfig()\n\t}\n\tif cfg == nil || !cfg.Queue.Enable {\n\t\treturn nil, nil\n\t}\n\n\tredisOpt, err := newRedisConnOpt(cfg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tclient := asynq.NewClient(redisOpt)\n\treturn &publisher{\n\t\tclient:    client,\n\t\tnamespace: strings.TrimSpace(cfg.Queue.Namespace),\n\t}, nil\n}\n\n// NewInspector 创建 Asynq inspector。\nfunc NewInspector(cfg *config.Conf) (queue.Inspector, error) {\n\tif cfg == nil {\n\t\tcfg = config.GetConfig()\n\t}\n\tif cfg == nil || !cfg.Queue.Enable {\n\t\treturn nil, nil\n\t}\n\n\tredisOpt, err := newRedisConnOpt(cfg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &inspector{\n\t\traw:       asynq.NewInspector(redisOpt),\n\t\tnamespace: strings.TrimSpace(cfg.Queue.Namespace),\n\t}, nil\n}\n\nfunc (p *publisher) Enqueue(ctx context.Context, job queue.Job) (queue.JobInfo, error) {\n\tif job == nil {\n\t\treturn queue.JobInfo{}, errors.New(\"queue job is nil\")\n\t}\n\tpayload, err := job.Payload()\n\tif err != nil {\n\t\treturn queue.JobInfo{}, err\n\t}\n\ttask := asynq.NewTask(job.Type(), payload)\n\n\toptions := make([]asynq.Option, 0, len(job.Options())+1)\n\toptions = append(options, asynq.Queue(prefixedQueueName(p.namespace, job.Queue())))\n\toptions = append(options, mapOptions(p.namespace, job.Options())...)\n\n\tinfo, err := p.client.EnqueueContext(ctx, task, options...)\n\tif err != nil {\n\t\treturn queue.JobInfo{}, err\n\t}\n\treturn queue.JobInfo{\n\t\tID:    info.ID,\n\t\tQueue: unprefixQueueName(p.namespace, info.Queue),\n\t\tType:  info.Type,\n\t}, nil\n}\n\nfunc (i *inspector) DeleteTask(ctx context.Context, queueName, taskID string) error {\n\t_ = ctx\n\tif i == nil || i.raw == nil {\n\t\treturn queue.ErrInspectorUnavailable\n\t}\n\treturn normalizeInspectorError(i.raw.DeleteTask(prefixedQueueName(i.namespace, queueName), taskID))\n}\n\nfunc (i *inspector) CancelProcessing(ctx context.Context, taskID string) error {\n\t_ = ctx\n\tif i == nil || i.raw == nil {\n\t\treturn queue.ErrInspectorUnavailable\n\t}\n\treturn normalizeInspectorError(i.raw.CancelProcessing(taskID))\n}\n\n// NewServer 创建 Asynq worker server 和 mux。\nfunc NewServer(cfg *config.Conf, registry queue.Registry) (*asynq.Server, *asynq.ServeMux, error) {\n\tif cfg == nil {\n\t\tcfg = config.GetConfig()\n\t}\n\tif cfg == nil {\n\t\treturn nil, nil, errors.New(\"queue config is nil\")\n\t}\n\tif registry == nil {\n\t\treturn nil, nil, errors.New(\"queue registry is nil\")\n\t}\n\n\tredisOpt, err := newRedisConnOpt(cfg)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tserver := asynq.NewServer(redisOpt, asynq.Config{\n\t\tConcurrency:    cfg.Queue.Concurrency,\n\t\tQueues:         prefixedQueues(cfg.Queue.Namespace, cfg.Queue.Queues),\n\t\tStrictPriority: cfg.Queue.StrictPriority,\n\t\tErrorHandler: asynq.ErrorHandlerFunc(func(ctx context.Context, task *asynq.Task, err error) {\n\t\t\tlog.Warn(\"Asynq task failed\",\n\t\t\t\tzap.String(\"task_type\", task.Type()),\n\t\t\t\tzap.Error(err))\n\t\t}),\n\t})\n\n\tmux := asynq.NewServeMux()\n\tfor _, entry := range registry.Entries() {\n\t\tentry := entry\n\t\tmux.HandleFunc(entry.TaskType, func(ctx context.Context, task *asynq.Task) error {\n\t\t\trun := recordAsynqTaskStart(ctx, task)\n\t\t\terr := entry.Handler(ctx, task.Payload())\n\t\t\trecordAsynqTaskFinish(ctx, run, err)\n\t\t\tif err == nil {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif errors.Is(err, queue.ErrSkipRetry) {\n\t\t\t\treturn fmt.Errorf(\"%w: %w\", err, asynq.SkipRetry)\n\t\t\t}\n\t\t\treturn err\n\t\t})\n\t}\n\treturn server, mux, nil\n}\n\nfunc recordAsynqTaskStart(ctx context.Context, task *asynq.Task) *model.TaskRun {\n\tif task == nil {\n\t\treturn nil\n\t}\n\n\ttaskID, _ := asynq.GetTaskID(ctx)\n\tretryCount, _ := asynq.GetRetryCount(ctx)\n\tmaxRetry, _ := asynq.GetMaxRetry(ctx)\n\tqueueName, _ := asynq.GetQueueName(ctx)\n\n\trecorder := taskcenter.NewRunRecorder()\n\trun, err := recorder.Start(ctx, taskcenter.RunStart{\n\t\tTaskCode: task.Type(),\n\t\tKind:     model.TaskKindAsync,\n\t\tSource:   model.TaskSourceQueue,\n\t\tSourceID: taskID,\n\t\tQueue:    unprefixQueueName(currentQueueNamespace(), queueName),\n\t\tAttempt:  retryCount + 1,\n\t\tMaxRetry: maxRetry,\n\t\tPayload:  task.Payload(),\n\t})\n\tif err != nil {\n\t\tlog.Warn(\"Record async task start failed\",\n\t\t\tzap.String(\"task_type\", task.Type()),\n\t\t\tzap.String(\"task_id\", taskID),\n\t\t\tzap.Error(err))\n\t\treturn nil\n\t}\n\treturn run\n}\n\nfunc recordAsynqTaskFinish(ctx context.Context, run *model.TaskRun, taskErr error) {\n\tif run == nil {\n\t\treturn\n\t}\n\tif err := taskcenter.NewRunRecorder().Finish(ctx, run, taskcenter.RunFinish{Error: taskErr}); err != nil {\n\t\tlog.Warn(\"Record async task finish failed\",\n\t\t\tzap.String(\"task_type\", run.TaskCode),\n\t\t\tzap.String(\"task_id\", run.SourceID),\n\t\t\tzap.Error(err))\n\t}\n}\n\nfunc currentQueueNamespace() string {\n\tcfg := config.GetConfig()\n\tif cfg == nil {\n\t\treturn \"\"\n\t}\n\treturn cfg.Queue.Namespace\n}\n\nfunc newRedisConnOpt(cfg *config.Conf) (asynq.RedisClientOpt, error) {\n\tif cfg == nil {\n\t\treturn asynq.RedisClientOpt{}, errors.New(\"queue config is nil\")\n\t}\n\tif cfg.Queue.UseDefaultRedis {\n\t\tif !cfg.Redis.Enable {\n\t\t\treturn asynq.RedisClientOpt{}, errors.New(\"queue uses default redis, but redis.enable is false\")\n\t\t}\n\t\thost := strings.TrimSpace(cfg.Redis.Host)\n\t\tport := strings.TrimSpace(cfg.Redis.Port)\n\t\tif host == \"\" || port == \"\" {\n\t\t\treturn asynq.RedisClientOpt{}, errors.New(\"queue uses default redis, but redis host/port is empty\")\n\t\t}\n\t\treturn asynq.RedisClientOpt{\n\t\t\tAddr:     host + \":\" + port,\n\t\t\tPassword: cfg.Redis.Password,\n\t\t\tDB:       cfg.Redis.Database,\n\t\t}, nil\n\t}\n\n\thost := strings.TrimSpace(cfg.Queue.Redis.Host)\n\tport := strings.TrimSpace(cfg.Queue.Redis.Port)\n\tif host == \"\" || port == \"\" {\n\t\treturn asynq.RedisClientOpt{}, errors.New(\"queue.redis host/port is required when queue.use_default_redis is false\")\n\t}\n\n\treturn asynq.RedisClientOpt{\n\t\tAddr:     host + \":\" + port,\n\t\tPassword: cfg.Queue.Redis.Password,\n\t\tDB:       cfg.Queue.Redis.Database,\n\t}, nil\n}\n\nfunc normalizeInspectorError(err error) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\tif errors.Is(err, asynq.ErrQueueNotFound) {\n\t\treturn queue.ErrQueueNotFound\n\t}\n\tif errors.Is(err, asynq.ErrTaskNotFound) {\n\t\treturn queue.ErrTaskNotFound\n\t}\n\treturn err\n}\n\nfunc mapOptions(namespace string, options []queue.JobOption) []asynq.Option {\n\tmapped := make([]asynq.Option, 0, len(options))\n\tfor _, option := range options {\n\t\tswitch option.Type {\n\t\tcase queue.JobOptionMaxRetry:\n\t\t\tmapped = append(mapped, asynq.MaxRetry(option.IntValue))\n\t\tcase queue.JobOptionQueue:\n\t\t\tmapped = append(mapped, asynq.Queue(prefixedQueueName(namespace, option.StringValue)))\n\t\tcase queue.JobOptionTimeout:\n\t\t\tmapped = append(mapped, asynq.Timeout(option.DurationValue))\n\t\tcase queue.JobOptionRetention:\n\t\t\tmapped = append(mapped, asynq.Retention(option.DurationValue))\n\t\tcase queue.JobOptionTaskID:\n\t\t\tmapped = append(mapped, asynq.TaskID(option.StringValue))\n\t\t}\n\t}\n\treturn mapped\n}\n\nfunc prefixedQueues(namespace string, queues map[string]int) map[string]int {\n\tif len(queues) == 0 {\n\t\treturn map[string]int{\"default\": 1}\n\t}\n\n\tprefixed := make(map[string]int, len(queues))\n\tfor name, priority := range queues {\n\t\tprefixed[prefixedQueueName(namespace, name)] = priority\n\t}\n\treturn prefixed\n}\n\nfunc prefixedQueueName(namespace, name string) string {\n\ttrimmedName := strings.TrimSpace(name)\n\tif trimmedName == \"\" {\n\t\ttrimmedName = \"default\"\n\t}\n\tnamespace = strings.TrimSpace(namespace)\n\tif namespace == \"\" {\n\t\treturn trimmedName\n\t}\n\treturn namespace + \":\" + trimmedName\n}\n\nfunc unprefixQueueName(namespace, name string) string {\n\tnamespace = strings.TrimSpace(namespace)\n\tif namespace == \"\" {\n\t\treturn name\n\t}\n\n\tprefix := namespace + \":\"\n\tif strings.HasPrefix(name, prefix) {\n\t\treturn strings.TrimPrefix(name, prefix)\n\t}\n\treturn name\n}\n"
  },
  {
    "path": "internal/queue/asynqx/asynq_test.go",
    "content": "package asynqx\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/hibiken/asynq\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/config/autoload\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n)\n\nfunc TestPrefixedQueueName(t *testing.T) {\n\tif got := prefixedQueueName(\"go_layout\", \"audit\"); got != \"go_layout:audit\" {\n\t\tt.Fatalf(\"unexpected prefixed queue: %s\", got)\n\t}\n\tif got := unprefixQueueName(\"go_layout\", \"go_layout:audit\"); got != \"audit\" {\n\t\tt.Fatalf(\"unexpected unprefixed queue: %s\", got)\n\t}\n\tif got := prefixedQueueName(\"\", \"audit\"); got != \"audit\" {\n\t\tt.Fatalf(\"unexpected queue without namespace: %s\", got)\n\t}\n}\n\nfunc TestMapOptions(t *testing.T) {\n\toptions := mapOptions(\"go_layout\", []queue.JobOption{\n\t\tqueue.WithMaxRetry(3),\n\t\tqueue.WithQueue(\"audit\"),\n\t\tqueue.WithTimeout(10 * time.Second),\n\t\tqueue.WithTaskID(\"task-1\"),\n\t})\n\tif len(options) != 4 {\n\t\tt.Fatalf(\"expected 4 options, got %d\", len(options))\n\t}\n\n\tassertOption := func(index int, wantType asynq.OptionType, wantValue any) {\n\t\tif options[index].Type() != wantType {\n\t\t\tt.Fatalf(\"option %d type mismatch: got %v want %v\", index, options[index].Type(), wantType)\n\t\t}\n\t\tif options[index].Value() != wantValue {\n\t\t\tt.Fatalf(\"option %d value mismatch: got %#v want %#v\", index, options[index].Value(), wantValue)\n\t\t}\n\t}\n\n\tassertOption(0, asynq.MaxRetryOpt, 3)\n\tassertOption(1, asynq.QueueOpt, \"go_layout:audit\")\n\tassertOption(2, asynq.TimeoutOpt, 10*time.Second)\n\tassertOption(3, asynq.TaskIDOpt, \"task-1\")\n}\n\nfunc TestNewRedisConnOptUsesDefaultRedis(t *testing.T) {\n\tcfg := &config.Conf{\n\t\tRedis: autoload.RedisConfig{\n\t\t\tEnable:   true,\n\t\t\tHost:     \"127.0.0.1\",\n\t\t\tPort:     \"6380\",\n\t\t\tPassword: \"default-pass\",\n\t\t\tDatabase: 3,\n\t\t},\n\t\tQueue: autoload.QueueConfig{\n\t\t\tEnable:          true,\n\t\t\tUseDefaultRedis: true,\n\t\t},\n\t}\n\n\topt, err := newRedisConnOpt(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif opt.Addr != \"127.0.0.1:6380\" {\n\t\tt.Fatalf(\"unexpected addr: %s\", opt.Addr)\n\t}\n\tif opt.Password != \"default-pass\" || opt.DB != 3 {\n\t\tt.Fatalf(\"unexpected redis option: %+v\", opt)\n\t}\n}\n\nfunc TestNewRedisConnOptUsesQueueRedis(t *testing.T) {\n\tcfg := &config.Conf{\n\t\tRedis: autoload.RedisConfig{\n\t\t\tEnable: false,\n\t\t},\n\t\tQueue: autoload.QueueConfig{\n\t\t\tEnable:          true,\n\t\t\tUseDefaultRedis: false,\n\t\t\tRedis: autoload.QueueRedisConfig{\n\t\t\t\tHost:     \"10.0.0.8\",\n\t\t\t\tPort:     \"6381\",\n\t\t\t\tPassword: \"queue-pass\",\n\t\t\t\tDatabase: 6,\n\t\t\t},\n\t\t},\n\t}\n\n\topt, err := newRedisConnOpt(cfg)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif opt.Addr != \"10.0.0.8:6381\" {\n\t\tt.Fatalf(\"unexpected addr: %s\", opt.Addr)\n\t}\n\tif opt.Password != \"queue-pass\" || opt.DB != 6 {\n\t\tt.Fatalf(\"unexpected redis option: %+v\", opt)\n\t}\n}\n\nfunc TestNewRedisConnOptReturnsErrorWhenDefaultRedisDisabled(t *testing.T) {\n\tcfg := &config.Conf{\n\t\tRedis: autoload.RedisConfig{\n\t\t\tEnable: false,\n\t\t\tHost:   \"127.0.0.1\",\n\t\t\tPort:   \"6379\",\n\t\t},\n\t\tQueue: autoload.QueueConfig{\n\t\t\tEnable:          true,\n\t\t\tUseDefaultRedis: true,\n\t\t},\n\t}\n\n\tif _, err := newRedisConnOpt(cfg); err == nil {\n\t\tt.Fatal(\"expected error when default redis is disabled\")\n\t}\n}\n"
  },
  {
    "path": "internal/queue/asynqx/inspector_test.go",
    "content": "package asynqx\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/hibiken/asynq\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n)\n\nfunc TestNormalizeInspectorError(t *testing.T) {\n\tcases := []struct {\n\t\tname string\n\t\tin   error\n\t\twant error\n\t}{\n\t\t{name: \"queue not found\", in: asynq.ErrQueueNotFound, want: queue.ErrQueueNotFound},\n\t\t{name: \"task not found\", in: asynq.ErrTaskNotFound, want: queue.ErrTaskNotFound},\n\t\t{name: \"other error\", in: errors.New(\"boom\"), want: errors.New(\"boom\")},\n\t}\n\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgot := normalizeInspectorError(tc.in)\n\t\t\tif tc.want == nil {\n\t\t\t\tif got != nil {\n\t\t\t\t\tt.Fatalf(\"expected nil, got %v\", got)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif tc.name == \"other error\" {\n\t\t\t\tif got == nil || got.Error() != \"boom\" {\n\t\t\t\t\tt.Fatalf(\"unexpected error: %v\", got)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif !errors.Is(got, tc.want) {\n\t\t\t\tt.Fatalf(\"expected %v, got %v\", tc.want, got)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/queue/asynqx/task_record_test.go",
    "content": "package asynqx\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/hibiken/asynq\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/taskcenter\"\n)\n\nfunc TestRecordAsynqTaskStartAndFinish(t *testing.T) {\n\tfake := &fakeRunRecorder{}\n\trestore := taskcenter.SetRecorderForTesting(fake)\n\tdefer restore()\n\n\ttask := asynq.NewTask(\"demo:send\", []byte(`{\"name\":\"codex\"}`))\n\trun := recordAsynqTaskStart(context.Background(), task)\n\tif run == nil {\n\t\tt.Fatal(\"expected run to be returned\")\n\t}\n\trecordAsynqTaskFinish(context.Background(), run, errors.New(\"boom\"))\n\n\tif len(fake.starts) != 1 {\n\t\tt.Fatalf(\"expected 1 start call, got %d\", len(fake.starts))\n\t}\n\tstart := fake.starts[0]\n\tif start.TaskCode != \"demo:send\" || start.Kind != model.TaskKindAsync || start.Source != model.TaskSourceQueue {\n\t\tt.Fatalf(\"unexpected start input: %#v\", start)\n\t}\n\tif string(start.Payload) != `{\"name\":\"codex\"}` {\n\t\tt.Fatalf(\"unexpected payload: %s\", string(start.Payload))\n\t}\n\tif len(fake.finishes) != 1 {\n\t\tt.Fatalf(\"expected 1 finish call, got %d\", len(fake.finishes))\n\t}\n\tif fake.finishes[0].Error == nil || fake.finishes[0].Error.Error() != \"boom\" {\n\t\tt.Fatalf(\"unexpected finish input: %#v\", fake.finishes[0])\n\t}\n}\n\ntype fakeRunRecorder struct {\n\tstarts   []taskcenter.RunStart\n\tfinishes []taskcenter.RunFinish\n}\n\nfunc (f *fakeRunRecorder) Enqueue(ctx context.Context, input taskcenter.RunStart) (*model.TaskRun, error) {\n\t_ = ctx\n\tf.starts = append(f.starts, input)\n\treturn &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.starts))}, TaskCode: input.TaskCode, Source: input.Source, SourceID: input.SourceID}, nil\n}\n\nfunc (f *fakeRunRecorder) Start(ctx context.Context, input taskcenter.RunStart) (*model.TaskRun, error) {\n\t_ = ctx\n\tf.starts = append(f.starts, input)\n\treturn &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.starts))}, TaskCode: input.TaskCode, Source: input.Source, SourceID: input.SourceID}, nil\n}\n\nfunc (f *fakeRunRecorder) Finish(ctx context.Context, run *model.TaskRun, input taskcenter.RunFinish) error {\n\t_ = ctx\n\t_ = run\n\tf.finishes = append(f.finishes, input)\n\treturn nil\n}\n"
  },
  {
    "path": "internal/queue/queue.go",
    "content": "package queue\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n)\n\nvar (\n\tErrPublisherUnavailable = errors.New(\"queue publisher unavailable\")\n\tErrInspectorUnavailable = errors.New(\"queue inspector unavailable\")\n\tErrQueueNotFound        = errors.New(\"queue not found\")\n\tErrTaskNotFound         = errors.New(\"queue task not found\")\n\tErrSkipRetry            = errors.New(\"queue skip retry\")\n)\n\nconst DefaultQueue = \"default\"\n\n// Job 表示一个可发布的异步任务。\ntype Job interface {\n\tType() string\n\tQueue() string\n\tPayload() ([]byte, error)\n\tOptions() []JobOption\n}\n\n// JobInfo 表示任务发布后的基础元信息。\ntype JobInfo struct {\n\tID    string\n\tQueue string\n\tType  string\n}\n\n// Publisher 负责发布任务。\ntype Publisher interface {\n\tEnqueue(ctx context.Context, job Job) (JobInfo, error)\n}\n\n// Inspector 负责对已入队任务执行控制操作。\ntype Inspector interface {\n\tDeleteTask(ctx context.Context, queueName, taskID string) error\n\tCancelProcessing(ctx context.Context, taskID string) error\n}\n\n// Handler 负责消费任务 payload。\ntype Handler func(ctx context.Context, payload []byte) error\n\n// Registry 保存任务类型到 handler 的映射。\ntype Registry interface {\n\tRegister(taskType string, handler Handler)\n\tEntries() []Registration\n}\n\n// Registration 描述一个已注册的任务处理器。\ntype Registration struct {\n\tTaskType string\n\tHandler  Handler\n}\n\n// Validatable 表示 payload 支持自校验。\ntype Validatable interface {\n\tValidate() error\n}\n\n// JobOptionType 表示任务选项类型。\ntype JobOptionType string\n\nconst (\n\tJobOptionMaxRetry  JobOptionType = \"max_retry\"\n\tJobOptionQueue     JobOptionType = \"queue\"\n\tJobOptionTimeout   JobOptionType = \"timeout\"\n\tJobOptionRetention JobOptionType = \"retention\"\n\tJobOptionTaskID    JobOptionType = \"task_id\"\n)\n\n// JobOption 表示项目内统一的任务选项。\ntype JobOption struct {\n\tType          JobOptionType\n\tIntValue      int\n\tStringValue   string\n\tDurationValue time.Duration\n}\n\ntype jsonJob struct {\n\ttaskType  string\n\tqueueName string\n\tpayload   any\n\toptions   []JobOption\n}\n\ntype skipRetryError struct {\n\terr error\n}\n\nfunc WithMaxRetry(n int) JobOption {\n\treturn JobOption{Type: JobOptionMaxRetry, IntValue: n}\n}\n\nfunc WithQueue(name string) JobOption {\n\treturn JobOption{Type: JobOptionQueue, StringValue: name}\n}\n\nfunc WithTimeout(timeout time.Duration) JobOption {\n\treturn JobOption{Type: JobOptionTimeout, DurationValue: timeout}\n}\n\nfunc WithRetention(retention time.Duration) JobOption {\n\treturn JobOption{Type: JobOptionRetention, DurationValue: retention}\n}\n\nfunc WithTaskID(taskID string) JobOption {\n\treturn JobOption{Type: JobOptionTaskID, StringValue: taskID}\n}\n\n// NewJSONJob 创建一个基于 JSON payload 的通用任务。\nfunc NewJSONJob(taskType, queueName string, payload any, opts ...JobOption) Job {\n\tif queueName == \"\" {\n\t\tqueueName = DefaultQueue\n\t}\n\treturn &jsonJob{\n\t\ttaskType:  taskType,\n\t\tqueueName: queueName,\n\t\tpayload:   payload,\n\t\toptions:   opts,\n\t}\n}\n\n// Publish 使用全局 publisher 发布任务。\nfunc Publish(ctx context.Context, job Job) (JobInfo, error) {\n\tpublisher := PublisherOrNil()\n\tif publisher == nil {\n\t\treturn JobInfo{}, ErrPublisherUnavailable\n\t}\n\treturn publisher.Enqueue(ctx, job)\n}\n\n// PublishJSON 发布一个 JSON 任务。\nfunc PublishJSON(ctx context.Context, taskType, queueName string, payload any, opts ...JobOption) (JobInfo, error) {\n\treturn Publish(ctx, NewJSONJob(taskType, queueName, payload, opts...))\n}\n\n// RegisterJSON 注册一个基于 JSON payload 的处理器。\nfunc RegisterJSON[T any](registry Registry, taskType string, handler func(ctx context.Context, payload T) error) {\n\tif registry == nil || taskType == \"\" || handler == nil {\n\t\treturn\n\t}\n\tregistry.Register(taskType, func(ctx context.Context, raw []byte) error {\n\t\tvar payload T\n\t\tif err := json.Unmarshal(raw, &payload); err != nil {\n\t\t\treturn SkipRetry(fmt.Errorf(\"decode %s payload failed: %w\", taskType, err))\n\t\t}\n\t\tif err := validatePayload(payload); err != nil {\n\t\t\treturn SkipRetry(fmt.Errorf(\"invalid %s payload: %w\", taskType, err))\n\t\t}\n\t\treturn handler(ctx, payload)\n\t})\n}\n\n// SkipRetry 标记任务错误为不再重试。\nfunc SkipRetry(err error) error {\n\treturn &skipRetryError{err: err}\n}\n\nfunc (j *jsonJob) Type() string {\n\treturn j.taskType\n}\n\nfunc (j *jsonJob) Queue() string {\n\tif j.queueName == \"\" {\n\t\treturn DefaultQueue\n\t}\n\treturn j.queueName\n}\n\nfunc (j *jsonJob) Payload() ([]byte, error) {\n\treturn json.Marshal(j.payload)\n}\n\nfunc (j *jsonJob) Options() []JobOption {\n\treturn append([]JobOption(nil), j.options...)\n}\n\nfunc (e *skipRetryError) Error() string {\n\tif e == nil || e.err == nil {\n\t\treturn ErrSkipRetry.Error()\n\t}\n\treturn e.err.Error()\n}\n\nfunc (e *skipRetryError) Unwrap() error {\n\tif e == nil {\n\t\treturn nil\n\t}\n\treturn e.err\n}\n\nfunc (e *skipRetryError) Is(target error) bool {\n\treturn target == ErrSkipRetry\n}\n\ntype publisherFactory func(cfg *config.Conf) (Publisher, error)\ntype inspectorFactory func(cfg *config.Conf) (Inspector, error)\n\nvar (\n\tpublisherMu      sync.RWMutex\n\tactivePublisher  Publisher\n\tactivePublisherF publisherFactory\n\tactivePublisherE error\n\n\tinspectorMu      sync.RWMutex\n\tactiveInspector  Inspector\n\tactiveInspectorF inspectorFactory\n\tactiveInspectorE error\n)\n\n// RegisterPublisherFactory 注册默认的 publisher 构建器。\nfunc RegisterPublisherFactory(factory func(cfg *config.Conf) (Publisher, error)) {\n\tpublisherMu.Lock()\n\tdefer publisherMu.Unlock()\n\tactivePublisherF = factory\n}\n\n// InitPublisher 根据当前配置初始化全局 publisher。\nfunc InitPublisher(cfg *config.Conf) error {\n\tpublisherMu.Lock()\n\tdefer publisherMu.Unlock()\n\n\tif cfg == nil || !cfg.Queue.Enable {\n\t\tactivePublisher = nil\n\t\tactivePublisherE = nil\n\t\treturn nil\n\t}\n\tif activePublisherF == nil {\n\t\tactivePublisher = nil\n\t\tactivePublisherE = errors.New(\"queue publisher factory not registered\")\n\t\treturn activePublisherE\n\t}\n\n\tpublisher, err := activePublisherF(cfg)\n\tif err != nil {\n\t\tactivePublisher = nil\n\t\tactivePublisherE = err\n\t\treturn err\n\t}\n\tactivePublisher = publisher\n\tactivePublisherE = nil\n\treturn nil\n}\n\n// RegisterInspectorFactory 注册默认的 inspector 构建器。\nfunc RegisterInspectorFactory(factory func(cfg *config.Conf) (Inspector, error)) {\n\tinspectorMu.Lock()\n\tdefer inspectorMu.Unlock()\n\tactiveInspectorF = factory\n}\n\n// InitInspector 根据当前配置初始化全局 inspector。\nfunc InitInspector(cfg *config.Conf) error {\n\tinspectorMu.Lock()\n\tdefer inspectorMu.Unlock()\n\n\tif cfg == nil || !cfg.Queue.Enable {\n\t\tactiveInspector = nil\n\t\tactiveInspectorE = nil\n\t\treturn nil\n\t}\n\tif activeInspectorF == nil {\n\t\tactiveInspector = nil\n\t\tactiveInspectorE = errors.New(\"queue inspector factory not registered\")\n\t\treturn activeInspectorE\n\t}\n\n\tinspector, err := activeInspectorF(cfg)\n\tif err != nil {\n\t\tactiveInspector = nil\n\t\tactiveInspectorE = err\n\t\treturn err\n\t}\n\tactiveInspector = inspector\n\tactiveInspectorE = nil\n\treturn nil\n}\n\n// PublisherOrNil 返回当前全局 publisher；未启用时返回 nil。\nfunc PublisherOrNil() Publisher {\n\tpublisherMu.RLock()\n\tdefer publisherMu.RUnlock()\n\treturn activePublisher\n}\n\n// PublisherInitError 返回最近一次 publisher 初始化错误。\nfunc PublisherInitError() error {\n\tpublisherMu.RLock()\n\tdefer publisherMu.RUnlock()\n\treturn activePublisherE\n}\n\n// InspectorOrNil 返回当前全局 inspector；未启用时返回 nil。\nfunc InspectorOrNil() Inspector {\n\tinspectorMu.RLock()\n\tdefer inspectorMu.RUnlock()\n\treturn activeInspector\n}\n\n// InspectorInitError 返回最近一次 inspector 初始化错误。\nfunc InspectorInitError() error {\n\tinspectorMu.RLock()\n\tdefer inspectorMu.RUnlock()\n\treturn activeInspectorE\n}\n\n// DeleteTask 删除队列中的任务（pending/scheduled/retry/archived）。\nfunc DeleteTask(ctx context.Context, queueName, taskID string) error {\n\tinspector := InspectorOrNil()\n\tif inspector == nil {\n\t\treturn ErrInspectorUnavailable\n\t}\n\treturn inspector.DeleteTask(ctx, queueName, taskID)\n}\n\n// CancelProcessing 发送取消正在执行任务的信号（best-effort）。\nfunc CancelProcessing(ctx context.Context, taskID string) error {\n\tinspector := InspectorOrNil()\n\tif inspector == nil {\n\t\treturn ErrInspectorUnavailable\n\t}\n\treturn inspector.CancelProcessing(ctx, taskID)\n}\n\n// SetPublisherForTesting 仅用于测试时替换全局 publisher。\nfunc SetPublisherForTesting(publisher Publisher) func() {\n\tpublisherMu.Lock()\n\tprevious := activePublisher\n\tpreviousErr := activePublisherE\n\tactivePublisher = publisher\n\tactivePublisherE = nil\n\tpublisherMu.Unlock()\n\n\treturn func() {\n\t\tpublisherMu.Lock()\n\t\tactivePublisher = previous\n\t\tactivePublisherE = previousErr\n\t\tpublisherMu.Unlock()\n\t}\n}\n\n// SetInspectorForTesting 仅用于测试时替换全局 inspector。\nfunc SetInspectorForTesting(inspector Inspector) func() {\n\tinspectorMu.Lock()\n\tprevious := activeInspector\n\tpreviousErr := activeInspectorE\n\tactiveInspector = inspector\n\tactiveInspectorE = nil\n\tinspectorMu.Unlock()\n\n\treturn func() {\n\t\tinspectorMu.Lock()\n\t\tactiveInspector = previous\n\t\tactiveInspectorE = previousErr\n\t\tinspectorMu.Unlock()\n\t}\n}\n\nfunc validatePayload(payload any) error {\n\tif payload == nil {\n\t\treturn nil\n\t}\n\tif validatable, ok := payload.(Validatable); ok {\n\t\treturn validatable.Validate()\n\t}\n\treturn nil\n}\n\ntype memoryRegistry struct {\n\tmu      sync.RWMutex\n\tentries map[string]Handler\n}\n\n// NewRegistry 创建一个内存 registry。\nfunc NewRegistry() Registry {\n\treturn &memoryRegistry{\n\t\tentries: make(map[string]Handler),\n\t}\n}\n\nfunc (r *memoryRegistry) Register(taskType string, handler Handler) {\n\tif taskType == \"\" || handler == nil {\n\t\treturn\n\t}\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\tr.entries[taskType] = handler\n}\n\nfunc (r *memoryRegistry) Entries() []Registration {\n\tr.mu.RLock()\n\tdefer r.mu.RUnlock()\n\n\tregistrations := make([]Registration, 0, len(r.entries))\n\tfor taskType, handler := range r.entries {\n\t\tregistrations = append(registrations, Registration{\n\t\t\tTaskType: taskType,\n\t\t\tHandler:  handler,\n\t\t})\n\t}\n\treturn registrations\n}\n"
  },
  {
    "path": "internal/queue/queue_test.go",
    "content": "package queue\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/config/autoload\"\n)\n\ntype testPayload struct {\n\tName string `json:\"name\"`\n}\n\nfunc (p testPayload) Validate() error {\n\tif p.Name == \"\" {\n\t\treturn errors.New(\"name is required\")\n\t}\n\treturn nil\n}\n\ntype stubPublisher struct {\n\tlastJob Job\n}\n\ntype stubInspector struct {\n\tdeleteCalled bool\n\tcancelCalled bool\n\tlastQueue    string\n\tlastTaskID   string\n\tlastCancelID string\n}\n\nfunc (s *stubPublisher) Enqueue(ctx context.Context, job Job) (JobInfo, error) {\n\t_ = ctx\n\ts.lastJob = job\n\treturn JobInfo{ID: \"job-1\", Queue: job.Queue(), Type: job.Type()}, nil\n}\n\nfunc (s *stubInspector) DeleteTask(ctx context.Context, queueName, taskID string) error {\n\t_ = ctx\n\ts.deleteCalled = true\n\ts.lastQueue = queueName\n\ts.lastTaskID = taskID\n\treturn nil\n}\n\nfunc (s *stubInspector) CancelProcessing(ctx context.Context, taskID string) error {\n\t_ = ctx\n\ts.cancelCalled = true\n\ts.lastCancelID = taskID\n\treturn nil\n}\n\nfunc TestPublishJSONUsesGlobalPublisher(t *testing.T) {\n\tpublisher := &stubPublisher{}\n\trestore := SetPublisherForTesting(publisher)\n\tdefer restore()\n\n\tinfo, err := PublishJSON(context.Background(), \"demo:send\", \"critical\", testPayload{Name: \"codex\"}, WithMaxRetry(3))\n\tif err != nil {\n\t\tt.Fatalf(\"PublishJSON returned error: %v\", err)\n\t}\n\tif info.Type != \"demo:send\" || info.Queue != \"critical\" {\n\t\tt.Fatalf(\"unexpected job info: %#v\", info)\n\t}\n\tif publisher.lastJob == nil {\n\t\tt.Fatal(\"expected publisher to receive job\")\n\t}\n}\n\nfunc TestRegisterJSONDecodesAndValidatesPayload(t *testing.T) {\n\tregistry := NewRegistry()\n\tcalled := false\n\n\tRegisterJSON(registry, \"demo:send\", func(ctx context.Context, payload testPayload) error {\n\t\t_ = ctx\n\t\tcalled = true\n\t\tif payload.Name != \"codex\" {\n\t\t\tt.Fatalf(\"unexpected payload: %#v\", payload)\n\t\t}\n\t\treturn nil\n\t})\n\n\tentries := registry.Entries()\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 registry entry, got %d\", len(entries))\n\t}\n\n\tif err := entries[0].Handler(context.Background(), []byte(`{\"name\":\"codex\"}`)); err != nil {\n\t\tt.Fatalf(\"handler returned error: %v\", err)\n\t}\n\tif !called {\n\t\tt.Fatal(\"expected handler to be called\")\n\t}\n}\n\nfunc TestRegisterJSONReturnsSkipRetryForInvalidPayload(t *testing.T) {\n\tregistry := NewRegistry()\n\tRegisterJSON(registry, \"demo:send\", func(ctx context.Context, payload testPayload) error {\n\t\t_ = ctx\n\t\t_ = payload\n\t\treturn nil\n\t})\n\n\tentries := registry.Entries()\n\tif len(entries) != 1 {\n\t\tt.Fatalf(\"expected 1 registry entry, got %d\", len(entries))\n\t}\n\n\terr := entries[0].Handler(context.Background(), []byte(`{}`))\n\tif err == nil {\n\t\tt.Fatal(\"expected error\")\n\t}\n\tif !errors.Is(err, ErrSkipRetry) {\n\t\tt.Fatalf(\"expected skip retry error, got %v\", err)\n\t}\n}\n\nfunc TestInitPublisherStoresLastError(t *testing.T) {\n\tpreviousFactory := activePublisherF\n\tpreviousPublisher := activePublisher\n\tpreviousInitErr := activePublisherE\n\tt.Cleanup(func() {\n\t\tactivePublisherF = previousFactory\n\t\tactivePublisher = previousPublisher\n\t\tactivePublisherE = previousInitErr\n\t})\n\n\tactivePublisherF = func(cfg *config.Conf) (Publisher, error) {\n\t\t_ = cfg\n\t\treturn nil, errTestPublisherInit\n\t}\n\n\tcfg := &config.Conf{\n\t\tQueue: autoload.QueueConfig{Enable: true},\n\t}\n\terr := InitPublisher(cfg)\n\tif !errors.Is(err, errTestPublisherInit) {\n\t\tt.Fatalf(\"expected init error %v, got %v\", errTestPublisherInit, err)\n\t}\n\tif !errors.Is(PublisherInitError(), errTestPublisherInit) {\n\t\tt.Fatalf(\"expected stored init error %v, got %v\", errTestPublisherInit, PublisherInitError())\n\t}\n\tif PublisherOrNil() != nil {\n\t\tt.Fatal(\"expected publisher to remain nil on init failure\")\n\t}\n}\n\nfunc TestDeleteTaskUsesGlobalInspector(t *testing.T) {\n\tinspector := &stubInspector{}\n\trestore := SetInspectorForTesting(inspector)\n\tdefer restore()\n\n\tif err := DeleteTask(context.Background(), \"default\", \"task-1\"); err != nil {\n\t\tt.Fatalf(\"DeleteTask returned error: %v\", err)\n\t}\n\tif !inspector.deleteCalled || inspector.lastQueue != \"default\" || inspector.lastTaskID != \"task-1\" {\n\t\tt.Fatalf(\"unexpected inspector state: %#v\", inspector)\n\t}\n}\n\nfunc TestCancelProcessingUsesGlobalInspector(t *testing.T) {\n\tinspector := &stubInspector{}\n\trestore := SetInspectorForTesting(inspector)\n\tdefer restore()\n\n\tif err := CancelProcessing(context.Background(), \"task-1\"); err != nil {\n\t\tt.Fatalf(\"CancelProcessing returned error: %v\", err)\n\t}\n\tif !inspector.cancelCalled || inspector.lastCancelID != \"task-1\" {\n\t\tt.Fatalf(\"unexpected inspector state: %#v\", inspector)\n\t}\n}\n\nfunc TestDeleteTaskReturnsUnavailableWithoutInspector(t *testing.T) {\n\trestore := SetInspectorForTesting(nil)\n\tdefer restore()\n\n\terr := DeleteTask(context.Background(), \"default\", \"task-1\")\n\tif !errors.Is(err, ErrInspectorUnavailable) {\n\t\tt.Fatalf(\"expected ErrInspectorUnavailable, got %v\", err)\n\t}\n}\n\nvar errTestPublisherInit = errors.New(\"publisher init failed\")\n"
  },
  {
    "path": "internal/resources/admin_user.go",
    "content": "package resources\n\nimport (\n\t\"github.com/samber/lo\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// AdminUserResources 是后台管理员用户的响应资源结构体\n// 用于对外暴露字段，避免直接返回数据库模型结构体\n// 可配合脱敏规则处理敏感信息\ntype AdminUserResources struct {\n\tID               uint             `json:\"id\"`                  // 管理员ID\n\tNickname         string           `json:\"nickname\"`            // 昵称\n\tUsername         string           `json:\"username\"`            // 用户名\n\tIsSuperAdmin     uint8            `json:\"is_super_admin\"`      // 是否为超级管理员\n\tIsSuperAdminName string           `json:\"is_super_admin_name\"` // 是否为超级管理员名称\n\tPhoneNumber      string           `json:\"phone_number\"`        // 手机号（可脱敏）\n\tCountryCode      string           `json:\"country_code\"`        // 国家区号\n\tEmail            string           `json:\"email\"`               // 邮箱（可脱敏）\n\tAvatar           string           `json:\"avatar\"`              // 头像链接\n\tCreatedAt        utils.FormatDate `json:\"created_at\"`          // 创建时间\n\tUpdatedAt        utils.FormatDate `json:\"updated_at\"`          // 更新时间\n\tStatus           uint8            `json:\"status\"`              // 状态（1启用/0禁用）\n\tStatusName       string           `json:\"status_name\"`         // 状态名称\n\tLastIp           string           `json:\"last_ip\"`             // 上次登录 IP\n\tLastLogin        utils.FormatDate `json:\"last_login\"`          // 上次登录时间\n\tDepartments      []department     `json:\"departments\"`         // 部门信息D\n\tRoleList         []uint           `json:\"role_list\"`           // 角色信息\n}\n\n// AdminUserTransformer 是 AdminUser 的资源转换器，实现 Resources 接口\n// 内部嵌入 BaseResources 实现结构复用\ntype AdminUserTransformer struct {\n\tBaseResources[*model.AdminUser, *AdminUserResources]\n}\n\n// SetCustomFields 填充管理员资源的映射字段和关联字段。\nfunc (r *AdminUserResources) SetCustomFields(data *model.AdminUser) {\n\t// 初始化 RoleList 和 Departments 为空切片，确保字段总是存在\n\tr.RoleList = []uint{}\n\tr.Departments = []department{}\n\tif data == nil {\n\t\treturn\n\t}\n\t// 设置映射字段\n\tr.IsSuperAdminName = data.IsSuperAdminMap()\n\tr.StatusName = data.StatusMap()\n\t// 头像URL原样返回\n\tr.Avatar = data.Avatar\n\t// 如果 RoleList 有数据，则提取 RoleId\n\tif len(data.RoleList) > 0 {\n\t\tr.RoleList = lo.Map(data.RoleList, func(m model.AdminUserRoleMap, _ int) uint {\n\t\t\treturn m.RoleId\n\t\t})\n\t}\n\t// 如果 Department 有数据，则转换为 department 结构\n\tif len(data.Department) > 0 {\n\t\tr.Departments = lo.Map(data.Department, func(d model.Department, _ int) department {\n\t\t\treturn department{\n\t\t\t\tID:   d.ID,\n\t\t\t\tName: d.Name,\n\t\t\t\tPid:  d.Pid,\n\t\t\t}\n\t\t})\n\t}\n}\n\n// NewAdminUserTransformer 返回 AdminUserTransformer 实例，绑定资源创建函数\nfunc NewAdminUserTransformer() AdminUserTransformer {\n\treturn AdminUserTransformer{\n\t\tBaseResources: BaseResources[*model.AdminUser, *AdminUserResources]{\n\t\t\tNewResource: func() *AdminUserResources {\n\t\t\t\treturn &AdminUserResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ToCollection 覆盖默认实现，支持手机号、邮箱等字段的自定义脱敏逻辑\n// 若无特殊处理需求，可不实现该方法，默认继承 BaseResources 的逻辑\nfunc (AdminUserTransformer) ToCollection(page, perPage int, total int64, data []*model.AdminUser) *Collection {\n\tresponse := make([]any, 0, len(data))\n\tphoneRule := utils.NewPhoneRule() // 手机号脱敏规则\n\temailRule := utils.NewEmailRule() // 邮箱脱敏规则\n\n\tfor _, v := range data {\n\t\tdeptSlice := make([]department, 0, len(v.Department))\n\t\tfor _, d := range v.Department {\n\t\t\tdeptSlice = append(deptSlice, department{\n\t\t\t\tID:   d.ID,\n\t\t\t\tName: d.Name,\n\t\t\t\tPid:  d.Pid,\n\t\t\t})\n\t\t}\n\n\t\tresponse = append(response, &AdminUserResources{\n\t\t\tID:               v.ID,\n\t\t\tNickname:         v.Nickname,\n\t\t\tUsername:         v.Username,\n\t\t\tIsSuperAdmin:     v.IsSuperAdmin,\n\t\t\tIsSuperAdminName: v.IsSuperAdminMap(),\n\t\t\tPhoneNumber:      phoneRule.Apply(v.PhoneNumber),\n\t\t\tCountryCode:      v.CountryCode,\n\t\t\tEmail:            emailRule.Apply(v.Email),\n\t\t\tAvatar:           v.Avatar,\n\t\t\tStatus:           v.Status,\n\t\t\tStatusName:       v.StatusMap(),\n\t\t\tLastIp:           v.LastIp,\n\t\t\tLastLogin:        v.LastLogin,\n\t\t\tCreatedAt:        v.CreatedAt,\n\t\t\tUpdatedAt:        v.UpdatedAt,\n\t\t\tDepartments:      deptSlice,\n\t\t\tRoleList:         []uint{},\n\t\t})\n\t}\n\n\treturn NewCollection().SetPaginate(page, perPage, total).ToCollection(response)\n}\n"
  },
  {
    "path": "internal/resources/api.go",
    "content": "package resources\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\n// ApiResources 表示接口权限的响应结构。\ntype ApiResources struct {\n\tID              uint    `json:\"id\"`\n\tName            string  `json:\"name\"`              // 权限名称\n\tCode            string  `json:\"code\"`              // 权限名称\n\tDescription     string  `json:\"description\"`       // 描述\n\tMethod          string  `json:\"method\"`            // 接口请求方法\n\tRoute           string  `json:\"route\"`             // 接口路由\n\tFunc            string  `json:\"func\"`              // 接口方法\n\tFuncPath        string  `json:\"func_path\"`         // 接口方法\n\tIsAuth          uint8   `json:\"is_auth\"`           // 接口鉴权模式\n\tIsEffective     uint8   `json:\"is_effective\"`      // 是否有效\n\tIsAuthName      *string `json:\"is_auth_name\"`      // 接口鉴权模式名称\n\tIsEffectiveName *string `json:\"is_effective_name\"` // 是否有效\n\tSort            int     `json:\"sort\"`              // 排序\n}\n\n// ApiTransformer 权限资源转换\ntype ApiTransformer struct {\n\tBaseResources[*model.Api, *ApiResources]\n}\n\n// NewApiTransformer 实例化权限资源转换器\nfunc NewApiTransformer() ApiTransformer {\n\treturn ApiTransformer{\n\t\tBaseResources: BaseResources[*model.Api, *ApiResources]{\n\t\t\tNewResource: func() *ApiResources {\n\t\t\t\treturn &ApiResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ToStruct 将 API 模型转换为响应结构。\nfunc (ApiTransformer) ToStruct(data *model.Api) *ApiResources {\n\tisAuthName := data.IsAuthMap()\n\tisEffectiveName := data.IsEffectiveMap()\n\treturn &ApiResources{\n\t\tID:              data.ID,\n\t\tName:            data.Name,\n\t\tDescription:     data.Description,\n\t\tMethod:          data.Method,\n\t\tRoute:           data.Route,\n\t\tFunc:            data.Func,\n\t\tFuncPath:        data.FuncPath,\n\t\tIsAuth:          data.IsAuth,\n\t\tIsAuthName:      &isAuthName,\n\t\tSort:            data.Sort,\n\t\tCode:            data.Code,\n\t\tIsEffective:     data.IsEffective,\n\t\tIsEffectiveName: &isEffectiveName,\n\t}\n}\n\n// ToCollection 将 API 模型集合转换为分页响应。\nfunc (ApiTransformer) ToCollection(page, perPage int, total int64, data []*model.Api) *Collection {\n\tresponse := make([]any, 0, len(data))\n\tfor _, v := range data {\n\t\tresponse = append(response, ApiTransformer{}.ToStruct(v))\n\t}\n\treturn NewCollection().SetPaginate(page, perPage, total).ToCollection(response)\n}\n"
  },
  {
    "path": "internal/resources/base.go",
    "content": "package resources\n\nimport (\n\t\"github.com/jinzhu/copier\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n)\n\n// Resources 定义模型到响应资源的转换接口。\ntype Resources[T any, R any] interface {\n\tToStruct(data T) R\n\tToCollection(page, perPage int, total int64, data []T) *Collection\n}\n\n// CustomFieldSetter 允许资源在复制基础字段后补充扩展信息。\ntype CustomFieldSetter[T any] interface {\n\tSetCustomFields(T)\n}\n\n// BaseResources 提供通用的资源转换实现。\ntype BaseResources[T any, R any] struct {\n\tNewResource func() R\n}\n\n// ToStruct 将单个模型复制为资源结构。\nfunc (br BaseResources[T, R]) ToStruct(data T) R {\n\tresource, _ := toGenericStruct(data, br.NewResource)\n\treturn resource\n}\n\n// ToCollection 将模型集合转换为统一分页响应。\nfunc (br BaseResources[T, R]) ToCollection(page, perPage int, total int64, data []T) *Collection {\n\titems := make([]any, 0, len(data))\n\tfor _, item := range data {\n\t\titems = append(items, br.ToStruct(item))\n\t}\n\treturn NewCollection().SetPaginate(page, perPage, total).ToCollection(items)\n}\n\n// ToAnySlice 将泛型切片转换为 []any。\nfunc ToAnySlice[T any](data []T) []any {\n\titems := make([]any, len(data))\n\tfor i, v := range data {\n\t\titems[i] = v\n\t}\n\treturn items\n}\n\n// TreeNode 表示可挂载子节点的树形资源。\ntype TreeNode[R any] interface {\n\tSetChildren(children []R)\n}\n\n// Identifiable 表示可参与树构建的节点标识接口。\ntype Identifiable interface {\n\tGetID() uint\n\tGetPID() uint\n}\n\n// TreeResources 定义树形资源的转换接口。\ntype TreeResources[T any, R TreeNode[R]] interface {\n\tToStruct(data T) R\n\tBuildTree(data []T, pidFn func(T) uint, idFn func(T) uint) []R\n}\n\n// TreeResource 提供通用的树形资源转换实现。\ntype TreeResource[T any, R TreeNode[R]] struct {\n\tNewResource func() R\n}\n\n// ToStruct 将单个模型复制为树形资源节点。\nfunc (tr TreeResource[T, R]) ToStruct(data T) R {\n\tresource, _ := toGenericStruct(data, tr.NewResource)\n\treturn resource\n}\n\n// BuildTree 根据父子关系构建树形结果。\nfunc (tr TreeResource[T, R]) BuildTree(data []T, pidFn func(T) uint, idFn func(T) uint, rootID uint) []R {\n\tparentMap := make(map[uint][]T)\n\tfor _, item := range data {\n\t\tpid := pidFn(item)\n\t\tparentMap[pid] = append(parentMap[pid], item)\n\t}\n\n\tvar build func(uint) []R\n\tbuild = func(parentID uint) []R {\n\t\tchildren, ok := parentMap[parentID]\n\t\tif !ok {\n\t\t\treturn nil\n\t\t}\n\n\t\tvar tree []R\n\t\tfor _, v := range children {\n\t\t\tresource := tr.ToStruct(v)\n\t\t\tresource.SetChildren(build(idFn(v)))\n\t\t\ttree = append(tree, resource)\n\t\t}\n\t\treturn tree\n\t}\n\n\treturn build(rootID)\n}\n\n// BuildTreeByNode 使用资源节点自带的标识信息构建树。\nfunc (tr TreeResource[T, R]) BuildTreeByNode(data []T, rootID uint) []R {\n\tif len(data) == 0 {\n\t\treturn []R{}\n\t}\n\n\tparentMap := make(map[uint][]T)\n\tfor _, item := range data {\n\t\tresource := tr.ToStruct(item)\n\t\tif identifiable, ok := any(resource).(Identifiable); ok {\n\t\t\tpid := identifiable.GetPID()\n\t\t\tparentMap[pid] = append(parentMap[pid], item)\n\t\t}\n\t}\n\n\tvar build func(uint) []R\n\tbuild = func(parentID uint) []R {\n\t\tchildren := parentMap[parentID]\n\t\tvar tree []R\n\t\tfor _, v := range children {\n\t\t\tresource := tr.ToStruct(v)\n\t\t\tif identifiable, ok := any(resource).(Identifiable); ok {\n\t\t\t\tresource.SetChildren(build(identifiable.GetID()))\n\t\t\t}\n\t\t\ttree = append(tree, resource)\n\t\t}\n\t\treturn tree\n\t}\n\n\treturn build(rootID)\n}\n\n// toGenericStruct 复制模型字段并补充自定义资源字段。\nfunc toGenericStruct[T any, R any](data T, newFunc func() R) (R, error) {\n\tvar resource = newFunc()\n\terr := copier.Copy(&resource, data)\n\tif err != nil {\n\t\tlog.Logger.Error(\"Copy data to struct error\", zap.Error(err))\n\t\treturn resource, err\n\t}\n\tif cfs, ok := any(resource).(CustomFieldSetter[T]); ok {\n\t\tcfs.SetCustomFields(data)\n\t}\n\treturn resource, nil\n}\n\n// Paginate 表示统一分页元数据。\ntype Paginate struct {\n\tTotal       int64 `json:\"total\"`\n\tPerPage     int   `json:\"per_page\"`\n\tCurrentPage int   `json:\"current_page\"`\n\tLastPage    int   `json:\"last_page\"`\n}\n\n// calculateLastPage 归一化分页参数并计算最后一页。\nfunc (p *Paginate) calculateLastPage() {\n\tif p.CurrentPage < 1 {\n\t\tp.CurrentPage = 1\n\t}\n\n\tif p.PerPage < 1 {\n\t\tp.PerPage = global.PerPage\n\t}\n\n\tif p.PerPage < 0 {\n\t\tp.PerPage = 10 // fallback 默认值\n\t}\n\n\tif p.Total == 0 {\n\t\tp.LastPage = 1\n\t\treturn\n\t}\n\tp.LastPage = int((p.Total + int64(p.PerPage) - 1) / int64(p.PerPage))\n}\n\n// ResponseCollectionInterface 定义分页集合的基础能力。\ntype ResponseCollectionInterface interface {\n\tGetPaginate() *Paginate\n\tSetPaginate(page, perPage int, total int64) *Collection\n\tToCollection(data []any) *Collection\n}\n\n// Collection 表示带分页信息的列表响应。\ntype Collection struct {\n\tPaginate\n\tData []any `json:\"data\"`\n}\n\n// GetPaginate 返回当前集合的分页信息。\nfunc (p *Collection) GetPaginate() *Paginate {\n\treturn &p.Paginate\n}\n\n// SetPaginate 设置集合的分页元数据。\nfunc (p *Collection) SetPaginate(page, perPage int, total int64) *Collection {\n\tp.Paginate = Paginate{\n\t\tTotal:       total,\n\t\tCurrentPage: page,\n\t\tPerPage:     perPage,\n\t}\n\tp.Paginate.calculateLastPage()\n\treturn p\n}\n\n// ToCollection 设置集合数据项。\nfunc (p *Collection) ToCollection(data []any) *Collection {\n\tp.Data = data\n\treturn p\n}\n\n// NewCollection 创建空的分页集合。\nfunc NewCollection() *Collection {\n\treturn &Collection{}\n}\n\n// ToRawCollection 直接将模型切片包装为分页响应。\nfunc ToRawCollection[T any](page, perPage int, total int64, data []T) *Collection {\n\titems := make([]any, len(data))\n\tfor i, v := range data {\n\t\titems[i] = v\n\t}\n\treturn NewCollection().SetPaginate(page, perPage, total).ToCollection(items)\n}\n"
  },
  {
    "path": "internal/resources/base_test.go",
    "content": "package resources\n\nimport \"testing\"\n\ntype baseTestModel struct {\n\tID uint\n}\n\ntype baseTestResource struct {\n\tID       uint\n\tComputed string\n}\n\nfunc (r *baseTestResource) SetCustomFields(data baseTestModel) {\n\tr.Computed = \"ok\"\n}\n\nfunc TestBaseResourcesToCollectionTransformsItems(t *testing.T) {\n\ttransformer := BaseResources[baseTestModel, *baseTestResource]{\n\t\tNewResource: func() *baseTestResource {\n\t\t\treturn &baseTestResource{}\n\t\t},\n\t}\n\n\tcollection := transformer.ToCollection(1, 10, 2, []baseTestModel{{ID: 1}, {ID: 2}})\n\tif len(collection.Data) != 2 {\n\t\tt.Fatalf(\"unexpected data len: %d\", len(collection.Data))\n\t}\n\n\titem, ok := collection.Data[0].(*baseTestResource)\n\tif !ok {\n\t\tt.Fatalf(\"expected transformed resource, got %#v\", collection.Data[0])\n\t}\n\tif item.Computed != \"ok\" {\n\t\tt.Fatalf(\"expected custom field to be applied, got %#v\", item)\n\t}\n}\n\nfunc TestPaginateCalculateLastPageUsesIntegerCeil(t *testing.T) {\n\tcollection := NewCollection().SetPaginate(1, 10, 21)\n\tif collection.LastPage != 3 {\n\t\tt.Fatalf(\"expected last page 3, got %d\", collection.LastPage)\n\t}\n}\n"
  },
  {
    "path": "internal/resources/common.go",
    "content": "package resources\n\ntype department struct {\n\tID   uint   `json:\"id\"`\n\tName string `json:\"name\"`\n\tPid  uint   `json:\"pid\"`\n}\n"
  },
  {
    "path": "internal/resources/dept.go",
    "content": "package resources\n\nimport (\n\t\"github.com/samber/lo\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// DeptResources 表示部门树节点的响应结构。\ntype DeptResources struct {\n\tID          uint             `json:\"id\"`\n\tCode        string           `json:\"code\"`\n\tIsSystem    uint8            `json:\"is_system\"`\n\tPid         uint             `json:\"pid\"`\n\tName        string           `json:\"name\"`\n\tDescription string           `json:\"description\"`\n\tLevel       uint8            `json:\"level\"`\n\tSort        uint16           `json:\"sort\"`\n\tChildrenNum uint             `json:\"children_num\"`\n\tChildren    []*DeptResources `json:\"children,omitempty\"`\n\tRoleList    []uint           `json:\"role_list\"`\n\tUserNumber  uint             `json:\"user_number\"`\n\tCreatedAt   utils.FormatDate `json:\"created_at\"`\n\tUpdatedAt   utils.FormatDate `json:\"updated_at\"`\n}\n\n// SetChildren 设置部门节点的子节点。\nfunc (r *DeptResources) SetChildren(children []*DeptResources) {\n\tr.Children = children\n}\n\n// GetID 返回当前部门节点 ID。\nfunc (r *DeptResources) GetID() uint {\n\treturn r.ID\n}\n\n// GetPID 返回当前部门节点父级 ID。\nfunc (r *DeptResources) GetPID() uint {\n\treturn r.Pid\n}\n\n// DeptTreeTransformer 负责把部门模型转换为树形响应结构。\ntype DeptTreeTransformer struct {\n\tTreeResource[*model.Department, *DeptResources]\n}\n\n// SetCustomFields 填充部门资源的扩展字段。\nfunc (r *DeptResources) SetCustomFields(data *model.Department) {\n\tr.RoleList = []uint{}\n\tif data == nil {\n\t\treturn\n\t}\n\tr.Code = data.Code\n\tr.IsSystem = data.IsSystem\n\tif len(data.RoleList) > 0 {\n\t\tr.RoleList = lo.Map(data.RoleList, func(m model.DeptRoleMap, _ int) uint {\n\t\t\treturn m.RoleId\n\t\t})\n\t}\n}\n\n// NewDeptTreeTransformer 创建部门树资源转换器。\nfunc NewDeptTreeTransformer() DeptTreeTransformer {\n\treturn DeptTreeTransformer{\n\t\tTreeResource: TreeResource[*model.Department, *DeptResources]{\n\t\t\tNewResource: func() *DeptResources {\n\t\t\t\treturn &DeptResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "internal/resources/file_resource.go",
    "content": "package resources\n\nimport (\n\t\"strings\"\n\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// FileResourceResources 文件资源响应结构。\ntype FileResourceResources struct {\n\tID                uint             `json:\"id\"`\n\tFileObjectID      uint             `json:\"file_object_id\"`\n\tUID               uint             `json:\"uid\"`\n\tFolderID          uint             `json:\"folder_id\"`\n\tLogicalPath       string           `json:\"logical_path\"`\n\tDisplayName       string           `json:\"display_name\"`\n\tOriginName        string           `json:\"origin_name\"`\n\tName              string           `json:\"name\"`\n\tPath              string           `json:\"path\"`\n\tSize              uint             `json:\"size\"`\n\tExt               string           `json:\"ext\"`\n\tHash              string           `json:\"hash\"`\n\tUUID              string           `json:\"uuid\"`\n\tMimeType          string           `json:\"mime_type\"`\n\tFileType          string           `json:\"file_type\"`\n\tIsPublic          uint8            `json:\"is_public\"`\n\tStorageDriver     string           `json:\"storage_driver\"`\n\tStorageBase       string           `json:\"storage_base\"`\n\tBucket            string           `json:\"bucket\"`\n\tStoragePath       string           `json:\"storage_path\"`\n\tObjectKey         string           `json:\"object_key\"`\n\tETag              string           `json:\"etag\"`\n\tStorageStatus     string           `json:\"storage_status\"`\n\tStorageStatusName string           `json:\"storage_status_name\"`\n\tUploadSource      string           `json:\"upload_source\"`\n\tUploadSourceName  string           `json:\"upload_source_name\"`\n\tUploadScene       string           `json:\"upload_scene\"`\n\tUploadStatus      string           `json:\"upload_status\"`\n\tUploadStatusName  string           `json:\"upload_status_name\"`\n\tReferenceCount    int64            `json:\"reference_count\"`\n\tObjectReuseCount  int64            `json:\"object_reuse_count\"`\n\tObjectStatus      string           `json:\"object_status\"`\n\tObjectStatusName  string           `json:\"object_status_name\"`\n\tURL               string           `json:\"url\"`\n\tCreatedAt         utils.FormatDate `json:\"created_at\"`\n\tUpdatedAt         utils.FormatDate `json:\"updated_at\"`\n\tLastAccessedAt    utils.FormatDate `json:\"last_accessed_at\"`\n\tDeletedBy         uint             `json:\"deleted_by\"`\n\tDeletedReason     string           `json:\"deleted_reason\"`\n\tDeletedAt         uint             `json:\"deleted_at\"`\n\tReferences        any              `json:\"references,omitempty\"`\n}\n\n// FileResourceTransformer 文件资源转换器。\ntype FileResourceTransformer struct {\n\tBaseResources[*model.UploadFiles, *FileResourceResources]\n}\n\nfunc NewFileResourceTransformer() FileResourceTransformer {\n\treturn FileResourceTransformer{\n\t\tBaseResources: BaseResources[*model.UploadFiles, *FileResourceResources]{\n\t\t\tNewResource: func() *FileResourceResources {\n\t\t\t\treturn &FileResourceResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc (r *FileResourceResources) SetCustomFields(data *model.UploadFiles) {\n\tr.URL = buildFileResourceURL(data.UUID)\n\tr.DeletedAt = uint(data.DeletedAt)\n\tif r.DisplayName == \"\" {\n\t\tr.DisplayName = data.OriginName\n\t}\n\tr.StorageStatusName = fileStorageStatusName(data.StorageStatus)\n\tif r.ObjectStatus == \"\" {\n\t\tr.ObjectStatus = data.ObjectStatus\n\t}\n\tr.ObjectStatusName = fileStorageStatusName(r.ObjectStatus)\n\tr.UploadSourceName = fileUploadSourceName(data.UploadSource)\n\tr.UploadStatusName = fileUploadStatusName(data.UploadStatus)\n}\n\nfunc fileStorageStatusName(status string) string {\n\tswitch status {\n\tcase model.StorageStatusStored:\n\t\treturn \"已存储\"\n\tcase model.StorageStatusDeleteFailed:\n\t\treturn \"删除失败\"\n\tcase \"uploading\":\n\t\treturn \"上传中\"\n\tcase \"missing\":\n\t\treturn \"对象缺失\"\n\tdefault:\n\t\treturn status\n\t}\n}\n\nfunc fileUploadSourceName(source string) string {\n\tswitch source {\n\tcase model.UploadSourceBackend:\n\t\treturn \"后端上传\"\n\tcase model.UploadSourceDirect:\n\t\treturn \"前端直传\"\n\tcase model.UploadSourceSystem:\n\t\treturn \"系统生成\"\n\tdefault:\n\t\treturn source\n\t}\n}\n\nfunc fileUploadStatusName(status string) string {\n\tswitch status {\n\tcase model.UploadStatusPending:\n\t\treturn \"待完成\"\n\tcase model.UploadStatusUploaded:\n\t\treturn \"已上传\"\n\tcase model.UploadStatusFailed:\n\t\treturn \"上传失败\"\n\tdefault:\n\t\treturn status\n\t}\n}\n\ntype FileFolderResources struct {\n\tID          uint                   `json:\"id\"`\n\tParentID    uint                   `json:\"parent_id\"`\n\tName        string                 `json:\"name\"`\n\tLogicalPath string                 `json:\"logical_path\"`\n\tSort        int                    `json:\"sort\"`\n\tFileCount   int64                  `json:\"file_count\"`\n\tTotalSize   int64                  `json:\"total_size\"`\n\tCreatedAt   utils.FormatDate       `json:\"created_at\"`\n\tUpdatedAt   utils.FormatDate       `json:\"updated_at\"`\n\tChildren    []*FileFolderResources `json:\"children,omitempty\"`\n}\n\ntype FileFolderTransformer struct {\n\tBaseResources[*model.UploadFileFolder, *FileFolderResources]\n}\n\nfunc NewFileFolderTransformer() FileFolderTransformer {\n\treturn FileFolderTransformer{\n\t\tBaseResources: BaseResources[*model.UploadFileFolder, *FileFolderResources]{\n\t\t\tNewResource: func() *FileFolderResources {\n\t\t\t\treturn &FileFolderResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\ntype FileMoveResult struct {\n\tTotal   int64 `json:\"total\"`\n\tMoved   int64 `json:\"moved\"`\n\tSkipped int64 `json:\"skipped\"`\n}\n\ntype FileUploadCredentialResources struct {\n\tStorageDriver   string            `json:\"storage_driver\"`\n\tDriver          string            `json:\"driver\"`\n\tBucket          string            `json:\"bucket\"`\n\tObjectKey       string            `json:\"object_key\"`\n\tUploadURL       string            `json:\"upload_url\"`\n\tURL             string            `json:\"url\"`\n\tMethod          string            `json:\"method\"`\n\tHeaders         map[string]string `json:\"headers\"`\n\tExpireAt        utils.FormatDate  `json:\"expire_at\"`\n\tReuse           bool              `json:\"reuse\"`\n\tFileObjectID    uint              `json:\"file_object_id\"`\n\tSize            uint              `json:\"size\"`\n\tHash            string            `json:\"hash\"`\n\tMimeType        string            `json:\"mime_type\"`\n\tETag            string            `json:\"etag\"`\n\tObjectStatus    string            `json:\"object_status\"`\n\tCompletePayload map[string]any    `json:\"complete_payload\"`\n}\n\ntype FileReferenceResources struct {\n\tID         uint             `json:\"id\"`\n\tFileID     uint             `json:\"file_id\"`\n\tUUID       string           `json:\"uuid\"`\n\tOwnerType  string           `json:\"owner_type\"`\n\tOwnerID    uint             `json:\"owner_id\"`\n\tOwnerField string           `json:\"owner_field\"`\n\tSourceName string           `json:\"source_name\"`\n\tFieldName  string           `json:\"field_name\"`\n\tCreatedAt  utils.FormatDate `json:\"created_at\"`\n\tUpdatedAt  utils.FormatDate `json:\"updated_at\"`\n}\n\ntype FileReferenceTransformer struct {\n\tBaseResources[*model.UploadFileReference, *FileReferenceResources]\n}\n\nfunc NewFileReferenceTransformer() FileReferenceTransformer {\n\treturn FileReferenceTransformer{\n\t\tBaseResources: BaseResources[*model.UploadFileReference, *FileReferenceResources]{\n\t\t\tNewResource: func() *FileReferenceResources {\n\t\t\t\treturn &FileReferenceResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc (r *FileReferenceResources) SetCustomFields(data *model.UploadFileReference) {\n\tr.SourceName = fileReferenceSourceName(data.OwnerType)\n\tr.FieldName = fileReferenceFieldName(data.OwnerField)\n}\n\nfunc fileReferenceSourceName(ownerType string) string {\n\tswitch ownerType {\n\tcase \"admin_user\":\n\t\treturn \"管理员\"\n\tdefault:\n\t\treturn ownerType\n\t}\n}\n\nfunc fileReferenceFieldName(ownerField string) string {\n\tswitch ownerField {\n\tcase \"avatar\":\n\t\treturn \"头像\"\n\tdefault:\n\t\treturn ownerField\n\t}\n}\n\nfunc buildFileResourceURL(uuid string) string {\n\tif uuid == \"\" {\n\t\treturn \"\"\n\t}\n\tbaseURL := strings.TrimSuffix(c.GetConfig().BaseURL, \"/\")\n\tif baseURL == \"\" {\n\t\treturn \"/admin/v1/file/\" + uuid\n\t}\n\treturn baseURL + \"/admin/v1/file/\" + uuid\n}\n"
  },
  {
    "path": "internal/resources/file_resource_test.go",
    "content": "package resources\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\nfunc TestFileReferenceTransformerAddsDisplayNames(t *testing.T) {\n\tresult := NewFileReferenceTransformer().ToStruct(&model.UploadFileReference{\n\t\tFileID:     1,\n\t\tOwnerType:  \"admin_user\",\n\t\tOwnerID:    2,\n\t\tOwnerField: \"avatar\",\n\t})\n\tif result.SourceName != \"管理员\" {\n\t\tt.Fatalf(\"expected source_name 管理员, got %q\", result.SourceName)\n\t}\n\tif result.FieldName != \"头像\" {\n\t\tt.Fatalf(\"expected field_name 头像, got %q\", result.FieldName)\n\t}\n}\n\nfunc TestFileReferenceTransformerFallsBackToRawNames(t *testing.T) {\n\tresult := NewFileReferenceTransformer().ToStruct(&model.UploadFileReference{\n\t\tOwnerType:  \"custom_owner\",\n\t\tOwnerField: \"custom_field\",\n\t})\n\tif result.SourceName != \"custom_owner\" {\n\t\tt.Fatalf(\"expected raw source_name, got %q\", result.SourceName)\n\t}\n\tif result.FieldName != \"custom_field\" {\n\t\tt.Fatalf(\"expected raw field_name, got %q\", result.FieldName)\n\t}\n}\n\nfunc TestFileResourceTransformerAddsStatusDisplayNames(t *testing.T) {\n\tresult := NewFileResourceTransformer().ToStruct(&model.UploadFiles{\n\t\tOriginName:    \"avatar.png\",\n\t\tStorageStatus: model.StorageStatusStored,\n\t\tUploadSource:  model.UploadSourceBackend,\n\t\tUploadStatus:  model.UploadStatusUploaded,\n\t})\n\tif result.StorageStatusName != \"已存储\" {\n\t\tt.Fatalf(\"expected storage_status_name 已存储, got %q\", result.StorageStatusName)\n\t}\n\tif result.UploadSourceName != \"后端上传\" {\n\t\tt.Fatalf(\"expected upload_source_name 后端上传, got %q\", result.UploadSourceName)\n\t}\n\tif result.UploadStatusName != \"已上传\" {\n\t\tt.Fatalf(\"expected upload_status_name 已上传, got %q\", result.UploadStatusName)\n\t}\n}\n"
  },
  {
    "path": "internal/resources/login_log.go",
    "content": "package resources\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// AdminLoginLogBaseResources 表示登录日志的公共响应字段。\ntype AdminLoginLogBaseResources struct {\n\tID              uint              `json:\"id\"`\n\tUID             uint              `json:\"uid\"`               // 用户 ID（登录失败时为 0）\n\tUsername        string            `json:\"username\"`          // 登录账号\n\tIP              string            `json:\"ip\"`                // 登录 IP(支持 IPv6)\n\tOS              string            `json:\"os\"`                // 操作系统\n\tBrowser         string            `json:\"browser\"`           // 浏览器\n\tExecutionTime   int               `json:\"execution_time\"`    // 登录耗时（毫秒）\n\tLoginStatus     uint8             `json:\"login_status\"`      // 登录状态：1=成功，0=失败\n\tLoginStatusName string            `json:\"login_status_name\"` // 登录状态名称\n\tLoginFailReason string            `json:\"login_fail_reason\"` // 登录失败原因\n\tType            uint8             `json:\"type\"`              // 操作类型：1=登录操作，2=刷新 token\n\tTypeName        string            `json:\"type_name\"`         // 操作类型名称\n\tIsRevoked       uint8             `json:\"is_revoked\"`        // 是否被撤销：0=否，1=是\n\tIsRevokedName   string            `json:\"is_revoked_name\"`   // 是否被撤销名称\n\tRevokedCode     uint8             `json:\"revoked_code\"`      // 撤销原因码\n\tRevokedCodeName string            `json:\"revoked_code_name\"` // 撤销原因码名称\n\tRevokedReason   string            `json:\"revoked_reason\"`    // 撤销原因\n\tRevokedAt       *utils.FormatDate `json:\"revoked_at\"`        // 撤销时间\n\tCreatedAt       utils.FormatDate  `json:\"created_at\"`        // 创建时间\n}\n\n// AdminLoginLogListResources 表示登录日志列表项。\ntype AdminLoginLogListResources struct {\n\tAdminLoginLogBaseResources\n}\n\n// AdminLoginLogResources 表示登录日志详情响应。\ntype AdminLoginLogResources struct {\n\tAdminLoginLogBaseResources\n\tJwtID            string            `json:\"jwt_id\"`                  // JWT 唯一标识 (jti claim)\n\tUserAgent        string            `json:\"user_agent\"`              // 用户代理（浏览器/设备信息）\n\tTokenHash        string            `json:\"token_hash\"`              // Token 的 SHA256 哈希值\n\tRefreshTokenHash string            `json:\"refresh_token_hash\"`      // Refresh Token 的哈希值\n\tTokenExpires     *utils.FormatDate `json:\"token_expires\"`           // Token 过期时间\n\tRefreshExpires   *utils.FormatDate `json:\"refresh_expires\"`         // Refresh Token 过期时间\n\tUpdatedAt        utils.FormatDate  `json:\"updated_at\"`              // 更新时间\n}\n\n// AdminLoginLogTransformer 负责登录日志资源转换。\ntype AdminLoginLogTransformer struct {\n\tBaseResources[*model.AdminLoginLogs, *AdminLoginLogResources]\n}\n\n// NewAdminLoginLogTransformer 创建登录日志资源转换器。\nfunc NewAdminLoginLogTransformer() AdminLoginLogTransformer {\n\treturn AdminLoginLogTransformer{\n\t\tBaseResources: BaseResources[*model.AdminLoginLogs, *AdminLoginLogResources]{\n\t\t\tNewResource: func() *AdminLoginLogResources {\n\t\t\t\treturn &AdminLoginLogResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\n// buildAdminLoginLogBaseResources 提取登录日志公共字段。\nfunc buildAdminLoginLogBaseResources(data *model.AdminLoginLogs) AdminLoginLogBaseResources {\n\treturn AdminLoginLogBaseResources{\n\t\tID:              data.ID,\n\t\tUID:             data.UID,\n\t\tUsername:        data.Username,\n\t\tIP:              data.IP,\n\t\tOS:              data.OS,\n\t\tBrowser:         data.Browser,\n\t\tExecutionTime:   data.ExecutionTime,\n\t\tLoginStatus:     data.LoginStatus,\n\t\tLoginStatusName: data.LoginStatusMap(),\n\t\tLoginFailReason: data.LoginFailReason,\n\t\tType:            data.Type,\n\t\tTypeName:        data.TypeMap(),\n\t\tIsRevoked:       data.IsRevoked,\n\t\tIsRevokedName:   data.IsRevokedMap(),\n\t\tRevokedCode:     data.RevokedCode,\n\t\tRevokedCodeName: data.RevokedCodeMap(),\n\t\tRevokedReason:   data.RevokedReason,\n\t\tRevokedAt:       data.RevokedAt,\n\t\tCreatedAt:       data.CreatedAt,\n\t}\n}\n\n// ToStruct 将登录日志模型转换为详情响应。\nfunc (r AdminLoginLogTransformer) ToStruct(data *model.AdminLoginLogs) *AdminLoginLogResources {\n\tbase := buildAdminLoginLogBaseResources(data)\n\treturn &AdminLoginLogResources{\n\t\tAdminLoginLogBaseResources: base,\n\t\tJwtID:                      data.JwtID,\n\t\tUserAgent:                  data.UserAgent,\n\t\tTokenHash:                  data.TokenHash,\n\t\tRefreshTokenHash:           data.RefreshTokenHash,\n\t\tTokenExpires:               data.TokenExpires,\n\t\tRefreshExpires:             data.RefreshExpires,\n\t\tUpdatedAt:                  data.UpdatedAt,\n\t}\n}\n\n// ToCollection 将登录日志模型集合转换为分页响应。\nfunc (r AdminLoginLogTransformer) ToCollection(page, perPage int, total int64, data []*model.AdminLoginLogs) *Collection {\n\tresponse := make([]any, 0, len(data))\n\tfor _, v := range data {\n\t\tbase := buildAdminLoginLogBaseResources(v)\n\t\tresponse = append(response, &AdminLoginLogListResources{\n\t\t\tAdminLoginLogBaseResources: base,\n\t\t})\n\t}\n\treturn NewCollection().SetPaginate(page, perPage, total).ToCollection(response)\n}\n"
  },
  {
    "path": "internal/resources/login_log_test.go",
    "content": "package resources\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\nfunc TestAdminLoginLogTransformerOnlyExposeTokenHashesInDetail(t *testing.T) {\n\tnow := utils.FormatDate{Time: time.Now()}\n\tresource := NewAdminLoginLogTransformer().ToStruct(&model.AdminLoginLogs{\n\t\tJwtID:            \"jwt-id\",\n\t\tUserAgent:        \"ua\",\n\t\tAccessToken:      \"plain-access-token\",\n\t\tRefreshToken:     \"plain-refresh-token\",\n\t\tTokenHash:        \"access-hash\",\n\t\tRefreshTokenHash: \"refresh-hash\",\n\t\tTokenExpires:     &now,\n\t\tRefreshExpires:   &now,\n\t})\n\n\tif resource.JwtID != \"jwt-id\" || resource.UserAgent != \"ua\" {\n\t\tt.Fatal(\"expected detail basic fields to be preserved\")\n\t}\n\tif resource.TokenHash != \"access-hash\" || resource.RefreshTokenHash != \"refresh-hash\" {\n\t\tt.Fatal(\"expected token hashes to be preserved\")\n\t}\n\tif resource.TokenExpires == nil || resource.RefreshExpires == nil {\n\t\tt.Fatal(\"expected token expiry fields to be preserved\")\n\t}\n\tif !resource.TokenExpires.Time.Equal(now.Time) || !resource.RefreshExpires.Time.Equal(now.Time) {\n\t\tt.Fatal(\"expected token expiry values to be preserved\")\n\t}\n\n\tpayload, err := json.Marshal(resource)\n\tif err != nil {\n\t\tt.Fatalf(\"marshal resource failed: %v\", err)\n\t}\n\tfields := map[string]any{}\n\tif err := json.Unmarshal(payload, &fields); err != nil {\n\t\tt.Fatalf(\"unmarshal resource payload failed: %v\", err)\n\t}\n\tif _, ok := fields[\"access_token\"]; ok {\n\t\tt.Fatal(\"expected access_token to be hidden from detail response\")\n\t}\n\tif _, ok := fields[\"refresh_token\"]; ok {\n\t\tt.Fatal(\"expected refresh_token to be hidden from detail response\")\n\t}\n}\n"
  },
  {
    "path": "internal/resources/menu.go",
    "content": "package resources\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// MenuBaseResources 表示菜单响应的公共字段。\ntype MenuBaseResources struct {\n\tID              uint             `json:\"id\"`\n\tIcon            string           `json:\"icon\"`                  // 图标\n\tTitle           string           `json:\"title,omitempty\"`       // 当前请求语言标题\n\tName            string           `json:\"name\"`                  // 路由名称\n\tCode            string           `json:\"code\"`                  // 前端权限标识\n\tPath            string           `json:\"path\"`                  // 前端路由地址\n\tIsExternalLinks uint8            `json:\"is_external_links\"`     // 是否外链 0:否 1:是\n\tIsAuth          uint8            `json:\"is_auth\"`               // 是否鉴权 0:否 1:是\n\tStatus          uint8            `json:\"status\"`                // 状态，0禁用 1启用\n\tStatusName      string           `json:\"status_name,omitempty\"` // 状态名称\n\tIsShow          uint8            `json:\"is_show\"`               // 是否显示，1是 0否\n\tIsNewWindow     uint8            `json:\"is_new_window\"`         // 是否新窗口打开, 1是 0否\n\tSort            uint             `json:\"sort\"`                  // 排序，数字越大，排名越靠前\n\tType            uint8            `json:\"type\"`                  // 菜单类型，1目录，2菜单，3按钮\n\tTypeName        string           `json:\"type_name,omitempty\"`\n\tPid             uint             `json:\"pid\"`          // 上级菜单id\n\tChildrenNum     uint             `json:\"children_num\"` // 子集数量\n\tDescription     string           `json:\"description\"`  // 描述\n\tComponent       string           `json:\"component\"`    // 前端组件路径\n\tRedirect        string           `json:\"redirect\"`     // 重定向地址\n\tFullPath        string           `json:\"full_path\"`\n\tCreatedAt       utils.FormatDate `json:\"created_at\"`\n\tUpdatedAt       utils.FormatDate `json:\"updated_at\"`\n}\n\n// MenuResources 表示菜单详情响应。\ntype MenuResources struct {\n\tMenuBaseResources\n\tTitleI18n           map[string]string `json:\"title_i18n,omitempty\"` // 多语言标题（仅详情返回）\n\tIsExternalLinksName string            `json:\"is_external_links_name,omitempty\"`\n\tIsAuthName          string            `json:\"is_auth_name,omitempty\"`\n\tIsShowName          string            `json:\"is_show_name,omitempty\"`\n\tISNewWindowName     string            `json:\"is_new_window_name,omitempty\"`\n\tLevel               uint8             `json:\"level\"`            // 层级\n\tAnimateEnter        string            `json:\"animate_enter\"`    // 进入动画\n\tAnimateLeave        string            `json:\"animate_leave\"`    // 离开动画\n\tAnimateDuration     float32           `json:\"animate_duration\"` // 动画时长\n\tChildren            []*MenuResources  `json:\"children,omitempty\"`\n\tApiList             []uint            `json:\"api_list\"`\n}\n\n// MenuTransformer 负责菜单详情资源转换。\ntype MenuTransformer struct {\n\tBaseResources[*model.Menu, *MenuResources]\n}\n\n// NewMenuTransformer 创建菜单资源转换器。\nfunc NewMenuTransformer() MenuTransformer {\n\treturn MenuTransformer{\n\t\tBaseResources: BaseResources[*model.Menu, *MenuResources]{\n\t\t\tNewResource: func() *MenuResources {\n\t\t\t\treturn &MenuResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\n// ToStruct 将菜单模型转换为详情响应。\nfunc (m MenuTransformer) ToStruct(data *model.Menu) *MenuResources {\n\treturn m.ToStructWithTitles(data, \"\", nil)\n}\n\n// ToStructWithTitles 将菜单模型转换为详情响应，并显式注入标题信息。\nfunc (m MenuTransformer) ToStructWithTitles(data *model.Menu, title string, titleI18n map[string]string) *MenuResources {\n\treturn buildMenuResource(data, title, titleI18n)\n}\n\n// ToCollection 将菜单模型集合转换为分页响应。\nfunc (m MenuTransformer) ToCollection(page, perPage int, total int64, data []*model.Menu) *Collection {\n\treturn m.ToCollectionWithTitles(page, perPage, total, data, nil)\n}\n\n// ToCollectionWithTitles 将菜单模型集合转换为分页响应，并显式注入标题信息。\nfunc (m MenuTransformer) ToCollectionWithTitles(page, perPage int, total int64, data []*model.Menu, titleByMenuID map[uint]string) *Collection {\n\tresponse := make([]any, 0, len(data))\n\tfor _, v := range data {\n\t\tresponse = append(response, buildListMenuResource(v, resolveTitleByMenuID(v.ID, titleByMenuID)))\n\t}\n\treturn NewCollection().SetPaginate(page, perPage, total).ToCollection(response)\n}\n\n// buildMenuBaseResources 提取菜单响应的公共字段。\nfunc buildMenuBaseResources(v *model.Menu, title string) MenuBaseResources {\n\treturn MenuBaseResources{\n\t\tID:              v.ID,\n\t\tIcon:            v.Icon,\n\t\tTitle:           title,\n\t\tName:            v.Name,\n\t\tComponent:       v.Component,\n\t\tCode:            v.Code,\n\t\tPath:            v.Path,\n\t\tFullPath:        v.FullPath,\n\t\tRedirect:        v.Redirect,\n\t\tIsExternalLinks: v.IsExternalLinks,\n\t\tIsAuth:          v.IsAuth,\n\t\tStatus:          v.Status,\n\t\tStatusName:      v.StatusMap(),\n\t\tIsShow:          v.IsShow,\n\t\tIsNewWindow:     v.IsNewWindow,\n\t\tSort:            v.Sort,\n\t\tType:            v.Type,\n\t\tTypeName:        v.MenuTypeMap(),\n\t\tPid:             v.Pid,\n\t\tDescription:     v.Description,\n\t\tChildrenNum:     v.ChildrenNum,\n\t\tCreatedAt:       v.CreatedAt,\n\t\tUpdatedAt:       v.UpdatedAt,\n\t}\n}\n\n// buildMenuResource 构建菜单详情响应。\nfunc buildMenuResource(v *model.Menu, title string, titleI18n map[string]string) *MenuResources {\n\tbase := buildMenuBaseResources(v, title)\n\treturn &MenuResources{\n\t\tMenuBaseResources:   base,\n\t\tTitleI18n:           titleI18n,\n\t\tIsExternalLinksName: v.IsExternalLinksMap(),\n\t\tIsAuthName:          v.IsAuthMap(),\n\t\tIsShowName:          v.IsShowMap(),\n\t\tISNewWindowName:     v.IsNewWindowMap(),\n\t\tLevel:               v.Level,\n\t\tAnimateEnter:        v.AnimateEnter,\n\t\tAnimateLeave:        v.AnimateLeave,\n\t\tAnimateDuration:     v.AnimateDuration,\n\t\tApiList:             v.GetApiIds(),\n\t}\n}\n\n// MenuCollectionResources 表示菜单树节点响应。\ntype MenuCollectionResources struct {\n\tMenuBaseResources\n\tChildren []*MenuCollectionResources `json:\"children,omitempty\"`\n}\n\n// SetChildren 设置菜单树节点的子节点。\nfunc (r *MenuCollectionResources) SetChildren(children []*MenuCollectionResources) {\n\tr.Children = children\n}\n\n// GetID 返回当前菜单节点 ID。\nfunc (r *MenuCollectionResources) GetID() uint {\n\treturn r.ID\n}\n\n// GetPID 返回当前菜单节点父级 ID。\nfunc (r *MenuCollectionResources) GetPID() uint {\n\treturn r.Pid\n}\n\n// SetCustomFields 填充菜单树节点的扩展字段。\nfunc (r *MenuCollectionResources) SetCustomFields(data *model.Menu) {\n\tr.TypeName = data.MenuTypeMap()\n}\n\n// buildListMenuResource 构建菜单树节点响应。\nfunc buildListMenuResource(v *model.Menu, title string) *MenuCollectionResources {\n\tbase := buildMenuBaseResources(v, title)\n\treturn &MenuCollectionResources{\n\t\tMenuBaseResources: base,\n\t\tChildren:          []*MenuCollectionResources{},\n\t}\n}\n\n// BuildMenuTree 构建菜单树，并显式注入本地化标题。\nfunc BuildMenuTree(data []*model.Menu, rootID uint, titleByMenuID map[uint]string) []*MenuCollectionResources {\n\tif len(data) == 0 {\n\t\treturn []*MenuCollectionResources{}\n\t}\n\n\tparentMap := make(map[uint][]*model.Menu)\n\tfor _, item := range data {\n\t\tif item == nil {\n\t\t\tcontinue\n\t\t}\n\t\tparentMap[item.Pid] = append(parentMap[item.Pid], item)\n\t}\n\n\tvar build func(parentID uint) []*MenuCollectionResources\n\tbuild = func(parentID uint) []*MenuCollectionResources {\n\t\tchildren := parentMap[parentID]\n\t\ttree := make([]*MenuCollectionResources, 0, len(children))\n\t\tfor _, menu := range children {\n\t\t\tnode := buildListMenuResource(menu, resolveTitleByMenuID(menu.ID, titleByMenuID))\n\t\t\tnode.TypeName = menu.MenuTypeMap()\n\t\t\tnode.Children = build(menu.ID)\n\t\t\ttree = append(tree, node)\n\t\t}\n\t\treturn tree\n\t}\n\n\treturn build(rootID)\n}\n\nfunc resolveTitleByMenuID(menuID uint, titleByMenuID map[uint]string) string {\n\tif len(titleByMenuID) == 0 {\n\t\treturn \"\"\n\t}\n\treturn titleByMenuID[menuID]\n}\n"
  },
  {
    "path": "internal/resources/request_log.go",
    "content": "package resources\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// RequestLogBaseResources 表示请求日志的公共响应字段。\ntype RequestLogBaseResources struct {\n\tID                  uint             `json:\"id\"`\n\tRequestID           string           `json:\"request_id\"`            // 请求唯一标识\n\tOperatorID          uint             `json:\"operator_id\"`           // 操作ID（用户ID）\n\tIP                  string           `json:\"ip\"`                    // 客户端IP地址\n\tMethod              string           `json:\"method\"`                // HTTP请求方法（GET/POST等）\n\tBaseURL             string           `json:\"base_url\"`              // 请求基础URL\n\tOperationName       string           `json:\"operation_name\"`        // 操作名称\n\tOperationStatus     int              `json:\"operation_status\"`      // 操作状态码（响应返回的code，0=成功，其他=失败）\n\tOperationStatusName string           `json:\"operation_status_name\"` // 操作状态名称\n\tIsHighRisk          uint8            `json:\"is_high_risk\"`          // 是否高危操作\n\tOperatorAccount     string           `json:\"operator_account\"`      // 操作账号\n\tOperatorName        string           `json:\"operator_name\"`         // 操作人员\n\tResponseStatus      int              `json:\"response_status\"`       // 响应状态码\n\tExecutionTime       float64          `json:\"execution_time\"`        // 执行时间（毫秒，支持小数，最多4位）\n\tCreatedAt           utils.FormatDate `json:\"created_at\"`            // 创建时间\n}\n\n// RequestLogListResources 表示请求日志列表项。\ntype RequestLogListResources struct {\n\tRequestLogBaseResources\n}\n\n// RequestLogResources 表示请求日志详情响应。\ntype RequestLogResources struct {\n\tRequestLogBaseResources\n\tJwtID          string           `json:\"jwt_id\"`          // 请求授权的jwtId\n\tUserAgent      string           `json:\"user_agent\"`      // 用户代理（浏览器/设备信息）\n\tOS             string           `json:\"os\"`              // 操作系统\n\tBrowser        string           `json:\"browser\"`         // 浏览器\n\tRequestHeaders string           `json:\"request_headers\"` // 请求头（JSON格式）\n\tRequestQuery   string           `json:\"request_query\"`   // 请求参数\n\tRequestBody    string           `json:\"request_body\"`    // 请求体\n\tChangeDiff     string           `json:\"change_diff\"`     // 关键变更前后差异（JSON）\n\tResponseBody   string           `json:\"response_body\"`   // 响应体\n\tResponseHeader string           `json:\"response_header\"` // 响应头\n\tUpdatedAt      utils.FormatDate `json:\"updated_at\"`      // 更新时间\n}\n\n// RequestLogTransformer 负责请求日志资源转换。\ntype RequestLogTransformer struct {\n\tBaseResources[*model.RequestLogs, *RequestLogResources]\n}\n\n// NewRequestLogTransformer 创建请求日志资源转换器。\nfunc NewRequestLogTransformer() RequestLogTransformer {\n\treturn RequestLogTransformer{\n\t\tBaseResources: BaseResources[*model.RequestLogs, *RequestLogResources]{\n\t\t\tNewResource: func() *RequestLogResources {\n\t\t\t\treturn &RequestLogResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\n// buildRequestLogBaseResources 提取请求日志公共字段。\nfunc buildRequestLogBaseResources(data *model.RequestLogs) RequestLogBaseResources {\n\treturn RequestLogBaseResources{\n\t\tID:                  data.ID,\n\t\tRequestID:           data.RequestID,\n\t\tOperatorID:          data.OperatorID,\n\t\tIP:                  data.IP,\n\t\tMethod:              data.Method,\n\t\tBaseURL:             data.BaseURL,\n\t\tOperationName:       data.OperationName,\n\t\tOperationStatus:     data.OperationStatus,\n\t\tOperationStatusName: getOperationStatusName(data.OperationStatus),\n\t\tIsHighRisk:          data.IsHighRisk,\n\t\tOperatorAccount:     data.OperatorAccount,\n\t\tOperatorName:        data.OperatorName,\n\t\tResponseStatus:      data.ResponseStatus,\n\t\tExecutionTime:       data.ExecutionTime,\n\t\tCreatedAt:           data.CreatedAt,\n\t}\n}\n\n// ToStruct 将请求日志模型转换为详情响应。\nfunc (r RequestLogTransformer) ToStruct(data *model.RequestLogs) *RequestLogResources {\n\tbase := buildRequestLogBaseResources(data)\n\treturn &RequestLogResources{\n\t\tRequestLogBaseResources: base,\n\t\tJwtID:                   data.JwtID,\n\t\tUserAgent:               data.UserAgent,\n\t\tOS:                      data.OS,\n\t\tBrowser:                 data.Browser,\n\t\tRequestHeaders:          data.RequestHeaders,\n\t\tRequestQuery:            data.RequestQuery,\n\t\tRequestBody:             data.RequestBody,\n\t\tChangeDiff:              data.ChangeDiff,\n\t\tResponseBody:            data.ResponseBody,\n\t\tResponseHeader:          data.ResponseHeader,\n\t\tUpdatedAt:               data.UpdatedAt,\n\t}\n}\n\n// ToCollection 将请求日志模型集合转换为分页响应。\nfunc (r RequestLogTransformer) ToCollection(page, perPage int, total int64, data []*model.RequestLogs) *Collection {\n\tresponse := make([]any, 0, len(data))\n\tfor _, v := range data {\n\t\tbase := buildRequestLogBaseResources(v)\n\t\tresponse = append(response, &RequestLogListResources{\n\t\t\tRequestLogBaseResources: base,\n\t\t})\n\t}\n\treturn NewCollection().SetPaginate(page, perPage, total).ToCollection(response)\n}\n\n// getOperationStatusName 将业务码映射为结果名称。\nfunc getOperationStatusName(code int) string {\n\tif code == 0 {\n\t\treturn \"成功\"\n\t}\n\treturn \"失败\"\n}\n"
  },
  {
    "path": "internal/resources/role.go",
    "content": "package resources\n\nimport (\n\t\"github.com/samber/lo\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// RoleResources 表示角色详情和树节点的响应结构。\ntype RoleResources struct {\n\tID          uint             `json:\"id\"`\n\tCode        string           `json:\"code\"`\n\tIsSystem    uint8            `json:\"is_system\"`\n\tPid         uint             `json:\"pid\"`\n\tName        string           `json:\"name\"`\n\tDescription string           `json:\"description\"`\n\tLevel       uint8            `json:\"level\"`\n\tSort        uint16           `json:\"sort\"`\n\tChildrenNum uint             `json:\"children_num\"`\n\tStatus      uint8            `json:\"status\"`\n\tStatusName  string           `json:\"status_name\"` // 状态名称\n\tMenuList    []uint           `json:\"menu_list\"`\n\tCreatedAt   utils.FormatDate `json:\"created_at\"`\n\tUpdatedAt   utils.FormatDate `json:\"updated_at\"`\n}\n\n// GetID 返回当前角色节点 ID。\nfunc (r *RoleResources) GetID() uint {\n\treturn r.ID\n}\n\n// GetPID 返回当前角色节点父级 ID。\nfunc (r *RoleResources) GetPID() uint {\n\treturn r.Pid\n}\n\n// SetCustomFields 填充角色资源的扩展字段。\nfunc (r *RoleResources) SetCustomFields(data *model.Role) {\n\tr.MenuList = []uint{}\n\tif data == nil {\n\t\treturn\n\t}\n\tr.Code = data.Code\n\tr.IsSystem = data.IsSystem\n\t// 设置映射字段\n\tr.StatusName = data.StatusMap()\n\tr.MenuList = lo.Map(data.MenuList, func(m model.RoleMenuMap, _ int) uint {\n\t\treturn m.MenuId\n\t})\n}\n\n// RoleTransformer 负责角色资源转换。\ntype RoleTransformer struct {\n\tBaseResources[*model.Role, *RoleResources]\n}\n\n// NewRoleTransformer 创建角色资源转换器。\nfunc NewRoleTransformer() RoleTransformer {\n\treturn RoleTransformer{\n\t\tBaseResources: BaseResources[*model.Role, *RoleResources]{\n\t\t\tNewResource: func() *RoleResources {\n\t\t\t\treturn &RoleResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "internal/resources/session.go",
    "content": "package resources\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// SessionResources 在线会话响应结构。\ntype SessionResources struct {\n\tID            uint              `json:\"id\"`\n\tUID           uint              `json:\"uid\"`\n\tUsername      string            `json:\"username\"`\n\tJwtID         string            `json:\"jwt_id\"`\n\tIP            string            `json:\"ip\"`\n\tOS            string            `json:\"os\"`\n\tBrowser       string            `json:\"browser\"`\n\tIsRevoked     uint8             `json:\"is_revoked\"`\n\tRevokedReason string            `json:\"revoked_reason\"`\n\tRevokedAt     *utils.FormatDate `json:\"revoked_at\"`\n\tTokenExpires  *utils.FormatDate `json:\"token_expires\"`\n\tCreatedAt     utils.FormatDate  `json:\"created_at\"`\n}\n\n// SessionTransformer 在线会话转换器。\ntype SessionTransformer struct {\n\tBaseResources[*model.AdminLoginLogs, *SessionResources]\n}\n\nfunc NewSessionTransformer() SessionTransformer {\n\treturn SessionTransformer{\n\t\tBaseResources: BaseResources[*model.AdminLoginLogs, *SessionResources]{\n\t\t\tNewResource: func() *SessionResources {\n\t\t\t\treturn &SessionResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "internal/resources/sys_config.go",
    "content": "package resources\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// SysConfigResources 系统参数响应结构。\ntype SysConfigResources struct {\n\tID             uint              `json:\"id\"`\n\tConfigKey      string            `json:\"config_key\"`\n\tConfigName     string            `json:\"config_name\"`\n\tConfigNameI18n map[string]string `json:\"config_name_i18n,omitempty\"`\n\tConfigValue    string            `json:\"config_value\"`\n\tValueType      string            `json:\"value_type\"`\n\tGroupCode      string            `json:\"group_code\"`\n\tIsSystem       uint8             `json:\"is_system\"`\n\tIsSensitive    uint8             `json:\"is_sensitive\"`\n\tIsVisible      uint8             `json:\"is_visible\"`\n\tManageTab      string            `json:\"manage_tab\"`\n\tStatus         uint8             `json:\"status\"`\n\tSort           uint              `json:\"sort\"`\n\tRemark         string            `json:\"remark\"`\n\tCreatedAt      utils.FormatDate  `json:\"created_at\"`\n\tUpdatedAt      utils.FormatDate  `json:\"updated_at\"`\n}\n\n// SysConfigTransformer 负责系统参数资源转换。\ntype SysConfigTransformer struct {\n\tBaseResources[*model.SysConfig, *SysConfigResources]\n}\n\nfunc NewSysConfigTransformer() SysConfigTransformer {\n\treturn SysConfigTransformer{\n\t\tBaseResources: BaseResources[*model.SysConfig, *SysConfigResources]{\n\t\t\tNewResource: func() *SysConfigResources {\n\t\t\t\treturn &SysConfigResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "internal/resources/sys_dict.go",
    "content": "package resources\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// SysDictTypeResources 系统字典类型响应结构。\ntype SysDictTypeResources struct {\n\tID           uint              `json:\"id\"`\n\tTypeCode     string            `json:\"type_code\"`\n\tTypeName     string            `json:\"type_name\"`\n\tTypeNameI18n map[string]string `json:\"type_name_i18n,omitempty\"`\n\tIsSystem     uint8             `json:\"is_system\"`\n\tStatus       uint8             `json:\"status\"`\n\tSort         uint              `json:\"sort\"`\n\tRemark       string            `json:\"remark\"`\n\tCreatedAt    utils.FormatDate  `json:\"created_at\"`\n\tUpdatedAt    utils.FormatDate  `json:\"updated_at\"`\n}\n\n// SysDictItemResources 系统字典项响应结构。\ntype SysDictItemResources struct {\n\tID        uint              `json:\"id\"`\n\tTypeCode  string            `json:\"type_code\"`\n\tLabel     string            `json:\"label\"`\n\tLabelI18n map[string]string `json:\"label_i18n,omitempty\"`\n\tValue     string            `json:\"value\"`\n\tColor     string            `json:\"color\"`\n\tTagType   string            `json:\"tag_type\"`\n\tIsDefault uint8             `json:\"is_default\"`\n\tIsSystem  uint8             `json:\"is_system\"`\n\tStatus    uint8             `json:\"status\"`\n\tSort      uint              `json:\"sort\"`\n\tRemark    string            `json:\"remark\"`\n\tCreatedAt utils.FormatDate  `json:\"created_at\"`\n\tUpdatedAt utils.FormatDate  `json:\"updated_at\"`\n}\n\n// SysDictOptionResources 前端下拉选项响应结构。\ntype SysDictOptionResources struct {\n\tLabel     string `json:\"label\"`\n\tValue     string `json:\"value\"`\n\tColor     string `json:\"color\"`\n\tTagType   string `json:\"tag_type\"`\n\tIsDefault uint8  `json:\"is_default\"`\n}\n\ntype SysDictTypeTransformer struct {\n\tBaseResources[*model.SysDictType, *SysDictTypeResources]\n}\n\ntype SysDictItemTransformer struct {\n\tBaseResources[*model.SysDictItem, *SysDictItemResources]\n}\n\nfunc NewSysDictTypeTransformer() SysDictTypeTransformer {\n\treturn SysDictTypeTransformer{\n\t\tBaseResources: BaseResources[*model.SysDictType, *SysDictTypeResources]{\n\t\t\tNewResource: func() *SysDictTypeResources {\n\t\t\t\treturn &SysDictTypeResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc NewSysDictItemTransformer() SysDictItemTransformer {\n\treturn SysDictItemTransformer{\n\t\tBaseResources: BaseResources[*model.SysDictItem, *SysDictItemResources]{\n\t\t\tNewResource: func() *SysDictItemResources {\n\t\t\t\treturn &SysDictItemResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc ToSysDictOptions(items []model.SysDictItem) []SysDictOptionResources {\n\toptions := make([]SysDictOptionResources, 0, len(items))\n\tfor _, item := range items {\n\t\toptions = append(options, SysDictOptionResources{\n\t\t\tLabel:     item.Label,\n\t\t\tValue:     item.Value,\n\t\t\tColor:     item.Color,\n\t\t\tTagType:   item.TagType,\n\t\t\tIsDefault: item.IsDefault,\n\t\t})\n\t}\n\treturn options\n}\n"
  },
  {
    "path": "internal/resources/task_center.go",
    "content": "package resources\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// TaskDefinitionResources 任务定义响应结构。\ntype TaskDefinitionResources struct {\n\tID          uint             `json:\"id\"`\n\tCode        string           `json:\"code\"`\n\tName        string           `json:\"name\"`\n\tKind        string           `json:\"kind\"`\n\tQueue       string           `json:\"queue\"`\n\tCronSpec    string           `json:\"cron_spec\"`\n\tHandler     string           `json:\"handler\"`\n\tStatus      uint8            `json:\"status\"`\n\tAllowManual uint8            `json:\"allow_manual\"`\n\tAllowRetry  uint8            `json:\"allow_retry\"`\n\tIsHighRisk  uint8            `json:\"is_high_risk\"`\n\tRemark      string           `json:\"remark\"`\n\tCreatedAt   utils.FormatDate `json:\"created_at\"`\n\tUpdatedAt   utils.FormatDate `json:\"updated_at\"`\n}\n\n// TaskDefinitionTransformer 任务定义资源转换器。\ntype TaskDefinitionTransformer struct {\n\tBaseResources[*model.TaskDefinition, *TaskDefinitionResources]\n}\n\nfunc NewTaskDefinitionTransformer() TaskDefinitionTransformer {\n\treturn TaskDefinitionTransformer{\n\t\tBaseResources: BaseResources[*model.TaskDefinition, *TaskDefinitionResources]{\n\t\t\tNewResource: func() *TaskDefinitionResources {\n\t\t\t\treturn &TaskDefinitionResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\n// TaskRunBaseResources 任务执行记录公共字段。\ntype TaskRunBaseResources struct {\n\tID             uint              `json:\"id\"`\n\tTaskCode       string            `json:\"task_code\"`\n\tKind           string            `json:\"kind\"`\n\tSource         string            `json:\"source\"`\n\tSourceID       string            `json:\"source_id\"`\n\tQueue          string            `json:\"queue\"`\n\tStatus         string            `json:\"status\"`\n\tAttempt        int               `json:\"attempt\"`\n\tMaxRetry       int               `json:\"max_retry\"`\n\tErrorMessage   string            `json:\"error_message\"`\n\tDurationMS     float64           `json:\"duration_ms\"`\n\tStartedAt      *utils.FormatDate `json:\"started_at\"`\n\tFinishedAt     *utils.FormatDate `json:\"finished_at\"`\n\tCreatedAt      utils.FormatDate  `json:\"created_at\"`\n\tTriggerUserID  uint              `json:\"trigger_user_id\"`\n\tTriggerAccount string            `json:\"trigger_account\"`\n}\n\n// TaskRunListResources 任务执行记录列表响应。\ntype TaskRunListResources struct {\n\tTaskRunBaseResources\n}\n\n// TaskRunResources 任务执行记录详情响应。\ntype TaskRunResources struct {\n\tTaskRunBaseResources\n\tPayload   string           `json:\"payload\"`\n\tUpdatedAt utils.FormatDate `json:\"updated_at\"`\n}\n\n// TaskRunEventResources 任务执行事件响应。\ntype TaskRunEventResources struct {\n\tID        uint             `json:\"id\"`\n\tRunID     uint             `json:\"run_id\"`\n\tEventType string           `json:\"event_type\"`\n\tMessage   string           `json:\"message\"`\n\tMeta      string           `json:\"meta\"`\n\tCreatedAt utils.FormatDate `json:\"created_at\"`\n}\n\n// TaskRunTransformer 任务执行记录资源转换器。\ntype TaskRunTransformer struct {\n\tBaseResources[*model.TaskRun, *TaskRunResources]\n}\n\nfunc NewTaskRunTransformer() TaskRunTransformer {\n\treturn TaskRunTransformer{\n\t\tBaseResources: BaseResources[*model.TaskRun, *TaskRunResources]{\n\t\t\tNewResource: func() *TaskRunResources {\n\t\t\t\treturn &TaskRunResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc buildTaskRunBaseResources(data *model.TaskRun) TaskRunBaseResources {\n\treturn TaskRunBaseResources{\n\t\tID:             data.ID,\n\t\tTaskCode:       data.TaskCode,\n\t\tKind:           data.Kind,\n\t\tSource:         data.Source,\n\t\tSourceID:       data.SourceID,\n\t\tQueue:          data.Queue,\n\t\tStatus:         data.Status,\n\t\tAttempt:        data.Attempt,\n\t\tMaxRetry:       data.MaxRetry,\n\t\tErrorMessage:   data.ErrorMessage,\n\t\tDurationMS:     data.DurationMS,\n\t\tStartedAt:      data.StartedAt,\n\t\tFinishedAt:     data.FinishedAt,\n\t\tCreatedAt:      data.CreatedAt,\n\t\tTriggerUserID:  data.TriggerUserID,\n\t\tTriggerAccount: data.TriggerAccount,\n\t}\n}\n\nfunc (r TaskRunTransformer) ToStruct(data *model.TaskRun) *TaskRunResources {\n\tbase := buildTaskRunBaseResources(data)\n\treturn &TaskRunResources{\n\t\tTaskRunBaseResources: base,\n\t\tPayload:              data.Payload,\n\t\tUpdatedAt:            data.UpdatedAt,\n\t}\n}\n\nfunc (r TaskRunTransformer) ToCollection(page, perPage int, total int64, data []*model.TaskRun) *Collection {\n\tresponse := make([]any, 0, len(data))\n\tfor _, v := range data {\n\t\tbase := buildTaskRunBaseResources(v)\n\t\tresponse = append(response, &TaskRunListResources{\n\t\t\tTaskRunBaseResources: base,\n\t\t})\n\t}\n\treturn NewCollection().SetPaginate(page, perPage, total).ToCollection(response)\n}\n\n// TaskRunEventTransformer 任务执行事件转换器。\ntype TaskRunEventTransformer struct {\n\tBaseResources[*model.TaskRunEvent, *TaskRunEventResources]\n}\n\nfunc NewTaskRunEventTransformer() TaskRunEventTransformer {\n\treturn TaskRunEventTransformer{\n\t\tBaseResources: BaseResources[*model.TaskRunEvent, *TaskRunEventResources]{\n\t\t\tNewResource: func() *TaskRunEventResources {\n\t\t\t\treturn &TaskRunEventResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n\n// CronTaskStateResources 定时任务最近状态响应结构。\ntype CronTaskStateResources struct {\n\tID             uint              `json:\"id\"`\n\tTaskCode       string            `json:\"task_code\"`\n\tCronSpec       string            `json:\"cron_spec\"`\n\tLastRunID      uint              `json:\"last_run_id\"`\n\tLastStatus     string            `json:\"last_status\"`\n\tLastStartedAt  *utils.FormatDate `json:\"last_started_at\"`\n\tLastFinishedAt *utils.FormatDate `json:\"last_finished_at\"`\n\tNextRunAt      *utils.FormatDate `json:\"next_run_at\"`\n\tLastError      string            `json:\"last_error\"`\n\tUpdatedAt      utils.FormatDate  `json:\"updated_at\"`\n}\n\n// CronTaskStateTransformer 定时任务状态资源转换器。\ntype CronTaskStateTransformer struct {\n\tBaseResources[*model.CronTaskState, *CronTaskStateResources]\n}\n\nfunc NewCronTaskStateTransformer() CronTaskStateTransformer {\n\treturn CronTaskStateTransformer{\n\t\tBaseResources: BaseResources[*model.CronTaskState, *CronTaskStateResources]{\n\t\t\tNewResource: func() *CronTaskStateResources {\n\t\t\t\treturn &CronTaskStateResources{}\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "internal/routers/admin_router.go",
    "content": "package routers\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n)\n\n// 路由构建辅助函数（减少重复代码）\nfunc GET(path, title string, auth AuthMode, handlers ...gin.HandlerFunc) RouteDef {\n\treturn RouteDef{Method: http.MethodGet, Path: path, Title: title, Auth: auth, Handlers: handlers}\n}\n\nfunc POST(path, title string, auth AuthMode, handlers ...gin.HandlerFunc) RouteDef {\n\treturn RouteDef{Method: http.MethodPost, Path: path, Title: title, Auth: auth, Handlers: handlers}\n}\n\n// AdminRouteTree 返回管理员后台路由声明树。\n// deps: 控制器依赖容器，传入 nil 则使用默认实现\nfunc AdminRouteTree(deps *ControllerDeps) RouteGroupDef {\n\tdeps = normalizeControllerDeps(deps)\n\n\treturn RouteGroupDef{\n\t\tPrefix: \"admin/v1\",\n\t\tChildren: []RouteGroupDef{\n\t\t\tadminOtherGroup(deps),\n\t\t\tadminAuthGroup(deps),\n\t\t},\n\t}\n}\n\n// adminOtherGroup 其他路由组（公开接口、登录等）\nfunc adminOtherGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tGroupCode: \"other\",\n\t\tRoutes: []RouteDef{\n\t\t\tGET(\"demo\", \"Demo 示例\", AuthModeNone, deps.Demo.HelloWorld).WithDesc(\"Demo 示例备注\"),\n\t\t\tGET(\"file/:uuid\", \"获取文件\", AuthModeNone, middleware.DatabaseReadyGuard(), deps.Common.GetFile),\n\t\t},\n\t\tChildren: []RouteGroupDef{\n\t\t\t{\n\t\t\t\tGroupCode: \"login\",\n\t\t\t\tRoutes: []RouteDef{\n\t\t\t\t\tPOST(\"login\", \"登录\", AuthModeNone, middleware.DatabaseReadyGuard(), deps.Login.Login).WithDesc(\"用户登录接口\"),\n\t\t\t\t\tGET(\"login-captcha\", \"验证码\", AuthModeNone, deps.Login.LoginCaptcha).WithDesc(\"获取登录验证码\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// adminAuthGroup 需要认证的路由组\nfunc adminAuthGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tMiddleware: []gin.HandlerFunc{middleware.OptionalDatabaseReadyGuard(), middleware.AdminAuthHandler()},\n\t\tChildren: []RouteGroupDef{\n\t\t\tcommonGroup(deps),\n\t\t\tdashboardGroup(deps),\n\t\t\tauthGroup(deps),\n\t\t\tadminUserGroup(deps),\n\t\t\tpermissionGroup(deps),\n\t\t\tmenuGroup(deps),\n\t\t\troleGroup(deps),\n\t\t\tdeptGroup(deps),\n\t\t\tsystemGroup(deps),\n\t\t\tlogGroup(deps),\n\t\t\ttaskGroup(deps),\n\t\t},\n\t}\n}\n\nfunc dashboardGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tPrefix:    \"dashboard\",\n\t\tGroupCode: \"dashboard\",\n\t\tRoutes: []RouteDef{\n\t\t\tGET(\"overview\", \"仪表盘概览\", AuthModeLogin, deps.Dashboard.Overview),\n\t\t},\n\t}\n}\n\n// commonGroup 通用接口组\nfunc commonGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tPrefix:    \"common\",\n\t\tGroupCode: \"common\",\n\t\tRoutes: []RouteDef{\n\t\t\tPOST(\"upload\", \"上传文件\", AuthModeLogin, deps.Common.Upload),\n\t\t},\n\t}\n}\n\n// authGroup 认证相关接口组\nfunc authGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tPrefix:    \"auth\",\n\t\tGroupCode: \"auth\",\n\t\tRoutes: []RouteDef{\n\t\t\tPOST(\"logout\", \"退出登录\", AuthModeLogin, deps.Login.Logout),\n\t\t\tGET(\"check-token\", \"检查 Token\", AuthModeLogin, deps.Login.CheckToken).WithDesc(\"验证 Token 有效性\"),\n\t\t},\n\t\tChildren: []RouteGroupDef{\n\t\t\t{\n\t\t\t\tPrefix:    \"session\",\n\t\t\t\tGroupCode: \"session\",\n\t\t\t\tRoutes: []RouteDef{\n\t\t\t\t\tGET(\"list\", \"在线会话列表\", AuthModeAuth, deps.Session.List),\n\t\t\t\t\tPOST(\"revoke\", \"撤销在线会话\", AuthModeAuth, deps.Session.Revoke),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// adminUserGroup 管理员用户管理组\nfunc adminUserGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tPrefix:    \"admin-user\",\n\t\tGroupCode: \"adminUser\",\n\t\tRoutes: []RouteDef{\n\t\t\t// 个人资料（AuthModeLogin：只需登录）\n\t\t\tGET(\"get\", \"获取当前用户信息\", AuthModeLogin, deps.AdminUser.GetUserInfo),\n\t\t\tGET(\"user-menu-info\", \"获取用户权限信息\", AuthModeLogin, deps.AdminUser.GetUserMenuInfo),\n\t\t\tPOST(\"update-profile\", \"更新个人资料\", AuthModeLogin, deps.AdminUser.UpdateProfile),\n\n\t\t\t// 用户管理（AuthModeAuth：需要权限）\n\t\t\tGET(\"list\", \"管理员列表\", AuthModeAuth, deps.AdminUser.List),\n\t\t\tGET(\"detail\", \"管理员详情\", AuthModeAuth, deps.AdminUser.Detail),\n\t\t\tGET(\"get-full-phone\", \"获取完整手机号\", AuthModeAuth, deps.AdminUser.GetFullPhone).WithDesc(\"脱敏前完整手机号\"),\n\t\t\tGET(\"get-full-email\", \"获取完整邮箱\", AuthModeAuth, deps.AdminUser.GetFullEmail).WithDesc(\"脱敏前完整邮箱\"),\n\t\t\tPOST(\"create\", \"新增管理员\", AuthModeAuth, deps.AdminUser.Create),\n\t\t\tPOST(\"update\", \"更新管理员\", AuthModeAuth, deps.AdminUser.Update),\n\t\t\tPOST(\"delete\", \"删除管理员\", AuthModeAuth, deps.AdminUser.Delete),\n\t\t\tPOST(\"bind-role\", \"绑定角色\", AuthModeAuth, deps.AdminUser.BindRole),\n\t\t},\n\t}\n}\n\n// permissionGroup 接口权限管理组\nfunc permissionGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tPrefix:    \"permission\",\n\t\tGroupCode: \"api\",\n\t\tRoutes: []RouteDef{\n\t\t\tPOST(\"update\", \"更新接口\", AuthModeAuth, deps.Api.Update),\n\t\t\tGET(\"list\", \"接口列表\", AuthModeAuth, deps.Api.List),\n\t\t},\n\t}\n}\n\n// menuGroup 菜单管理组\nfunc menuGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tPrefix:    \"menu\",\n\t\tGroupCode: \"menu\",\n\t\tRoutes: []RouteDef{\n\t\t\tGET(\"list\", \"菜单列表\", AuthModeAuth, deps.Menu.List),\n\t\t\tPOST(\"delete\", \"删除菜单\", AuthModeAuth, deps.Menu.Delete),\n\t\t\tPOST(\"create\", \"新增菜单\", AuthModeAuth, deps.Menu.Create),\n\t\t\tPOST(\"update\", \"更新菜单\", AuthModeAuth, deps.Menu.Update),\n\t\t\tPOST(\"update-all-menu-permissions\", \"刷新菜单权限缓存\", AuthModeAuth, deps.Menu.UpdateAllMenuPermissions),\n\t\t\tGET(\"detail\", \"菜单详情\", AuthModeAuth, deps.Menu.Detail),\n\t\t},\n\t}\n}\n\n// roleGroup 角色管理组\nfunc roleGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tPrefix:    \"role\",\n\t\tGroupCode: \"role\",\n\t\tRoutes: []RouteDef{\n\t\t\tGET(\"list\", \"角色列表\", AuthModeAuth, deps.Role.List),\n\t\t\tPOST(\"create\", \"新增角色\", AuthModeAuth, deps.Role.Create),\n\t\t\tPOST(\"update\", \"更新角色\", AuthModeAuth, deps.Role.Update),\n\t\t\tGET(\"detail\", \"角色详情\", AuthModeAuth, deps.Role.Detail),\n\t\t\tPOST(\"delete\", \"删除角色\", AuthModeAuth, deps.Role.Delete),\n\t\t},\n\t}\n}\n\n// deptGroup 部门管理组\nfunc deptGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tPrefix:    \"department\",\n\t\tGroupCode: \"department\",\n\t\tRoutes: []RouteDef{\n\t\t\tGET(\"list\", \"部门列表\", AuthModeAuth, deps.Dept.List),\n\t\t\tPOST(\"create\", \"新增部门\", AuthModeAuth, deps.Dept.Create),\n\t\t\tPOST(\"update\", \"更新部门\", AuthModeAuth, deps.Dept.Update),\n\t\t\tGET(\"detail\", \"部门详情\", AuthModeAuth, deps.Dept.Detail),\n\t\t\tPOST(\"delete\", \"删除部门\", AuthModeAuth, deps.Dept.Delete),\n\t\t\tPOST(\"bind-role\", \"部门绑定角色\", AuthModeAuth, deps.Dept.BindRole),\n\t\t},\n\t}\n}\n\n// systemGroup 系统管理组\nfunc systemGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tPrefix:    \"system\",\n\t\tGroupCode: \"system\",\n\t\tChildren: []RouteGroupDef{\n\t\t\t{\n\t\t\t\tPrefix:    \"config\",\n\t\t\t\tGroupCode: \"sysConfig\",\n\t\t\t\tRoutes: []RouteDef{\n\t\t\t\t\tGET(\"list\", \"系统参数列表\", AuthModeAuth, deps.SysConfig.List),\n\t\t\t\t\tGET(\"detail\", \"系统参数详情\", AuthModeAuth, deps.SysConfig.Detail),\n\t\t\t\t\tGET(\"value\", \"获取系统参数值\", AuthModeAuth, deps.SysConfig.Value),\n\t\t\t\t\tPOST(\"create\", \"新增系统参数\", AuthModeAuth, deps.SysConfig.Create),\n\t\t\t\t\tPOST(\"update\", \"更新系统参数\", AuthModeAuth, deps.SysConfig.Update),\n\t\t\t\t\tPOST(\"delete\", \"删除系统参数\", AuthModeAuth, deps.SysConfig.Delete),\n\t\t\t\t\tPOST(\"refresh\", \"刷新系统参数缓存\", AuthModeAuth, deps.SysConfig.Refresh),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tPrefix:    \"dict\",\n\t\t\t\tGroupCode: \"sysDict\",\n\t\t\t\tRoutes: []RouteDef{\n\t\t\t\t\tGET(\"type/list\", \"字典类型列表\", AuthModeAuth, deps.SysDict.TypeList),\n\t\t\t\t\tGET(\"type/detail\", \"字典类型详情\", AuthModeAuth, deps.SysDict.TypeDetail),\n\t\t\t\t\tPOST(\"type/create\", \"新增字典类型\", AuthModeAuth, deps.SysDict.TypeCreate),\n\t\t\t\t\tPOST(\"type/update\", \"更新字典类型\", AuthModeAuth, deps.SysDict.TypeUpdate),\n\t\t\t\t\tPOST(\"type/delete\", \"删除字典类型\", AuthModeAuth, deps.SysDict.TypeDelete),\n\t\t\t\t\tGET(\"item/list\", \"字典项列表\", AuthModeAuth, deps.SysDict.ItemList),\n\t\t\t\t\tPOST(\"item/create\", \"新增字典项\", AuthModeAuth, deps.SysDict.ItemCreate),\n\t\t\t\t\tPOST(\"item/update\", \"更新字典项\", AuthModeAuth, deps.SysDict.ItemUpdate),\n\t\t\t\t\tPOST(\"item/delete\", \"删除字典项\", AuthModeAuth, deps.SysDict.ItemDelete),\n\t\t\t\t\tGET(\"options\", \"字典选项\", AuthModeAuth, deps.SysDict.Options),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tPrefix:    \"file\",\n\t\t\t\tGroupCode: \"file\",\n\t\t\t\tRoutes: []RouteDef{\n\t\t\t\t\tGET(\"list\", \"文件资源列表\", AuthModeAuth, deps.File.List),\n\t\t\t\t\tGET(\"detail\", \"文件资源详情\", AuthModeAuth, deps.File.Detail),\n\t\t\t\t\tGET(\"folder/tree\", \"文件目录树\", AuthModeAuth, deps.File.FolderTree),\n\t\t\t\t\tPOST(\"folder/create\", \"创建文件目录\", AuthModeAuth, deps.File.FolderCreate),\n\t\t\t\t\tPOST(\"folder/update\", \"更新文件目录\", AuthModeAuth, deps.File.FolderUpdate),\n\t\t\t\t\tPOST(\"folder/delete\", \"删除文件目录\", AuthModeAuth, deps.File.FolderDelete),\n\t\t\t\t\tPOST(\"folder/move\", \"移动文件目录\", AuthModeAuth, deps.File.FolderMove),\n\t\t\t\t\tPOST(\"move\", \"移动文件资源\", AuthModeAuth, deps.File.Move),\n\t\t\t\t\tPOST(\"upload/local\", \"本地上传文件资源\", AuthModeAuth, deps.File.UploadLocal),\n\t\t\t\t\tPOST(\"upload/credential\", \"获取文件直传凭证\", AuthModeAuth, deps.File.UploadCredential),\n\t\t\t\t\tPOST(\"upload/complete\", \"完成文件直传登记\", AuthModeAuth, deps.File.UploadComplete),\n\t\t\t\t\tPOST(\"delete\", \"删除文件资源\", AuthModeAuth, deps.File.Delete),\n\t\t\t\t\tGET(\"trash/list\", \"文件回收站列表\", AuthModeAuth, deps.File.TrashList),\n\t\t\t\t\tPOST(\"trash/restore\", \"恢复文件资源\", AuthModeAuth, deps.File.Restore),\n\t\t\t\t\tPOST(\"trash/destroy\", \"硬删除文件资源\", AuthModeAuth, deps.File.Destroy),\n\t\t\t\t\tGET(\"references\", \"文件引用列表\", AuthModeAuth, deps.File.References),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tPrefix:    \"storage\",\n\t\t\t\tGroupCode: \"storage\",\n\t\t\t\tRoutes: []RouteDef{\n\t\t\t\t\tGET(\"config\", \"存储配置\", AuthModeAuth, deps.Storage.Config),\n\t\t\t\t\tPOST(\"config\", \"保存存储配置\", AuthModeAuth, deps.Storage.Save),\n\t\t\t\t\tPOST(\"test\", \"测试存储配置\", AuthModeAuth, deps.Storage.Test),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// logGroup 日志管理组\nfunc logGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tPrefix:    \"log\",\n\t\tGroupCode: \"log\",\n\t\tChildren: []RouteGroupDef{\n\t\t\t{\n\t\t\t\tPrefix: \"request\",\n\t\t\t\tRoutes: []RouteDef{\n\t\t\t\t\tGET(\"list\", \"请求日志列表\", AuthModeAuth, deps.RequestLog.List),\n\t\t\t\t\tGET(\"detail\", \"请求日志详情\", AuthModeAuth, deps.RequestLog.Detail),\n\t\t\t\t\tGET(\"export\", \"导出请求日志\", AuthModeAuth, deps.RequestLog.Export),\n\t\t\t\t\tGET(\"mask-config\", \"获取请求日志脱敏配置\", AuthModeAuth, deps.RequestLog.MaskConfig),\n\t\t\t\t\tPOST(\"mask-config\", \"更新请求日志脱敏配置\", AuthModeAuth, deps.RequestLog.UpdateMaskConfig),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tPrefix: \"login\",\n\t\t\t\tRoutes: []RouteDef{\n\t\t\t\t\tGET(\"list\", \"登录日志列表\", AuthModeAuth, deps.LoginLog.List),\n\t\t\t\t\tGET(\"detail\", \"登录日志详情\", AuthModeAuth, deps.LoginLog.Detail),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// taskGroup 任务中心组\nfunc taskGroup(deps *ControllerDeps) RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tPrefix:    \"task\",\n\t\tGroupCode: \"task\",\n\t\tRoutes: []RouteDef{\n\t\t\tGET(\"list\", \"任务定义列表\", AuthModeAuth, deps.TaskCenter.TaskList),\n\t\t\tPOST(\"trigger\", \"手动触发任务\", AuthModeAuth, deps.TaskCenter.Trigger),\n\t\t},\n\t\tChildren: []RouteGroupDef{\n\t\t\t{\n\t\t\t\tPrefix: \"run\",\n\t\t\t\tRoutes: []RouteDef{\n\t\t\t\t\tGET(\"list\", \"任务执行记录列表\", AuthModeAuth, deps.TaskCenter.RunList),\n\t\t\t\t\tGET(\"detail\", \"任务执行记录详情\", AuthModeAuth, deps.TaskCenter.RunDetail),\n\t\t\t\t\tGET(\"events\", \"任务执行事件列表\", AuthModeAuth, deps.TaskCenter.RunEvents),\n\t\t\t\t\tPOST(\"retry\", \"重试失败任务\", AuthModeAuth, deps.TaskCenter.Retry),\n\t\t\t\t\tPOST(\"cancel\", \"取消任务\", AuthModeAuth, deps.TaskCenter.Cancel),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tPrefix: \"cron\",\n\t\t\t\tRoutes: []RouteDef{\n\t\t\t\t\tGET(\"state\", \"定时任务最近状态\", AuthModeAuth, deps.TaskCenter.CronStateList),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "internal/routers/defs.go",
    "content": "package routers\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n)\n\n// AuthMode 定义路由认证授权模式。\ntype AuthMode = global.ApiAuthMode\n\nconst (\n\t// AuthModeNone 无需登录，无需权限校验（如：登录、验证码、公开 API）\n\tAuthModeNone = global.ApiAuthModeNone\n\t// AuthModeLogin 需要登录，但无需菜单权限校验（如：获取当前用户信息、退出登录）\n\tAuthModeLogin = global.ApiAuthModeLogin\n\t// AuthModeAuth 需要登录且需要api权限校验（如：增删改查业务数据）\n\tAuthModeAuth = global.ApiAuthModeAuth\n)\n\n// RouteDef 定义单条路由。\n// 建议使用辅助函数 GET()/POST() 创建，避免手写冗长结构。\ntype RouteDef struct {\n\tMethod   string            // HTTP 方法：GET, POST, PUT, DELETE 等\n\tPath     string            // 相对路径，如 \"list\", \":id\"\n\tTitle    string            // 路由标题，用于 API 文档\n\tDesc     string            // 路由描述，补充 Title 未涵盖的信息\n\tAuth     AuthMode          // 认证授权模式，使用 AuthModeNone/Login/Auth\n\tHandlers []gin.HandlerFunc // Gin 处理器链\n}\n\n// WithDesc 设置路由描述，便于在路由声明时按需补充说明。\nfunc (r RouteDef) WithDesc(desc string) RouteDef {\n\tr.Desc = desc\n\treturn r\n}\n\n// RouteGroupDef 定义一组共享前缀、中间件和分组编码的路由。\n// 用于组织路由树结构，支持嵌套分组。\ntype RouteGroupDef struct {\n\tPrefix     string            // 路由前缀，如 \"admin/v1\", \"user\"\n\tGroupCode  string            // 分组编码，用于权限分组和 API 文档归类\n\tMiddleware []gin.HandlerFunc // 组内路由共享的中间件（按顺序执行）\n\tRoutes     []RouteDef        // 直接子路由列表\n\tChildren   []RouteGroupDef   // 嵌套子分组（支持无限层级）\n}\n\n// RouteMeta 表示写入 API 权限表所需的路由元数据。\n// 由 RouteDef 派生，不包含 Handlers（避免序列化）。\ntype RouteMeta struct {\n\tMethod    string   // HTTP 方法\n\tPath      string   // 完整路径（含前缀）\n\tTitle     string   // 路由标题\n\tDesc      string   // 路由描述\n\tAuth      AuthMode // 认证授权模式\n\tGroupCode string   // 所属分组编码\n}\n\n// RouteMetaMap 按 method+path 的哈希值保存路由元数据。\n// 用于快速查找和权限校验。\ntype RouteMetaMap map[string]*RouteMeta\n"
  },
  {
    "path": "internal/routers/deps.go",
    "content": "package routers\n\nimport (\n\t\"sync\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\tadmin_v1 \"github.com/wannanbigpig/gin-layout/internal/controller/admin_v1\"\n)\n\n// ControllerDeps 控制器依赖容器。\n// 所有控制器在此集中注册，便于单元测试替换和灰度切换。\ntype ControllerDeps struct {\n\tDemo       *controller.DemoController\n\tLogin      *admin_v1.LoginController\n\tCommon     *admin_v1.CommonController\n\tDashboard  *admin_v1.DashboardController\n\tAdminUser  *admin_v1.AdminUserController\n\tApi        *admin_v1.ApiController\n\tMenu       *admin_v1.MenuController\n\tRole       *admin_v1.RoleController\n\tDept       *admin_v1.DeptController\n\tSysConfig  *admin_v1.SysConfigController\n\tSysDict    *admin_v1.SysDictController\n\tStorage    *admin_v1.StorageConfigController\n\tFile       *admin_v1.FileResourceController\n\tSession    *admin_v1.SessionController\n\tRequestLog *admin_v1.RequestLogController\n\tLoginLog   *admin_v1.AdminLoginLogController\n\tTaskCenter *admin_v1.TaskCenterController\n}\n\nvar defaultDepsOnce sync.Once\nvar defaultDeps *ControllerDeps\n\n// DefaultControllerDeps 返回默认控制器依赖（生产环境使用）。\n// 使用 sync.Once 确保只初始化一次，提升性能。\nfunc DefaultControllerDeps() *ControllerDeps {\n\tdefaultDepsOnce.Do(func() {\n\t\tdefaultDeps = &ControllerDeps{\n\t\t\tDemo:       controller.NewDemoController(),\n\t\t\tLogin:      admin_v1.NewLoginController(),\n\t\t\tCommon:     admin_v1.NewCommonController(),\n\t\t\tDashboard:  admin_v1.NewDashboardController(),\n\t\t\tAdminUser:  admin_v1.NewAdminUserController(),\n\t\t\tApi:        admin_v1.NewApiController(),\n\t\t\tMenu:       admin_v1.NewMenuController(),\n\t\t\tRole:       admin_v1.NewRoleController(),\n\t\t\tDept:       admin_v1.NewDeptController(),\n\t\t\tSysConfig:  admin_v1.NewSysConfigController(),\n\t\t\tSysDict:    admin_v1.NewSysDictController(),\n\t\t\tStorage:    admin_v1.NewStorageConfigController(),\n\t\t\tFile:       admin_v1.NewFileResourceController(),\n\t\t\tSession:    admin_v1.NewSessionController(),\n\t\t\tRequestLog: admin_v1.NewRequestLogController(),\n\t\t\tLoginLog:   admin_v1.NewAdminLoginLogController(),\n\t\t\tTaskCenter: admin_v1.NewTaskCenterController(),\n\t\t}\n\t})\n\treturn defaultDeps\n}\n\nfunc normalizeControllerDeps(deps *ControllerDeps) *ControllerDeps {\n\tdefaultDeps := DefaultControllerDeps()\n\tif deps == nil {\n\t\treturn defaultDeps\n\t}\n\tif deps.Demo == nil {\n\t\tdeps.Demo = defaultDeps.Demo\n\t}\n\tif deps.Login == nil {\n\t\tdeps.Login = defaultDeps.Login\n\t}\n\tif deps.Common == nil {\n\t\tdeps.Common = defaultDeps.Common\n\t}\n\tif deps.Dashboard == nil {\n\t\tdeps.Dashboard = defaultDeps.Dashboard\n\t}\n\tif deps.AdminUser == nil {\n\t\tdeps.AdminUser = defaultDeps.AdminUser\n\t}\n\tif deps.Api == nil {\n\t\tdeps.Api = defaultDeps.Api\n\t}\n\tif deps.Menu == nil {\n\t\tdeps.Menu = defaultDeps.Menu\n\t}\n\tif deps.Role == nil {\n\t\tdeps.Role = defaultDeps.Role\n\t}\n\tif deps.Dept == nil {\n\t\tdeps.Dept = defaultDeps.Dept\n\t}\n\tif deps.SysConfig == nil {\n\t\tdeps.SysConfig = defaultDeps.SysConfig\n\t}\n\tif deps.SysDict == nil {\n\t\tdeps.SysDict = defaultDeps.SysDict\n\t}\n\tif deps.Storage == nil {\n\t\tdeps.Storage = defaultDeps.Storage\n\t}\n\tif deps.File == nil {\n\t\tdeps.File = defaultDeps.File\n\t}\n\tif deps.Session == nil {\n\t\tdeps.Session = defaultDeps.Session\n\t}\n\tif deps.RequestLog == nil {\n\t\tdeps.RequestLog = defaultDeps.RequestLog\n\t}\n\tif deps.LoginLog == nil {\n\t\tdeps.LoginLog = defaultDeps.LoginLog\n\t}\n\tif deps.TaskCenter == nil {\n\t\tdeps.TaskCenter = defaultDeps.TaskCenter\n\t}\n\treturn deps\n}\n\n// MockControllerDeps 返回测试用控制器依赖（可传入 mock 实现）。\nfunc MockControllerDeps(deps *ControllerDeps) *ControllerDeps {\n\tif deps == nil {\n\t\treturn DefaultControllerDeps()\n\t}\n\treturn normalizeControllerDeps(deps)\n}\n"
  },
  {
    "path": "internal/routers/meta.go",
    "content": "package routers\n\nimport \"github.com/wannanbigpig/gin-layout/pkg/utils\"\n\n// CollectRouteMeta 根据路由树递归收集路由元数据。\nfunc CollectRouteMeta(root RouteGroupDef) RouteMetaMap {\n\tmetaMap := make(RouteMetaMap)\n\tcollectRouteMeta(metaMap, root, \"\", \"\")\n\treturn metaMap\n}\n\nfunc collectRouteMeta(metaMap RouteMetaMap, group RouteGroupDef, basePath, inheritedGroupCode string) {\n\tfullPrefix := joinFullPath(basePath, group.Prefix)\n\tgroupCode := inheritedGroupCode\n\tif group.GroupCode != \"\" {\n\t\tgroupCode = group.GroupCode\n\t}\n\n\tfor _, route := range group.Routes {\n\t\tfullPath := joinFullPath(fullPrefix, route.Path)\n\t\tmeta := &RouteMeta{\n\t\t\tMethod:    route.Method,\n\t\t\tPath:      fullPath,\n\t\t\tTitle:     route.Title,\n\t\t\tDesc:      route.Desc,\n\t\t\tAuth:      route.Auth,\n\t\t\tGroupCode: groupCode,\n\t\t}\n\t\tmetaMap[utils.MD5(meta.Method+\"_\"+meta.Path)] = meta\n\t}\n\n\tfor _, child := range group.Children {\n\t\tcollectRouteMeta(metaMap, child, fullPrefix, groupCode)\n\t}\n}\n"
  },
  {
    "path": "internal/routers/register.go",
    "content": "package routers\n\nimport (\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// RegisterRoutes 根据路由树递归注册 Gin 路由。\nfunc RegisterRoutes(engine *gin.Engine, root RouteGroupDef) {\n\tregisterGroup(&engine.RouterGroup, root)\n}\n\nfunc registerGroup(routes *gin.RouterGroup, group RouteGroupDef) {\n\tcurrent := routes\n\tif group.Prefix != \"\" || len(group.Middleware) > 0 {\n\t\tcurrent = routes.Group(normalizeRelativePath(group.Prefix), group.Middleware...)\n\t}\n\n\tfor _, route := range group.Routes {\n\t\tcurrent.Handle(route.Method, normalizeRelativePath(route.Path), route.Handlers...)\n\t}\n\n\tfor _, child := range group.Children {\n\t\tregisterGroup(current, child)\n\t}\n}\n\nfunc normalizeRelativePath(path string) string {\n\ttrimmed := strings.Trim(path, \"/\")\n\tif trimmed == \"\" {\n\t\treturn \"\"\n\t}\n\treturn \"/\" + trimmed\n}\n\nfunc joinFullPath(parts ...string) string {\n\tsegments := make([]string, 0, len(parts))\n\tfor _, part := range parts {\n\t\ttrimmed := strings.Trim(part, \"/\")\n\t\tif trimmed != \"\" {\n\t\t\tsegments = append(segments, trimmed)\n\t\t}\n\t}\n\tif len(segments) == 0 {\n\t\treturn \"/\"\n\t}\n\treturn \"/\" + strings.Join(segments, \"/\")\n}\n"
  },
  {
    "path": "internal/routers/router.go",
    "content": "package routers\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/middleware\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tresponse2 \"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n)\n\n// SetRouters 创建 Gin 引擎并注册全部应用路由。\nfunc SetRouters() (*gin.Engine, error) {\n\treturn SetRoutersWithTree(AppRouteTree())\n}\n\n// SetRoutersWithTree 使用指定路由树创建 Gin 引擎并注册路由。\nfunc SetRoutersWithTree(routeTree RouteGroupDef) (*gin.Engine, error) {\n\t// 启动时校验路由树\n\tif err := ValidateRouteTree(routeTree); err != nil {\n\t\treturn nil, fmt.Errorf(\"route tree validation failed: %w\", err)\n\t}\n\n\tengine, err := createEngine()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tRegisterRoutes(engine, routeTree)\n\n\t// 统一处理 404\n\tengine.NoRoute(func(c *gin.Context) {\n\t\tresponse2.Resp().SetHttpCode(http.StatusNotFound).FailCode(c, errors.NotFound)\n\t})\n\n\treturn engine, nil\n}\n\n// createEngine 创建 gin 引擎并设置相关中间件\nfunc createEngine() (*gin.Engine, error) {\n\tvar engine *gin.Engine\n\tcfg := config.GetConfig()\n\n\tif cfg != nil && cfg.Debug {\n\t\t// 开发调试模式\n\t\tengine = gin.New()\n\t\tengine.Use(\n\t\t\tmiddleware.CorsHandler(),\n\t\t\tmiddleware.RequestCostHandler(), // 请求耗时统计\n\t\t\tmiddleware.RequestLocaleHandler(),\n\t\t\tmiddleware.ParseTokenHandler(), // 全局token解析（所有路由都走）\n\t\t\tgin.Logger(),\n\t\t\tmiddleware.CustomRecovery(),\n\t\t\tmiddleware.CustomLogger(),\n\t\t)\n\n\t} else {\n\t\t// 生产模式\n\t\tengine = ReleaseRouter()\n\t\tengine.Use(\n\t\t\tmiddleware.CorsHandler(),\n\t\t\tmiddleware.RequestCostHandler(), // 请求耗时统计\n\t\t\tmiddleware.RequestLocaleHandler(),\n\t\t\tmiddleware.ParseTokenHandler(), // 全局token解析（所有路由都走）\n\t\t\tmiddleware.CustomRecovery(),\n\t\t\tmiddleware.CustomLogger(),\n\t\t)\n\t}\n\t// 配置受信任代理，决定是否信任 X-Forwarded-For / X-Real-IP 等代理头。\n\ttrustedProxies := []string(nil)\n\tif cfg != nil {\n\t\ttrustedProxies = cfg.TrustedProxies\n\t}\n\tif err := engine.SetTrustedProxies(trustedProxies); err != nil {\n\t\treturn nil, fmt.Errorf(\"set trusted proxies failed: %w\", err)\n\t}\n\n\treturn engine, nil\n}\n\n// ReleaseRouter 生产模式使用官方建议设置为 release 模式\nfunc ReleaseRouter() *gin.Engine {\n\t// 切换到生产模式\n\tgin.SetMode(gin.ReleaseMode)\n\t// 禁用 gin 输出接口访问日志\n\tgin.DefaultWriter = io.Discard\n\n\tengine := gin.New()\n\n\treturn engine\n}\n\n// AppRouteTree 返回应用完整路由树。\nfunc AppRouteTree() RouteGroupDef {\n\treturn RouteGroupDef{\n\t\tRoutes: []RouteDef{\n\t\t\t{\n\t\t\t\tMethod: http.MethodGet,\n\t\t\t\tPath:   \"ping\",\n\t\t\t\tTitle:  \"ping\",\n\t\t\t\tDesc:   \"服务心跳检测接口\",\n\t\t\t\tAuth:   AuthModeNone,\n\t\t\t\tHandlers: []gin.HandlerFunc{func(c *gin.Context) {\n\t\t\t\t\tc.String(http.StatusOK, \"pong\")\n\t\t\t\t}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tMethod: http.MethodGet,\n\t\t\t\tPath:   \"health/readiness\",\n\t\t\t\tTitle:  \"readiness\",\n\t\t\t\tDesc:   \"服务依赖就绪状态\",\n\t\t\t\tAuth:   AuthModeNone,\n\t\t\t\tHandlers: []gin.HandlerFunc{func(c *gin.Context) {\n\t\t\t\t\tstatus := buildReadinessStatus()\n\t\t\t\t\thttpCode := http.StatusOK\n\t\t\t\t\tif !status.Ready {\n\t\t\t\t\t\thttpCode = http.StatusServiceUnavailable\n\t\t\t\t\t}\n\t\t\t\t\tc.JSON(httpCode, status)\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t\tChildren: []RouteGroupDef{AdminRouteTree(nil)},\n\t}\n}\n\ntype readinessStatus struct {\n\tReady        bool               `json:\"ready\"`\n\tTimestamp    string             `json:\"timestamp\"`\n\tDependencies readinessComponent `json:\"dependencies\"`\n}\n\ntype readinessComponent struct {\n\tMysql dependencyStatus `json:\"mysql\"`\n\tRedis dependencyStatus `json:\"redis\"`\n\tQueue dependencyStatus `json:\"queue\"`\n}\n\ntype dependencyStatus struct {\n\tEnabled  bool   `json:\"enabled\"`\n\tRequired bool   `json:\"required\"`\n\tReady    bool   `json:\"ready\"`\n\tMessage  string `json:\"message,omitempty\"`\n}\n\nfunc buildReadinessStatus() readinessStatus {\n\tcfg := config.GetConfig()\n\tmysqlStatus := buildMySQLReadiness(cfg)\n\tredisStatus := buildRedisReadiness(cfg)\n\tqueueStatus := buildQueueReadiness(cfg)\n\n\tready := mysqlStatus.Ready\n\tif redisStatus.Enabled && !redisStatus.Ready {\n\t\tready = false\n\t}\n\tif queueStatus.Enabled && !queueStatus.Ready {\n\t\tready = false\n\t}\n\n\treturn readinessStatus{\n\t\tReady:     ready,\n\t\tTimestamp: time.Now().Format(time.RFC3339Nano),\n\t\tDependencies: readinessComponent{\n\t\t\tMysql: mysqlStatus,\n\t\t\tRedis: redisStatus,\n\t\t\tQueue: queueStatus,\n\t\t},\n\t}\n}\n\nfunc buildMySQLReadiness(cfg *config.Conf) dependencyStatus {\n\tif cfg == nil || !cfg.Mysql.Enable {\n\t\treturn dependencyStatus{\n\t\t\tEnabled:  false,\n\t\t\tRequired: true,\n\t\t\tReady:    false,\n\t\t\tMessage:  \"mysql is disabled\",\n\t\t}\n\t}\n\n\tmysqlStatus := data.MysqlRuntimeStatus()\n\tif mysqlStatus.Ready {\n\t\treturn dependencyStatus{\n\t\t\tEnabled:  true,\n\t\t\tRequired: true,\n\t\t\tReady:    true,\n\t\t}\n\t}\n\n\tmessage := \"mysql connection is unavailable\"\n\tif mysqlStatus.Error != nil {\n\t\tmessage = mysqlStatus.Error.Error()\n\t}\n\treturn dependencyStatus{\n\t\tEnabled:  true,\n\t\tRequired: true,\n\t\tReady:    false,\n\t\tMessage:  message,\n\t}\n}\n\nfunc buildRedisReadiness(cfg *config.Conf) dependencyStatus {\n\tif cfg == nil || !cfg.Redis.Enable {\n\t\treturn dependencyStatus{\n\t\t\tEnabled:  false,\n\t\t\tRequired: false,\n\t\t\tReady:    false,\n\t\t\tMessage:  \"redis is disabled\",\n\t\t}\n\t}\n\n\tredisStatus := data.RedisRuntimeStatus()\n\tif redisStatus.Ready {\n\t\treturn dependencyStatus{\n\t\t\tEnabled:  true,\n\t\t\tRequired: false,\n\t\t\tReady:    true,\n\t\t}\n\t}\n\n\tmessage := \"redis client is unavailable\"\n\tif redisStatus.Error != nil {\n\t\tmessage = redisStatus.Error.Error()\n\t}\n\treturn dependencyStatus{\n\t\tEnabled:  true,\n\t\tRequired: false,\n\t\tReady:    false,\n\t\tMessage:  message,\n\t}\n}\n\nfunc buildQueueReadiness(cfg *config.Conf) dependencyStatus {\n\tif cfg == nil || !cfg.Queue.Enable {\n\t\treturn dependencyStatus{\n\t\t\tEnabled:  false,\n\t\t\tRequired: false,\n\t\t\tReady:    false,\n\t\t\tMessage:  \"queue is disabled\",\n\t\t}\n\t}\n\n\tif queue.PublisherOrNil() != nil {\n\t\treturn dependencyStatus{\n\t\t\tEnabled:  true,\n\t\t\tRequired: false,\n\t\t\tReady:    true,\n\t\t}\n\t}\n\n\tmessage := \"queue publisher is unavailable\"\n\tif err := queue.PublisherInitError(); err != nil {\n\t\tmessage = err.Error()\n\t}\n\treturn dependencyStatus{\n\t\tEnabled:  true,\n\t\tRequired: false,\n\t\tReady:    false,\n\t\tMessage:  message,\n\t}\n}\n"
  },
  {
    "path": "internal/routers/router_deps_test.go",
    "content": "package routers\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/controller\"\n\tadmin_v1 \"github.com/wannanbigpig/gin-layout/internal/controller/admin_v1\"\n)\n\n// TestAdminRouteTree_WithCustomDeps 测试使用自定义依赖的路由树\nfunc TestAdminRouteTree_WithCustomDeps(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\t// 构建依赖容器（全部使用默认实现）\n\tdeps := &ControllerDeps{\n\t\tDemo:       controller.NewDemoController(),\n\t\tLogin:      admin_v1.NewLoginController(),\n\t\tCommon:     admin_v1.NewCommonController(),\n\t\tAdminUser:  admin_v1.NewAdminUserController(),\n\t\tApi:        admin_v1.NewApiController(),\n\t\tMenu:       admin_v1.NewMenuController(),\n\t\tRole:       admin_v1.NewRoleController(),\n\t\tDept:       admin_v1.NewDeptController(),\n\t\tRequestLog: admin_v1.NewRequestLogController(),\n\t\tLoginLog:   admin_v1.NewAdminLoginLogController(),\n\t}\n\n\t// 构建路由树\n\trouteTree := AdminRouteTree(deps)\n\n\t// 验证路由树非空\n\tassert.NotNil(t, routeTree)\n\tassert.Equal(t, \"admin/v1\", routeTree.Prefix)\n}\n\n// TestValidateRouteTree 测试路由树校验\nfunc TestValidateRouteTree(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\t// 测试正常路由树\n\trouteTree := AppRouteTree()\n\terr := ValidateRouteTree(routeTree)\n\tassert.NoError(t, err)\n\n\t// 测试空 Handler 的路由\n\tinvalidTree := RouteGroupDef{\n\t\tPrefix: \"test\",\n\t\tRoutes: []RouteDef{\n\t\t\t{\n\t\t\t\tMethod:   http.MethodGet,\n\t\t\t\tPath:     \"invalid\",\n\t\t\t\tHandlers: []gin.HandlerFunc{}, // 空 handler\n\t\t\t},\n\t\t},\n\t}\n\terr = ValidateRouteTree(invalidTree)\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"no handlers registered\")\n}\n\n// TestAdminRouteTree_DefaultDeps 测试使用默认依赖\nfunc TestAdminRouteTree_DefaultDeps(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\t// 传入 nil 应该使用默认依赖\n\trouteTree := AdminRouteTree(nil)\n\n\tassert.NotNil(t, routeTree)\n\tassert.Equal(t, \"admin/v1\", routeTree.Prefix)\n\n\t// 验证路由树可以正常遍历\n\terr := ValidateRouteTree(routeTree)\n\tassert.NoError(t, err)\n}\n\n// TestCollectRouteMeta 测试路由元数据收集\nfunc TestCollectRouteMeta(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tmetaMap := CollectRouteMeta(AppRouteTree())\n\tassert.NotEmpty(t, metaMap)\n}\n\n// BenchmarkAdminRouteTree 性能测试\nfunc BenchmarkAdminRouteTree(b *testing.B) {\n\tgin.SetMode(gin.TestMode)\n\n\tb.ResetTimer()\n\tfor i := 0; i < b.N; i++ {\n\t\trouteTree := AdminRouteTree(nil)\n\t\t_ = ValidateRouteTree(routeTree)\n\t}\n}\n\n// TestRouterIntegration 集成测试示例\nfunc TestRouterIntegration(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\t// 创建测试引擎\n\tengine := gin.New()\n\n\t// 使用简化路由树进行测试\n\ttestTree := RouteGroupDef{\n\t\tPrefix: \"test\",\n\t\tRoutes: []RouteDef{\n\t\t\t{\n\t\t\t\tMethod: http.MethodGet,\n\t\t\t\tPath:   \"ping\",\n\t\t\t\tAuth:   AuthModeNone,\n\t\t\t\tHandlers: []gin.HandlerFunc{\n\t\t\t\t\tfunc(c *gin.Context) {\n\t\t\t\t\t\tc.String(http.StatusOK, \"pong\")\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tRegisterRoutes(engine, testTree)\n\n\t// 发起测试请求\n\treq, _ := http.NewRequest(http.MethodGet, \"/test/ping\", nil)\n\tw := httptest.NewRecorder()\n\tengine.ServeHTTP(w, req)\n\n\tassert.Equal(t, http.StatusOK, w.Code)\n\tassert.Equal(t, \"pong\", w.Body.String())\n}\n"
  },
  {
    "path": "internal/routers/router_test.go",
    "content": "package routers\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\nfunc TestSetRoutersRegistersApiMetadata(t *testing.T) {\n\troutes := CollectRouteMeta(AppRouteTree())\n\n\tmenuCode := utils.MD5(http.MethodPost + \"_/admin/v1/menu/update\")\n\tmenuRoute, ok := routes[menuCode]\n\tif !ok {\n\t\tt.Fatalf(\"missing route metadata for menu update\")\n\t}\n\tif menuRoute.GroupCode != \"menu\" {\n\t\tt.Fatalf(\"unexpected group code: %s\", menuRoute.GroupCode)\n\t}\n\tif menuRoute.Auth != AuthModeAuth {\n\t\tt.Fatalf(\"unexpected auth flag: %d\", menuRoute.Auth)\n\t}\n}\n\nfunc TestSetRoutersRegistersPermissionCriticalRoutes(t *testing.T) {\n\troutes := CollectRouteMeta(AppRouteTree())\n\n\tcheckTokenCode := utils.MD5(http.MethodGet + \"_/admin/v1/auth/check-token\")\n\tif route, ok := routes[checkTokenCode]; !ok || route.Auth != AuthModeLogin {\n\t\tt.Fatalf(\"missing or invalid auth check-token route: %#v\", route)\n\t}\n\n\tupdateProfileCode := utils.MD5(http.MethodPost + \"_/admin/v1/admin-user/update-profile\")\n\tif route, ok := routes[updateProfileCode]; !ok || route.Auth != AuthModeLogin {\n\t\tt.Fatalf(\"missing or invalid update-profile route: %#v\", route)\n\t}\n\n\tfileCode := utils.MD5(http.MethodGet + \"_/admin/v1/file/:uuid\")\n\tif route, ok := routes[fileCode]; !ok || route.Auth != AuthModeNone {\n\t\tt.Fatalf(\"missing or invalid file route: %#v\", route)\n\t}\n\n\tstorageConfigCode := utils.MD5(http.MethodGet + \"_/admin/v1/system/storage/config\")\n\tif route, ok := routes[storageConfigCode]; !ok || route.Auth != AuthModeAuth {\n\t\tt.Fatalf(\"missing or invalid storage config route: %#v\", route)\n\t}\n\n\tfileDestroyCode := utils.MD5(http.MethodPost + \"_/admin/v1/system/file/trash/destroy\")\n\tif route, ok := routes[fileDestroyCode]; !ok || route.Auth != AuthModeAuth {\n\t\tt.Fatalf(\"missing or invalid file destroy route: %#v\", route)\n\t}\n}\n\nfunc TestSetRoutersRegistersCriticalRoutes(t *testing.T) {\n\tengine, err := SetRouters()\n\tif err != nil {\n\t\tt.Fatalf(\"SetRouters returned error: %v\", err)\n\t}\n\trouteMap := make(map[string]bool)\n\tfor _, route := range engine.Routes() {\n\t\trouteMap[route.Method+\" \"+route.Path] = true\n\t}\n\n\trequired := []string{\n\t\thttp.MethodGet + \" /ping\",\n\t\thttp.MethodGet + \" /admin/v1/admin-user/list\",\n\t\thttp.MethodPost + \" /admin/v1/permission/update\",\n\t\thttp.MethodGet + \" /admin/v1/menu/list\",\n\t\thttp.MethodGet + \" /admin/v1/role/list\",\n\t\thttp.MethodGet + \" /admin/v1/department/list\",\n\t\thttp.MethodGet + \" /admin/v1/log/request/list\",\n\t\thttp.MethodGet + \" /admin/v1/log/login/list\",\n\t}\n\n\tfor _, route := range required {\n\t\tif !routeMap[route] {\n\t\t\tt.Fatalf(\"missing registered route: %s\", route)\n\t\t}\n\t}\n}\n\nfunc TestLoginRouteReturnsDependencyNotReadyWhenMysqlUnavailable(t *testing.T) {\n\trestoreMysql := disableMysqlForRouterTest(t)\n\tdefer restoreMysql()\n\n\tengine, err := SetRouters()\n\tif err != nil {\n\t\tt.Fatalf(\"SetRouters returned error: %v\", err)\n\t}\n\n\trecorder := httptest.NewRecorder()\n\trequest := httptest.NewRequest(http.MethodPost, \"/admin/v1/login\", strings.NewReader(`{}`))\n\trequest.Header.Set(\"Content-Type\", \"application/json\")\n\tengine.ServeHTTP(recorder, request)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, recorder.Code)\n\t}\n\n\tvar result routeResult\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal login response: %v\", err)\n\t}\n\tif result.Code != e.ServiceDependencyNotReady {\n\t\tt.Fatalf(\"expected code %d, got %d\", e.ServiceDependencyNotReady, result.Code)\n\t}\n}\n\nfunc TestLoginCaptchaRouteRemainsAvailableWithoutMysql(t *testing.T) {\n\trestoreMysql := disableMysqlForRouterTest(t)\n\tdefer restoreMysql()\n\n\tengine, err := SetRouters()\n\tif err != nil {\n\t\tt.Fatalf(\"SetRouters returned error: %v\", err)\n\t}\n\n\trecorder := httptest.NewRecorder()\n\trequest := httptest.NewRequest(http.MethodGet, \"/admin/v1/login-captcha\", nil)\n\tengine.ServeHTTP(recorder, request)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusOK, recorder.Code)\n\t}\n\n\tvar result routeResult\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal login captcha response: %v\", err)\n\t}\n\tif result.Code != e.SUCCESS {\n\t\tt.Fatalf(\"expected code %d, got %d\", e.SUCCESS, result.Code)\n\t}\n}\n\nfunc TestReadinessRouteReportsMysqlUnavailable(t *testing.T) {\n\trestoreState := disableDependenciesForReadinessTest(t)\n\tdefer restoreState()\n\n\tengine, err := SetRouters()\n\tif err != nil {\n\t\tt.Fatalf(\"SetRouters returned error: %v\", err)\n\t}\n\n\trecorder := httptest.NewRecorder()\n\trequest := httptest.NewRequest(http.MethodGet, \"/health/readiness\", nil)\n\tengine.ServeHTTP(recorder, request)\n\n\tif recorder.Code != http.StatusServiceUnavailable {\n\t\tt.Fatalf(\"expected status %d, got %d\", http.StatusServiceUnavailable, recorder.Code)\n\t}\n\n\tvar status readinessStatus\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &status); err != nil {\n\t\tt.Fatalf(\"unmarshal readiness response: %v\", err)\n\t}\n\tif status.Ready {\n\t\tt.Fatal(\"expected readiness to be false when mysql is unavailable\")\n\t}\n\tif status.Dependencies.Mysql.Ready {\n\t\tt.Fatal(\"expected mysql readiness to be false\")\n\t}\n\tif !status.Dependencies.Mysql.Required {\n\t\tt.Fatal(\"expected mysql to be marked as required\")\n\t}\n}\n\ntype routeResult struct {\n\tCode int `json:\"code\"`\n}\n\nfunc disableMysqlForRouterTest(t *testing.T) func() {\n\tt.Helper()\n\n\trestoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {\n\t\tcfg.Mysql.Enable = false\n\t})\n\tif err := data.CloseMysql(); err != nil {\n\t\tt.Fatalf(\"close mysql: %v\", err)\n\t}\n\n\treturn func() {\n\t\trestoreConfig()\n\t\tif err := data.CloseMysql(); err != nil {\n\t\t\tt.Fatalf(\"close mysql: %v\", err)\n\t\t}\n\t}\n}\n\nfunc disableDependenciesForReadinessTest(t *testing.T) func() {\n\tt.Helper()\n\n\trestoreConfig := config.UpdateConfigForTesting(func(cfg *config.Conf) {\n\t\tcfg.Mysql.Enable = false\n\t\tcfg.Redis.Enable = false\n\t\tcfg.Queue.Enable = false\n\t})\n\tif err := data.CloseMysql(); err != nil {\n\t\tt.Fatalf(\"close mysql: %v\", err)\n\t}\n\tif err := data.CloseRedis(); err != nil {\n\t\tt.Fatalf(\"close redis: %v\", err)\n\t}\n\n\treturn func() {\n\t\trestoreConfig()\n\t\tif err := data.CloseRedis(); err != nil {\n\t\t\tt.Fatalf(\"close redis: %v\", err)\n\t\t}\n\t\tif err := data.CloseMysql(); err != nil {\n\t\t\tt.Fatalf(\"close mysql: %v\", err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/routers/validate.go",
    "content": "package routers\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n)\n\n// RouteTreeError 路由树校验错误。\ntype RouteTreeError struct {\n\tPath    string\n\tMessage string\n}\n\nfunc (e *RouteTreeError) Error() string {\n\treturn fmt.Sprintf(\"route tree error at %s: %s\", e.Path, e.Message)\n}\n\n// ValidateRouteTree 校验路由树的完整性（启动时调用）。\n// 检查项：\n// 1. 路由路径非空且合法\n// 2. HTTP 方法合法\n// 3. Handler 非空\n// 4. 重复路由检测\nfunc ValidateRouteTree(root RouteGroupDef) error {\n\treturn validateRouteTree(root, \"\", make(map[string]bool))\n}\n\nfunc validateRouteTree(group RouteGroupDef, basePath string, seen map[string]bool) error {\n\tfullPrefix := joinFullPath(basePath, group.Prefix)\n\n\tfor _, route := range group.Routes {\n\t\t// 检查路径非空\n\t\tif strings.TrimSpace(route.Path) == \"\" {\n\t\t\treturn &RouteTreeError{Path: fullPrefix, Message: \"route path is empty\"}\n\t\t}\n\n\t\t// 检查 HTTP 方法合法\n\t\tif !isValidHTTPMethod(route.Method) {\n\t\t\treturn &RouteTreeError{\n\t\t\t\tPath:    joinFullPath(fullPrefix, route.Path),\n\t\t\t\tMessage: fmt.Sprintf(\"invalid HTTP method: %s\", route.Method),\n\t\t\t}\n\t\t}\n\n\t\t// 检查 Handler 非空\n\t\tif len(route.Handlers) == 0 {\n\t\t\treturn &RouteTreeError{\n\t\t\t\tPath:    joinFullPath(fullPrefix, route.Path),\n\t\t\t\tMessage: \"no handlers registered\",\n\t\t\t}\n\t\t}\n\n\t\t// 检查重复路由\n\t\trouteKey := route.Method + \":\" + joinFullPath(fullPrefix, route.Path)\n\t\tif seen[routeKey] {\n\t\t\treturn &RouteTreeError{\n\t\t\t\tPath:    routeKey,\n\t\t\t\tMessage: \"duplicate route definition\",\n\t\t\t}\n\t\t}\n\t\tseen[routeKey] = true\n\t}\n\n\tfor _, child := range group.Children {\n\t\tif err := validateRouteTree(child, fullPrefix, seen); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc isValidHTTPMethod(method string) bool {\n\tvalidMethods := map[string]bool{\n\t\thttp.MethodGet:     true,\n\t\thttp.MethodPost:    true,\n\t\thttp.MethodPut:     true,\n\t\thttp.MethodDelete:  true,\n\t\thttp.MethodPatch:   true,\n\t\thttp.MethodHead:    true,\n\t\thttp.MethodOptions: true,\n\t}\n\treturn validMethods[method]\n}\n"
  },
  {
    "path": "internal/runtime/config_reload.go",
    "content": "package runtime\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\tcasbinx \"github.com/wannanbigpig/gin-layout/internal/access/casbin\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n)\n\nvar registerOnce sync.Once\n\n// RegisterConfigReloadHandlers 注册配置热更新处理器。\nfunc RegisterConfigReloadHandlers() {\n\tregisterOnce.Do(func() {\n\t\tconfig.RegisterConfigReloadHandler(config.ConfigReloadHandler{\n\t\t\tName:     \"logger\",\n\t\t\tPriority: 10,\n\t\t\tHandle:   reloadLogger,\n\t\t})\n\t\tconfig.RegisterConfigReloadHandler(config.ConfigReloadHandler{\n\t\t\tName:     \"data\",\n\t\t\tPriority: 20,\n\t\t\tHandle:   reloadData,\n\t\t})\n\t\tconfig.RegisterConfigReloadHandler(config.ConfigReloadHandler{\n\t\t\tName:     \"casbin\",\n\t\t\tPriority: 30,\n\t\t\tHandle:   reloadCasbin,\n\t\t})\n\t\tconfig.RegisterConfigReloadHandler(config.ConfigReloadHandler{\n\t\t\tName:     \"warnings\",\n\t\t\tPriority: 100,\n\t\t\tHandle:   logWarnings,\n\t\t})\n\t})\n}\n\nfunc reloadLogger(oldConfig, newConfig *config.Conf, diff config.ConfigDiff) error {\n\tif !diff.LoggerChanged {\n\t\treturn nil\n\t}\n\treturn log.ReloadLogger(newConfig)\n}\n\nfunc reloadData(oldConfig, newConfig *config.Conf, diff config.ConfigDiff) error {\n\tif diff.MysqlChanged {\n\t\tif err := data.ReloadMysql(newConfig); err != nil {\n\t\t\treturn fmt.Errorf(\"mysql reload failed: %w\", err)\n\t\t}\n\t\tlog.Logger.Info(\"MySQL runtime reloaded\")\n\t}\n\tif diff.RedisChanged {\n\t\tif err := data.ReloadRedis(newConfig); err != nil {\n\t\t\treturn fmt.Errorf(\"redis reload failed: %w\", err)\n\t\t}\n\t\tlog.Logger.Info(\"Redis runtime reloaded\")\n\t}\n\treturn nil\n}\n\nfunc reloadCasbin(oldConfig, newConfig *config.Conf, diff config.ConfigDiff) error {\n\tif !diff.MysqlChanged {\n\t\treturn nil\n\t}\n\tif !newConfig.Mysql.Enable {\n\t\treturn nil\n\t}\n\tif err := casbinx.ReloadEnforcer(); err != nil {\n\t\treturn fmt.Errorf(\"casbin reload failed: %w\", err)\n\t}\n\tlog.Logger.Info(\"Casbin runtime reloaded\")\n\treturn nil\n}\n\nfunc logWarnings(oldConfig, newConfig *config.Conf, diff config.ConfigDiff) error {\n\tif len(diff.ChangedFields) > 0 {\n\t\tlog.Logger.Info(\"Detected config changes\",\n\t\t\tzap.Strings(\"fields\", diff.ChangedFields),\n\t\t)\n\t}\n\tif len(diff.RestartRequiredFields) > 0 {\n\t\tlog.Logger.Warn(\"Detected config changes that require process restart\",\n\t\t\tzap.Strings(\"fields\", diff.RestartRequiredFields),\n\t\t)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/service/access/api_cache.go",
    "content": "package access\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/redis/go-redis/v9\"\n\t\"go.uber.org/zap\"\n\t\"golang.org/x/sync/singleflight\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n)\n\nconst (\n\tapiRedisKey                 = \"api_info_map\"\n\tapiCacheRedisTimeout        = 3 * time.Second\n\tapiCacheRefreshTotalTimeout = 15 * time.Second\n\tapiCacheWriteBatch          = 500\n)\n\n// ApiRouteInfo 描述接口路由的鉴权模式和展示名称。\ntype ApiRouteInfo struct {\n\tIsAuth uint8  `json:\"is_auth\"`\n\tName   string `json:\"name\"`\n}\n\ntype apiRouteCacheMetrics struct {\n\trequestTotal       atomic.Uint64\n\tcacheHitTotal      atomic.Uint64\n\tcacheMissTotal     atomic.Uint64\n\tsourceLoadTotal    atomic.Uint64\n\tsingleflightShared atomic.Uint64\n\trefreshBatchTotal  atomic.Uint64\n\trefreshWriteTotal  atomic.Uint64\n}\n\ntype apiRouteCacheEntry struct {\n\tfield string\n\tvalue string\n}\n\n// ApiRouteCacheMetricsSnapshot 用于观测 API 路由缓存命中与回源情况。\ntype ApiRouteCacheMetricsSnapshot struct {\n\tRequestTotal       uint64  `json:\"request_total\"`\n\tCacheHitTotal      uint64  `json:\"cache_hit_total\"`\n\tCacheMissTotal     uint64  `json:\"cache_miss_total\"`\n\tHitRate            float64 `json:\"hit_rate\"`\n\tSourceLoadTotal    uint64  `json:\"source_load_total\"`\n\tSingleflightShared uint64  `json:\"singleflight_shared_total\"`\n\tRefreshBatchTotal  uint64  `json:\"refresh_batch_total\"`\n\tRefreshWriteTotal  uint64  `json:\"refresh_write_total\"`\n}\n\n// ApiRouteCacheService 负责缓存 API 路由元数据。\ntype ApiRouteCacheService struct {\n\tloadRouteInfo     func(route string, method string) (*ApiRouteInfo, error)\n\tsingleflightGroup *singleflight.Group\n\tmetrics           *apiRouteCacheMetrics\n\tconfigProvider    func() *config.Conf\n}\n\n// NewApiRouteCacheService 创建 API 路由缓存服务实例。\nfunc NewApiRouteCacheService() *ApiRouteCacheService {\n\treturn &ApiRouteCacheService{\n\t\tsingleflightGroup: &singleflight.Group{},\n\t\tmetrics:           &apiRouteCacheMetrics{},\n\t\tconfigProvider:    config.GetConfig,\n\t}\n}\n\nfunc (s *ApiRouteCacheService) ensureRuntimeDeps() {\n\tif s.singleflightGroup == nil {\n\t\ts.singleflightGroup = &singleflight.Group{}\n\t}\n\tif s.metrics == nil {\n\t\ts.metrics = &apiRouteCacheMetrics{}\n\t}\n\tif s.configProvider == nil {\n\t\ts.configProvider = config.GetConfig\n\t}\n}\n\nfunc (s *ApiRouteCacheService) currentConfig() *config.Conf {\n\ts.ensureRuntimeDeps()\n\treturn config.GetConfigFrom(s.configProvider)\n}\n\n// MetricsSnapshot 返回当前 API 路由缓存指标快照。\nfunc (s *ApiRouteCacheService) MetricsSnapshot() ApiRouteCacheMetricsSnapshot {\n\ts.ensureRuntimeDeps()\n\n\trequestTotal := s.metrics.requestTotal.Load()\n\tcacheHitTotal := s.metrics.cacheHitTotal.Load()\n\tcacheMissTotal := s.metrics.cacheMissTotal.Load()\n\n\thitRate := 0.0\n\tif requestTotal > 0 {\n\t\thitRate = float64(cacheHitTotal) / float64(requestTotal)\n\t}\n\n\treturn ApiRouteCacheMetricsSnapshot{\n\t\tRequestTotal:       requestTotal,\n\t\tCacheHitTotal:      cacheHitTotal,\n\t\tCacheMissTotal:     cacheMissTotal,\n\t\tHitRate:            hitRate,\n\t\tSourceLoadTotal:    s.metrics.sourceLoadTotal.Load(),\n\t\tSingleflightShared: s.metrics.singleflightShared.Load(),\n\t\tRefreshBatchTotal:  s.metrics.refreshBatchTotal.Load(),\n\t\tRefreshWriteTotal:  s.metrics.refreshWriteTotal.Load(),\n\t}\n}\n\n// ResetMetrics 清空 API 路由缓存指标。\nfunc (s *ApiRouteCacheService) ResetMetrics() {\n\ts.ensureRuntimeDeps()\n\n\ts.metrics.requestTotal.Store(0)\n\ts.metrics.cacheHitTotal.Store(0)\n\ts.metrics.cacheMissTotal.Store(0)\n\ts.metrics.sourceLoadTotal.Store(0)\n\ts.metrics.singleflightShared.Store(0)\n\ts.metrics.refreshBatchTotal.Store(0)\n\ts.metrics.refreshWriteTotal.Store(0)\n}\n\nfunc (s *ApiRouteCacheService) cacheKey(route string, method string) string {\n\treturn fmt.Sprintf(\"%s:%s\", method, route)\n}\n\nfunc redisContext() (context.Context, context.CancelFunc) {\n\treturn context.WithTimeout(context.Background(), apiCacheRedisTimeout)\n}\n\nfunc redisContextWithTimeout(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {\n\tif parent == nil {\n\t\treturn context.WithTimeout(context.Background(), timeout)\n\t}\n\tif deadline, ok := parent.Deadline(); ok {\n\t\tremaining := time.Until(deadline)\n\t\tif remaining <= 0 {\n\t\t\treturn context.WithCancel(parent)\n\t\t}\n\t\tif remaining < timeout {\n\t\t\treturn context.WithTimeout(parent, remaining)\n\t\t}\n\t}\n\treturn context.WithTimeout(parent, timeout)\n}\n\nfunc (s *ApiRouteCacheService) refreshTempKey() string {\n\treturn fmt.Sprintf(\"%s:refresh:%d\", apiRedisKey, time.Now().UnixNano())\n}\n\nfunc (s *ApiRouteCacheService) writeRouteCacheBatch(parent context.Context, client *redis.Client, redisKey string, batch []apiRouteCacheEntry) error {\n\tif len(batch) == 0 {\n\t\treturn nil\n\t}\n\n\tctx, cancel := redisContextWithTimeout(parent, apiCacheRedisTimeout)\n\tdefer cancel()\n\n\tpipe := client.Pipeline()\n\tfor _, entry := range batch {\n\t\tpipe.HSet(ctx, redisKey, entry.field, entry.value)\n\t}\n\tif _, err := pipe.Exec(ctx); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// RefreshCache 重建 Redis 中的 API 路由缓存。\nfunc (s *ApiRouteCacheService) RefreshCache() error {\n\ts.ensureRuntimeDeps()\n\n\tcfg := s.currentConfig()\n\tif !cfg.Redis.Enable {\n\t\treturn nil\n\t}\n\n\tapis, err := model.ListE(model.NewApi(), \"\", nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\tclient := data.RedisClient()\n\tif client == nil {\n\t\treturn nil\n\t}\n\tif len(apis) == 0 {\n\t\tctx, cancel := redisContext()\n\t\tdefer cancel()\n\t\tif err := client.Del(ctx, apiRedisKey).Err(); err != nil {\n\t\t\treturn fmt.Errorf(\"clear empty api route cache failed: %w\", err)\n\t\t}\n\t\treturn nil\n\t}\n\n\ttotalCtx, totalCancel := context.WithTimeout(context.Background(), apiCacheRefreshTotalTimeout)\n\tdefer totalCancel()\n\n\ttempKey := s.refreshTempKey()\n\tshouldCleanupTempKey := true\n\tdefer func() {\n\t\tif !shouldCleanupTempKey {\n\t\t\treturn\n\t\t}\n\t\tctx, cancel := redisContextWithTimeout(context.Background(), apiCacheRedisTimeout)\n\t\tdefer cancel()\n\t\tif err := client.Del(ctx, tempKey).Err(); err != nil && !errors.Is(err, redis.Nil) {\n\t\t\tlog.Logger.Warn(\"清理 API 路由缓存临时 key 失败\",\n\t\t\t\tzap.String(\"key\", tempKey),\n\t\t\t\tzap.Error(err))\n\t\t}\n\t}()\n\n\tbatch := make([]apiRouteCacheEntry, 0, apiCacheWriteBatch)\n\tbatchCount := 0\n\twriteCount := 0\n\n\tflush := func() error {\n\t\tif len(batch) == 0 {\n\t\t\treturn nil\n\t\t}\n\t\tif err := s.writeRouteCacheBatch(totalCtx, client, tempKey, batch); err != nil {\n\t\t\treturn fmt.Errorf(\"write api route cache batch failed: %w\", err)\n\t\t}\n\t\tbatchCount++\n\t\twriteCount += len(batch)\n\t\tbatch = batch[:0]\n\t\treturn nil\n\t}\n\n\tfor _, api := range apis {\n\t\tcacheInfo := ApiRouteInfo{IsAuth: api.IsAuth, Name: api.Name}\n\t\tcacheInfoBytes, err := json.Marshal(cacheInfo)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tbatch = append(batch, apiRouteCacheEntry{\n\t\t\tfield: s.cacheKey(api.Route, api.Method),\n\t\t\tvalue: string(cacheInfoBytes),\n\t\t})\n\t\tif len(batch) >= apiCacheWriteBatch {\n\t\t\tif err := flush(); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\tif err := flush(); err != nil {\n\t\treturn err\n\t}\n\n\trenameCtx, renameCancel := redisContextWithTimeout(totalCtx, apiCacheRedisTimeout)\n\tdefer renameCancel()\n\tif err := client.Rename(renameCtx, tempKey, apiRedisKey).Err(); err != nil {\n\t\treturn fmt.Errorf(\"swap api route cache key failed: %w\", err)\n\t}\n\tshouldCleanupTempKey = false\n\n\ts.metrics.refreshBatchTotal.Add(uint64(batchCount))\n\ts.metrics.refreshWriteTotal.Add(uint64(writeCount))\n\treturn nil\n}\n\n// GetRouteInfo 返回指定路由的方法元数据。\nfunc (s *ApiRouteCacheService) GetRouteInfo(route string, method string) (*ApiRouteInfo, error) {\n\ts.ensureRuntimeDeps()\n\ts.metrics.requestTotal.Add(1)\n\n\tcfg := s.currentConfig()\n\tcacheKey := s.cacheKey(route, method)\n\n\tclient := data.RedisClient()\n\tif cfg.Redis.Enable && client != nil {\n\t\tctx, cancel := redisContext()\n\t\tdefer cancel()\n\t\tval, err := client.HGet(ctx, apiRedisKey, cacheKey).Result()\n\t\tif err == nil {\n\t\t\tvar cacheInfo ApiRouteInfo\n\t\t\tunmarshalErr := json.Unmarshal([]byte(val), &cacheInfo)\n\t\t\tif unmarshalErr == nil {\n\t\t\t\ts.metrics.cacheHitTotal.Add(1)\n\t\t\t\treturn &cacheInfo, nil\n\t\t\t}\n\t\t\tlogError(\"api 路由缓存反序列化失败\", unmarshalErr, route, method)\n\t\t\tif delErr := client.HDel(ctx, apiRedisKey, cacheKey).Err(); delErr != nil {\n\t\t\t\tlogError(\"api 路由缓存删除损坏值失败\", delErr, route, method)\n\t\t\t}\n\t\t} else if !errors.Is(err, redis.Nil) {\n\t\t\tlogError(\"api表Redis查询出错\", err, route, method)\n\t\t}\n\t}\n\n\ts.metrics.cacheMissTotal.Add(1)\n\tvalue, err, shared := s.singleflightGroup.Do(cacheKey, func() (interface{}, error) {\n\t\treturn s.loadRouteInfoFromSource(route, method)\n\t})\n\tif shared {\n\t\ts.metrics.singleflightShared.Add(1)\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcacheInfo, ok := value.(*ApiRouteInfo)\n\tif !ok || cacheInfo == nil {\n\t\treturn nil, fmt.Errorf(\"invalid api route info type\")\n\t}\n\treturn cacheInfo, nil\n}\n\nfunc (s *ApiRouteCacheService) loadRouteInfoFromSource(route string, method string) (*ApiRouteInfo, error) {\n\ts.ensureRuntimeDeps()\n\ts.metrics.sourceLoadTotal.Add(1)\n\n\tif s.loadRouteInfo != nil {\n\t\treturn s.loadRouteInfo(route, method)\n\t}\n\n\tapi := model.NewApi()\n\tif err := api.GetDetail(\"route = ? AND method = ? AND deleted_at = 0\", route, method); err != nil {\n\t\treturn nil, err\n\t}\n\n\tcacheInfo := &ApiRouteInfo{IsAuth: api.IsAuth, Name: api.Name}\n\tcfg := s.currentConfig()\n\tclient := data.RedisClient()\n\tif cfg.Redis.Enable && client != nil {\n\t\tif cacheInfoBytes, err := json.Marshal(cacheInfo); err == nil {\n\t\t\tctx, cancel := redisContext()\n\t\t\tdefer cancel()\n\t\t\tcacheKey := s.cacheKey(route, method)\n\t\t\tif err := client.HSet(ctx, apiRedisKey, cacheKey, string(cacheInfoBytes)).Err(); err != nil {\n\t\t\t\tlogError(\"api 路由缓存写入 Redis 失败\", err, route, method)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn cacheInfo, nil\n}\n\n// CheckoutRouteIsAuth 判断指定路由是否要求 API 权限校验。\nfunc (s *ApiRouteCacheService) CheckoutRouteIsAuth(route string, method string) bool {\n\tcacheInfo, err := s.GetRouteInfo(route, method)\n\tif err != nil {\n\t\tif !errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\tlogError(\"api表数据库查询出错\", err, route, method)\n\t\t}\n\t\treturn true\n\t}\n\treturn global.ApiAuthMode(cacheInfo.IsAuth).RequiresAPIPermission()\n}\n\n// GetApiName 返回指定路由的人类可读名称。\nfunc (s *ApiRouteCacheService) GetApiName(route string, method string) string {\n\tcacheInfo, err := s.GetRouteInfo(route, method)\n\tif err != nil {\n\t\tif !errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\tlogError(\"api名称数据库查询出错\", err, route, method)\n\t\t}\n\t\treturn \"\"\n\t}\n\treturn cacheInfo.Name\n}\n\nfunc logError(message string, err error, route string, method string) {\n\tif log.Logger == nil {\n\t\treturn\n\t}\n\tlog.Logger.Error(message, zap.Error(err), zap.String(\"route\", route), zap.String(\"method\", method))\n}\n"
  },
  {
    "path": "internal/service/access/api_cache_test.go",
    "content": "package access\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/config/autoload\"\n)\n\nfunc TestApiRouteCacheServiceDefaultsWithoutDatabase(t *testing.T) {\n\tservice := NewApiRouteCacheService()\n\tservice.ResetMetrics()\n\n\tif got := service.GetApiName(\"/missing\", \"GET\"); got != \"\" {\n\t\tt.Fatalf(\"expected empty api name, got %q\", got)\n\t}\n\n\tif got := service.CheckoutRouteIsAuth(\"/missing\", \"GET\"); !got {\n\t\tt.Fatal(\"expected route to default to auth-required when lookup fails\")\n\t}\n}\n\nfunc TestApiRouteCacheServiceCacheKey(t *testing.T) {\n\tservice := NewApiRouteCacheService()\n\tservice.ResetMetrics()\n\tif got := service.cacheKey(\"/admin/v1/users\", \"GET\"); got != \"GET:/admin/v1/users\" {\n\t\tt.Fatalf(\"unexpected cache key: %s\", got)\n\t}\n}\n\nfunc TestApiRouteCacheServiceGetRouteInfoSingleflightDeduplicates(t *testing.T) {\n\tservice := NewApiRouteCacheService()\n\tservice.ResetMetrics()\n\tservice.configProvider = func() *config.Conf {\n\t\treturn &config.Conf{\n\t\t\tRedis: autoload.RedisConfig{Enable: false},\n\t\t}\n\t}\n\tvar loadCalls int32\n\tservice.loadRouteInfo = func(route string, method string) (*ApiRouteInfo, error) {\n\t\tatomic.AddInt32(&loadCalls, 1)\n\t\ttime.Sleep(30 * time.Millisecond)\n\t\treturn &ApiRouteInfo{IsAuth: 1, Name: \"demo\"}, nil\n\t}\n\n\tstart := make(chan struct{})\n\tvar wg sync.WaitGroup\n\terrCh := make(chan error, 16)\n\n\tfor i := 0; i < 16; i++ {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\t<-start\n\t\t\tinfo, err := service.GetRouteInfo(\"/admin/v1/demo\", \"GET\")\n\t\t\tif err != nil {\n\t\t\t\terrCh <- err\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif info == nil || info.Name != \"demo\" || info.IsAuth != 1 {\n\t\t\t\terrCh <- fmt.Errorf(\"unexpected route info: %#v\", info)\n\t\t\t}\n\t\t}()\n\t}\n\tclose(start)\n\twg.Wait()\n\tclose(errCh)\n\n\tfor err := range errCh {\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t}\n\tif got := atomic.LoadInt32(&loadCalls); got != 1 {\n\t\tt.Fatalf(\"expected loadRouteInfo to be called once, got %d\", got)\n\t}\n\n\tsnapshot := service.MetricsSnapshot()\n\tif snapshot.RequestTotal != 16 {\n\t\tt.Fatalf(\"expected request_total=16, got %d\", snapshot.RequestTotal)\n\t}\n\tif snapshot.CacheMissTotal != 16 {\n\t\tt.Fatalf(\"expected cache_miss_total=16, got %d\", snapshot.CacheMissTotal)\n\t}\n\tif snapshot.SourceLoadTotal != 1 {\n\t\tt.Fatalf(\"expected source_load_total=1, got %d\", snapshot.SourceLoadTotal)\n\t}\n\tif snapshot.SingleflightShared == 0 {\n\t\tt.Fatal(\"expected singleflight_shared_total > 0\")\n\t}\n\tif snapshot.CacheHitTotal != 0 {\n\t\tt.Fatalf(\"expected cache_hit_total=0, got %d\", snapshot.CacheHitTotal)\n\t}\n\tif snapshot.HitRate != 0 {\n\t\tt.Fatalf(\"expected hit_rate=0, got %f\", snapshot.HitRate)\n\t}\n}\n\nfunc TestApiRouteCacheServiceResetMetrics(t *testing.T) {\n\tservice := NewApiRouteCacheService()\n\tservice.ResetMetrics()\n\n\tservice.metrics.requestTotal.Store(3)\n\tservice.metrics.cacheHitTotal.Store(2)\n\tservice.metrics.cacheMissTotal.Store(1)\n\tservice.metrics.sourceLoadTotal.Store(1)\n\tservice.metrics.singleflightShared.Store(1)\n\tservice.metrics.refreshBatchTotal.Store(2)\n\tservice.metrics.refreshWriteTotal.Store(9)\n\n\tservice.ResetMetrics()\n\tsnapshot := service.MetricsSnapshot()\n\n\tif snapshot.RequestTotal != 0 ||\n\t\tsnapshot.CacheHitTotal != 0 ||\n\t\tsnapshot.CacheMissTotal != 0 ||\n\t\tsnapshot.SourceLoadTotal != 0 ||\n\t\tsnapshot.SingleflightShared != 0 ||\n\t\tsnapshot.RefreshBatchTotal != 0 ||\n\t\tsnapshot.RefreshWriteTotal != 0 ||\n\t\tsnapshot.HitRate != 0 {\n\t\tt.Fatalf(\"expected metrics reset to zero, got %#v\", snapshot)\n\t}\n}\n\nfunc TestApiRouteCacheServiceCheckoutRouteIsAuthUsesThreeStateMode(t *testing.T) {\n\tservice := NewApiRouteCacheService()\n\tservice.configProvider = func() *config.Conf {\n\t\treturn &config.Conf{\n\t\t\tRedis: autoload.RedisConfig{Enable: false},\n\t\t}\n\t}\n\n\tservice.loadRouteInfo = func(route string, method string) (*ApiRouteInfo, error) {\n\t\tswitch route {\n\t\tcase \"/public\":\n\t\t\treturn &ApiRouteInfo{IsAuth: 0, Name: \"public\"}, nil\n\t\tcase \"/login-only\":\n\t\t\treturn &ApiRouteInfo{IsAuth: 1, Name: \"login\"}, nil\n\t\tcase \"/authz\":\n\t\t\treturn &ApiRouteInfo{IsAuth: 2, Name: \"authz\"}, nil\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"unexpected route: %s\", route)\n\t\t}\n\t}\n\n\tif service.CheckoutRouteIsAuth(\"/public\", \"GET\") {\n\t\tt.Fatal(\"expected public route to not require api permission\")\n\t}\n\tif service.CheckoutRouteIsAuth(\"/login-only\", \"GET\") {\n\t\tt.Fatal(\"expected login-only route to not require api permission\")\n\t}\n\tif !service.CheckoutRouteIsAuth(\"/authz\", \"GET\") {\n\t\tt.Fatal(\"expected authz route to require api permission\")\n\t}\n}\n\nfunc TestRedisContextWithTimeoutHonorsParentDeadline(t *testing.T) {\n\tparent, cancelParent := context.WithTimeout(context.Background(), 20*time.Millisecond)\n\tdefer cancelParent()\n\n\tctx, cancel := redisContextWithTimeout(parent, time.Second)\n\tdefer cancel()\n\n\tparentDeadline, ok := parent.Deadline()\n\tif !ok {\n\t\tt.Fatal(\"expected parent deadline\")\n\t}\n\tdeadline, ok := ctx.Deadline()\n\tif !ok {\n\t\tt.Fatal(\"expected derived deadline\")\n\t}\n\tif deadline.After(parentDeadline) {\n\t\tt.Fatalf(\"expected derived deadline %v to not exceed parent deadline %v\", deadline, parentDeadline)\n\t}\n}\n\nfunc TestApiRouteCacheServiceRefreshTempKeyUsesShadowKey(t *testing.T) {\n\tservice := NewApiRouteCacheService()\n\n\ttempKey := service.refreshTempKey()\n\tif tempKey == apiRedisKey {\n\t\tt.Fatal(\"expected temp key to differ from live cache key\")\n\t}\n\texpectedPrefix := apiRedisKey + \":refresh:\"\n\tif len(tempKey) <= len(expectedPrefix) || tempKey[:len(expectedPrefix)] != expectedPrefix {\n\t\tt.Fatalf(\"expected temp key prefix %q, got %q\", expectedPrefix, tempKey)\n\t}\n}\n"
  },
  {
    "path": "internal/service/access/common.go",
    "content": "package access\n\nimport (\n\tcasbinx \"github.com/wannanbigpig/gin-layout/internal/access/casbin\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"gorm.io/gorm\"\n)\n\nfunc defaultReloadPolicy() error {\n\treturn casbinx.ReloadPolicy()\n}\n\n// getPolicyEnforcer 返回已初始化的 Casbin 封装实例。\nfunc getPolicyEnforcer() (*casbinx.CasbinEnforcer, error) {\n\tenforcer, err := casbinx.GetEnforcer()\n\tif err != nil {\n\t\treturn nil, e.NewBusinessError(e.CasbinInitFailed)\n\t}\n\treturn enforcer, nil\n}\n\n// FirstTx 返回可选事务切片中的第一个事务。\nfunc FirstTx(tx []*gorm.DB) *gorm.DB {\n\tif len(tx) == 0 {\n\t\treturn nil\n\t}\n\treturn tx[0]\n}\n"
  },
  {
    "path": "internal/service/access/coordinator.go",
    "content": "package access\n\nimport (\n\t\"gorm.io/gorm\"\n\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\n// PermissionSyncCoordinator 统一协调权限重建触发逻辑。\ntype PermissionSyncCoordinator struct {\n\t// syncer 执行用户权限重建与清理。\n\tsyncer *UserPermissionSyncService\n\t// resolver 解析资源变更对应的受影响用户集合。\n\tresolver *AffectedUsersResolver\n}\n\n// PermissionSyncCoordinatorDeps 描述 PermissionSyncCoordinator 可注入依赖。\ntype PermissionSyncCoordinatorDeps struct {\n\t// Syncer 自定义权限同步服务实现。\n\tSyncer *UserPermissionSyncService\n\t// Resolver 自定义受影响用户解析实现。\n\tResolver *AffectedUsersResolver\n}\n\n// NewPermissionSyncCoordinator 创建权限同步协调器。\nfunc NewPermissionSyncCoordinator() *PermissionSyncCoordinator {\n\treturn NewPermissionSyncCoordinatorWithDeps(PermissionSyncCoordinatorDeps{})\n}\n\n// NewPermissionSyncCoordinatorWithDeps 创建带依赖注入的权限同步协调器。\nfunc NewPermissionSyncCoordinatorWithDeps(deps PermissionSyncCoordinatorDeps) *PermissionSyncCoordinator {\n\tcoordinator := &PermissionSyncCoordinator{\n\t\tsyncer:   deps.Syncer,\n\t\tresolver: deps.Resolver,\n\t}\n\tif coordinator.syncer == nil {\n\t\tcoordinator.syncer = NewUserPermissionSyncService()\n\t}\n\tif coordinator.resolver == nil {\n\t\tcoordinator.resolver = NewAffectedUsersResolver()\n\t}\n\treturn coordinator\n}\n\n// SyncAll 重建全部用户最终 API 权限。\nfunc (c *PermissionSyncCoordinator) SyncAll() error {\n\tif err := NewSystemDefaultsService().Ensure(); err != nil {\n\t\treturn err\n\t}\n\treturn c.syncer.SyncAllUsers()\n}\n\n// SyncAllInTx 在事务内重建全部用户最终 API 权限。\nfunc (c *PermissionSyncCoordinator) SyncAllInTx(tx *gorm.DB) error {\n\tif err := NewSystemDefaultsService().Ensure(tx); err != nil {\n\t\treturn err\n\t}\n\treturn c.syncer.SyncAllUsers(tx)\n}\n\n// SyncUser 重建单个用户最终 API 权限。\nfunc (c *PermissionSyncCoordinator) SyncUser(userID uint, tx ...*gorm.DB) error {\n\treturn c.syncer.SyncUser(userID, tx...)\n}\n\n// SyncUsers 重建多个用户最终 API 权限。\nfunc (c *PermissionSyncCoordinator) SyncUsers(userIDs []uint, tx ...*gorm.DB) error {\n\treturn c.syncer.SyncUsers(userIDs, tx...)\n}\n\n// SyncUsersAffectedByScope 根据资源变更范围重建受影响用户权限。\nfunc (c *PermissionSyncCoordinator) SyncUsersAffectedByScope(scope PermissionChangeScope, tx ...*gorm.DB) error {\n\tuserIDs, err := c.resolver.Resolve(scope, tx...)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn c.syncer.SyncUsers(userIDs, tx...)\n}\n\n// SyncUsersAffectedByAPIs 重建受指定 API 变更影响的用户权限。\nfunc (c *PermissionSyncCoordinator) SyncUsersAffectedByAPIs(apiIDs []uint, tx ...*gorm.DB) error {\n\treturn c.SyncUsersAffectedByScope(PermissionChangeScope{APIIDs: apiIDs}, tx...)\n}\n\n// SyncUsersAffectedByMenus 重建受指定菜单变更影响的用户权限。\nfunc (c *PermissionSyncCoordinator) SyncUsersAffectedByMenus(menuIDs []uint, tx ...*gorm.DB) error {\n\treturn c.SyncUsersAffectedByScope(PermissionChangeScope{MenuIDs: menuIDs}, tx...)\n}\n\n// SyncUsersAffectedByRoles 重建受指定角色变更影响的用户权限。\nfunc (c *PermissionSyncCoordinator) SyncUsersAffectedByRoles(roleIDs []uint, tx ...*gorm.DB) error {\n\treturn c.SyncUsersAffectedByScope(PermissionChangeScope{RoleIDs: roleIDs}, tx...)\n}\n\n// SyncUsersAffectedByDepartments 重建受指定部门变更影响的用户权限。\nfunc (c *PermissionSyncCoordinator) SyncUsersAffectedByDepartments(deptIDs []uint, tx ...*gorm.DB) error {\n\treturn c.SyncUsersAffectedByScope(PermissionChangeScope{DepartmentIDs: deptIDs}, tx...)\n}\n\n// ClearUser 清理单个用户最终 API 权限。\nfunc (c *PermissionSyncCoordinator) ClearUser(userID uint, tx ...*gorm.DB) error {\n\treturn c.syncer.ClearUser(userID, tx...)\n}\n\n// AccessibleMenuIDs 返回用户可访问菜单 ID。\nfunc (c *PermissionSyncCoordinator) AccessibleMenuIDs(userID uint, includeParents bool, tx ...*gorm.DB) ([]uint, error) {\n\treturn c.syncer.AccessibleMenuIDs(userID, includeParents, tx...)\n}\n\n// ReloadPolicyCache 在事务提交后刷新共享 Casbin Enforcer 的内存策略。\nfunc (c *PermissionSyncCoordinator) ReloadPolicyCache() error {\n\treturn c.syncer.ReloadPolicyCache()\n}\n\n// ReloadPolicyCacheWithMessage 在事务提交后刷新共享策略，并统一包装业务错误。\nfunc (c *PermissionSyncCoordinator) ReloadPolicyCacheWithMessage(_ string) error {\n\treturn c.ReloadPolicyCacheWithCode(e.FAILURE)\n}\n\n// ReloadPolicyCacheWithCode 在事务提交后刷新共享策略，并按错误码包装业务错误。\nfunc (c *PermissionSyncCoordinator) ReloadPolicyCacheWithCode(code int) error {\n\tif err := c.ReloadPolicyCache(); err != nil {\n\t\treturn e.NewBusinessError(code)\n\t}\n\treturn nil\n}\n\n// RunAfterCommit 执行事务逻辑并在成功提交后刷新共享策略缓存。\nfunc (c *PermissionSyncCoordinator) RunAfterCommit(db *gorm.DB, _ string, fn func(tx *gorm.DB) error) error {\n\treturn c.RunAfterCommitWithCode(db, e.FAILURE, fn)\n}\n\n// RunAfterCommitWithCode 执行事务逻辑并在成功提交后刷新共享策略缓存，失败时返回指定错误码。\nfunc (c *PermissionSyncCoordinator) RunAfterCommitWithCode(db *gorm.DB, code int, fn func(tx *gorm.DB) error) error {\n\tif err := RunInTransaction(db, fn); err != nil {\n\t\treturn err\n\t}\n\treturn c.ReloadPolicyCacheWithCode(code)\n}\n"
  },
  {
    "path": "internal/service/access/graph_loader.go",
    "content": "package access\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\n// -------------------- 单用户权限展开链路 --------------------\n\n// collectUserPolicies 根据数据库关系展开单个用户的最终接口权限。\nfunc (s *UserPermissionSyncService) collectUserPolicies(userID uint, tx ...*gorm.DB) ([][]string, error) {\n\tuserInfo, err := s.userInfo(userID, tx...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif !isSyncableUser(userInfo) {\n\t\treturn nil, nil\n\t}\n\n\troleIDs, err := s.userRoleIDs(userID, tx...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmenuIDs, err := s.RoleMenuIDs(roleIDs, tx...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn s.menuAPIPolicies(menuIDs, tx...)\n}\n\n// -------------------- 批量用户权限同步链路 --------------------\n\nfunc (s *UserPermissionSyncService) collectPoliciesForUsers(userIDs []uint, tx *gorm.DB) (map[uint][][]string, error) {\n\tuniqueIDs := UniqueUintSlice(userIDs)\n\tresult := make(map[uint][][]string, len(uniqueIDs))\n\tif len(uniqueIDs) == 0 {\n\t\treturn result, nil\n\t}\n\n\tactiveUserIDs, err := s.collectActiveUserIDs(uniqueIDs, result, tx)\n\tif err != nil || len(activeUserIDs) == 0 {\n\t\treturn result, err\n\t}\n\n\tuserRoleMap, err := s.userBaseRoleMap(activeUserIDs, tx)\n\tif err != nil || len(userRoleMap) == 0 {\n\t\treturn result, err\n\t}\n\n\troleStatusMap, err := s.loadRoleStatusMap(tx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuserExpandedRoles, allRoleIDs := expandUserRoles(userRoleMap, roleStatusMap)\n\tif len(allRoleIDs) == 0 {\n\t\treturn result, nil\n\t}\n\n\troleMenuMap, err := s.roleMenuMap(allRoleIDs, tx)\n\tif err != nil || len(roleMenuMap) == 0 {\n\t\treturn result, err\n\t}\n\n\tenabledMenus, menuPolicies, err := s.collectMenuPermissionData(roleMenuMap.AllMenuIDs(), tx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor userID, roleIDs := range userExpandedRoles {\n\t\tresult[userID] = buildUserPolicies(roleIDs, roleMenuMap, enabledMenus, menuPolicies)\n\t}\n\n\treturn result, nil\n}\n\nfunc (s *UserPermissionSyncService) collectActiveUserIDs(userIDs []uint, result map[uint][][]string, tx *gorm.DB) ([]uint, error) {\n\tuserModel := model.NewAdminUsers()\n\tuserModel.SetDB(tx)\n\tusers, err := userModel.SyncUserRows(userIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tactiveUserIDs := make([]uint, 0, len(users))\n\tfor _, user := range users {\n\t\tif user.Status != model.AdminUserStatusEnabled || user.ID == global.SuperAdminId || user.IsSuperAdmin == global.Yes {\n\t\t\tresult[user.ID] = nil\n\t\t\tcontinue\n\t\t}\n\t\tactiveUserIDs = append(activeUserIDs, user.ID)\n\t}\n\treturn activeUserIDs, nil\n}\n\nfunc (s *UserPermissionSyncService) collectMenuPermissionData(menuIDs []uint, tx *gorm.DB) (map[uint]struct{}, map[uint][][]string, error) {\n\tenabledMenus, err := s.enabledMenuSet(menuIDs, tx)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tmenuPolicies, err := s.menuPolicyMap(menuIDs, tx)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\treturn enabledMenus, menuPolicies, nil\n}\n\nfunc (s *UserPermissionSyncService) userRoleIDs(userID uint, tx ...*gorm.DB) ([]uint, error) {\n\troleMapModel := model.NewAdminUserRoleMap()\n\tdeptMapModel := model.NewAdminUserDeptMap()\n\tdeptRoleMapModel := model.NewDeptRoleMap()\n\tif t := FirstTx(tx); t != nil {\n\t\troleMapModel.SetDB(t)\n\t\tdeptMapModel.SetDB(t)\n\t\tdeptRoleMapModel.SetDB(t)\n\t}\n\n\tdirectRoleIDs, err := roleMapModel.RoleIdsByUid(userID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdeptIDs, err := deptMapModel.DeptIdsByUid(userID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdeptRoleIDs, err := deptRoleMapModel.RoleIdsByDeptIds(deptIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\troleIDs := UniqueUintSlice(append(directRoleIDs, deptRoleIDs...))\n\treturn s.expandRoleIDs(roleIDs, tx...)\n}\n\nfunc (s *UserPermissionSyncService) expandRoleIDs(roleIDs []uint, tx ...*gorm.DB) ([]uint, error) {\n\tif len(roleIDs) == 0 {\n\t\treturn nil, nil\n\t}\n\n\troleModel := model.NewRole()\n\tif t := FirstTx(tx); t != nil {\n\t\troleModel.SetDB(t)\n\t}\n\n\troles, err := roleModel.FindPidsByIds(roleIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\troleSet := buildAncestorSet(roleIDs, func(add func(uint)) {\n\t\tfor _, role := range roles {\n\t\t\taddAncestorIDs(role.Pids, add)\n\t\t}\n\t})\n\n\treturn roleModel.EnabledIdsByIds(roleSet)\n}\n\n// RoleMenuIDs 根据角色列表解析出启用状态的菜单 ID。\nfunc (s *UserPermissionSyncService) RoleMenuIDs(roleIDs []uint, tx ...*gorm.DB) ([]uint, error) {\n\tif len(roleIDs) == 0 {\n\t\treturn nil, nil\n\t}\n\n\troleMenuMapModel := model.NewRoleMenuMap()\n\tmenuModel := model.NewMenu()\n\tif t := FirstTx(tx); t != nil {\n\t\troleMenuMapModel.SetDB(t)\n\t\tmenuModel.SetDB(t)\n\t}\n\n\tmenuIDs, err := roleMenuMapModel.MenuIdsByRoleIds(roleIDs)\n\tif err != nil || len(menuIDs) == 0 {\n\t\treturn nil, err\n\t}\n\n\treturn menuModel.EnabledIdsByIds(UniqueUintSlice(menuIDs))\n}\n\nfunc (s *UserPermissionSyncService) expandMenuIDsWithParents(menuIDs []uint, tx ...*gorm.DB) ([]uint, error) {\n\tif len(menuIDs) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tmenuModel := model.NewMenu()\n\tif t := FirstTx(tx); t != nil {\n\t\tmenuModel.SetDB(t)\n\t}\n\n\tmenus, err := menuModel.FindPidsByIds(menuIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmenuSet := buildAncestorSet(menuIDs, func(add func(uint)) {\n\t\tfor _, menu := range menus {\n\t\t\taddAncestorIDs(menu.Pids, add)\n\t\t}\n\t})\n\n\treturn menuModel.EnabledIdsByIds(menuSet)\n}\n\nfunc (s *UserPermissionSyncService) menuAPIPolicies(menuIDs []uint, tx ...*gorm.DB) ([][]string, error) {\n\tif len(menuIDs) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tmenuApiMapModel := model.NewMenuApiMap()\n\tif t := FirstTx(tx); t != nil {\n\t\tmenuApiMapModel.SetDB(t)\n\t}\n\n\tpermissions, err := menuApiMapModel.ApiPermissionsByMenuIds(menuIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpolicies := make([][]string, 0, len(permissions))\n\tfor _, permission := range permissions {\n\t\tif permission.Route == \"\" || permission.Method == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tpolicies = append(policies, []string{permission.Route, permission.Method})\n\t}\n\treturn policies, nil\n}\n\nfunc (s *UserPermissionSyncService) allUserIDs(tx ...*gorm.DB) ([]uint, error) {\n\tuserModel := model.NewAdminUsers()\n\tif t := FirstTx(tx); t != nil {\n\t\tuserModel.SetDB(t)\n\t}\n\treturn userModel.AllIds()\n}\n\nfunc (s *UserPermissionSyncService) userInfo(userID uint, tx ...*gorm.DB) (*model.AdminUser, error) {\n\tuser := model.NewAdminUsers()\n\tif FirstTx(tx) != nil {\n\t\tuser.SetDB(FirstTx(tx))\n\t}\n\tif err := user.GetById(userID); err != nil {\n\t\treturn nil, err\n\t}\n\treturn user, nil\n}\n\n// UserKey 生成管理员用户对应的 Casbin subject。\nfunc (s *UserPermissionSyncService) UserKey(userID uint) string {\n\treturn fmt.Sprintf(\"%s%s%d\", global.CasbinAdminUserPrefix, global.CasbinSeparator, userID)\n}\n\n// -------------------- 角色 / 菜单聚合上下文 --------------------\n\ntype roleStatusInfo struct {\n\tID     uint\n\tPids   string\n\tStatus uint8\n}\n\ntype roleMenuIDMap map[uint][]uint\n\nfunc (m roleMenuIDMap) AllMenuIDs() []uint {\n\tmenuIDs := make([]uint, 0)\n\tfor _, values := range m {\n\t\tmenuIDs = append(menuIDs, values...)\n\t}\n\treturn UniqueUintSlice(menuIDs)\n}\n\nfunc (s *UserPermissionSyncService) userBaseRoleMap(userIDs []uint, tx *gorm.DB) (map[uint][]uint, error) {\n\tuserRoleMap := make(map[uint][]uint, len(userIDs))\n\tif len(userIDs) == 0 {\n\t\treturn userRoleMap, nil\n\t}\n\n\tif err := s.appendDirectRoles(userRoleMap, userIDs, tx); err != nil {\n\t\treturn nil, err\n\t}\n\n\tuserDepts, deptIDs, err := s.userDepartmentMap(userIDs, tx)\n\tif err != nil || len(deptIDs) == 0 {\n\t\treturn userRoleMap, err\n\t}\n\n\tdeptRoleMap, err := s.departmentRoleMap(deptIDs, tx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor userID, deptIDs := range userDepts {\n\t\tfor _, deptID := range deptIDs {\n\t\t\tuserRoleMap[userID] = append(userRoleMap[userID], deptRoleMap[deptID]...)\n\t\t}\n\t\tuserRoleMap[userID] = UniqueUintSlice(userRoleMap[userID])\n\t}\n\n\treturn userRoleMap, nil\n}\n\nfunc (s *UserPermissionSyncService) appendDirectRoles(userRoleMap map[uint][]uint, userIDs []uint, tx *gorm.DB) error {\n\troleMapModel := model.NewAdminUserRoleMap()\n\troleMapModel.SetDB(tx)\n\tdirectMap, err := roleMapModel.UserRoleMapByUids(userIDs)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor uid, roleIDs := range directMap {\n\t\tuserRoleMap[uid] = append(userRoleMap[uid], roleIDs...)\n\t}\n\treturn nil\n}\n\nfunc (s *UserPermissionSyncService) userDepartmentMap(userIDs []uint, tx *gorm.DB) (map[uint][]uint, []uint, error) {\n\tdeptMapModel := model.NewAdminUserDeptMap()\n\tdeptMapModel.SetDB(tx)\n\treturn deptMapModel.UserDeptMapByUids(userIDs)\n}\n\nfunc (s *UserPermissionSyncService) departmentRoleMap(deptIDs []uint, tx *gorm.DB) (map[uint][]uint, error) {\n\tdeptRoleMapModel := model.NewDeptRoleMap()\n\tdeptRoleMapModel.SetDB(tx)\n\treturn deptRoleMapModel.DeptRoleMapByDeptIds(deptIDs)\n}\n\nfunc (s *UserPermissionSyncService) loadRoleStatusMap(tx *gorm.DB) (map[uint]roleStatusInfo, error) {\n\troleModel := model.NewRole()\n\troleModel.SetDB(tx)\n\trows, err := roleModel.AllRoleStatusInfos()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\troleMap := make(map[uint]roleStatusInfo, len(rows))\n\tfor _, row := range rows {\n\t\troleMap[row.ID] = roleStatusInfo{\n\t\t\tID:     row.ID,\n\t\t\tPids:   row.Pids,\n\t\t\tStatus: row.Status,\n\t\t}\n\t}\n\treturn roleMap, nil\n}\n\nfunc (s *UserPermissionSyncService) roleMenuMap(roleIDs []uint, tx *gorm.DB) (roleMenuIDMap, error) {\n\troleMenuMapModel := model.NewRoleMenuMap()\n\troleMenuMapModel.SetDB(tx)\n\tm, err := roleMenuMapModel.RoleMenuMapByRoleIds(roleIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn roleMenuIDMap(m), nil\n}\n\nfunc (s *UserPermissionSyncService) enabledMenuSet(menuIDs []uint, tx *gorm.DB) (map[uint]struct{}, error) {\n\tmenuIDs = UniqueUintSlice(menuIDs)\n\tresult := make(map[uint]struct{}, len(menuIDs))\n\tif len(menuIDs) == 0 {\n\t\treturn result, nil\n\t}\n\n\tmenuModel := model.NewMenu()\n\tmenuModel.SetDB(tx)\n\tenabledMenuIDs, err := menuModel.EnabledIdsByIds(menuIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, menuID := range enabledMenuIDs {\n\t\tresult[menuID] = struct{}{}\n\t}\n\treturn result, nil\n}\n\nfunc (s *UserPermissionSyncService) menuPolicyMap(menuIDs []uint, tx *gorm.DB) (map[uint][][]string, error) {\n\tmenuIDs = UniqueUintSlice(menuIDs)\n\tresult := make(map[uint][][]string, len(menuIDs))\n\tif len(menuIDs) == 0 {\n\t\treturn result, nil\n\t}\n\n\tmenuApiMapModel := model.NewMenuApiMap()\n\tmenuApiMapModel.SetDB(tx)\n\trows, err := menuApiMapModel.MenuApiPermissionsByMenuIds(menuIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, row := range rows {\n\t\tif row.Route == \"\" || row.Method == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tresult[row.MenuId] = append(result[row.MenuId], []string{row.Route, row.Method})\n\t}\n\treturn result, nil\n}\n\n// -------------------- 树关系与去重工具 --------------------\n\nfunc buildAncestorSet(baseIDs []uint, collect func(add func(uint))) []uint {\n\tidSet := make(map[uint]struct{}, len(baseIDs))\n\tfor _, id := range baseIDs {\n\t\tidSet[id] = struct{}{}\n\t}\n\tcollect(func(id uint) {\n\t\tidSet[id] = struct{}{}\n\t})\n\n\tresult := make([]uint, 0, len(idSet))\n\tfor id := range idSet {\n\t\tresult = append(result, id)\n\t}\n\treturn result\n}\n\nfunc addAncestorIDs(pids string, add func(uint)) {\n\tif pids == \"\" || pids == \"0\" {\n\t\treturn\n\t}\n\tfor _, pid := range strings.Split(pids, \",\") {\n\t\tpid = strings.TrimSpace(pid)\n\t\tif pid == \"\" || pid == \"0\" {\n\t\t\tcontinue\n\t\t}\n\t\tif parsed, err := strconv.ParseUint(pid, 10, 64); err == nil {\n\t\t\tadd(uint(parsed))\n\t\t}\n\t}\n}\n\nfunc isSyncableUser(userInfo *model.AdminUser) bool {\n\treturn userInfo != nil &&\n\t\tuserInfo.ID != 0 &&\n\t\tuserInfo.Status == model.AdminUserStatusEnabled &&\n\t\tuserInfo.ID != global.SuperAdminId\n}\n\nfunc expandUserRoles(userRoleMap map[uint][]uint, roleStatusMap map[uint]roleStatusInfo) (map[uint][]uint, []uint) {\n\tuserExpandedRoles := make(map[uint][]uint, len(userRoleMap))\n\tallRoleIDs := make([]uint, 0, len(userRoleMap)*2)\n\tfor userID, roleIDs := range userRoleMap {\n\t\texpanded := expandRoleAncestors(roleIDs, roleStatusMap)\n\t\tuserExpandedRoles[userID] = expanded\n\t\tallRoleIDs = append(allRoleIDs, expanded...)\n\t}\n\treturn userExpandedRoles, UniqueUintSlice(allRoleIDs)\n}\n\nfunc buildUserPolicies(roleIDs []uint, roleMenuMap roleMenuIDMap, enabledMenus map[uint]struct{}, menuPolicies map[uint][][]string) [][]string {\n\tmenuSet := collectEnabledMenuSet(roleIDs, roleMenuMap, enabledMenus)\n\treturn dedupePolicies(menuSet, menuPolicies)\n}\n\nfunc collectEnabledMenuSet(roleIDs []uint, roleMenuMap roleMenuIDMap, enabledMenus map[uint]struct{}) map[uint]struct{} {\n\tmenuSet := make(map[uint]struct{})\n\tfor _, roleID := range roleIDs {\n\t\tfor _, menuID := range roleMenuMap[roleID] {\n\t\t\tif _, ok := enabledMenus[menuID]; ok {\n\t\t\t\tmenuSet[menuID] = struct{}{}\n\t\t\t}\n\t\t}\n\t}\n\treturn menuSet\n}\n\nfunc dedupePolicies(menuSet map[uint]struct{}, menuPolicies map[uint][][]string) [][]string {\n\tpolicies := make([][]string, 0, len(menuSet)*5)\n\tseenPolicy := make(map[string]struct{})\n\tfor menuID := range menuSet {\n\t\tfor _, policy := range menuPolicies[menuID] {\n\t\t\tif len(policy) < 2 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := policy[0] + \"::\" + policy[1]\n\t\t\tif _, exists := seenPolicy[key]; exists {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseenPolicy[key] = struct{}{}\n\t\t\tpolicies = append(policies, policy)\n\t\t}\n\t}\n\treturn policies\n}\n\nfunc expandRoleAncestors(roleIDs []uint, roleStatusMap map[uint]roleStatusInfo) []uint {\n\troleSet := make(map[uint]struct{})\n\tfor _, roleID := range UniqueUintSlice(roleIDs) {\n\t\trole, ok := roleStatusMap[roleID]\n\t\tif !ok || role.Status != global.Yes {\n\t\t\tcontinue\n\t\t}\n\t\troleSet[roleID] = struct{}{}\n\t\taddAncestorIDs(role.Pids, func(ancestorID uint) {\n\t\t\tif ancestor, ok := roleStatusMap[ancestorID]; ok && ancestor.Status == global.Yes {\n\t\t\t\troleSet[ancestorID] = struct{}{}\n\t\t\t}\n\t\t})\n\t}\n\n\tresult := make([]uint, 0, len(roleSet))\n\tfor roleID := range roleSet {\n\t\tresult = append(result, roleID)\n\t}\n\treturn UniqueUintSlice(result)\n}\n"
  },
  {
    "path": "internal/service/access/menu_api_defaults.go",
    "content": "package access\n\nimport (\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\ntype defaultMenuAPIBinding struct {\n\t// MenuCode 菜单编码。\n\tMenuCode string\n\t// Route 绑定的接口路由。\n\tRoute string\n\t// Method 绑定的 HTTP 方法。\n\tMethod string\n}\n\nvar builtInDefaultMenuAPIBindings = [...]defaultMenuAPIBinding{\n\t{MenuCode: \"adminUser:update\", Route: \"/admin/v1/admin-user/update\", Method: \"POST\"},\n\t{MenuCode: \"adminUser:add\", Route: \"/admin/v1/admin-user/create\", Method: \"POST\"},\n\t{MenuCode: \"adminUser:bindRole\", Route: \"/admin/v1/admin-user/bind-role\", Method: \"POST\"},\n\t{MenuCode: \"adminUser:bindRole\", Route: \"/admin/v1/admin-user/detail\", Method: \"GET\"},\n\t{MenuCode: \"adminUser:bindRole\", Route: \"/admin/v1/role/list\", Method: \"GET\"},\n\t{MenuCode: \"adminUser:delete\", Route: \"/admin/v1/admin-user/delete\", Method: \"POST\"},\n\t{MenuCode: \"menu:add\", Route: \"/admin/v1/menu/create\", Method: \"POST\"},\n\t{MenuCode: \"menu:add\", Route: \"/admin/v1/permission/list\", Method: \"GET\"},\n\t{MenuCode: \"menu:addChild\", Route: \"/admin/v1/menu/create\", Method: \"POST\"},\n\t{MenuCode: \"menu:addChild\", Route: \"/admin/v1/permission/list\", Method: \"GET\"},\n\t{MenuCode: \"menu:update\", Route: \"/admin/v1/menu/detail\", Method: \"GET\"},\n\t{MenuCode: \"menu:update\", Route: \"/admin/v1/menu/update\", Method: \"POST\"},\n\t{MenuCode: \"menu:update\", Route: \"/admin/v1/permission/list\", Method: \"GET\"},\n\t{MenuCode: \"menu:delete\", Route: \"/admin/v1/menu/delete\", Method: \"POST\"},\n\t{MenuCode: \"role:add\", Route: \"/admin/v1/menu/list\", Method: \"GET\"},\n\t{MenuCode: \"role:add\", Route: \"/admin/v1/role/create\", Method: \"POST\"},\n\t{MenuCode: \"role:update\", Route: \"/admin/v1/menu/list\", Method: \"GET\"},\n\t{MenuCode: \"role:update\", Route: \"/admin/v1/role/detail\", Method: \"GET\"},\n\t{MenuCode: \"role:update\", Route: \"/admin/v1/role/update\", Method: \"POST\"},\n\t{MenuCode: \"role:delete\", Route: \"/admin/v1/role/delete\", Method: \"POST\"},\n\t{MenuCode: \"department:add\", Route: \"/admin/v1/department/create\", Method: \"POST\"},\n\t{MenuCode: \"department:addChild\", Route: \"/admin/v1/department/create\", Method: \"POST\"},\n\t{MenuCode: \"department:update\", Route: \"/admin/v1/department/update\", Method: \"POST\"},\n\t{MenuCode: \"department:bindRole\", Route: \"/admin/v1/department/bind-role\", Method: \"POST\"},\n\t{MenuCode: \"department:bindRole\", Route: \"/admin/v1/department/detail\", Method: \"GET\"},\n\t{MenuCode: \"department:bindRole\", Route: \"/admin/v1/role/list\", Method: \"GET\"},\n\t{MenuCode: \"department:delete\", Route: \"/admin/v1/department/delete\", Method: \"POST\"},\n\t{MenuCode: \"api:update\", Route: \"/admin/v1/permission/update\", Method: \"POST\"},\n\t{MenuCode: \"role:addChild\", Route: \"/admin/v1/role/create\", Method: \"POST\"},\n\t{MenuCode: \"adminLoginLog:detail\", Route: \"/admin/v1/log/login/detail\", Method: \"GET\"},\n\t{MenuCode: \"requestLog:detail\", Route: \"/admin/v1/log/request/detail\", Method: \"GET\"},\n\t{MenuCode: \"adminUser:list\", Route: \"/admin/v1/department/list\", Method: \"GET\"},\n\t{MenuCode: \"adminUser:list\", Route: \"/admin/v1/admin-user/list\", Method: \"GET\"},\n\t{MenuCode: \"department:list\", Route: \"/admin/v1/department/list\", Method: \"GET\"},\n\t{MenuCode: \"role:list\", Route: \"/admin/v1/role/list\", Method: \"GET\"},\n\t{MenuCode: \"menu:list\", Route: \"/admin/v1/menu/list\", Method: \"GET\"},\n\t{MenuCode: \"api:list\", Route: \"/admin/v1/permission/list\", Method: \"GET\"},\n\t{MenuCode: \"adminLoginLog:list\", Route: \"/admin/v1/log/login/list\", Method: \"GET\"},\n\t{MenuCode: \"requestLog:list\", Route: \"/admin/v1/log/request/list\", Method: \"GET\"},\n\t{MenuCode: \"requestLog:export\", Route: \"/admin/v1/log/request/export\", Method: \"GET\"},\n\t{MenuCode: \"requestLog:maskConfig\", Route: \"/admin/v1/log/request/mask-config\", Method: \"GET\"},\n\t{MenuCode: \"requestLog:maskConfig\", Route: \"/admin/v1/log/request/mask-config\", Method: \"POST\"},\n\t{MenuCode: \"sysConfig:list\", Route: \"/admin/v1/system/config/list\", Method: \"GET\"},\n\t{MenuCode: \"sysConfig:list\", Route: \"/admin/v1/system/config/detail\", Method: \"GET\"},\n\t{MenuCode: \"sysConfig:list\", Route: \"/admin/v1/system/config/value\", Method: \"GET\"},\n\t{MenuCode: \"sysConfig:add\", Route: \"/admin/v1/system/config/create\", Method: \"POST\"},\n\t{MenuCode: \"sysConfig:update\", Route: \"/admin/v1/system/config/update\", Method: \"POST\"},\n\t{MenuCode: \"sysConfig:delete\", Route: \"/admin/v1/system/config/delete\", Method: \"POST\"},\n\t{MenuCode: \"sysConfig:refresh\", Route: \"/admin/v1/system/config/refresh\", Method: \"POST\"},\n\t{MenuCode: \"sysDict:list\", Route: \"/admin/v1/system/dict/type/list\", Method: \"GET\"},\n\t{MenuCode: \"sysDict:list\", Route: \"/admin/v1/system/dict/type/detail\", Method: \"GET\"},\n\t{MenuCode: \"sysDict:list\", Route: \"/admin/v1/system/dict/item/list\", Method: \"GET\"},\n\t{MenuCode: \"sysDict:list\", Route: \"/admin/v1/system/dict/options\", Method: \"GET\"},\n\t{MenuCode: \"sysDict:add\", Route: \"/admin/v1/system/dict/type/create\", Method: \"POST\"},\n\t{MenuCode: \"sysDict:add\", Route: \"/admin/v1/system/dict/item/create\", Method: \"POST\"},\n\t{MenuCode: \"sysDict:update\", Route: \"/admin/v1/system/dict/type/update\", Method: \"POST\"},\n\t{MenuCode: \"sysDict:update\", Route: \"/admin/v1/system/dict/item/update\", Method: \"POST\"},\n\t{MenuCode: \"sysDict:delete\", Route: \"/admin/v1/system/dict/type/delete\", Method: \"POST\"},\n\t{MenuCode: \"sysDict:delete\", Route: \"/admin/v1/system/dict/item/delete\", Method: \"POST\"},\n\t{MenuCode: \"file:list\", Route: \"/admin/v1/system/file/list\", Method: \"GET\"},\n\t{MenuCode: \"file:list\", Route: \"/admin/v1/system/file/detail\", Method: \"GET\"},\n\t{MenuCode: \"file:list\", Route: \"/admin/v1/system/file/folder/tree\", Method: \"GET\"},\n\t{MenuCode: \"file:list\", Route: \"/admin/v1/system/file/trash/list\", Method: \"GET\"},\n\t{MenuCode: \"file:list\", Route: \"/admin/v1/system/file/references\", Method: \"GET\"},\n\t{MenuCode: \"file:list\", Route: \"/admin/v1/system/file/upload/credential\", Method: \"POST\"},\n\t{MenuCode: \"file:list\", Route: \"/admin/v1/system/file/upload/complete\", Method: \"POST\"},\n\t{MenuCode: \"file:list\", Route: \"/admin/v1/system/file/upload/local\", Method: \"POST\"},\n\t{MenuCode: \"file:update\", Route: \"/admin/v1/system/file/folder/create\", Method: \"POST\"},\n\t{MenuCode: \"file:update\", Route: \"/admin/v1/system/file/folder/update\", Method: \"POST\"},\n\t{MenuCode: \"file:update\", Route: \"/admin/v1/system/file/folder/delete\", Method: \"POST\"},\n\t{MenuCode: \"file:update\", Route: \"/admin/v1/system/file/folder/move\", Method: \"POST\"},\n\t{MenuCode: \"file:update\", Route: \"/admin/v1/system/file/move\", Method: \"POST\"},\n\t{MenuCode: \"file:delete\", Route: \"/admin/v1/system/file/delete\", Method: \"POST\"},\n\t{MenuCode: \"file:restore\", Route: \"/admin/v1/system/file/trash/restore\", Method: \"POST\"},\n\t{MenuCode: \"file:destroy\", Route: \"/admin/v1/system/file/trash/destroy\", Method: \"POST\"},\n\t{MenuCode: \"storage:config\", Route: \"/admin/v1/system/storage/config\", Method: \"GET\"},\n\t{MenuCode: \"storage:update\", Route: \"/admin/v1/system/storage/config\", Method: \"POST\"},\n\t{MenuCode: \"storage:test\", Route: \"/admin/v1/system/storage/test\", Method: \"POST\"},\n\t{MenuCode: \"session:list\", Route: \"/admin/v1/auth/session/list\", Method: \"GET\"},\n\t{MenuCode: \"session:revoke\", Route: \"/admin/v1/auth/session/revoke\", Method: \"POST\"},\n\t{MenuCode: \"task:list\", Route: \"/admin/v1/task/list\", Method: \"GET\"},\n\t{MenuCode: \"task:list\", Route: \"/admin/v1/task/run/list\", Method: \"GET\"},\n\t{MenuCode: \"task:detail\", Route: \"/admin/v1/task/run/detail\", Method: \"GET\"},\n\t{MenuCode: \"task:detail\", Route: \"/admin/v1/task/run/events\", Method: \"GET\"},\n\t{MenuCode: \"task:list\", Route: \"/admin/v1/task/cron/state\", Method: \"GET\"},\n\t{MenuCode: \"task:trigger\", Route: \"/admin/v1/task/trigger\", Method: \"POST\"},\n\t{MenuCode: \"task:retry\", Route: \"/admin/v1/task/run/retry\", Method: \"POST\"},\n\t{MenuCode: \"task:cancel\", Route: \"/admin/v1/task/run/cancel\", Method: \"POST\"},\n}\n\n// MenuAPIDefaultsService 负责初始化默认菜单与接口映射关系。\ntype MenuAPIDefaultsService struct {\n\t// bindings 默认菜单与接口绑定配置。\n\tbindings []defaultMenuAPIBinding\n}\n\n// MenuAPIDefaultsServiceDeps 描述 MenuAPIDefaultsService 可注入依赖。\ntype MenuAPIDefaultsServiceDeps struct {\n\t// Bindings 自定义默认菜单接口绑定。\n\tBindings []defaultMenuAPIBinding\n}\n\n// NewMenuAPIDefaultsService 创建默认菜单接口映射服务实例。\nfunc NewMenuAPIDefaultsService() *MenuAPIDefaultsService {\n\treturn NewMenuAPIDefaultsServiceWithDeps(MenuAPIDefaultsServiceDeps{})\n}\n\n// NewMenuAPIDefaultsServiceWithDeps 创建带依赖注入的默认菜单接口映射服务实例。\nfunc NewMenuAPIDefaultsServiceWithDeps(deps MenuAPIDefaultsServiceDeps) *MenuAPIDefaultsService {\n\ts := &MenuAPIDefaultsService{}\n\tif deps.Bindings != nil {\n\t\ts.bindings = cloneMenuAPIBindings(deps.Bindings)\n\t} else {\n\t\ts.bindings = defaultMenuAPIBindings()\n\t}\n\treturn s\n}\n\nfunc defaultMenuAPIBindings() []defaultMenuAPIBinding {\n\treturn cloneMenuAPIBindings(builtInDefaultMenuAPIBindings[:])\n}\n\nfunc cloneMenuAPIBindings(source []defaultMenuAPIBinding) []defaultMenuAPIBinding {\n\tif len(source) == 0 {\n\t\treturn nil\n\t}\n\tcloned := make([]defaultMenuAPIBinding, len(source))\n\tcopy(cloned, source)\n\treturn cloned\n}\n\n// Sync 将默认菜单接口映射写入数据库。\nfunc (s *MenuAPIDefaultsService) Sync(tx ...*gorm.DB) error {\n\tbindings := s.bindings\n\tif len(bindings) == 0 {\n\t\treturn nil\n\t}\n\n\tdb, err := defaultMenuAPIDB(FirstTx(tx))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tmenuCodes, routes, methods := collectMenuAPIBindingKeys(bindings)\n\ttargets, err := loadDefaultMenuAPITargets(db, menuCodes, routes, methods)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tmappings := buildDefaultMenuAPIMappings(bindings, targets)\n\tif len(mappings) == 0 {\n\t\treturn nil\n\t}\n\n\treturn db.Clauses(clause.OnConflict{\n\t\tColumns:   []clause.Column{{Name: \"menu_id\"}, {Name: \"api_id\"}},\n\t\tDoNothing: true,\n\t}).Create(&mappings).Error\n}\n\nfunc defaultMenuAPIDB(tx *gorm.DB) (*gorm.DB, error) {\n\tif tx != nil {\n\t\treturn tx, nil\n\t}\n\treturn model.NewMenuApiMap().GetDB()\n}\n\nfunc collectMenuAPIBindingKeys(bindings []defaultMenuAPIBinding) (menuCodes []string, routes []string, methods []string) {\n\tmenuCodeSet := make(map[string]struct{}, len(bindings))\n\trouteSet := make(map[string]struct{}, len(bindings))\n\tmethodSet := make(map[string]struct{}, len(bindings))\n\n\tfor _, item := range bindings {\n\t\tif _, ok := menuCodeSet[item.MenuCode]; !ok {\n\t\t\tmenuCodeSet[item.MenuCode] = struct{}{}\n\t\t\tmenuCodes = append(menuCodes, item.MenuCode)\n\t\t}\n\t\tif _, ok := routeSet[item.Route]; !ok {\n\t\t\trouteSet[item.Route] = struct{}{}\n\t\t\troutes = append(routes, item.Route)\n\t\t}\n\t\tif _, ok := methodSet[item.Method]; !ok {\n\t\t\tmethodSet[item.Method] = struct{}{}\n\t\t\tmethods = append(methods, item.Method)\n\t\t}\n\t}\n\treturn menuCodes, routes, methods\n}\n\ntype defaultMenuAPITargets struct {\n\tmenuIDByCode       map[string]uint\n\tapiIDByRouteMethod map[string]uint\n}\n\nfunc loadDefaultMenuAPITargets(db *gorm.DB, menuCodes []string, routes []string, methods []string) (defaultMenuAPITargets, error) {\n\tmenuModel := model.NewMenu()\n\tmenuModel.SetDB(db)\n\tmenus, err := menuModel.FindIdsByCodes(menuCodes)\n\tif err != nil {\n\t\treturn defaultMenuAPITargets{}, err\n\t}\n\tmenuIDByCode := make(map[string]uint, len(menus))\n\tfor _, menu := range menus {\n\t\tmenuIDByCode[menu.Code] = menu.ID\n\t}\n\n\tapiModel := model.NewApi()\n\tapiModel.SetDB(db)\n\tapis, err := apiModel.FindIdsByRouteAndMethod(routes, methods)\n\tif err != nil {\n\t\treturn defaultMenuAPITargets{}, err\n\t}\n\tapiIDByRouteMethod := make(map[string]uint, len(apis))\n\tfor _, api := range apis {\n\t\tapiIDByRouteMethod[api.Method+\":\"+api.Route] = api.ID\n\t}\n\n\treturn defaultMenuAPITargets{\n\t\tmenuIDByCode:       menuIDByCode,\n\t\tapiIDByRouteMethod: apiIDByRouteMethod,\n\t}, nil\n}\n\nfunc buildDefaultMenuAPIMappings(bindings []defaultMenuAPIBinding, targets defaultMenuAPITargets) []*model.MenuApiMap {\n\tmappings := make([]*model.MenuApiMap, 0, len(bindings))\n\tfor _, item := range bindings {\n\t\tmenuID, ok := targets.menuIDByCode[item.MenuCode]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tapiID, ok := targets.apiIDByRouteMethod[item.Method+\":\"+item.Route]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tmappings = append(mappings, &model.MenuApiMap{\n\t\t\tMenuId: menuID,\n\t\t\tApiId:  apiID,\n\t\t})\n\t}\n\treturn mappings\n}\n"
  },
  {
    "path": "internal/service/access/menu_api_defaults_test.go",
    "content": "package access\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\nfunc TestNewMenuAPIDefaultsServiceUsesIsolatedDefaultBindings(t *testing.T) {\n\tfirst := NewMenuAPIDefaultsService()\n\tsecond := NewMenuAPIDefaultsService()\n\tif len(first.bindings) == 0 || len(second.bindings) == 0 {\n\t\tt.Fatal(\"expected default bindings to be initialized\")\n\t}\n\n\toriginalRoute := second.bindings[0].Route\n\tfirst.bindings[0].Route = \"/mutated-by-test\"\n\n\tif second.bindings[0].Route != originalRoute {\n\t\tt.Fatal(\"expected default bindings to be isolated per service instance\")\n\t}\n}\n\nfunc TestNewMenuAPIDefaultsServiceWithDepsClonesBindings(t *testing.T) {\n\tcustomBindings := []defaultMenuAPIBinding{\n\t\t{MenuCode: \"menu:test\", Route: \"/custom\", Method: \"GET\"},\n\t}\n\n\tservice := NewMenuAPIDefaultsServiceWithDeps(MenuAPIDefaultsServiceDeps{\n\t\tBindings: customBindings,\n\t})\n\tcustomBindings[0].Route = \"/changed\"\n\n\tif service.bindings[0].Route != \"/custom\" {\n\t\tt.Fatal(\"expected custom bindings to be cloned\")\n\t}\n}\n\nfunc TestNewMenuAPIDefaultsServiceWithDepsAllowsEmptyBindings(t *testing.T) {\n\tservice := NewMenuAPIDefaultsServiceWithDeps(MenuAPIDefaultsServiceDeps{\n\t\tBindings: []defaultMenuAPIBinding{},\n\t})\n\n\tif len(service.bindings) != 0 {\n\t\tt.Fatalf(\"expected empty bindings, got %d\", len(service.bindings))\n\t}\n}\n\nfunc TestDefaultMenuAPIBindingsCoverManagementRoutes(t *testing.T) {\n\tservice := NewMenuAPIDefaultsService()\n\tbindingSet := make(map[string]struct{}, len(service.bindings))\n\tfor _, binding := range service.bindings {\n\t\tbindingSet[binding.Method+\" \"+binding.Route] = struct{}{}\n\t}\n\n\trequired := []string{\n\t\thttp.MethodGet + \" /admin/v1/system/config/list\",\n\t\thttp.MethodGet + \" /admin/v1/system/config/detail\",\n\t\thttp.MethodPost + \" /admin/v1/system/config/create\",\n\t\thttp.MethodPost + \" /admin/v1/system/config/update\",\n\t\thttp.MethodGet + \" /admin/v1/system/dict/type/list\",\n\t\thttp.MethodGet + \" /admin/v1/system/dict/options\",\n\t\thttp.MethodGet + \" /admin/v1/task/list\",\n\t\thttp.MethodPost + \" /admin/v1/task/trigger\",\n\t\thttp.MethodPost + \" /admin/v1/task/run/retry\",\n\t\thttp.MethodPost + \" /admin/v1/task/run/cancel\",\n\t\thttp.MethodGet + \" /admin/v1/log/request/list\",\n\t\thttp.MethodGet + \" /admin/v1/log/request/detail\",\n\t\thttp.MethodPost + \" /admin/v1/log/request/mask-config\",\n\t}\n\tfor _, route := range required {\n\t\tif _, ok := bindingSet[route]; !ok {\n\t\t\tt.Fatalf(\"missing default menu API binding for %s\", route)\n\t\t}\n\t}\n}\n\nfunc TestCollectMenuAPIBindingKeysKeepsUniqueValues(t *testing.T) {\n\tbindings := []defaultMenuAPIBinding{\n\t\t{MenuCode: \"menu:list\", Route: \"/admin/v1/menu/list\", Method: http.MethodGet},\n\t\t{MenuCode: \"menu:list\", Route: \"/admin/v1/menu/list\", Method: http.MethodGet},\n\t\t{MenuCode: \"menu:update\", Route: \"/admin/v1/menu/update\", Method: http.MethodPost},\n\t}\n\n\tmenuCodes, routes, methods := collectMenuAPIBindingKeys(bindings)\n\n\tif len(menuCodes) != 2 || menuCodes[0] != \"menu:list\" || menuCodes[1] != \"menu:update\" {\n\t\tt.Fatalf(\"unexpected menu codes: %#v\", menuCodes)\n\t}\n\tif len(routes) != 2 || routes[0] != \"/admin/v1/menu/list\" || routes[1] != \"/admin/v1/menu/update\" {\n\t\tt.Fatalf(\"unexpected routes: %#v\", routes)\n\t}\n\tif len(methods) != 2 || methods[0] != http.MethodGet || methods[1] != http.MethodPost {\n\t\tt.Fatalf(\"unexpected methods: %#v\", methods)\n\t}\n}\n\nfunc TestBuildDefaultMenuAPIMappingsSkipsMissingTargets(t *testing.T) {\n\tbindings := []defaultMenuAPIBinding{\n\t\t{MenuCode: \"menu:list\", Route: \"/admin/v1/menu/list\", Method: http.MethodGet},\n\t\t{MenuCode: \"menu:missing\", Route: \"/admin/v1/menu/list\", Method: http.MethodGet},\n\t\t{MenuCode: \"menu:list\", Route: \"/admin/v1/menu/missing\", Method: http.MethodGet},\n\t}\n\ttargets := defaultMenuAPITargets{\n\t\tmenuIDByCode: map[string]uint{\n\t\t\t\"menu:list\": 10,\n\t\t},\n\t\tapiIDByRouteMethod: map[string]uint{\n\t\t\thttp.MethodGet + \":/admin/v1/menu/list\": 20,\n\t\t},\n\t}\n\n\tmappings := buildDefaultMenuAPIMappings(bindings, targets)\n\n\tif len(mappings) != 1 {\n\t\tt.Fatalf(\"expected one mapping, got %d\", len(mappings))\n\t}\n\twant := &model.MenuApiMap{MenuId: 10, ApiId: 20}\n\tif mappings[0].MenuId != want.MenuId || mappings[0].ApiId != want.ApiId {\n\t\tt.Fatalf(\"unexpected mapping: got=%+v want=%+v\", mappings[0], want)\n\t}\n}\n"
  },
  {
    "path": "internal/service/access/scope_resolver.go",
    "content": "package access\n\nimport (\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\n// PermissionChangeScope 描述一次业务变更影响的权限对象范围。\ntype PermissionChangeScope struct {\n\tAPIIDs        []uint\n\tMenuIDs       []uint\n\tRoleIDs       []uint\n\tDepartmentIDs []uint\n\tUserIDs       []uint\n}\n\n// AffectedUsersResolver 负责将资源变更转换成受影响用户集合。\ntype AffectedUsersResolver struct{}\n\n// NewAffectedUsersResolver 创建受影响用户解析器。\nfunc NewAffectedUsersResolver() *AffectedUsersResolver {\n\treturn &AffectedUsersResolver{}\n}\n\n// Resolve 返回指定作用域下的受影响用户集合。\nfunc (r *AffectedUsersResolver) Resolve(scope PermissionChangeScope, tx ...*gorm.DB) ([]uint, error) {\n\tuserSet := make([]uint, 0, len(scope.UserIDs))\n\tuserSet = append(userSet, scope.UserIDs...)\n\n\tmenuIDs := UniqueUintSlice(scope.MenuIDs)\n\tif len(scope.APIIDs) > 0 {\n\t\tmenuApiMapModel := model.NewMenuApiMap()\n\t\tif t := FirstTx(tx); t != nil {\n\t\t\tmenuApiMapModel.SetDB(t)\n\t\t}\n\t\tapiMenuIDs, err := menuApiMapModel.MenuIdsByApiIds(scope.APIIDs)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tmenuIDs = UniqueUintSlice(append(menuIDs, apiMenuIDs...))\n\t}\n\n\troleIDs := UniqueUintSlice(scope.RoleIDs)\n\tif len(menuIDs) > 0 {\n\t\troleMenuMapModel := model.NewRoleMenuMap()\n\t\tif t := FirstTx(tx); t != nil {\n\t\t\troleMenuMapModel.SetDB(t)\n\t\t}\n\t\tmenuRoleIDs, err := roleMenuMapModel.RoleIdsByMenuIds(menuIDs)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\troleIDs = UniqueUintSlice(append(roleIDs, menuRoleIDs...))\n\t}\n\n\tif len(roleIDs) > 0 {\n\t\troleUserIDs, err := r.userIDsByRoles(roleIDs, tx...)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tuserSet = append(userSet, roleUserIDs...)\n\t}\n\n\tif len(scope.DepartmentIDs) > 0 {\n\t\tdeptMapModel := model.NewAdminUserDeptMap()\n\t\tif t := FirstTx(tx); t != nil {\n\t\t\tdeptMapModel.SetDB(t)\n\t\t}\n\t\tdeptUserIDs, err := deptMapModel.UidsByDeptIds(scope.DepartmentIDs)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tuserSet = append(userSet, deptUserIDs...)\n\t}\n\n\treturn UniqueUintSlice(userSet), nil\n}\n\nfunc (r *AffectedUsersResolver) userIDsByRoles(roleIDs []uint, tx ...*gorm.DB) ([]uint, error) {\n\troleIDs = UniqueUintSlice(roleIDs)\n\tif len(roleIDs) == 0 {\n\t\treturn nil, nil\n\t}\n\n\texpandedRoleIDs, err := r.expandRoleSubtree(roleIDs, tx...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\troleMapModel := model.NewAdminUserRoleMap()\n\tdeptRoleMapModel := model.NewDeptRoleMap()\n\tdeptMapModel := model.NewAdminUserDeptMap()\n\tif t := FirstTx(tx); t != nil {\n\t\troleMapModel.SetDB(t)\n\t\tdeptRoleMapModel.SetDB(t)\n\t\tdeptMapModel.SetDB(t)\n\t}\n\n\tuserIDs, err := roleMapModel.UidsByRoleIds(expandedRoleIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdeptIDs, err := deptRoleMapModel.DeptIdsByRoleIds(expandedRoleIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(deptIDs) == 0 {\n\t\treturn userIDs, nil\n\t}\n\n\tdeptUserIDs, err := deptMapModel.UidsByDeptIds(deptIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn UniqueUintSlice(append(userIDs, deptUserIDs...)), nil\n}\n\nfunc (r *AffectedUsersResolver) expandRoleSubtree(roleIDs []uint, tx ...*gorm.DB) ([]uint, error) {\n\troleIDs = UniqueUintSlice(roleIDs)\n\tif len(roleIDs) == 0 {\n\t\treturn nil, nil\n\t}\n\n\troleModel := model.NewRole()\n\tif t := FirstTx(tx); t != nil {\n\t\troleModel.SetDB(t)\n\t}\n\tsubtreeIDs, err := roleModel.SubtreeIdsByRootIds(roleIDs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn UniqueUintSlice(append(subtreeIDs, roleIDs...)), nil\n}\n"
  },
  {
    "path": "internal/service/access/system_defaults.go",
    "content": "package access\n\nimport (\n\t\"errors\"\n\n\t\"github.com/samber/lo\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\n// SystemDefaultsService 负责校验和补齐系统默认角色、部门与关联关系。\ntype SystemDefaultsService struct{}\n\n// NewSystemDefaultsService 创建系统默认数据服务实例。\nfunc NewSystemDefaultsService() *SystemDefaultsService {\n\treturn &SystemDefaultsService{}\n}\n\n// Ensure 确保系统默认数据和关联关系存在。\nfunc (s *SystemDefaultsService) Ensure(tx ...*gorm.DB) error {\n\texistingTx := FirstTx(tx)\n\tif existingTx != nil {\n\t\treturn s.ensureWithTx(existingTx)\n\t}\n\n\tdb, err := model.NewAdminUsers().GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Transaction(func(execTx *gorm.DB) error {\n\t\treturn s.ensureWithTx(execTx)\n\t})\n}\n\n// IsProtectedRole 判断角色是否为系统保护角色。\nfunc (s *SystemDefaultsService) IsProtectedRole(role *model.Role) bool {\n\treturn role != nil && role.IsSystemRole() && role.Code == global.SuperAdminRoleCode\n}\n\n// IsProtectedDepartment 判断部门是否为系统保护部门。\nfunc (s *SystemDefaultsService) IsProtectedDepartment(dept *model.Department) bool {\n\treturn dept != nil && dept.IsSystemDepartment() && dept.Code == global.DefaultDepartmentCode\n}\n\n// EnsureSuperAdminRoleMenus 兼容旧入口，确保超级管理员角色菜单完整。\nfunc (s *SystemDefaultsService) EnsureSuperAdminRoleMenus(tx ...*gorm.DB) error {\n\treturn s.Ensure(tx...)\n}\n\nfunc (s *SystemDefaultsService) ensureWithTx(tx *gorm.DB) error {\n\tdept, err := s.ensureDefaultDepartment(tx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trole, err := s.ensureSuperAdminRole(tx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.ensureSuperAdminUser(tx); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.ensureSuperAdminUserDept(tx, dept.ID); err != nil {\n\t\treturn err\n\t}\n\n\tif err := s.ensureSuperAdminUserRole(tx, role.ID); err != nil {\n\t\treturn err\n\t}\n\n\treturn s.ensureSuperAdminRoleMenusWithTx(tx, role.ID)\n}\n\nfunc (s *SystemDefaultsService) ensureDefaultDepartment(tx *gorm.DB) (*model.Department, error) {\n\tdept := model.NewDepartment()\n\tdept.SetDB(tx)\n\tif err := dept.FindByCode(global.DefaultDepartmentCode); err != nil {\n\t\tif !errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tdept.Code = global.DefaultDepartmentCode\n\t\tdept.IsSystem = global.Yes\n\t\tdept.Pid = 0\n\t\tdept.Pids = \"0\"\n\t\tdept.Level = 1\n\t\tdept.Name = \"默认部门\"\n\t\tdept.Description = \"系统默认部门\"\n\t\tdept.Sort = 100\n\t\tif err := dept.Save(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn dept, nil\n\t}\n\n\tupdates := map[string]any{}\n\tif dept.IsSystem != global.Yes {\n\t\tupdates[\"is_system\"] = global.Yes\n\t}\n\tif dept.Pid != 0 {\n\t\tupdates[\"pid\"] = 0\n\t}\n\tif dept.Pids != \"0\" {\n\t\tupdates[\"pids\"] = \"0\"\n\t}\n\tif dept.Level != 1 {\n\t\tupdates[\"level\"] = 1\n\t}\n\tif dept.Code != global.DefaultDepartmentCode {\n\t\tupdates[\"code\"] = global.DefaultDepartmentCode\n\t}\n\tif len(updates) > 0 {\n\t\tif err := dept.UpdateById(dept.ID, updates); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn dept, nil\n}\n\nfunc (s *SystemDefaultsService) ensureSuperAdminRole(tx *gorm.DB) (*model.Role, error) {\n\trole := model.NewRole()\n\trole.SetDB(tx)\n\tif err := role.FindByCode(global.SuperAdminRoleCode); err != nil {\n\t\tif !errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, err\n\t\t}\n\n\t\trole.Code = global.SuperAdminRoleCode\n\t\trole.IsSystem = global.Yes\n\t\trole.Pid = 0\n\t\trole.Pids = \"0\"\n\t\trole.Level = 1\n\t\trole.Name = \"超级管理员\"\n\t\trole.Description = \"系统默认超级管理员角色\"\n\t\trole.Sort = 100\n\t\trole.Status = global.Yes\n\t\tif err := role.Save(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn role, nil\n\t}\n\n\tupdates := map[string]any{}\n\tif role.IsSystem != global.Yes {\n\t\tupdates[\"is_system\"] = global.Yes\n\t}\n\tif role.Code != global.SuperAdminRoleCode {\n\t\tupdates[\"code\"] = global.SuperAdminRoleCode\n\t}\n\tif role.Pid != 0 {\n\t\tupdates[\"pid\"] = 0\n\t}\n\tif role.Pids != \"0\" {\n\t\tupdates[\"pids\"] = \"0\"\n\t}\n\tif role.Level != 1 {\n\t\tupdates[\"level\"] = 1\n\t}\n\tif role.Name != \"超级管理员\" {\n\t\tupdates[\"name\"] = \"超级管理员\"\n\t}\n\tif role.Description != \"系统默认超级管理员角色\" {\n\t\tupdates[\"description\"] = \"系统默认超级管理员角色\"\n\t}\n\tif role.Sort != 100 {\n\t\tupdates[\"sort\"] = 100\n\t}\n\tif role.Status != 1 {\n\t\tupdates[\"status\"] = 1\n\t}\n\tif len(updates) > 0 {\n\t\tif err := role.UpdateById(role.ID, updates); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn role, nil\n}\n\nfunc (s *SystemDefaultsService) ensureSuperAdminUser(tx *gorm.DB) error {\n\tadminUser := model.NewAdminUsers()\n\tadminUser.SetDB(tx)\n\tif err := adminUser.GetById(global.SuperAdminId); err != nil {\n\t\treturn err\n\t}\n\n\tupdates := map[string]any{}\n\tif adminUser.IsSuperAdmin != global.Yes {\n\t\tupdates[\"is_super_admin\"] = global.Yes\n\t}\n\tif adminUser.Status != model.AdminUserStatusEnabled {\n\t\tupdates[\"status\"] = model.AdminUserStatusEnabled\n\t}\n\tif adminUser.Username != global.SuperAdminRoleCode {\n\t\tupdates[\"username\"] = global.SuperAdminRoleCode\n\t}\n\tif len(updates) == 0 {\n\t\treturn nil\n\t}\n\treturn adminUser.UpdateById(adminUser.ID, updates)\n}\n\nfunc (s *SystemDefaultsService) ensureSuperAdminUserDept(tx *gorm.DB, deptID uint) error {\n\trel := model.NewAdminUserDeptMap()\n\trel.SetDB(tx)\n\tcount, err := rel.CountByCondition(\"uid = ? AND dept_id = ?\", global.SuperAdminId, deptID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif count > 0 {\n\t\treturn nil\n\t}\n\trel.Uid = global.SuperAdminId\n\trel.DeptId = deptID\n\treturn rel.CreateOne()\n}\n\nfunc (s *SystemDefaultsService) ensureSuperAdminUserRole(tx *gorm.DB, roleID uint) error {\n\trel := model.NewAdminUserRoleMap()\n\trel.SetDB(tx)\n\tcount, err := rel.CountByCondition(\"uid = ? AND role_id = ?\", global.SuperAdminId, roleID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif count > 0 {\n\t\treturn nil\n\t}\n\trel.Uid = global.SuperAdminId\n\trel.RoleId = roleID\n\treturn rel.CreateOne()\n}\n\nfunc (s *SystemDefaultsService) ensureSuperAdminRoleMenusWithTx(tx *gorm.DB, roleID uint) error {\n\tmenuModel := model.NewMenu()\n\tmenuModel.SetDB(tx)\n\tallMenuIDs, err := menuModel.AllIds()\n\tif err != nil {\n\t\treturn err\n\t}\n\tallMenuIDs = lo.Uniq(allMenuIDs)\n\n\troleMenuMap := model.NewRoleMenuMap()\n\troleMenuMap.SetDB(tx)\n\texistingIDs, err := model.ExtractColumnsByCondition[model.RoleMenuMap, *model.RoleMenuMap, uint](roleMenuMap, \"menu_id\", \"role_id = ?\", roleID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttoDelete, toAdd, _ := utils.CalculateChanges(existingIDs, allMenuIDs)\n\tif len(toDelete) > 0 {\n\t\tif err := roleMenuMap.DeleteWhere(\"role_id = ? AND menu_id IN (?)\", roleID, toDelete); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif len(toAdd) == 0 {\n\t\treturn nil\n\t}\n\n\tnewMappings := lo.Map(toAdd, func(menuID uint, _ int) *model.RoleMenuMap {\n\t\treturn &model.RoleMenuMap{RoleId: roleID, MenuId: menuID}\n\t})\n\treturn roleMenuMap.CreateBatch(newMappings)\n}\n\n// RequireSuperAdminRoleForUser 确保超级管理员用户始终保留超级管理员角色。\nfunc (s *SystemDefaultsService) RequireSuperAdminRoleForUser(uid uint, roleIDs []uint) error {\n\tif uid != global.SuperAdminId {\n\t\treturn nil\n\t}\n\n\trole := model.NewRole()\n\tif err := role.FindByCode(global.SuperAdminRoleCode); err != nil {\n\t\treturn err\n\t}\n\tif lo.Contains(roleIDs, role.ID) {\n\t\treturn nil\n\t}\n\treturn e.NewBusinessError(e.SuperAdminMustKeepRole)\n}\n"
  },
  {
    "path": "internal/service/access/system_defaults_test.go",
    "content": "package access\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\nfunc TestSystemDefaultsServiceProtectedPredicates(t *testing.T) {\n\tservice := NewSystemDefaultsService()\n\n\tprotectedRole := model.NewRole()\n\tprotectedRole.Code = global.SuperAdminRoleCode\n\tprotectedRole.IsSystem = global.Yes\n\tif !service.IsProtectedRole(protectedRole) {\n\t\tt.Fatal(\"expected super admin role to be protected\")\n\t}\n\n\tnormalRole := model.NewRole()\n\tnormalRole.Code = \"editor\"\n\tnormalRole.IsSystem = global.No\n\tif service.IsProtectedRole(normalRole) {\n\t\tt.Fatal(\"expected normal role to be mutable\")\n\t}\n\n\tprotectedDept := model.NewDepartment()\n\tprotectedDept.Code = global.DefaultDepartmentCode\n\tprotectedDept.IsSystem = global.Yes\n\tif !service.IsProtectedDepartment(protectedDept) {\n\t\tt.Fatal(\"expected default department to be protected\")\n\t}\n\n\tnormalDept := model.NewDepartment()\n\tnormalDept.Code = \"sales\"\n\tnormalDept.IsSystem = global.No\n\tif service.IsProtectedDepartment(normalDept) {\n\t\tt.Fatal(\"expected normal department to be mutable\")\n\t}\n}\n\nfunc TestRequireSuperAdminRoleForNonSuperAdminUserSkipsLookup(t *testing.T) {\n\tservice := NewSystemDefaultsService()\n\tif err := service.RequireSuperAdminRoleForUser(2, nil); err != nil {\n\t\tt.Fatalf(\"expected non-super-admin user to skip protected role validation, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/service/access/transaction.go",
    "content": "package access\n\nimport \"gorm.io/gorm\"\n\n// RunInTransaction 统一执行事务。\nfunc RunInTransaction(db *gorm.DB, fn func(tx *gorm.DB) error) error {\n\treturn db.Transaction(fn)\n}\n"
  },
  {
    "path": "internal/service/access/user_permission_sync.go",
    "content": "package access\n\nimport (\n\t\"gorm.io/gorm\"\n\n\tcasbinx \"github.com/wannanbigpig/gin-layout/internal/access/casbin\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\n// UserPermissionSyncService 负责把数据库关系展开为用户最终接口权限。\ntype UserPermissionSyncService struct {\n\t// reloadPolicyFn 刷新 Casbin 内存策略缓存。\n\treloadPolicyFn func() error\n}\n\n// UserPermissionSyncServiceDeps 描述 UserPermissionSyncService 可注入依赖。\ntype UserPermissionSyncServiceDeps struct {\n\t// ReloadPolicy 自定义策略刷新实现（测试场景常用）。\n\tReloadPolicy func() error\n}\n\n// NewUserPermissionSyncService 创建用户权限同步服务实例。\nfunc NewUserPermissionSyncService() *UserPermissionSyncService {\n\treturn NewUserPermissionSyncServiceWithDeps(UserPermissionSyncServiceDeps{})\n}\n\n// NewUserPermissionSyncServiceWithDeps 创建带依赖注入的用户权限同步服务实例。\nfunc NewUserPermissionSyncServiceWithDeps(deps UserPermissionSyncServiceDeps) *UserPermissionSyncService {\n\ts := &UserPermissionSyncService{\n\t\treloadPolicyFn: deps.ReloadPolicy,\n\t}\n\tif s.reloadPolicyFn == nil {\n\t\ts.reloadPolicyFn = defaultReloadPolicy\n\t}\n\treturn s\n}\n\n// ReloadPolicyCache 刷新共享 Casbin Enforcer 策略缓存。\nfunc (s *UserPermissionSyncService) ReloadPolicyCache() error {\n\treturn s.reloadPolicyFn()\n}\n\n// SyncUser 重建单个用户的最终接口权限并同步到 Casbin。\nfunc (s *UserPermissionSyncService) SyncUser(userID uint, tx ...*gorm.DB) error {\n\treturn s.withSyncTransaction(tx, func(execTx *gorm.DB) error { return s.syncUserWithTx(userID, execTx) })\n}\n\n// SyncUsers 重建多个用户的最终接口权限并同步到 Casbin。\nfunc (s *UserPermissionSyncService) SyncUsers(userIDs []uint, tx ...*gorm.DB) error {\n\treturn s.withSyncTransaction(tx, func(execTx *gorm.DB) error {\n\t\tenforcer, err := getPolicyEnforcer()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn s.batchSyncUsersWithEnforcer(userIDs, enforcer, execTx)\n\t})\n}\n\n// SyncAllUsers 重建全部用户的最终接口权限并同步到 Casbin。\nfunc (s *UserPermissionSyncService) SyncAllUsers(tx ...*gorm.DB) error {\n\treturn s.withSyncTransaction(tx, func(execTx *gorm.DB) error {\n\t\tuserIDs, err := s.allUserIDs(execTx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tenforcer, err := getPolicyEnforcer()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn s.batchSyncUsersWithEnforcer(userIDs, enforcer, execTx)\n\t})\n}\n\n// ClearUser 清理单个用户在 Casbin 中的最终接口权限。\nfunc (s *UserPermissionSyncService) ClearUser(userID uint, tx ...*gorm.DB) error {\n\treturn s.withSyncTransaction(tx, func(execTx *gorm.DB) error {\n\t\tenforcer, err := getPolicyEnforcer()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn enforcer.SetDB(execTx).EditPolicyPermissions(s.UserKey(userID), nil)\n\t})\n}\n\n// AccessibleMenuIDs 返回用户可访问的菜单 ID 列表。\n// 当 includeParents 为 true 时，会补齐菜单树展示所需的父级目录。\nfunc (s *UserPermissionSyncService) AccessibleMenuIDs(userID uint, includeParents bool, tx ...*gorm.DB) ([]uint, error) {\n\troleIDs, err := s.userRoleIDs(userID, tx...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmenuIDs, err := s.RoleMenuIDs(roleIDs, tx...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif includeParents {\n\t\treturn s.expandMenuIDsWithParents(menuIDs, tx...)\n\t}\n\treturn menuIDs, nil\n}\n\n// withSyncTransaction 使用现有事务或新事务执行权限同步，确保写入原子性。\n// 如果有现有事务则复用，否则创建新事务并在完成后刷新 Casbin 策略。\nfunc (s *UserPermissionSyncService) withSyncTransaction(tx []*gorm.DB, fn func(execTx *gorm.DB) error) error {\n\tif existingTx := FirstTx(tx); existingTx != nil {\n\t\treturn fn(existingTx)\n\t}\n\tdb, err := model.NewAdminUsers().GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := db.Transaction(fn); err != nil {\n\t\treturn err\n\t}\n\treturn s.ReloadPolicyCache()\n}\n\n// forEachUser 遍历用户 ID 列表并执行回调函数，遇到错误立即返回。\nfunc (s *UserPermissionSyncService) forEachUser(userIDs []uint, fn func(userID uint) error) error {\n\tuniqueIDs := UniqueUintSlice(userIDs)\n\tif len(uniqueIDs) == 0 {\n\t\treturn nil\n\t}\n\tfor _, userID := range uniqueIDs {\n\t\tif err := fn(userID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// batchSyncUsersWithEnforcer 批量同步多个用户的权限，使用同一个enforcer减少重复获取\n// batchSyncUsersWithEnforcer 批量同步多个用户的权限到 Casbin，使用同一个 enforcer 减少重复开销。\n// 参数：\n//   - userIDs: 用户 ID 列表\n//   - enforcer: Casbin Enforcer 实例\n//   - tx: 事务实例\nfunc (s *UserPermissionSyncService) batchSyncUsersWithEnforcer(userIDs []uint, enforcer *casbinx.CasbinEnforcer, tx *gorm.DB) error {\n\tuniqueIDs := UniqueUintSlice(userIDs)\n\tif len(uniqueIDs) == 0 {\n\t\treturn nil\n\t}\n\n\t// 收集所有用户的权限策略\n\tpoliciesByUser, err := s.collectPoliciesForUsers(uniqueIDs, tx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// 构建 subject -> policies 映射\n\tsubjectPolicies := make(map[string][][]string, len(uniqueIDs))\n\tfor _, userID := range uniqueIDs {\n\t\tsubjectPolicies[s.UserKey(userID)] = policiesByUser[userID]\n\t}\n\n\treturn enforcer.EditPolicyPermissionsBatch(subjectPolicies, tx)\n}\n\n// syncUserWithTx 在指定事务内同步单个用户的最终接口权限到 Casbin。\nfunc (s *UserPermissionSyncService) syncUserWithTx(userID uint, tx *gorm.DB) error {\n\tenforcer, err := getPolicyEnforcer()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// 收集用户的所有权限策略\n\tpolicies, err := s.collectUserPolicies(userID, tx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn enforcer.SetDB(tx).EditPolicyPermissions(s.UserKey(userID), policies)\n}\n\n// UniqueUintSlice 对 uint 切片去重并保留首次出现顺序。\nfunc UniqueUintSlice(values []uint) []uint {\n\tif len(values) == 0 {\n\t\treturn nil\n\t}\n\tset := make(map[uint]struct{}, len(values))\n\tresult := make([]uint, 0, len(values))\n\tfor _, value := range values {\n\t\tif _, ok := set[value]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tset[value] = struct{}{}\n\t\tresult = append(result, value)\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "internal/service/access/user_permission_sync_bench_test.go",
    "content": "package access\n\nimport \"testing\"\n\nfunc BenchmarkBatchPermissionSync(b *testing.B) {\n\troleIDs := []uint{11, 12, 13, 14}\n\troleMenuMap := roleMenuIDMap{\n\t\t11: {101, 102, 103},\n\t\t12: {102, 104, 105},\n\t\t13: {106, 107, 108},\n\t\t14: {101, 108, 109},\n\t}\n\tenabledMenus := map[uint]struct{}{\n\t\t101: {}, 102: {}, 103: {}, 104: {}, 105: {}, 106: {}, 107: {}, 108: {}, 109: {},\n\t}\n\tmenuPolicies := map[uint][][]string{\n\t\t101: {{\"/admin/v1/user/list\", \"GET\"}},\n\t\t102: {{\"/admin/v1/user/create\", \"POST\"}},\n\t\t103: {{\"/admin/v1/user/update\", \"PUT\"}},\n\t\t104: {{\"/admin/v1/role/list\", \"GET\"}},\n\t\t105: {{\"/admin/v1/role/bind\", \"POST\"}},\n\t\t106: {{\"/admin/v1/dept/list\", \"GET\"}},\n\t\t107: {{\"/admin/v1/dept/update\", \"PUT\"}},\n\t\t108: {{\"/admin/v1/menu/list\", \"GET\"}},\n\t\t109: {{\"/admin/v1/menu/update\", \"PUT\"}},\n\t}\n\n\tb.ReportAllocs()\n\tfor i := 0; i < b.N; i++ {\n\t\tpolicies := buildUserPolicies(roleIDs, roleMenuMap, enabledMenus, menuPolicies)\n\t\tif len(policies) == 0 {\n\t\t\tb.Fatal(\"expected policies\")\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/service/access/user_permission_sync_test.go",
    "content": "package access\n\nimport (\n\t\"errors\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc TestUserPermissionSyncForEachUserDeduplicatesInOrder(t *testing.T) {\n\tservice := NewUserPermissionSyncService()\n\tvar visited []uint\n\n\terr := service.forEachUser([]uint{2, 5, 2, 0, 5}, func(userID uint) error {\n\t\tvisited = append(visited, userID)\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\twant := []uint{2, 5, 0}\n\tif !reflect.DeepEqual(visited, want) {\n\t\tt.Fatalf(\"unexpected visit order: got %v want %v\", visited, want)\n\t}\n}\n\nfunc TestUserPermissionSyncForEachUserStopsOnError(t *testing.T) {\n\tservice := NewUserPermissionSyncService()\n\twantErr := errors.New(\"stop\")\n\n\terr := service.forEachUser([]uint{1, 2, 3}, func(userID uint) error {\n\t\tif userID == 2 {\n\t\t\treturn wantErr\n\t\t}\n\t\treturn nil\n\t})\n\tif !errors.Is(err, wantErr) {\n\t\tt.Fatalf(\"expected %v, got %v\", wantErr, err)\n\t}\n}\n\nfunc TestExpandRoleAncestorsSkipsDisabledRoles(t *testing.T) {\n\troleMap := map[uint]roleStatusInfo{\n\t\t1: {ID: 1, Status: 1},\n\t\t2: {ID: 2, Pids: \"1\", Status: 1},\n\t\t3: {ID: 3, Pids: \"1,2\", Status: 0},\n\t}\n\n\tgot := expandRoleAncestors([]uint{2, 3}, roleMap)\n\twant := []uint{2, 1}\n\tif !reflect.DeepEqual(got, want) && !reflect.DeepEqual(got, []uint{1, 2}) {\n\t\tt.Fatalf(\"unexpected expanded roles: got %v want %v\", got, want)\n\t}\n}\n\nfunc TestPermissionSyncCoordinatorRunAfterCommitReloadsOnce(t *testing.T) {\n\tdb, err := gorm.Open(sqlite.Open(\"file::memory:?cache=shared\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to open sqlite: %v\", err)\n\t}\n\n\treloadCount := 0\n\tcoordinator := NewPermissionSyncCoordinatorWithDeps(PermissionSyncCoordinatorDeps{\n\t\tSyncer: NewUserPermissionSyncServiceWithDeps(UserPermissionSyncServiceDeps{\n\t\t\tReloadPolicy: func() error {\n\t\t\t\treloadCount++\n\t\t\t\treturn nil\n\t\t\t},\n\t\t}),\n\t})\n\n\terr = coordinator.RunAfterCommit(db, \"reload failed\", func(tx *gorm.DB) error {\n\t\tif tx == nil {\n\t\t\tt.Fatal(\"expected transaction\")\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\tif reloadCount != 1 {\n\t\tt.Fatalf(\"expected reload once, got %d\", reloadCount)\n\t}\n}\n"
  },
  {
    "path": "internal/service/admin/admin_user.go",
    "content": "package admin\n\nimport (\n\t\"errors\"\n\n\t\"go.uber.org/zap\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/i18n\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/auth\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// AdminUserService 授权服务。\ntype AdminUserService struct {\n\tservice.Base\n}\n\nconst (\n\tmenuQuerySuperAdmin = \"status = ?\"\n\tmenuQueryNoAuth     = \"status = ? AND is_auth = ?\"\n\tmenuQueryWithAuth   = \"status = ? AND (is_auth = ? OR (is_auth = ? AND id IN (?)))\"\n)\n\n// NewAdminUserService 创建管理员用户服务实例。\nfunc NewAdminUserService() *AdminUserService {\n\treturn &AdminUserService{}\n}\n\nfunc (s *AdminUserService) handleMutationError(err error, fallbackCode int) error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\n\tvar businessErr *e.BusinessError\n\tif errors.As(err, &businessErr) {\n\t\treturn businessErr\n\t}\n\n\treturn e.NewBusinessError(fallbackCode)\n}\n\nfunc (s *AdminUserService) revokeUserTokens(tx *gorm.DB, userID uint, revokedCode uint8, revokedReason string) {\n\tloginService := auth.NewLoginService()\n\tif err := loginService.RevokeUserTokens(userID, revokedCode, revokedReason, tx); err != nil {\n\t\tlog.Logger.Error(\"撤销用户token失败\", zap.Error(err), zap.Uint(\"user_id\", userID))\n\t}\n}\n\ntype userTokenRevocation struct {\n\tuserID        uint\n\trevokedCode   uint8\n\trevokedReason string\n}\n\nfunc (s *AdminUserService) revokeUserTokensAfterCommit(items []userTokenRevocation) {\n\tfor _, item := range items {\n\t\ts.revokeUserTokens(nil, item.userID, item.revokedCode, item.revokedReason)\n\t}\n}\n\n// GetUserInfo 获取用户信息。\nfunc (s *AdminUserService) GetUserInfo(id uint) (*resources.AdminUserResources, error) {\n\tadminUsersModel := model.NewAdminUsers()\n\terr := adminUsersModel.GetByIdWithPreload(id, \"RoleList\", \"Department\")\n\tif err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, e.NewBusinessError(e.UserDoesNotExist)\n\t\t}\n\t\treturn nil, err\n\t}\n\n\treturn resources.NewAdminUserTransformer().ToStruct(adminUsersModel), nil\n}\n\n// GetUserMenuInfo 获取用户权限信息。\nfunc (s *AdminUserService) GetUserMenuInfo(id uint, locale string) (any, error) {\n\tadminUsersModel := model.NewAdminUsers()\n\terr := adminUsersModel.GetById(id)\n\tif err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, e.NewBusinessError(e.UserDoesNotExist)\n\t\t}\n\t\treturn nil, err\n\t}\n\tcondition, args := s.userMenuQuery(id == global.SuperAdminId, nil)\n\tif id != global.SuperAdminId {\n\t\tmenuIDs, err := access.NewPermissionSyncCoordinator().AccessibleMenuIDs(id, true)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tcondition, args = s.userMenuQuery(false, menuIDs)\n\t}\n\n\tmenus, err := model.ListE(model.NewMenu(), condition, args, model.ListOptionalParams{\n\t\tOrderBy: \"sort desc, id desc\",\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tmenuIDs := make([]uint, 0, len(menus))\n\tfor _, menu := range menus {\n\t\tif menu == nil {\n\t\t\tcontinue\n\t\t}\n\t\tmenuIDs = append(menuIDs, menu.ID)\n\t}\n\ttitleMap, err := model.NewMenuI18n().LocalizedTitleMapByMenuIDs(menuIDs, []string{\n\t\ti18n.NormalizeLocale(locale),\n\t\ti18n.LocaleZhCN,\n\t\ti18n.LocaleEnUS,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn resources.BuildMenuTree(menus, 0, titleMap), nil\n}\n\n// List 返回管理员分页列表。\nfunc (s *AdminUserService) List(params *form.AdminUserList) *resources.Collection {\n\tconditionStr, args := s.buildListCondition(params)\n\tadminUserModel := model.NewAdminUsers()\n\ttotal, collection, err := model.ListPageE(adminUserModel, params.Page, params.PerPage, conditionStr, args, s.adminUserListOptions())\n\tif err != nil {\n\t\tlog.Logger.Error(\"查询管理员列表失败\", zap.Error(err))\n\t\treturn resources.NewAdminUserTransformer().ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\treturn resources.NewAdminUserTransformer().ToCollection(params.Page, params.PerPage, total, collection)\n}\n\n// adminUserListOptions 返回管理员列表查询选项。\n// 列表页仅使用部门 id/name/pid，避免 Preload 全字段带来额外 IO。\nfunc (s *AdminUserService) adminUserListOptions() model.ListOptionalParams {\n\treturn model.ListOptionalParams{\n\t\tOrderBy: \"created_at desc, id desc\",\n\t\tPreload: map[string]func(db *gorm.DB) *gorm.DB{\n\t\t\t\"Department\": func(db *gorm.DB) *gorm.DB {\n\t\t\t\treturn db.Select(\"id\", \"name\", \"pid\")\n\t\t\t},\n\t\t},\n\t}\n}\n\n// buildListCondition 构建管理员列表查询条件。\nfunc (s *AdminUserService) buildListCondition(params *form.AdminUserList) (string, []any) {\n\tqb := query_builder.New().\n\t\tAddLike(\"username\", params.UserName).\n\t\tAddEq(\"id\", zeroToNil(params.ID)).\n\t\tAddLike(\"nickname\", params.NickName).\n\t\tAddLike(\"email\", params.Email).\n\t\tAddLike(\"full_phone_number\", params.PhoneNumber).\n\t\tAddEq(\"status\", params.Status)\n\n\t// 部门筛选：使用 EXISTS 子查询关联用户 - 部门映射表\n\tif params.DeptId > 0 {\n\t\tqb.AddCondition(\n\t\t\t\"EXISTS (SELECT 1 FROM admin_user_department_map WHERE admin_user_department_map.uid = admin_user.id AND admin_user_department_map.dept_id = ?)\",\n\t\t\tparams.DeptId,\n\t\t)\n\t}\n\n\treturn qb.Build()\n}\n\n// zeroToNil 将 0 转换为 nil，用于查询条件构建时排除空值筛选。\nfunc zeroToNil(value uint) any {\n\tif value == 0 {\n\t\treturn nil\n\t}\n\treturn value\n}\n\n// userMenuQuery 构建用户菜单查询条件。\n// 参数：\n//   - isSuperAdmin: 是否为超级管理员\n//   - menuIDs: 用户可访问的菜单 ID 列表\n//\n// 返回：查询条件和参数\nfunc (s *AdminUserService) userMenuQuery(isSuperAdmin bool, menuIDs []uint) (string, []any) {\n\tif isSuperAdmin {\n\t\treturn menuQuerySuperAdmin, []any{1}\n\t}\n\tif len(menuIDs) == 0 {\n\t\treturn menuQueryNoAuth, []any{1, 0}\n\t}\n\treturn menuQueryWithAuth, []any{1, 0, 1, menuIDs}\n}\n\n// adminUserEditParams 管理员用户编辑参数，字段使用指针支持部分更新。\ntype adminUserEditParams struct {\n\tId          uint    // 用户 ID，0 表示新增\n\tUsername    *string // 用户名\n\tNickname    *string // 昵称\n\tPassword    *string // 密码\n\tPhoneNumber *string // 手机号\n\tCountryCode *string // 国家代码\n\tEmail       *string // 邮箱\n\tStatus      *uint8  // 状态\n\tAvatar      *string // 头像\n\tDeptIds     *[]uint // 关联的部门 ID 列表\n}\n"
  },
  {
    "path": "internal/service/admin/admin_user_bind.go",
    "content": "package admin\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/samber/lo\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// BindDept 绑定部门。\nfunc (s *AdminUserService) BindDept(uid uint, deptId []uint, tx ...*gorm.DB) (err error) {\n\tvar dbTx *gorm.DB\n\tif len(tx) > 0 {\n\t\tdbTx = tx[0]\n\t} else {\n\t\tdbTx, err = model.NewAdminUserDeptMap().GetDB()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tadminUserDeptMap := model.NewAdminUserDeptMap()\n\tadminUserDeptMap.SetDB(dbTx)\n\n\texistingIds, err := model.ExtractColumnsByCondition[model.AdminUserDeptMap, *model.AdminUserDeptMap, uint](adminUserDeptMap, \"dept_id\", \"uid = ?\", uid)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttoDelete, toAdd, _ := utils.CalculateChanges(existingIds, deptId)\n\tif len(toDelete) > 0 {\n\t\tif err := adminUserDeptMap.DeleteWhere(\"uid = ? AND dept_id IN (?)\", []any{uid, toDelete}...); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := s.updateDeptUserNumber(toDelete, -1, dbTx); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(toAdd) > 0 {\n\t\tnewMappings := lo.Map(toAdd, func(deptId uint, _ int) *model.AdminUserDeptMap {\n\t\t\treturn &model.AdminUserDeptMap{DeptId: deptId, Uid: uid}\n\t\t})\n\t\tif err := adminUserDeptMap.CreateBatch(newMappings); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := s.updateDeptUserNumber(toAdd, 1, dbTx); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn access.NewPermissionSyncCoordinator().SyncUser(uid, tx...)\n}\n\nfunc (s *AdminUserService) updateDeptUserNumber(deptIds []uint, delta int, tx *gorm.DB) error {\n\tif len(deptIds) == 0 {\n\t\treturn nil\n\t}\n\n\tdeptModel := model.NewDepartment()\n\tdeptModel.SetDB(tx)\n\n\tvar updateExpr string\n\tif delta < 0 {\n\t\tupdateExpr = fmt.Sprintf(\"GREATEST(user_number + %d, 0)\", delta)\n\t} else {\n\t\tupdateExpr = fmt.Sprintf(\"user_number + %d\", delta)\n\t}\n\n\treturn deptModel.UpdateUserNumberByIds(deptIds, updateExpr)\n}\n\n// BindRole 绑定角色。\nfunc (s *AdminUserService) BindRole(params *form.BindRole) error {\n\tadminUserModel := model.NewAdminUsers()\n\terr := adminUserModel.GetById(params.UserId)\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.UserDoesNotExist)\n\t}\n\n\tids, err := model.VerifyExistingIDs(model.NewRole(), params.RoleIds)\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.RoleNotFound)\n\t}\n\tif err := access.NewSystemDefaultsService().RequireSuperAdminRoleForUser(adminUserModel.ID, ids); err != nil {\n\t\treturn err\n\t}\n\n\tdb, err := model.NewAdminUserRoleMap().GetDB()\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.UpdateUserFailed)\n\t}\n\terr = access.NewPermissionSyncCoordinator().RunAfterCommitWithCode(db, e.UpdateUserFailed, func(tx *gorm.DB) error {\n\t\treturn s.updateAdminUserRole(adminUserModel.ID, ids, tx)\n\t})\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.UpdateUserFailed)\n\t}\n\treturn nil\n}\n\nfunc (s *AdminUserService) updateAdminUserRole(uid uint, roleIds []uint, tx ...*gorm.DB) error {\n\tif err := access.NewSystemDefaultsService().RequireSuperAdminRoleForUser(uid, roleIds); err != nil {\n\t\treturn err\n\t}\n\n\tadminUserRoleMap := model.NewAdminUserRoleMap()\n\tif len(tx) > 0 {\n\t\tadminUserRoleMap.SetDB(tx[0])\n\t}\n\texistingIds, err := model.ExtractColumnsByCondition[model.AdminUserRoleMap, *model.AdminUserRoleMap, uint](adminUserRoleMap, \"role_id\", \"uid = ?\", uid)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttoDelete, toAdd, _ := utils.CalculateChanges(existingIds, roleIds)\n\tif len(toDelete) > 0 {\n\t\tif err := adminUserRoleMap.DeleteWhere(\"uid = ? AND role_id IN (?)\", []any{uid, toDelete}...); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(toAdd) > 0 {\n\t\tnewMappings := lo.Map(toAdd, func(roleId uint, _ int) *model.AdminUserRoleMap {\n\t\t\treturn &model.AdminUserRoleMap{RoleId: roleId, Uid: uid}\n\t\t})\n\t\tif err := adminUserRoleMap.CreateBatch(newMappings); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn access.NewPermissionSyncCoordinator().SyncUser(uid, tx...)\n}\n"
  },
  {
    "path": "internal/service/admin/admin_user_create_test.go",
    "content": "package admin\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nfunc TestAdminUserCreateRequiresUsername(t *testing.T) {\n\tservice := NewAdminUserService()\n\tnickname := \"nick\"\n\tparams := form.NewCreateAdminUser()\n\tparams.Nickname = &nickname\n\n\terr := service.Create(params)\n\n\tassertBusinessErrorMessage(t, err, e.UsernameRequired, \"用户名必填\")\n}\n\nfunc TestAdminUserCreateRequiresNickname(t *testing.T) {\n\tservice := NewAdminUserService()\n\tusername := \"admin\"\n\tpassword := \"123456\"\n\tparams := form.NewCreateAdminUser()\n\tparams.Username = &username\n\tparams.Password = &password\n\n\terr := service.Create(params)\n\n\tassertBusinessErrorMessage(t, err, e.NicknameRequired, \"昵称必填\")\n}\n\nfunc TestAdminUserCreateRequiresPassword(t *testing.T) {\n\tservice := NewAdminUserService()\n\tusername := \"admin\"\n\tnickname := \"nick\"\n\tparams := form.NewCreateAdminUser()\n\tparams.Username = &username\n\tparams.Nickname = &nickname\n\n\terr := service.Create(params)\n\n\tassertBusinessErrorMessage(t, err, e.PasswordRequired, \"密码必填\")\n}\n\nfunc assertBusinessErrorMessage(t *testing.T, err error, code int, message string) {\n\tt.Helper()\n\n\tvar businessErr *e.BusinessError\n\tif !errors.As(err, &businessErr) {\n\t\tt.Fatalf(\"expected business error, got %v\", err)\n\t}\n\tif businessErr.GetCode() != code {\n\t\tt.Fatalf(\"expected code %d, got %d\", code, businessErr.GetCode())\n\t}\n\tif businessErr.GetMessage() != message {\n\t\tt.Fatalf(\"expected message %q, got %q\", message, businessErr.GetMessage())\n\t}\n}\n"
  },
  {
    "path": "internal/service/admin/admin_user_mutation.go",
    "content": "package admin\n\nimport (\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n\tutils2 \"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\n// Create 新增管理员用户。\nfunc (s *AdminUserService) Create(params *form.CreateAdminUser) error {\n\treturn s.saveAdminUserMutation(&adminUserEditParams{\n\t\tUsername:    params.Username,\n\t\tNickname:    params.Nickname,\n\t\tPassword:    params.Password,\n\t\tPhoneNumber: params.PhoneNumber,\n\t\tCountryCode: params.CountryCode,\n\t\tEmail:       params.Email,\n\t\tStatus:      params.Status,\n\t\tAvatar:      params.Avatar,\n\t\tDeptIds:     params.DeptIds,\n\t})\n}\n\n// Update 更新管理员用户。\nfunc (s *AdminUserService) Update(params *form.UpdateAdminUser) error {\n\treturn s.saveAdminUserMutation(&adminUserEditParams{\n\t\tId:          params.Id,\n\t\tUsername:    params.Username,\n\t\tNickname:    params.Nickname,\n\t\tPassword:    params.Password,\n\t\tPhoneNumber: params.PhoneNumber,\n\t\tCountryCode: params.CountryCode,\n\t\tEmail:       params.Email,\n\t\tStatus:      params.Status,\n\t\tAvatar:      params.Avatar,\n\t\tDeptIds:     params.DeptIds,\n\t})\n}\n\n// saveAdminUserMutation 执行管理员用户变更操作（新增/更新）。\n// 处理逻辑：\n// 1. 验证用户是否存在（更新时）\n// 2. 应用字段变更（创建/更新场景分别处理）\n// 3. 验证唯一字段（用户名、手机号、邮箱）\n// 4. 事务保存：用户数据、Token 撤销（密码变更/禁用时）、部门绑定\n// 5. 同步用户权限缓存\nfunc (s *AdminUserService) saveAdminUserMutation(params *adminUserEditParams) error {\n\tmutationFailedCode := e.CreateUserFailed\n\texcludeID := uint(0)\n\toldStatus := uint8(0)\n\tadminUserModel := model.NewAdminUsers()\n\n\tif params.Id > 0 {\n\t\tmutationFailedCode = e.UpdateUserFailed\n\t\tif err := adminUserModel.GetById(params.Id); err != nil {\n\t\t\treturn e.NewBusinessError(e.UserDoesNotExist)\n\t\t}\n\t\texcludeID = params.Id\n\t\toldStatus = adminUserModel.Status\n\t}\n\toldAvatar := adminUserModel.Avatar\n\n\tvar err error\n\tif params.Id > 0 {\n\t\terr = s.applyUpdateFields(adminUserModel, params)\n\t} else {\n\t\terr = s.applyCreateFields(adminUserModel, params)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdb, err := adminUserModel.GetDB()\n\tif err != nil {\n\t\treturn e.NewBusinessError(mutationFailedCode)\n\t}\n\n\tpendingRevocations := make([]userTokenRevocation, 0, 2)\n\terr = access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tadminUserModel.SetDB(tx)\n\n\t\tvalidateParams := map[string]interface{}{\n\t\t\t\"username\": params.Username,\n\t\t\t\"email\":    params.Email,\n\t\t}\n\t\tif (params.PhoneNumber != nil || params.CountryCode != nil) && adminUserModel.PhoneNumber != \"\" {\n\t\t\tvalidateParams[\"full_phone_number\"] = adminUserModel.FullPhoneNumber\n\t\t}\n\t\tif err := s.validateUniqueFieldsWithLock(tx, validateParams, excludeID); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := adminUserModel.Save(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif params.Avatar != nil {\n\t\t\trefService := service.NewFileReferenceService(tx)\n\t\t\tif oldAvatar != \"\" && oldAvatar != adminUserModel.Avatar {\n\t\t\t\tif err := refService.ReleaseReference(oldAvatar, \"admin_user\", adminUserModel.ID, \"avatar\"); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t\tif adminUserModel.Avatar != \"\" {\n\t\t\t\tif err := refService.BindReference(adminUserModel.Avatar, \"admin_user\", adminUserModel.ID, \"avatar\"); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif params.Id > 0 && params.Status != nil &&\n\t\t\toldStatus == model.AdminUserStatusEnabled &&\n\t\t\t*params.Status == model.AdminUserStatusDisabled {\n\t\t\tpendingRevocations = append(pendingRevocations, userTokenRevocation{\n\t\t\t\tuserID:        adminUserModel.ID,\n\t\t\t\trevokedCode:   model.RevokedCodeSystemForce,\n\t\t\t\trevokedReason: \"系统强制登出（账号被封）\",\n\t\t\t})\n\t\t}\n\t\tif params.Id > 0 && params.Password != nil && *params.Password != \"\" {\n\t\t\tpendingRevocations = append(pendingRevocations, userTokenRevocation{\n\t\t\t\tuserID:        adminUserModel.ID,\n\t\t\t\trevokedCode:   model.RevokedCodePasswordChangeAdmin,\n\t\t\t\trevokedReason: \"管理员修改密码\",\n\t\t\t})\n\t\t}\n\n\t\tif params.DeptIds != nil {\n\t\t\tdeptIDs := access.UniqueUintSlice(*params.DeptIds)\n\t\t\treturn s.BindDept(adminUserModel.ID, deptIDs, tx)\n\t\t}\n\n\t\treturn access.NewPermissionSyncCoordinator().SyncUser(adminUserModel.ID, tx)\n\t})\n\tif err := s.handleMutationError(err, mutationFailedCode); err != nil {\n\t\treturn err\n\t}\n\ts.revokeUserTokensAfterCommit(pendingRevocations)\n\treturn access.NewPermissionSyncCoordinator().ReloadPolicyCacheWithCode(mutationFailedCode)\n}\n\n// applyUpdateFields 应用更新场景的字段变更。\n// 只更新非 nil 指针字段，支持部分更新语义。\n// 特殊处理：\n// - 超级管理员不可修改密码\n// - 密码相同时拒绝更新\n// - 超级管理员不可禁用\nfunc (s *AdminUserService) applyUpdateFields(adminUserModel *model.AdminUser, params *adminUserEditParams) error {\n\tif params.Username != nil {\n\t\tadminUserModel.Username = *params.Username\n\t}\n\tif params.Nickname != nil {\n\t\tadminUserModel.Nickname = *params.Nickname\n\t}\n\tif params.Password != nil && *params.Password != \"\" {\n\t\tif adminUserModel.ID == global.SuperAdminId {\n\t\t\treturn e.NewBusinessError(e.SuperAdminCannotModify)\n\t\t}\n\t\tif utils2.ComparePasswords(adminUserModel.Password, *params.Password) {\n\t\t\treturn e.NewBusinessError(e.SamePassword)\n\t\t}\n\t\tif err := setHashedPassword(adminUserModel, *params.Password); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif params.PhoneNumber != nil {\n\t\tadminUserModel.PhoneNumber = *params.PhoneNumber\n\t}\n\tif params.CountryCode != nil {\n\t\tadminUserModel.CountryCode = *params.CountryCode\n\t} else if params.PhoneNumber != nil {\n\t\tadminUserModel.CountryCode = global.ChinaCountryCode\n\t}\n\tif params.Email != nil {\n\t\tadminUserModel.Email = *params.Email\n\t}\n\tif params.Status != nil {\n\t\tif *params.Status == model.AdminUserStatusDisabled && adminUserModel.ID == global.SuperAdminId {\n\t\t\treturn e.NewBusinessError(e.SuperAdminCannotDisable)\n\t\t}\n\t\tadminUserModel.Status = *params.Status\n\t}\n\tif params.Avatar != nil {\n\t\tadminUserModel.Avatar = *params.Avatar\n\t}\n\tif params.PhoneNumber != nil || params.CountryCode != nil {\n\t\tif adminUserModel.PhoneNumber == \"\" {\n\t\t\tadminUserModel.FullPhoneNumber = \"\"\n\t\t} else {\n\t\t\tadminUserModel.FullPhoneNumber = adminUserModel.CountryCode + adminUserModel.PhoneNumber\n\t\t}\n\t}\n\treturn nil\n}\n\n// applyCreateFields 应用新增场景的字段填充。\n// 必填字段：用户名、昵称、密码\n// 默认值：国家代码默认为中国\nfunc (s *AdminUserService) applyCreateFields(adminUserModel *model.AdminUser, params *adminUserEditParams) error {\n\tif params.Username == nil || *params.Username == \"\" {\n\t\treturn e.NewBusinessError(e.UsernameRequired)\n\t}\n\tif params.Nickname == nil || *params.Nickname == \"\" {\n\t\treturn e.NewBusinessError(e.NicknameRequired)\n\t}\n\tif params.Password == nil || *params.Password == \"\" {\n\t\treturn e.NewBusinessError(e.PasswordRequired)\n\t}\n\n\tadminUserModel.Username = *params.Username\n\tadminUserModel.Nickname = *params.Nickname\n\tif params.PhoneNumber != nil {\n\t\tadminUserModel.PhoneNumber = *params.PhoneNumber\n\t}\n\tif params.CountryCode != nil {\n\t\tadminUserModel.CountryCode = *params.CountryCode\n\t} else {\n\t\tadminUserModel.CountryCode = global.ChinaCountryCode\n\t}\n\tif params.Email != nil {\n\t\tadminUserModel.Email = *params.Email\n\t}\n\tif err := setHashedPassword(adminUserModel, *params.Password); err != nil {\n\t\treturn err\n\t}\n\tif params.Avatar != nil {\n\t\tadminUserModel.Avatar = *params.Avatar\n\t}\n\tif params.Status != nil {\n\t\tadminUserModel.Status = *params.Status\n\t}\n\tif adminUserModel.PhoneNumber != \"\" {\n\t\tadminUserModel.FullPhoneNumber = adminUserModel.CountryCode + adminUserModel.PhoneNumber\n\t}\n\treturn nil\n}\n\n// setHashedPassword 对用户密码进行哈希处理后设置到模型。\nfunc setHashedPassword(adminUserModel *model.AdminUser, plainPassword string) error {\n\tpasswordHash, err := utils2.PasswordHash(plainPassword)\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.PasswordProcessFailed)\n\t}\n\tadminUserModel.Password = passwordHash\n\treturn nil\n}\n\n// UpdateProfile 更新个人资料。\nfunc (s *AdminUserService) UpdateProfile(uid uint, params *form.UpdateProfile) error {\n\tadminUserModel := model.NewAdminUsers()\n\terr := adminUserModel.GetById(uid)\n\tif err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn e.NewBusinessError(e.UserDoesNotExist)\n\t\t}\n\t\treturn err\n\t}\n\toldAvatar := adminUserModel.Avatar\n\n\tpasswordChanged := params.Password != nil && *params.Password != \"\"\n\thasUpdate := params.Nickname != nil ||\n\t\tpasswordChanged ||\n\t\tparams.PhoneNumber != nil ||\n\t\tparams.CountryCode != nil ||\n\t\tparams.Email != nil ||\n\t\tparams.Avatar != nil\n\tif !hasUpdate {\n\t\treturn nil\n\t}\n\n\teditParams := &adminUserEditParams{\n\t\tId:          uid,\n\t\tNickname:    params.Nickname,\n\t\tPassword:    params.Password,\n\t\tPhoneNumber: params.PhoneNumber,\n\t\tCountryCode: params.CountryCode,\n\t\tEmail:       params.Email,\n\t\tAvatar:      params.Avatar,\n\t}\n\tif err := s.applyUpdateFields(adminUserModel, editParams); err != nil {\n\t\treturn err\n\t}\n\n\tdb, err := adminUserModel.GetDB()\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.UpdateUserFailed)\n\t}\n\tpendingRevocations := make([]userTokenRevocation, 0, 1)\n\terr = access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tadminUserModel.SetDB(tx)\n\n\t\tvalidateParams := map[string]interface{}{\n\t\t\t\"email\": params.Email,\n\t\t}\n\t\tif (params.PhoneNumber != nil || params.CountryCode != nil) && adminUserModel.PhoneNumber != \"\" {\n\t\t\tvalidateParams[\"full_phone_number\"] = adminUserModel.FullPhoneNumber\n\t\t}\n\t\tif err := s.validateUniqueFieldsWithLock(tx, validateParams, uid); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := adminUserModel.Save(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif params.Avatar != nil {\n\t\t\trefService := service.NewFileReferenceService(tx)\n\t\t\tif oldAvatar != \"\" && oldAvatar != adminUserModel.Avatar {\n\t\t\t\tif err := refService.ReleaseReference(oldAvatar, \"admin_user\", uid, \"avatar\"); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t\tif adminUserModel.Avatar != \"\" {\n\t\t\t\tif err := refService.BindReference(adminUserModel.Avatar, \"admin_user\", uid, \"avatar\"); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif passwordChanged {\n\t\t\tpendingRevocations = append(pendingRevocations, userTokenRevocation{\n\t\t\t\tuserID:        uid,\n\t\t\t\trevokedCode:   model.RevokedCodePasswordChangeSelf,\n\t\t\t\trevokedReason: \"用户自己修改密码\",\n\t\t\t})\n\t\t}\n\t\treturn nil\n\t})\n\tif err := s.handleMutationError(err, e.UpdateUserFailed); err != nil {\n\t\treturn err\n\t}\n\ts.revokeUserTokensAfterCommit(pendingRevocations)\n\treturn nil\n}\n\n// validateUniqueFieldsWithLock 验证唯一字段（用户名、手机号、邮箱），使用数据库锁防止并发冲突。\n// 参数：\n//   - tx: 事务实例\n//   - params: 待验证的字段值 map\n//   - excludeId: 排除的当前用户 ID（更新场景）\nfunc (s *AdminUserService) validateUniqueFieldsWithLock(tx *gorm.DB, params map[string]interface{}, excludeId uint) error {\n\tcheckModel := model.NewAdminUsers()\n\tcheckModel.SetDB(tx)\n\n\t// 验证用户名唯一性\n\tif usernameVal, ok := params[\"username\"]; ok {\n\t\tif username, ok := usernameVal.(*string); ok && username != nil && *username != \"\" {\n\t\t\texists, err := checkModel.ExistsWithLockExcludeId(\"username\", *username, excludeId)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif exists {\n\t\t\t\treturn e.NewBusinessError(e.UserExists)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 验证手机号唯一性（使用模型最终的完整手机号，覆盖仅修改国家代码的场景）。\n\tif fullPhoneNumberVal, ok := params[\"full_phone_number\"]; ok {\n\t\tif fullPhoneNumber, ok := fullPhoneNumberVal.(string); ok && fullPhoneNumber != \"\" {\n\t\t\texists, err := checkModel.ExistsWithLockExcludeId(\"full_phone_number\", fullPhoneNumber, excludeId)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif exists {\n\t\t\t\treturn e.NewBusinessError(e.PhoneNumberExists)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 验证邮箱唯一性\n\tif emailVal, ok := params[\"email\"]; ok {\n\t\tif email, ok := emailVal.(*string); ok && email != nil && *email != \"\" {\n\t\t\texists, err := checkModel.ExistsWithLockExcludeId(\"email\", *email, excludeId)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif exists {\n\t\t\t\treturn e.NewBusinessError(e.EmailExists)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Delete 删除用户。\nfunc (s *AdminUserService) Delete(id uint) error {\n\tif id == global.SuperAdminId {\n\t\treturn e.NewBusinessError(e.SuperAdminCannotDelete)\n\t}\n\n\tadminUserModel := model.NewAdminUsers()\n\tadminUserDeptMap := model.NewAdminUserDeptMap()\n\tdeptIds, err := model.ExtractColumnsByCondition[model.AdminUserDeptMap, *model.AdminUserDeptMap, uint](adminUserDeptMap, \"dept_id\", \"uid = ?\", id)\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.QueryUserDeptFailed)\n\t}\n\n\tdb, err := adminUserModel.GetDB()\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.DeleteUserFailed)\n\t}\n\tpendingRevocations := []userTokenRevocation{{\n\t\tuserID:        id,\n\t\trevokedCode:   model.RevokedCodeOther,\n\t\trevokedReason: \"管理员删除用户\",\n\t}}\n\terr = access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tadminUserModel.SetDB(tx)\n\n\t\tif _, err := adminUserModel.DeleteByID(id); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := service.NewFileReferenceService(tx).ReleaseReferencesByOwner(\"admin_user\", id, \"avatar\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tadminUserDeptMap.SetDB(tx)\n\t\tif err := adminUserDeptMap.DeleteWhere(\"uid = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tadminUserRoleMap := model.NewAdminUserRoleMap()\n\t\tadminUserRoleMap.SetDB(tx)\n\t\tif err := adminUserRoleMap.DeleteWhere(\"uid = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif len(deptIds) > 0 {\n\t\t\tif err := s.updateDeptUserNumber(deptIds, -1, tx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn access.NewPermissionSyncCoordinator().ClearUser(id, tx)\n\t})\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.DeleteUserFailed)\n\t}\n\n\ts.revokeUserTokensAfterCommit(pendingRevocations)\n\treturn access.NewPermissionSyncCoordinator().ReloadPolicyCacheWithCode(e.DeleteUserFailed)\n}\n"
  },
  {
    "path": "internal/service/admin/admin_user_test.go",
    "content": "package admin\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n)\n\nfunc TestAdminUserBuildListCondition(t *testing.T) {\n\tstatus := uint8(1)\n\tparams := &form.AdminUserList{\n\t\tUserName:    \"root\",\n\t\tID:          7,\n\t\tNickName:    \"admin\",\n\t\tEmail:       \"a@example.com\",\n\t\tPhoneNumber: \"138\",\n\t\tStatus:      &status,\n\t\tDeptId:      3,\n\t}\n\n\tcondition, args := NewAdminUserService().buildListCondition(params)\n\texpected := \"username like ? AND id = ? AND nickname like ? AND email like ? AND full_phone_number like ? AND status = ? AND EXISTS (SELECT 1 FROM admin_user_department_map WHERE admin_user_department_map.uid = admin_user.id AND admin_user_department_map.dept_id = ?)\"\n\tif condition != expected {\n\t\tt.Fatalf(\"unexpected condition: %s\", condition)\n\t}\n\tif len(args) != 7 {\n\t\tt.Fatalf(\"unexpected args len: %d\", len(args))\n\t}\n}\n\nfunc TestUniqueUintSlice(t *testing.T) {\n\tmenuIDs := access.UniqueUintSlice([]uint{2, 5, 2, 0, 5})\n\tif len(menuIDs) != 3 {\n\t\tt.Fatalf(\"unexpected menu id count: %#v\", menuIDs)\n\t}\n\tif menuIDs[0] != 2 || menuIDs[1] != 5 || menuIDs[2] != 0 {\n\t\tt.Fatalf(\"unexpected menu ids: %#v\", menuIDs)\n\t}\n}\n\nfunc TestUserPermissionSyncUserKey(t *testing.T) {\n\tkey := access.NewUserPermissionSyncService().UserKey(12)\n\tif key != \"adminUser:12\" {\n\t\tt.Fatalf(\"unexpected user key: %s\", key)\n\t}\n}\n\nfunc TestAdminUserMenuQuery(t *testing.T) {\n\tservice := NewAdminUserService()\n\n\tcondition, args := service.userMenuQuery(true, nil)\n\tif condition != \"status = ?\" || len(args) != 1 {\n\t\tt.Fatalf(\"unexpected super admin query: %s %#v\", condition, args)\n\t}\n\n\tcondition, args = service.userMenuQuery(false, nil)\n\tif condition != \"status = ? AND is_auth = ?\" || len(args) != 2 {\n\t\tt.Fatalf(\"unexpected anonymous menu query: %s %#v\", condition, args)\n\t}\n\n\tcondition, args = service.userMenuQuery(false, []uint{1, 2})\n\tif condition != \"status = ? AND (is_auth = ? OR (is_auth = ? AND id IN (?)))\" || len(args) != 4 {\n\t\tt.Fatalf(\"unexpected scoped menu query: %s %#v\", condition, args)\n\t}\n}\n\nfunc TestAdminUserHandleMutationErrorKeepsBusinessError(t *testing.T) {\n\tservice := NewAdminUserService()\n\tbusinessErr := e.NewBusinessError(e.FAILURE, \"business\")\n\n\tgot := service.handleMutationError(businessErr, e.FAILURE)\n\tif got != businessErr {\n\t\tt.Fatalf(\"expected original business error, got %#v\", got)\n\t}\n}\n\nfunc TestAdminUserHandleMutationErrorWrapsPlainError(t *testing.T) {\n\tservice := NewAdminUserService()\n\n\terr := service.handleMutationError(errors.New(\"plain\"), e.CreateUserFailed)\n\tassertBusinessErrorMessage(t, err, e.CreateUserFailed, \"创建用户失败，请重试\")\n}\n\nfunc TestAdminUserListOptionsDepartmentSelectFields(t *testing.T) {\n\tservice := NewAdminUserService()\n\toptions := service.adminUserListOptions()\n\tscope, ok := options.Preload[\"Department\"]\n\tif !ok || scope == nil {\n\t\tt.Fatal(\"expected Department preload scope\")\n\t}\n\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{DryRun: true})\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create dry-run db: %v\", err)\n\t}\n\n\tscopedDB := scope(db)\n\tselects := strings.Join(scopedDB.Statement.Selects, \",\")\n\tfor _, field := range []string{\"id\", \"name\", \"pid\"} {\n\t\tif !strings.Contains(selects, field) {\n\t\t\tt.Fatalf(\"expected preload select to include %q, got %q\", field, selects)\n\t\t}\n\t}\n}\n\nfunc TestApplyUpdateFieldsDefaultsCountryCodeWhenPhoneChanges(t *testing.T) {\n\tservice := NewAdminUserService()\n\tphoneNumber := \"13800138000\"\n\tadminUserModel := &model.AdminUser{\n\t\tCountryCode: \"+1\",\n\t}\n\n\terr := service.applyUpdateFields(adminUserModel, &adminUserEditParams{\n\t\tId:          1,\n\t\tPhoneNumber: &phoneNumber,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif adminUserModel.CountryCode != global.ChinaCountryCode {\n\t\tt.Fatalf(\"expected default country code %q, got %q\", global.ChinaCountryCode, adminUserModel.CountryCode)\n\t}\n\tif adminUserModel.FullPhoneNumber != global.ChinaCountryCode+phoneNumber {\n\t\tt.Fatalf(\"unexpected full phone number: %q\", adminUserModel.FullPhoneNumber)\n\t}\n}\n\nfunc TestApplyUpdateFieldsClearsFullPhoneWhenPhoneCleared(t *testing.T) {\n\tservice := NewAdminUserService()\n\tphoneNumber := \"\"\n\tadminUserModel := &model.AdminUser{\n\t\tCountryCode:     \"+86\",\n\t\tPhoneNumber:     \"13800138000\",\n\t\tFullPhoneNumber: \"+8613800138000\",\n\t}\n\n\terr := service.applyUpdateFields(adminUserModel, &adminUserEditParams{\n\t\tId:          1,\n\t\tPhoneNumber: &phoneNumber,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif adminUserModel.PhoneNumber != \"\" {\n\t\tt.Fatalf(\"expected phone number to be cleared, got %q\", adminUserModel.PhoneNumber)\n\t}\n\tif adminUserModel.FullPhoneNumber != \"\" {\n\t\tt.Fatalf(\"expected full phone number to be cleared, got %q\", adminUserModel.FullPhoneNumber)\n\t}\n}\n\nfunc TestApplyUpdateFieldsRejectsSuperAdminPasswordChange(t *testing.T) {\n\tservice := NewAdminUserService()\n\tpassword := \"new-password\"\n\tadminUserModel := &model.AdminUser{\n\t\tContainsDeleteBaseModel: model.ContainsDeleteBaseModel{\n\t\t\tBaseModel: model.BaseModel{ID: global.SuperAdminId},\n\t\t},\n\t\tPassword: \"hashed-password\",\n\t}\n\n\terr := service.applyUpdateFields(adminUserModel, &adminUserEditParams{\n\t\tId:       global.SuperAdminId,\n\t\tPassword: &password,\n\t})\n\n\tassertBusinessErrorMessage(t, err, e.SuperAdminCannotModify, \"系统默认超级管理员不允许修改密码\")\n}\n"
  },
  {
    "path": "internal/service/admin/audit_diff.go",
    "content": "package admin\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nvar adminUserDiffRules = []auditdiff.FieldRule{\n\t{Field: \"id\", Label: \"用户ID\"},\n\t{Field: \"username\", Label: \"用户名\"},\n\t{Field: \"nickname\", Label: \"昵称\"},\n\t{Field: \"phone_number\", Label: \"手机号\"},\n\t{Field: \"country_code\", Label: \"国家区号\"},\n\t{Field: \"email\", Label: \"邮箱\"},\n\t{Field: \"avatar\", Label: \"头像\"},\n\t{\n\t\tField: \"status\",\n\t\tLabel: \"状态\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"禁用\",\n\t\t\t\"1\": \"启用\",\n\t\t},\n\t},\n\t{\n\t\tField: \"is_super_admin\",\n\t\tLabel: \"超级管理员\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"否\",\n\t\t\t\"1\": \"是\",\n\t\t},\n\t},\n\t{Field: \"dept_ids\", Label: \"部门ID列表\"},\n\t{Field: \"role_ids\", Label: \"角色ID列表\"},\n}\n\nvar adminUserRoleBindingDiffRules = []auditdiff.FieldRule{\n\t{Field: \"user_id\", Label: \"用户ID\"},\n\t{Field: \"role_ids\", Label: \"角色ID列表\"},\n}\n\n// CreateWithAuditDiff 新增管理员并返回精确 change_diff。\nfunc (s *AdminUserService) CreateWithAuditDiff(params *form.CreateAdminUser) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tif err := s.Create(params); err != nil {\n\t\treturn \"\", err\n\t}\n\tusername := \"\"\n\tif params.Username != nil {\n\t\tusername = strings.TrimSpace(*params.Username)\n\t}\n\tif username == \"\" {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\tafter, err := s.snapshotAdminUserByUsername(username)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\treturn buildAdminUserDiff(nil, after), nil\n}\n\n// UpdateWithAuditDiff 更新管理员并返回精确 change_diff。\nfunc (s *AdminUserService) UpdateWithAuditDiff(params *form.UpdateAdminUser) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tbefore, err := s.snapshotAdminUserByID(params.Id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.Update(params); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotAdminUserByID(params.Id)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\treturn buildAdminUserDiff(before, after), nil\n}\n\n// DeleteWithAuditDiff 删除管理员并返回精确 change_diff。\nfunc (s *AdminUserService) DeleteWithAuditDiff(id uint) (string, error) {\n\tbefore, err := s.snapshotAdminUserByID(id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.Delete(id); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn buildAdminUserDiff(before, nil), nil\n}\n\n// UpdateProfileWithAuditDiff 更新个人资料并返回精确 change_diff。\nfunc (s *AdminUserService) UpdateProfileWithAuditDiff(uid uint, params *form.UpdateProfile) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tbefore, err := s.snapshotAdminUserByID(uid)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.UpdateProfile(uid, params); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotAdminUserByID(uid)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\treturn buildAdminUserDiff(before, after), nil\n}\n\n// BindRoleWithAuditDiff 绑定角色并返回精确 change_diff。\nfunc (s *AdminUserService) BindRoleWithAuditDiff(params *form.BindRole) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tbefore, err := s.snapshotAdminUserRoleBinding(params.UserId)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.BindRole(params); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotAdminUserRoleBinding(params.UserId)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\titems := auditdiff.BuildFieldDiff(before, after, adminUserRoleBindingDiffRules)\n\treturn auditdiff.Marshal(items), nil\n}\n\nfunc (s *AdminUserService) snapshotAdminUserByUsername(username string) (map[string]any, error) {\n\tusername = strings.TrimSpace(username)\n\tif username == \"\" {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter)\n\t}\n\tuser := model.NewAdminUsers()\n\tif err := user.GetDetail(\"username = ? AND deleted_at = 0\", username); err != nil || user.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.UserDoesNotExist)\n\t}\n\treturn s.snapshotAdminUserByID(user.ID)\n}\n\nfunc (s *AdminUserService) snapshotAdminUserByID(id uint) (map[string]any, error) {\n\tuser := model.NewAdminUsers()\n\tif err := user.GetById(id); err != nil || user.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.UserDoesNotExist)\n\t}\n\tdeptIDs, err := model.NewAdminUserDeptMap().DeptIdsByUid(user.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\troleIDs, err := model.NewAdminUserRoleMap().RoleIdsByUid(user.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortUintSlice(deptIDs)\n\tsortUintSlice(roleIDs)\n\treturn map[string]any{\n\t\t\"id\":             user.ID,\n\t\t\"username\":       user.Username,\n\t\t\"nickname\":       user.Nickname,\n\t\t\"phone_number\":   user.PhoneNumber,\n\t\t\"country_code\":   user.CountryCode,\n\t\t\"email\":          user.Email,\n\t\t\"avatar\":         user.Avatar,\n\t\t\"status\":         user.Status,\n\t\t\"is_super_admin\": user.IsSuperAdmin,\n\t\t\"dept_ids\":       deptIDs,\n\t\t\"role_ids\":       roleIDs,\n\t}, nil\n}\n\nfunc (s *AdminUserService) snapshotAdminUserRoleBinding(userID uint) (map[string]any, error) {\n\tuser := model.NewAdminUsers()\n\tif err := user.GetById(userID); err != nil || user.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.UserDoesNotExist)\n\t}\n\troleIDs, err := model.NewAdminUserRoleMap().RoleIdsByUid(userID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsortUintSlice(roleIDs)\n\treturn map[string]any{\n\t\t\"user_id\":  userID,\n\t\t\"role_ids\": roleIDs,\n\t}, nil\n}\n\nfunc buildAdminUserDiff(before, after map[string]any) string {\n\titems := auditdiff.BuildFieldDiff(before, after, adminUserDiffRules)\n\treturn auditdiff.Marshal(items)\n}\n\nfunc sortUintSlice(values []uint) {\n\tif len(values) == 0 {\n\t\treturn\n\t}\n\tsort.Slice(values, func(i, j int) bool {\n\t\treturn values[i] < values[j]\n\t})\n}\n"
  },
  {
    "path": "internal/service/admin/audit_diff_test.go",
    "content": "package admin\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestBuildAdminUserDiffIncludesStatusDisplay(t *testing.T) {\n\traw := buildAdminUserDiff(\n\t\tmap[string]any{\n\t\t\t\"id\":       uint(1),\n\t\t\t\"status\":   uint8(0),\n\t\t\t\"role_ids\": []uint{1},\n\t\t},\n\t\tmap[string]any{\n\t\t\t\"id\":       uint(1),\n\t\t\t\"status\":   uint8(1),\n\t\t\t\"role_ids\": []uint{1, 2},\n\t\t},\n\t)\n\tvar items []map[string]any\n\tif err := json.Unmarshal([]byte(raw), &items); err != nil {\n\t\tt.Fatalf(\"expected valid json diff, got err=%v raw=%s\", err, raw)\n\t}\n\tif len(items) != 2 {\n\t\tt.Fatalf(\"expected 2 diff items, got %d\", len(items))\n\t}\n\tfor _, item := range items {\n\t\tif item[\"field\"] != \"status\" {\n\t\t\tcontinue\n\t\t}\n\t\tif item[\"before_display\"] != \"禁用\" || item[\"after_display\"] != \"启用\" {\n\t\t\tt.Fatalf(\"unexpected status display mapping: %#v\", item)\n\t\t}\n\t\treturn\n\t}\n\tt.Fatalf(\"expected status diff item, got %#v\", items)\n}\n"
  },
  {
    "path": "internal/service/api_permission/api.go",
    "content": "package api_permission\n\nimport (\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// ApiService 处理 API 权限的维护与查询。\ntype ApiService struct {\n\tservice.Base\n}\n\n// NewApiService 创建 API 服务实例。\nfunc NewApiService() *ApiService {\n\treturn &ApiService{}\n}\n\n// Update 更新 API 权限。\nfunc (s *ApiService) Update(params *form.UpdatePermission) error {\n\tapiModel := model.NewApi()\n\texists, err := apiModel.ExistsById(params.Id)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn e.NewBusinessError(e.NotFound)\n\t}\n\tdata := map[string]any{\n\t\t\"name\":        params.Name,\n\t\t\"description\": params.Description,\n\t\t\"is_auth\":     params.IsAuth,\n\t\t\"sort\":        params.Sort,\n\t}\n\tdb, err := apiModel.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tapiModel.SetDB(tx)\n\t\treturn apiModel.UpdateById(params.Id, data)\n\t}); err != nil {\n\t\treturn err\n\t}\n\tif err := access.NewApiRouteCacheService().RefreshCache(); err != nil {\n\t\treturn err\n\t}\n\treturn access.NewPermissionSyncCoordinator().SyncUsersAffectedByAPIs([]uint{params.Id})\n}\n\n// ListPage 分页查询 API 权限列表。\nfunc (s *ApiService) ListPage(params *form.ListPermission) *resources.Collection {\n\tcondition, args := s.buildListCondition(params)\n\n\tapiModel := model.NewApi()\n\ttotal, collection, err := model.ListPageE(\n\t\tapiModel,\n\t\tparams.Page,\n\t\tparams.PerPage,\n\t\tcondition,\n\t\targs,\n\t\tmodel.ListOptionalParams{\n\t\t\tOrderBy: \"sort desc, id desc\",\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn resources.NewApiTransformer().ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\n\treturn resources.NewApiTransformer().ToCollection(params.Page, params.PerPage, total, collection)\n}\n\n// buildListCondition 构建 API 权限列表查询条件。\nfunc (s *ApiService) buildListCondition(params *form.ListPermission) (string, []any) {\n\tqb := query_builder.New()\n\tif params.Keyword != \"\" {\n\t\tqb.AddCondition(\"(name like ? OR route like ? OR code = ?)\", \"%\"+params.Keyword+\"%\", \"%\"+params.Keyword+\"%\", params.Keyword)\n\t}\n\n\tqb.AddLike(\"name\", params.Name).\n\t\tAddEq(\"method\", emptyToNil(params.Method)).\n\t\tAddLike(\"route\", params.Route).\n\t\tAddEq(\"is_auth\", params.IsAuth).\n\t\tAddEq(\"is_effective\", params.IsEffective)\n\n\treturn qb.Build()\n}\n\nfunc emptyToNil(value string) any {\n\tif value == \"\" {\n\t\treturn nil\n\t}\n\treturn value\n}\n"
  },
  {
    "path": "internal/service/api_permission/api_test.go",
    "content": "package api_permission\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nfunc TestApiBuildListCondition(t *testing.T) {\n\tisAuth := int8(1)\n\tisEffective := int8(0)\n\tparams := &form.ListPermission{\n\t\tName:        \"ping\",\n\t\tMethod:      \"GET\",\n\t\tRoute:       \"/ping\",\n\t\tKeyword:     \"svc\",\n\t\tIsAuth:      &isAuth,\n\t\tIsEffective: &isEffective,\n\t}\n\n\tcondition, args := NewApiService().buildListCondition(params)\n\texpected := \"(name like ? OR route like ? OR code = ?) AND name like ? AND method = ? AND route like ? AND is_auth = ? AND is_effective = ?\"\n\tif condition != expected {\n\t\tt.Fatalf(\"unexpected condition: %s\", condition)\n\t}\n\tif len(args) != 8 {\n\t\tt.Fatalf(\"unexpected args len: %d\", len(args))\n\t}\n}\n"
  },
  {
    "path": "internal/service/api_permission/audit_diff.go",
    "content": "package api_permission\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nvar apiPermissionDiffRules = []auditdiff.FieldRule{\n\t{Field: \"id\", Label: \"接口ID\"},\n\t{Field: \"name\", Label: \"接口名称\"},\n\t{Field: \"description\", Label: \"描述\"},\n\t{\n\t\tField: \"is_auth\",\n\t\tLabel: \"鉴权模式\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"无需登录\",\n\t\t\t\"1\": \"需要登录\",\n\t\t\t\"2\": \"需要登录且鉴权\",\n\t\t},\n\t},\n\t{Field: \"sort\", Label: \"排序\"},\n}\n\n// UpdateWithAuditDiff 更新 API 权限并返回精确 change_diff。\nfunc (s *ApiService) UpdateWithAuditDiff(params *form.UpdatePermission) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tbefore, err := s.snapshotAPIPermissionByID(params.Id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.Update(params); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotAPIPermissionByID(params.Id)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\titems := auditdiff.BuildFieldDiff(before, after, apiPermissionDiffRules)\n\treturn auditdiff.Marshal(items), nil\n}\n\nfunc (s *ApiService) snapshotAPIPermissionByID(id uint) (map[string]any, error) {\n\tapiModel := model.NewApi()\n\tif err := apiModel.GetById(id); err != nil || apiModel.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\treturn map[string]any{\n\t\t\"id\":          apiModel.ID,\n\t\t\"name\":        apiModel.Name,\n\t\t\"description\": apiModel.Description,\n\t\t\"is_auth\":     apiModel.IsAuth,\n\t\t\"sort\":        apiModel.Sort,\n\t}, nil\n}\n"
  },
  {
    "path": "internal/service/api_permission/audit_diff_test.go",
    "content": "package api_permission\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n)\n\nfunc TestAPIPermissionDiffIncludesAuthModeDisplay(t *testing.T) {\n\titems := auditdiff.BuildFieldDiff(\n\t\tmap[string]any{\"is_auth\": uint8(0)},\n\t\tmap[string]any{\"is_auth\": uint8(2)},\n\t\tapiPermissionDiffRules,\n\t)\n\traw := auditdiff.Marshal(items)\n\tvar decoded []map[string]any\n\tif err := json.Unmarshal([]byte(raw), &decoded); err != nil {\n\t\tt.Fatalf(\"expected valid json diff, got err=%v raw=%s\", err, raw)\n\t}\n\tif len(decoded) != 1 {\n\t\tt.Fatalf(\"expected 1 diff item, got %d\", len(decoded))\n\t}\n\tif decoded[0][\"before_display\"] != \"无需登录\" || decoded[0][\"after_display\"] != \"需要登录且鉴权\" {\n\t\tt.Fatalf(\"unexpected auth display mapping: %#v\", decoded[0])\n\t}\n}\n"
  },
  {
    "path": "internal/service/audit/list_helpers.go",
    "content": "package audit\n\nimport \"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\ntype logListQuery struct {\n\t*query_builder.QueryBuilder\n}\n\nfunc newLogListQuery() *logListQuery {\n\treturn &logListQuery{QueryBuilder: query_builder.New()}\n}\n\nfunc (q *logListQuery) addEq(field string, value any) *logListQuery {\n\tq.QueryBuilder.AddEq(field, value)\n\treturn q\n}\n\nfunc (q *logListQuery) addLike(field, value string) *logListQuery {\n\tq.QueryBuilder.AddLike(field, value)\n\treturn q\n}\n\nfunc (q *logListQuery) addCondition(condition string, args ...any) *logListQuery {\n\tq.QueryBuilder.AddCondition(condition, args...)\n\treturn q\n}\n\nfunc (q *logListQuery) addCreatedAtRange(startTime, endTime string) *logListQuery {\n\tif startTime != \"\" {\n\t\tq.QueryBuilder.AddCondition(\"created_at >= ?\", startTime)\n\t}\n\tif endTime != \"\" {\n\t\tq.QueryBuilder.AddCondition(\"created_at <= ?\", endTime)\n\t}\n\treturn q\n}\n\nfunc uintFilterValue(value uint) any {\n\tif value == 0 {\n\t\treturn nil\n\t}\n\treturn value\n}\n"
  },
  {
    "path": "internal/service/audit/login_log.go",
    "content": "package audit\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n\t\"github.com/wannanbigpig/gin-layout/pkg/utils/crypto\"\n\t\"go.uber.org/zap\"\n)\n\n// LoginLogService 登录日志服务\ntype AdminLoginLogService struct {\n\tservice.Base\n\t// configProvider 提供运行时配置读取入口。\n\tconfigProvider func() *config.Conf\n}\n\n// AdminLoginLogServiceDeps 描述 AdminLoginLogService 可注入依赖。\ntype AdminLoginLogServiceDeps struct {\n\t// ConfigProvider 自定义配置读取函数。\n\tConfigProvider func() *config.Conf\n}\n\n// NewAdminLoginLogService 创建登录日志服务实例\nfunc NewAdminLoginLogService() *AdminLoginLogService {\n\treturn NewAdminLoginLogServiceWithDeps(AdminLoginLogServiceDeps{})\n}\n\n// NewAdminLoginLogServiceWithDeps 创建带依赖注入的登录日志服务实例。\nfunc NewAdminLoginLogServiceWithDeps(deps AdminLoginLogServiceDeps) *AdminLoginLogService {\n\ts := &AdminLoginLogService{\n\t\tconfigProvider: deps.ConfigProvider,\n\t}\n\ts.ensureRuntimeDeps()\n\treturn s\n}\n\nfunc (s *AdminLoginLogService) ensureRuntimeDeps() {\n\tif s.configProvider == nil {\n\t\ts.configProvider = config.GetConfig\n\t}\n}\n\nfunc (s *AdminLoginLogService) currentConfig() *config.Conf {\n\ts.ensureRuntimeDeps()\n\treturn config.GetConfigFrom(s.configProvider)\n}\n\n// List 分页查询登录日志列表\nfunc (s *AdminLoginLogService) List(params *form.AdminLoginLogList) *resources.Collection {\n\tquery := newLogListQuery().\n\t\taddLike(\"username\", params.Username).\n\t\taddEq(\"login_status\", params.LoginStatus).\n\t\taddLike(\"ip\", params.IP).\n\t\taddCreatedAtRange(params.StartTime, params.EndTime)\n\tconditionStr, args := query.Build()\n\tloginLogModel := model.NewAdminLoginLogs()\n\n\t// 构建查询参数，只查询列表需要的字段，排除大字段\n\tlistOptionalParams := model.ListOptionalParams{\n\t\tSelectFields: []string{\n\t\t\t\"id\",\n\t\t\t\"uid\",\n\t\t\t\"username\",\n\t\t\t\"ip\",\n\t\t\t\"os\",\n\t\t\t\"browser\",\n\t\t\t\"execution_time\",\n\t\t\t\"login_status\",\n\t\t\t\"login_fail_reason\",\n\t\t\t\"type\",\n\t\t\t\"is_revoked\",\n\t\t\t\"revoked_code\",\n\t\t\t\"revoked_reason\",\n\t\t\t\"revoked_at\",\n\t\t\t\"created_at\",\n\t\t},\n\t\tOrderBy: \"created_at DESC, id DESC\",\n\t}\n\n\t// 分页查询（只查询列表需要的字段）\n\ttotal, collection, err := model.ListPageE(loginLogModel, params.Page, params.PerPage, conditionStr, args, listOptionalParams)\n\tif err != nil {\n\t\tlog.Logger.Error(\"查询登录日志列表失败\", zap.Error(err))\n\t\treturn resources.NewAdminLoginLogTransformer().ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\n\t// 使用资源类转换，列表不包含大字段\n\ttransformer := resources.NewAdminLoginLogTransformer()\n\treturn transformer.ToCollection(params.Page, params.PerPage, total, collection)\n}\n\n// Detail 获取登录日志详情\nfunc (s *AdminLoginLogService) Detail(id uint) (any, error) {\n\tloginLog := model.NewAdminLoginLogs()\n\tif err := loginLog.GetById(id); err != nil || loginLog.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\tdecryptKey := s.currentConfig().Jwt.SecretKey\n\tloginLog.AccessToken = decryptLoginTokenIfNeeded(loginLog.AccessToken, decryptKey)\n\tloginLog.RefreshToken = decryptLoginTokenIfNeeded(loginLog.RefreshToken, decryptKey)\n\n\ttransformer := resources.NewAdminLoginLogTransformer()\n\treturn transformer.ToStruct(loginLog), nil\n}\n\nfunc decryptLoginTokenIfNeeded(token, decryptKey string) string {\n\tif token == \"\" || decryptKey == \"\" {\n\t\treturn token\n\t}\n\tdecrypted, err := crypto.Decrypt(decryptKey, token)\n\tif err != nil {\n\t\treturn token\n\t}\n\treturn decrypted\n}\n"
  },
  {
    "path": "internal/service/audit/login_log_test.go",
    "content": "package audit\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/config/autoload\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/testkit\"\n\t\"github.com/wannanbigpig/gin-layout/pkg/utils/crypto\"\n)\n\nfunc TestDecryptLoginTokenIfNeeded(t *testing.T) {\n\tkey := testkit.SecretKey(\"audit-login-log\")\n\tconst plain = \"header.payload.signature\"\n\n\tencrypted, err := crypto.Encrypt(key, plain)\n\tif err != nil {\n\t\tt.Fatalf(\"encrypt token failed: %v\", err)\n\t}\n\n\tif got := decryptLoginTokenIfNeeded(encrypted, key); got != plain {\n\t\tt.Fatalf(\"expected decrypted token %q, got %q\", plain, got)\n\t}\n}\n\nfunc TestDecryptLoginTokenIfNeededFallbackOnDecryptError(t *testing.T) {\n\tkey := testkit.SecretKey(\"audit-login-log\")\n\tconst raw = \"not-encrypted-token\"\n\n\tif got := decryptLoginTokenIfNeeded(raw, key); got != raw {\n\t\tt.Fatalf(\"expected fallback raw token %q, got %q\", raw, got)\n\t}\n}\n\nfunc TestCurrentAuditConfigUsesInjectedProvider(t *testing.T) {\n\tservice := NewAdminLoginLogServiceWithDeps(AdminLoginLogServiceDeps{\n\t\tConfigProvider: func() *config.Conf {\n\t\t\treturn &config.Conf{\n\t\t\t\tJwt: autoload.JwtConfig{\n\t\t\t\t\tSecretKey: \"audit-key\",\n\t\t\t\t},\n\t\t\t}\n\t\t},\n\t})\n\n\tif got := service.currentConfig().Jwt.SecretKey; got != \"audit-key\" {\n\t\tt.Fatalf(\"expected injected key %q, got %q\", \"audit-key\", got)\n\t}\n}\n"
  },
  {
    "path": "internal/service/audit/request_log.go",
    "content": "package audit\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n\t\"go.uber.org/zap\"\n)\n\n// RequestLogService 请求日志服务\ntype RequestLogService struct {\n\tservice.Base\n}\n\n// NewRequestLogService 创建请求日志服务实例\nfunc NewRequestLogService() *RequestLogService {\n\treturn &RequestLogService{}\n}\n\n// List 分页查询请求日志列表\nfunc (s *RequestLogService) List(params *form.RequestLogList) *resources.Collection {\n\tquery := buildRequestLogQuery(requestLogQueryInput{\n\t\tOperatorID:      params.OperatorID,\n\t\tOperatorAccount: params.OperatorAccount,\n\t\tOperationStatus: params.OperationStatus,\n\t\tIsHighRisk:      params.IsHighRisk,\n\t\tMethod:          params.Method,\n\t\tBaseURL:         params.BaseURL,\n\t\tOperationName:   params.OperationName,\n\t\tIP:              params.IP,\n\t\tStartTime:       params.StartTime,\n\t\tEndTime:         params.EndTime,\n\t})\n\tconditionStr, args := query.Build()\n\trequestLogModel := model.NewRequestLogs()\n\n\t// 构建查询参数，只查询列表需要的字段，排除大字段\n\tlistOptionalParams := model.ListOptionalParams{\n\t\tSelectFields: []string{\n\t\t\t\"id\",\n\t\t\t\"request_id\",\n\t\t\t\"operator_id\",\n\t\t\t\"ip\",\n\t\t\t\"method\",\n\t\t\t\"base_url\",\n\t\t\t\"operation_name\",\n\t\t\t\"operation_status\",\n\t\t\t\"is_high_risk\",\n\t\t\t\"operator_account\",\n\t\t\t\"operator_name\",\n\t\t\t\"response_status\",\n\t\t\t\"execution_time\",\n\t\t\t\"created_at\",\n\t\t},\n\t\tOrderBy: \"created_at DESC, id DESC\",\n\t}\n\ttransformer := resources.NewRequestLogTransformer()\n\n\t// 分页查询（只查询列表需要的字段）\n\ttotal, collection, err := model.ListPageE(requestLogModel, params.Page, params.PerPage, conditionStr, args, listOptionalParams)\n\tif err != nil {\n\t\tlog.Logger.Error(\"查询请求日志列表失败\", zap.Error(err))\n\t\treturn transformer.ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\n\t// 使用资源类转换，列表不包含大字段\n\treturn transformer.ToCollection(params.Page, params.PerPage, total, collection)\n}\n\n// Detail 获取请求日志详情\nfunc (s *RequestLogService) Detail(id uint) (any, error) {\n\trequestLog := model.NewRequestLogs()\n\tif err := requestLog.GetById(id); err != nil || requestLog.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\t// 使用资源类转换，详情包含所有字段\n\ttransformer := resources.NewRequestLogTransformer()\n\treturn transformer.ToStruct(requestLog), nil\n}\n\ntype requestLogQueryInput struct {\n\tOperatorID      uint\n\tOperatorAccount string\n\tOperationStatus *int\n\tIsHighRisk      *uint8\n\tMethod          string\n\tBaseURL         string\n\tOperationName   string\n\tIP              string\n\tStartTime       string\n\tEndTime         string\n}\n\nfunc buildRequestLogQuery(input requestLogQueryInput) *logListQuery {\n\tquery := newLogListQuery().\n\t\taddEq(\"operator_id\", uintFilterValue(input.OperatorID)).\n\t\taddLike(\"operator_account\", input.OperatorAccount).\n\t\taddEq(\"base_url\", input.BaseURL).\n\t\taddEq(\"method\", input.Method).\n\t\taddLike(\"operation_name\", input.OperationName).\n\t\taddLike(\"ip\", input.IP).\n\t\taddEq(\"is_high_risk\", input.IsHighRisk).\n\t\taddCreatedAtRange(input.StartTime, input.EndTime)\n\n\tif input.OperationStatus != nil {\n\t\tswitch *input.OperationStatus {\n\t\tcase 0:\n\t\t\tquery.addCondition(\"operation_status = ?\", 0)\n\t\tcase 1:\n\t\t\tquery.addCondition(\"operation_status != ?\", 0)\n\t\t}\n\t}\n\treturn query\n}\n"
  },
  {
    "path": "internal/service/audit/request_log_manage.go",
    "content": "package audit\n\nimport (\n\t\"bytes\"\n\t\"encoding/csv\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/sensitive\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/sys_config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nconst defaultRequestLogExportLimit = 1000\n\nconst (\n\trequestLogMaskConfigGroupCode = \"audit\"\n\trequestLogMaskConfigSort      = 95\n\trequestLogMaskConfigRemark    = \"请求日志脱敏字段配置\"\n\trequestLogMaskConfigManageTab = \"audit_mask\"\n)\n\nvar requestLogMaskConfigNameI18n = map[string]string{\n\t\"zh-CN\": \"请求日志脱敏配置\",\n\t\"en-US\": \"Request Log Mask Config\",\n}\n\nvar requestLogMaskConfigDiffRules = []auditdiff.FieldRule{\n\t{Field: \"common\", Label: \"通用脱敏字段\"},\n\t{Field: \"request_header\", Label: \"请求头脱敏字段\"},\n\t{Field: \"request_body\", Label: \"请求体脱敏字段\"},\n\t{Field: \"response_header\", Label: \"响应头脱敏字段\"},\n\t{Field: \"response_body\", Label: \"响应体脱敏字段\"},\n}\n\n// ExportCSV 导出请求日志 CSV。\nfunc (s *RequestLogService) ExportCSV(params *form.RequestLogExport) ([]byte, string, error) {\n\tquery := buildRequestLogQuery(requestLogQueryInput{\n\t\tOperatorID:      params.OperatorID,\n\t\tOperatorAccount: params.OperatorAccount,\n\t\tOperationStatus: params.OperationStatus,\n\t\tIsHighRisk:      params.IsHighRisk,\n\t\tMethod:          params.Method,\n\t\tBaseURL:         params.BaseURL,\n\t\tOperationName:   params.OperationName,\n\t\tIP:              params.IP,\n\t\tStartTime:       params.StartTime,\n\t\tEndTime:         params.EndTime,\n\t})\n\tcondition, args := query.Build()\n\n\tlimit := params.Limit\n\tif limit <= 0 {\n\t\tlimit = defaultRequestLogExportLimit\n\t}\n\n\tlogModel := model.NewRequestLogs()\n\tlistOptionalParams := model.ListOptionalParams{\n\t\tSelectFields: []string{\n\t\t\t\"id\",\n\t\t\t\"request_id\",\n\t\t\t\"operator_account\",\n\t\t\t\"operator_name\",\n\t\t\t\"ip\",\n\t\t\t\"method\",\n\t\t\t\"base_url\",\n\t\t\t\"operation_name\",\n\t\t\t\"operation_status\",\n\t\t\t\"is_high_risk\",\n\t\t\t\"response_status\",\n\t\t\t\"execution_time\",\n\t\t\t\"change_diff\",\n\t\t\t\"created_at\",\n\t\t},\n\t\tOrderBy: \"created_at DESC, id DESC\",\n\t}\n\t_, records, err := model.ListPageE(logModel, 1, limit, condition, args, listOptionalParams)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tbuffer := &bytes.Buffer{}\n\twriter := csv.NewWriter(buffer)\n\tif err := writer.Write([]string{\n\t\t\"id\",\n\t\t\"request_id\",\n\t\t\"operator_account\",\n\t\t\"operator_name\",\n\t\t\"ip\",\n\t\t\"method\",\n\t\t\"base_url\",\n\t\t\"operation_name\",\n\t\t\"operation_status\",\n\t\t\"is_high_risk\",\n\t\t\"response_status\",\n\t\t\"execution_time_ms\",\n\t\t\"change_diff\",\n\t\t\"created_at\",\n\t}); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tfor _, record := range records {\n\t\tif err := writer.Write([]string{\n\t\t\tstrconv.FormatUint(uint64(record.ID), 10),\n\t\t\trecord.RequestID,\n\t\t\trecord.OperatorAccount,\n\t\t\trecord.OperatorName,\n\t\t\trecord.IP,\n\t\t\trecord.Method,\n\t\t\trecord.BaseURL,\n\t\t\trecord.OperationName,\n\t\t\tstrconv.Itoa(record.OperationStatus),\n\t\t\tstrconv.Itoa(int(record.IsHighRisk)),\n\t\t\tstrconv.Itoa(record.ResponseStatus),\n\t\t\tstrconv.FormatFloat(record.ExecutionTime, 'f', 4, 64),\n\t\t\trecord.ChangeDiff,\n\t\t\trecord.CreatedAt.String(),\n\t\t}); err != nil {\n\t\t\treturn nil, \"\", err\n\t\t}\n\t}\n\n\twriter.Flush()\n\tif err := writer.Error(); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tfileName := \"request_logs_\" + time.Now().Format(\"20060102150405\") + \".csv\"\n\treturn buffer.Bytes(), fileName, nil\n}\n\n// GetMaskConfig 获取当前敏感字段脱敏配置。\nfunc (s *RequestLogService) GetMaskConfig() map[string]any {\n\tcfg, err := loadMaskConfigFromSysConfig()\n\tif err != nil {\n\t\tcfg = sensitive.GetSensitiveFieldsConfig()\n\t}\n\treturn toMaskConfigMap(cfg)\n}\n\nfunc toMaskConfigMap(cfg sensitive.SensitiveFieldsConfig) map[string]any {\n\treturn map[string]any{\n\t\t\"common\":          cfg.Common,\n\t\t\"request_header\":  cfg.RequestHeader,\n\t\t\"request_body\":    cfg.RequestBody,\n\t\t\"response_header\": cfg.ResponseHeader,\n\t\t\"response_body\":   cfg.ResponseBody,\n\t}\n}\n\n// UpdateMaskConfig 更新敏感字段脱敏配置（运行时生效）。\nfunc (s *RequestLogService) UpdateMaskConfig(params *form.RequestLogMaskConfigForm) (map[string]any, error) {\n\tif params == nil {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter)\n\t}\n\n\tnextConfig := sensitive.SensitiveFieldsConfig{\n\t\tCommon:         normalizeSensitiveFieldList(params.Common),\n\t\tRequestHeader:  normalizeSensitiveFieldList(params.RequestHeader),\n\t\tRequestBody:    normalizeSensitiveFieldList(params.RequestBody),\n\t\tResponseHeader: normalizeSensitiveFieldList(params.ResponseHeader),\n\t\tResponseBody:   normalizeSensitiveFieldList(params.ResponseBody),\n\t}\n\n\tpersisted, err := saveMaskConfigToSysConfig(nextConfig)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif persisted {\n\t\tif err := sys_config.NewSysConfigService().RefreshCache(); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn s.GetMaskConfig(), nil\n\t}\n\n\t// 兼容 sys_config 表尚未初始化的场景，保持运行时即时生效。\n\tsensitive.LoadSensitiveFieldsConfig(nextConfig)\n\treturn toMaskConfigMap(nextConfig), nil\n}\n\n// UpdateMaskConfigWithAuditDiff 更新脱敏配置并返回精确 change_diff JSON。\nfunc (s *RequestLogService) UpdateMaskConfigWithAuditDiff(params *form.RequestLogMaskConfigForm) (map[string]any, string, error) {\n\tbefore := s.GetMaskConfig()\n\tafter, err := s.UpdateMaskConfig(params)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\tchangeDiff := buildMaskConfigAuditDiff(before, after)\n\treturn after, changeDiff, nil\n}\n\nfunc buildMaskConfigAuditDiff(before, after map[string]any) string {\n\titems := auditdiff.BuildFieldDiff(before, after, requestLogMaskConfigDiffRules)\n\treturn auditdiff.Marshal(items)\n}\n\nfunc normalizeSensitiveFieldList(input []string) []string {\n\tif len(input) == 0 {\n\t\treturn []string{}\n\t}\n\tseen := make(map[string]struct{}, len(input))\n\tresult := make([]string, 0, len(input))\n\tfor _, item := range input {\n\t\ttrimmed := strings.ToLower(strings.TrimSpace(item))\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, exists := seen[trimmed]; exists {\n\t\t\tcontinue\n\t\t}\n\t\tseen[trimmed] = struct{}{}\n\t\tresult = append(result, trimmed)\n\t}\n\treturn result\n}\n\nfunc loadMaskConfigFromSysConfig() (sensitive.SensitiveFieldsConfig, error) {\n\titem, err := sys_config.NewSysConfigService().Value(sys_config.AuditSensitiveFieldsConfigKey)\n\tif err != nil {\n\t\treturn sensitive.SensitiveFieldsConfig{}, err\n\t}\n\tif model.NormalizeValueType(item.ValueType) != model.SysConfigValueTypeJSON {\n\t\treturn sensitive.SensitiveFieldsConfig{}, fmt.Errorf(\"%s value_type must be json\", sys_config.AuditSensitiveFieldsConfigKey)\n\t}\n\treturn decodeMaskConfig(item.ConfigValue)\n}\n\nfunc decodeMaskConfig(raw string) (sensitive.SensitiveFieldsConfig, error) {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn sensitive.DefaultSensitiveFieldsConfig(), nil\n\t}\n\tvar config sensitive.SensitiveFieldsConfig\n\tif err := json.Unmarshal([]byte(raw), &config); err != nil {\n\t\treturn sensitive.SensitiveFieldsConfig{}, err\n\t}\n\treturn sensitive.SensitiveFieldsConfig{\n\t\tCommon:         normalizeSensitiveFieldList(config.Common),\n\t\tRequestHeader:  normalizeSensitiveFieldList(config.RequestHeader),\n\t\tRequestBody:    normalizeSensitiveFieldList(config.RequestBody),\n\t\tResponseHeader: normalizeSensitiveFieldList(config.ResponseHeader),\n\t\tResponseBody:   normalizeSensitiveFieldList(config.ResponseBody),\n\t}, nil\n}\n\nfunc saveMaskConfigToSysConfig(config sensitive.SensitiveFieldsConfig) (bool, error) {\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tconfigModel := model.NewSysConfig()\n\tif !db.Migrator().HasTable(configModel.TableName()) {\n\t\treturn false, nil\n\t}\n\n\tpayload, err := json.Marshal(config)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\terr = configModel.FindByKey(sys_config.AuditSensitiveFieldsConfigKey)\n\tif err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn false, err\n\t}\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\tconfigModel = model.NewSysConfig()\n\t\tconfigModel.ConfigKey = sys_config.AuditSensitiveFieldsConfigKey\n\t\tconfigModel.Sort = requestLogMaskConfigSort\n\t}\n\n\tconfigModel.ConfigValue = string(payload)\n\tconfigModel.ValueType = model.SysConfigValueTypeJSON\n\tconfigModel.GroupCode = requestLogMaskConfigGroupCode\n\tconfigModel.IsSystem = 1\n\tconfigModel.IsSensitive = 1\n\tconfigModel.IsVisible = 0\n\tconfigModel.ManageTab = requestLogMaskConfigManageTab\n\tconfigModel.Status = 1\n\tconfigModel.Remark = requestLogMaskConfigRemark\n\tif configModel.Sort == 0 {\n\t\tconfigModel.Sort = requestLogMaskConfigSort\n\t}\n\n\tif err := db.Transaction(func(tx *gorm.DB) error {\n\t\tconfigModel.SetDB(tx)\n\t\tif err := configModel.Save(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn model.NewSysConfigI18n().UpsertConfigNames(configModel.ID, requestLogMaskConfigNameI18n, tx)\n\t}); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/service/audit/request_log_manage_test.go",
    "content": "package audit\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/sensitive\"\n)\n\nfunc TestDecodeMaskConfigNormalizesFields(t *testing.T) {\n\tconfig, err := decodeMaskConfig(`{\n\t\t\"common\": [\" Password \", \"password\", \"Token\"],\n\t\t\"request_header\": [\"authorization\", \"Authorization\", \"\"],\n\t\t\"request_body\": [\"mobile\", \"MOBILE\"],\n\t\t\"response_header\": [],\n\t\t\"response_body\": [\"IDCard\", \"idcard\"]\n\t}`)\n\tif err != nil {\n\t\tt.Fatalf(\"decodeMaskConfig returned error: %v\", err)\n\t}\n\n\tif got, want := len(config.Common), 2; got != want {\n\t\tt.Fatalf(\"unexpected common length: got=%d want=%d\", got, want)\n\t}\n\tif config.Common[0] != \"password\" || config.Common[1] != \"token\" {\n\t\tt.Fatalf(\"unexpected normalized common fields: %#v\", config.Common)\n\t}\n\tif got, want := len(config.RequestHeader), 1; got != want {\n\t\tt.Fatalf(\"unexpected request_header length: got=%d want=%d\", got, want)\n\t}\n\tif config.RequestHeader[0] != \"authorization\" {\n\t\tt.Fatalf(\"unexpected request_header fields: %#v\", config.RequestHeader)\n\t}\n\tif got, want := len(config.RequestBody), 1; got != want {\n\t\tt.Fatalf(\"unexpected request_body length: got=%d want=%d\", got, want)\n\t}\n\tif config.RequestBody[0] != \"mobile\" {\n\t\tt.Fatalf(\"unexpected request_body fields: %#v\", config.RequestBody)\n\t}\n\tif got, want := len(config.ResponseBody), 1; got != want {\n\t\tt.Fatalf(\"unexpected response_body length: got=%d want=%d\", got, want)\n\t}\n\tif config.ResponseBody[0] != \"idcard\" {\n\t\tt.Fatalf(\"unexpected response_body fields: %#v\", config.ResponseBody)\n\t}\n}\n\nfunc TestDecodeMaskConfigEmptyUsesDefault(t *testing.T) {\n\tconfig, err := decodeMaskConfig(\"   \")\n\tif err != nil {\n\t\tt.Fatalf(\"decodeMaskConfig returned error: %v\", err)\n\t}\n\n\tdefaultConfig := sensitive.DefaultSensitiveFieldsConfig()\n\tif len(config.Common) != len(defaultConfig.Common) {\n\t\tt.Fatalf(\"unexpected default common length: got=%d want=%d\", len(config.Common), len(defaultConfig.Common))\n\t}\n\tif len(config.RequestHeader) != len(defaultConfig.RequestHeader) {\n\t\tt.Fatalf(\"unexpected default request_header length: got=%d want=%d\", len(config.RequestHeader), len(defaultConfig.RequestHeader))\n\t}\n}\n\nfunc TestBuildMaskConfigAuditDiff(t *testing.T) {\n\tbefore := map[string]any{\n\t\t\"common\":          []string{\"password\"},\n\t\t\"request_header\":  []string{\"authorization\"},\n\t\t\"request_body\":    []string{\"mobile\"},\n\t\t\"response_header\": []string{},\n\t\t\"response_body\":   []string{\"idcard\"},\n\t}\n\tafter := map[string]any{\n\t\t\"common\":          []string{\"password\", \"token\"},\n\t\t\"request_header\":  []string{\"authorization\"},\n\t\t\"request_body\":    []string{\"mobile\"},\n\t\t\"response_header\": []string{},\n\t\t\"response_body\":   []string{\"idcard\"},\n\t}\n\n\traw := buildMaskConfigAuditDiff(before, after)\n\tvar items []map[string]any\n\tif err := json.Unmarshal([]byte(raw), &items); err != nil {\n\t\tt.Fatalf(\"expected valid json diff, got err=%v raw=%s\", err, raw)\n\t}\n\tif len(items) != 1 {\n\t\tt.Fatalf(\"expected 1 diff item, got %d\", len(items))\n\t}\n\tif items[0][\"field\"] != \"common\" {\n\t\tt.Fatalf(\"expected diff field common, got %#v\", items[0][\"field\"])\n\t}\n}\n"
  },
  {
    "path": "internal/service/audit/request_log_write.go",
    "content": "package audit\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\n// AuditLogSnapshot 表示请求结束时提取出的审计日志快照。\ntype AuditLogSnapshot struct {\n\tRequestID       string  `json:\"request_id\"`\n\tJwtID           string  `json:\"jwt_id\"`\n\tOperatorID      uint    `json:\"operator_id\"`\n\tOperatorAccount string  `json:\"operator_account\"`\n\tOperatorName    string  `json:\"operator_name\"`\n\tIP              string  `json:\"ip\"`\n\tUserAgent       string  `json:\"user_agent\"`\n\tOS              string  `json:\"os\"`\n\tBrowser         string  `json:\"browser\"`\n\tMethod          string  `json:\"method\"`\n\tBaseURL         string  `json:\"base_url\"`\n\tOperationName   string  `json:\"operation_name\"`\n\tOperationStatus int     `json:\"operation_status\"`\n\tIsHighRisk      uint8   `json:\"is_high_risk\"`\n\tRequestHeaders  string  `json:\"request_headers\"`\n\tRequestQuery    string  `json:\"request_query\"`\n\tRequestBody     string  `json:\"request_body\"`\n\tChangeDiff      string  `json:\"change_diff\"`\n\tResponseStatus  int     `json:\"response_status\"`\n\tResponseBody    string  `json:\"response_body\"`\n\tResponseHeader  string  `json:\"response_header\"`\n\tExecutionTime   float64 `json:\"execution_time\"`\n}\n\n// PersistAuditLog 将请求审计日志快照写入数据库。\nfunc PersistAuditLog(snapshot *AuditLogSnapshot) error {\n\tif snapshot == nil || snapshot.RequestID == \"\" {\n\t\treturn nil\n\t}\n\n\trequestLog := model.NewRequestLogs()\n\trequestLog.RequestID = snapshot.RequestID\n\trequestLog.JwtID = snapshot.JwtID\n\trequestLog.OperatorID = snapshot.OperatorID\n\trequestLog.IP = snapshot.IP\n\trequestLog.UserAgent = snapshot.UserAgent\n\trequestLog.OS = snapshot.OS\n\trequestLog.Browser = snapshot.Browser\n\trequestLog.Method = snapshot.Method\n\trequestLog.BaseURL = snapshot.BaseURL\n\trequestLog.OperationName = snapshot.OperationName\n\trequestLog.OperationStatus = snapshot.OperationStatus\n\trequestLog.IsHighRisk = snapshot.IsHighRisk\n\trequestLog.OperatorAccount = snapshot.OperatorAccount\n\trequestLog.OperatorName = snapshot.OperatorName\n\trequestLog.RequestHeaders = snapshot.RequestHeaders\n\trequestLog.RequestQuery = snapshot.RequestQuery\n\trequestLog.RequestBody = snapshot.RequestBody\n\trequestLog.ChangeDiff = snapshot.ChangeDiff\n\trequestLog.ResponseStatus = snapshot.ResponseStatus\n\trequestLog.ResponseBody = snapshot.ResponseBody\n\trequestLog.ResponseHeader = snapshot.ResponseHeader\n\trequestLog.ExecutionTime = snapshot.ExecutionTime\n\n\treturn requestLog.Create()\n}\n"
  },
  {
    "path": "internal/service/auth/login.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\tstderrors \"errors\"\n\t\"time\"\n\n\t\"go.uber.org/zap\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\tutils2 \"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\n// LoginService 登录授权服务。\ntype LoginService struct {\n\tservice.Base\n\t// configProvider 提供运行时配置读取入口。\n\tconfigProvider func() *config.Conf\n\t// blacklistLookupFn 查询 token 是否在 Redis 黑名单中。\n\tblacklistLookupFn func(jwtID string) (bool, error)\n\t// tokenRevokedLookupFn 查询 token 是否在登录日志中已标记撤销。\n\ttokenRevokedLookupFn func(jwtID string) bool\n\t// mysqlReadyFn 判断 MySQL 依赖是否可用。\n\tmysqlReadyFn func() bool\n\t// refreshLockStore 在 Redis 不可用时提供进程内刷新锁。\n\trefreshLockStore *refreshTokenLock\n\t// tryRefreshPrincipalFn 执行 principal 自动刷新逻辑。\n\ttryRefreshPrincipalFn func(principal *AuthPrincipal)\n\t// markTokensRevokedFn 批量标记 token 为撤销状态。\n\tmarkTokensRevokedFn func(ctx context.Context, jwtIDs []string, revokedCode uint8, revokedReason string) error\n\t// writeTokenToBlacklistFn 将 token 写入 Redis 黑名单。\n\twriteTokenToBlacklistFn func(jwtID string, remainingTime time.Duration) error\n\t// loginLogDB 允许测试或事务场景指定登录日志查询连接。\n\tloginLogDB *gorm.DB\n}\n\n// LoginServiceDeps 描述 LoginService 可注入依赖。\ntype LoginServiceDeps struct {\n\t// ConfigProvider 自定义配置读取函数。\n\tConfigProvider func() *config.Conf\n\t// BlacklistLookup 自定义 Redis 黑名单查询实现。\n\tBlacklistLookup func(jwtID string) (bool, error)\n\t// TokenRevokedLookup 自定义登录日志撤销状态查询实现。\n\tTokenRevokedLookup func(jwtID string) bool\n\t// MySQLReady 自定义 MySQL 可用性判断逻辑。\n\tMySQLReady func() bool\n\t// RefreshLockStore 自定义内存刷新锁存储实现。\n\tRefreshLockStore *refreshTokenLock\n\t// TryRefreshPrincipal 自定义自动刷新 principal 的执行入口。\n\tTryRefreshPrincipal func(principal *AuthPrincipal)\n\t// MarkTokensRevoked 自定义 token 撤销标记逻辑。\n\tMarkTokensRevoked func(ctx context.Context, jwtIDs []string, revokedCode uint8, revokedReason string) error\n\t// WriteTokenToBlacklist 自定义写 Redis 黑名单逻辑。\n\tWriteTokenToBlacklist func(jwtID string, remainingTime time.Duration) error\n\t// LoginLogDB 自定义登录日志查询连接。\n\tLoginLogDB *gorm.DB\n}\n\n// NewLoginService 创建登录服务实例。\nfunc NewLoginService() *LoginService {\n\treturn NewLoginServiceWithDeps(LoginServiceDeps{})\n}\n\n// NewLoginServiceWithDeps 创建带依赖注入的登录服务实例。\nfunc NewLoginServiceWithDeps(deps LoginServiceDeps) *LoginService {\n\ts := &LoginService{\n\t\tconfigProvider:          deps.ConfigProvider,\n\t\tblacklistLookupFn:       deps.BlacklistLookup,\n\t\ttokenRevokedLookupFn:    deps.TokenRevokedLookup,\n\t\tmysqlReadyFn:            deps.MySQLReady,\n\t\trefreshLockStore:        deps.RefreshLockStore,\n\t\ttryRefreshPrincipalFn:   deps.TryRefreshPrincipal,\n\t\tmarkTokensRevokedFn:     deps.MarkTokensRevoked,\n\t\twriteTokenToBlacklistFn: deps.WriteTokenToBlacklist,\n\t\tloginLogDB:              deps.LoginLogDB,\n\t}\n\ts.ensureRuntimeDeps()\n\treturn s\n}\n\nfunc (s *LoginService) ensureRuntimeDeps() {\n\tif s.configProvider == nil {\n\t\ts.configProvider = config.GetConfig\n\t}\n\tif s.blacklistLookupFn == nil {\n\t\ts.blacklistLookupFn = s.IsInBlacklist\n\t}\n\tif s.tokenRevokedLookupFn == nil {\n\t\ts.tokenRevokedLookupFn = s.isTokenRevokedInLog\n\t}\n\tif s.mysqlReadyFn == nil {\n\t\ts.mysqlReadyFn = data.MysqlReady\n\t}\n\tif s.refreshLockStore == nil {\n\t\ts.refreshLockStore = defaultRefreshLockStore()\n\t}\n\tif s.tryRefreshPrincipalFn == nil {\n\t\ts.tryRefreshPrincipalFn = s.tryRefreshToken\n\t}\n\tif s.markTokensRevokedFn == nil {\n\t\ts.markTokensRevokedFn = func(ctx context.Context, jwtIDs []string, revokedCode uint8, revokedReason string) error {\n\t\t\treturn s.markTokensRevoked(ctx, jwtIDs, revokedCode, revokedReason)\n\t\t}\n\t}\n\tif s.writeTokenToBlacklistFn == nil {\n\t\ts.writeTokenToBlacklistFn = func(jwtID string, remainingTime time.Duration) error {\n\t\t\treturn s.writeTokenToBlacklist(jwtID, remainingTime)\n\t\t}\n\t}\n}\n\nfunc (s *LoginService) currentConfig() *config.Conf {\n\ts.ensureRuntimeDeps()\n\treturn config.GetConfigFrom(s.configProvider)\n}\n\n// Login 用户登录。\nfunc (s *LoginService) Login(username, password string, logInfo LoginLogInfo) (*TokenResponse, error) {\n\tstartTime := time.Now()\n\tif err := s.ensureLoginAllowed(username); err != nil {\n\t\tlogInfo.ExecutionTime = int(time.Since(startTime).Milliseconds())\n\t\ts.HandleLoginFailure(username, s.extractErrorMessage(err), logInfo, false)\n\t\treturn nil, err\n\t}\n\n\tadminUser, err := s.validateUser(username, password)\n\tif err != nil {\n\t\tlogInfo.ExecutionTime = int(time.Since(startTime).Milliseconds())\n\t\ts.HandleLoginFailure(username, s.extractErrorMessage(err), logInfo, s.shouldCountLockFailure(err))\n\t\treturn nil, err\n\t}\n\n\tclaims := s.newAdminCustomClaims(adminUser)\n\taccessToken, err := token.Generate(claims)\n\tif err != nil {\n\t\tlogInfo.ExecutionTime = int(time.Since(startTime).Milliseconds())\n\t\ts.HandleLoginFailure(username, \"生成Token失败\", logInfo, false)\n\t\treturn nil, e.NewBusinessError(e.TokenGenerateFailed)\n\t}\n\n\tlogInfo.ExecutionTime = int(time.Since(startTime).Milliseconds())\n\tif err := s.recordLoginLog(adminUser, claims, accessToken, \"\", logInfo, model.LoginTypeLogin); err != nil {\n\t\treturn nil, e.NewBusinessError(e.LoginFailed)\n\t}\n\tif err := s.clearLoginFailState(username); err != nil && !isTableNotFoundErr(err) {\n\t\tlog.Logger.Warn(\"清理登录失败计数失败\", zap.String(\"username\", username), zap.Error(err))\n\t}\n\n\treturn &TokenResponse{\n\t\tAccessToken:  accessToken,\n\t\tRefreshToken: \"\",\n\t\tTokenType:    tokenTypeBearer,\n\t\tExpiresAt:    claims.ExpiresAt.Unix(),\n\t}, nil\n}\n\n// validateUser 验证用户信息。\nfunc (s *LoginService) validateUser(username, password string) (*model.AdminUser, error) {\n\tadminUser := model.NewAdminUsers()\n\tif err := adminUser.GetUserInfo(username); err != nil {\n\t\tswitch {\n\t\tcase e.IsDependencyNotReady(err):\n\t\t\treturn nil, e.NewDependencyNotReadyError()\n\t\tcase stderrors.Is(err, gorm.ErrRecordNotFound):\n\t\t\treturn nil, e.NewBusinessError(e.UserDoesNotExist)\n\t\tdefault:\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif adminUser.Status != model.AdminUserStatusEnabled {\n\t\treturn nil, e.NewBusinessError(e.UserDisable)\n\t}\n\tif !utils2.ComparePasswords(adminUser.Password, password) {\n\t\treturn nil, e.NewBusinessError(e.UserPasswordWrong)\n\t}\n\treturn adminUser, nil\n}\n\n// recordLoginLog 记录登录日志并更新用户信息。\nfunc (s *LoginService) recordLoginLog(adminUser *model.AdminUser, claims token.AdminCustomClaims, accessToken, refreshToken string, logInfo LoginLogInfo, logType uint8) error {\n\tdb, err := model.NewAdminLoginLogs().GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Transaction(func(tx *gorm.DB) error {\n\t\tloginLog := s.buildLoginLog(adminUser.ID, adminUser.Username, claims.ID, accessToken, refreshToken, claims.ExpiresAt.Time, logInfo, model.LoginStatusSuccess, \"\", logType)\n\t\tloginLog.SetDB(tx)\n\t\tif err := loginLog.Create(); err != nil {\n\t\t\tlog.Logger.Error(\"记录登录日志失败\", zap.Error(err), zap.Uint(\"user_id\", adminUser.ID), zap.String(\"username\", adminUser.Username))\n\t\t\treturn err\n\t\t}\n\n\t\tif logType == model.LoginTypeLogin {\n\t\t\tadminUser.LastIp = logInfo.IP\n\t\t\tadminUser.LastLogin = utils.FormatDate{Time: time.Now()}\n\t\t\tadminUser.SetDB(tx)\n\t\t\tif err := adminUser.Save(); err != nil {\n\t\t\t\tlog.Logger.Error(\"更新用户最后登录信息失败\", zap.Error(err), zap.Uint(\"user_id\", adminUser.ID))\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// Refresh 刷新 Token。\nfunc (s *LoginService) Refresh(id uint, logInfo LoginLogInfo) (*TokenResponse, error) {\n\tstartTime := time.Now()\n\n\tadminUserModel := model.NewAdminUsers()\n\tif err := adminUserModel.GetById(id); err != nil {\n\t\treturn nil, e.NewBusinessError(e.UpdateUserFailed)\n\t}\n\tif adminUserModel.Status != model.AdminUserStatusEnabled {\n\t\treturn nil, e.NewBusinessError(e.UserDisable)\n\t}\n\n\tclaims := s.newAdminCustomClaims(adminUserModel)\n\taccessToken, err := token.Refresh(claims)\n\tif err != nil {\n\t\treturn nil, e.NewBusinessError(e.TokenGenerateFailed)\n\t}\n\n\tlogInfo.ExecutionTime = int(time.Since(startTime).Milliseconds())\n\tif err := s.recordLoginLog(adminUserModel, claims, accessToken, \"\", logInfo, model.LoginTypeRefresh); err != nil {\n\t\tlog.Logger.Error(\"记录刷新token日志失败\", zap.Error(err), zap.Uint(\"user_id\", id))\n\t}\n\n\treturn &TokenResponse{\n\t\tAccessToken:  accessToken,\n\t\tRefreshToken: \"\",\n\t\tTokenType:    tokenTypeBearer,\n\t\tExpiresAt:    claims.ExpiresAt.Unix(),\n\t}, nil\n}\n\n// newAdminCustomClaims 创建管理员自定义 Claims。\nfunc (s *LoginService) newAdminCustomClaims(user *model.AdminUser) token.AdminCustomClaims {\n\treturn token.NewAdminCustomClaims(user)\n}\n"
  },
  {
    "path": "internal/service/auth/login_bench_test.go",
    "content": "package auth\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token\"\n)\n\nfunc BenchmarkResolvePrincipal(b *testing.B) {\n\tservice := NewLoginService()\n\tclaims := &token.AdminCustomClaims{\n\t\tAdminUserInfo: token.AdminUserInfo{\n\t\t\tUserID:          12,\n\t\t\tUsername:        \"tester\",\n\t\t\tNickname:        \"Tester\",\n\t\t\tEmail:           \"tester@example.com\",\n\t\t\tFullPhoneNumber: \"+8613800000000\",\n\t\t\tPhoneNumber:     \"13800000000\",\n\t\t\tCountryCode:     \"+86\",\n\t\t\tIsSuperAdmin:    0,\n\t\t},\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tID:        \"jwt-bench\",\n\t\t\tExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),\n\t\t},\n\t}\n\n\tservice.blacklistLookupFn = func(_ string) (bool, error) {\n\t\treturn false, nil\n\t}\n\tservice.tokenRevokedLookupFn = func(_ string) bool {\n\t\treturn false\n\t}\n\n\tb.ReportAllocs()\n\tfor i := 0; i < b.N; i++ {\n\t\tprincipal, ok := service.resolvePrincipalFromClaims(claims)\n\t\tif !ok || principal == nil {\n\t\t\tb.Fatal(\"expected principal\")\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/service/auth/login_blacklist.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"go.uber.org/zap\"\n)\n\nconst redisOpTimeout = 3 * time.Second\n\nvar errRedisUnavailable = errors.New(\"redis client is not available\")\n\n// Logout 退出登录。\nfunc (s *LoginService) Logout(accessToken string) error {\n\ts.ensureRuntimeDeps()\n\tclaims, err := s.parseToken(accessToken)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\texp, err := claims.GetExpirationTime()\n\tif err != nil || exp == nil {\n\t\treturn err\n\t}\n\n\tif err := s.markTokensRevokedFn(context.Background(), []string{claims.ID}, model.RevokedCodeUserLogout, \"用户主动登出（退出登录）\"); err != nil {\n\t\treturn err\n\t}\n\n\tremainingTime := time.Until(exp.Time)\n\tif err := s.writeTokenToBlacklistFn(claims.ID, remainingTime); err != nil {\n\t\tlog.Logger.Warn(\"Redis blacklist write failed after database revocation, treat logout as success\",\n\t\t\tzap.String(\"jwt_id\", claims.ID),\n\t\t\tzap.Bool(\"redis_unavailable\", errors.Is(err, errRedisUnavailable)),\n\t\t\tzap.Error(err))\n\t\treturn nil\n\t}\n\n\treturn nil\n}\n\n// parseToken 解析 Token。\nfunc (s *LoginService) parseToken(accessToken string) (*token.AdminCustomClaims, error) {\n\tclaims := new(token.AdminCustomClaims)\n\tsecret := []byte(s.currentConfig().Jwt.SecretKey)\n\tparsedToken, err := jwt.ParseWithClaims(accessToken, claims, func(jwtToken *jwt.Token) (interface{}, error) {\n\t\tif _, ok := jwtToken.Method.(*jwt.SigningMethodHMAC); !ok {\n\t\t\treturn nil, fmt.Errorf(\"unexpected signing method: %v\", jwtToken.Header[\"alg\"])\n\t\t}\n\t\treturn secret, nil\n\t}, jwt.WithSubject(global.PcAdminSubject), jwt.WithIssuer(global.Issuer))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif !parsedToken.Valid {\n\t\treturn nil, e.NewBusinessError(e.NotLogin)\n\t}\n\treturn claims, nil\n}\n\n// IsInBlacklist 判断 Token 是否在黑名单中。\nfunc (s *LoginService) IsInBlacklist(jwtId string) (bool, error) {\n\tredisClient := data.RedisClient()\n\tif redisClient == nil {\n\t\tif err := data.GetRedisInitError(); err != nil {\n\t\t\treturn false, fmt.Errorf(\"%w: %v\", errRedisUnavailable, err)\n\t\t}\n\t\treturn false, errRedisUnavailable\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout)\n\tdefer cancel()\n\tresult, err := redisClient.Exists(ctx, s.getBlacklistKey(jwtId)).Result()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn result > 0, nil\n}\n\nfunc (s *LoginService) writeTokenToBlacklist(jwtID string, remainingTime time.Duration) error {\n\tif jwtID == \"\" || remainingTime <= 0 {\n\t\treturn nil\n\t}\n\n\tredisClient := data.RedisClient()\n\tif redisClient == nil {\n\t\tif err := data.GetRedisInitError(); err != nil {\n\t\t\treturn fmt.Errorf(\"%w: %v\", errRedisUnavailable, err)\n\t\t}\n\t\treturn errRedisUnavailable\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout)\n\tdefer cancel()\n\treturn redisClient.Set(ctx, s.getBlacklistKey(jwtID), \"1\", remainingTime).Err()\n}\n\n// getBlacklistKey 获取 Redis 黑名单 key。\nfunc (s *LoginService) getBlacklistKey(jwtId string) string {\n\treturn blacklistPrefix + jwtId\n}\n\n// addTokensToBlacklist 批量将 token 加入 Redis 黑名单。\nfunc (s *LoginService) addTokensToBlacklist(loginLogs []model.AdminLoginLogs) error {\n\tif len(loginLogs) == 0 {\n\t\treturn nil\n\t}\n\n\tredisClient := data.RedisClient()\n\tif redisClient == nil {\n\t\tif err := data.GetRedisInitError(); err != nil {\n\t\t\treturn fmt.Errorf(\"%w: %v\", errRedisUnavailable, err)\n\t\t}\n\t\treturn errRedisUnavailable\n\t}\n\tctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout)\n\tdefer cancel()\n\tpipe := redisClient.Pipeline()\n\n\tnow := time.Now()\n\tqueued := 0\n\tfor _, item := range loginLogs {\n\t\tif item.JwtID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tremainingTime := s.calculateRemainingTime(item.TokenExpires, now)\n\t\tif remainingTime <= 0 {\n\t\t\tcontinue\n\t\t}\n\t\tblacklistKey := s.getBlacklistKey(item.JwtID)\n\t\tpipe.Set(ctx, blacklistKey, \"1\", remainingTime)\n\t\tqueued++\n\t}\n\n\tif queued == 0 {\n\t\treturn nil\n\t}\n\tif _, err := pipe.Exec(ctx); err != nil {\n\t\tlog.Logger.Error(\"批量将 token 加入 Redis 黑名单失败\", zap.Error(err), zap.Int(\"count\", queued))\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// calculateRemainingTime 计算 token 剩余过期时间。\nfunc (s *LoginService) calculateRemainingTime(tokenExpires *utils.FormatDate, now time.Time) time.Duration {\n\tif tokenExpires != nil {\n\t\tremainingTime := tokenExpires.Time.Sub(now)\n\t\tif remainingTime > 0 {\n\t\t\treturn remainingTime\n\t\t}\n\t\treturn 0\n\t}\n\treturn defaultTokenTTL\n}\n\n// RevokeUserTokens 撤销用户所有未过期的 token。\nfunc (s *LoginService) RevokeUserTokens(userId uint, revokedCode uint8, revokedReason string, tx ...*gorm.DB) error {\n\tif userId == 0 {\n\t\treturn nil\n\t}\n\n\tloginLog := model.NewAdminLoginLogs()\n\tif existingTx := access.FirstTx(tx); existingTx != nil {\n\t\tloginLog.SetDB(existingTx)\n\t} else {\n\t\tif _, err := loginLog.GetDB(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tloginLogs, err := loginLog.FindActiveTokensByUserId(userId, time.Now())\n\tif err != nil || len(loginLogs) == 0 {\n\t\treturn err\n\t}\n\n\tjwtIds := collectJWTIDs(loginLogs)\n\tif len(jwtIds) == 0 {\n\t\treturn nil\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), revokeLogAsyncTimeout)\n\tdefer cancel()\n\tif err := s.markTokensRevokedFn(ctx, jwtIds, revokedCode, revokedReason); err != nil {\n\t\treturn err\n\t}\n\treturn s.addTokensToBlacklist(loginLogs)\n}\n\nfunc collectJWTIDs(loginLogs []model.AdminLoginLogs) []string {\n\tjwtIds := make([]string, 0, len(loginLogs))\n\tfor _, item := range loginLogs {\n\t\tif item.JwtID != \"\" {\n\t\t\tjwtIds = append(jwtIds, item.JwtID)\n\t\t}\n\t}\n\treturn jwtIds\n}\n"
  },
  {
    "path": "internal/service/auth/login_helpers_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/golang-jwt/jwt/v5\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/config/autoload\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/testkit\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token\"\n)\n\n// TestExtractErrorMessage 验证错误消息提取逻辑。\nfunc TestExtractErrorMessage(t *testing.T) {\n\tservice := NewLoginService()\n\n\tbusinessErr := e.NewBusinessError(e.CaptchaErr)\n\tif got := service.extractErrorMessage(businessErr); got != businessErr.GetMessage() {\n\t\tt.Fatalf(\"expected business message %q, got %q\", businessErr.GetMessage(), got)\n\t}\n\n\tplainErr := errors.New(\"plain error\")\n\tif got := service.extractErrorMessage(plainErr); got != plainErr.Error() {\n\t\tt.Fatalf(\"expected plain error message, got %q\", got)\n\t}\n}\n\n// TestCalculateTokenHash 验证 token 哈希值计算结果稳定。\nfunc TestCalculateTokenHash(t *testing.T) {\n\tservice := NewLoginService()\n\tconst tokenValue = \"token-value\"\n\tconst expected = \"e6c02a5742ea9d4de588eb9b9de7bed43dc17011552186bed3e98b2c5958ff4a\"\n\n\tif got := service.calculateTokenHash(tokenValue); got != expected {\n\t\tt.Fatalf(\"expected %s, got %s\", expected, got)\n\t}\n}\n\n// TestGetBlacklistKey 验证黑名单 key 前缀拼接。\nfunc TestGetBlacklistKey(t *testing.T) {\n\tservice := NewLoginService()\n\n\tif got := service.getBlacklistKey(\"jwt-id\"); got != \"blacklist:jwt-id\" {\n\t\tt.Fatalf(\"unexpected blacklist key: %s\", got)\n\t}\n}\n\n// TestCalculateRemainingTime 验证剩余时间计算逻辑。\nfunc TestCalculateRemainingTime(t *testing.T) {\n\tservice := NewLoginService()\n\tnow := time.Now()\n\texpires := &utils.FormatDate{Time: now.Add(2 * time.Minute)}\n\n\tif got := service.calculateRemainingTime(expires, now); got < time.Minute || got > 2*time.Minute {\n\t\tt.Fatalf(\"unexpected remaining time: %v\", got)\n\t}\n\n\texpired := &utils.FormatDate{Time: now.Add(-time.Minute)}\n\tif got := service.calculateRemainingTime(expired, now); got != 0 {\n\t\tt.Fatalf(\"expected 0 for expired token, got %v\", got)\n\t}\n\n\tif got := service.calculateRemainingTime(nil, now); got != defaultTokenTTL {\n\t\tt.Fatalf(\"expected default ttl %v, got %v\", defaultTokenTTL, got)\n\t}\n}\n\n// TestBuildRefreshLockKey 验证刷新锁 key 拼接。\nfunc TestBuildRefreshLockKey(t *testing.T) {\n\tservice := NewLoginService()\n\tclaims := &token.AdminCustomClaims{AdminUserInfo: token.AdminUserInfo{UserID: 12}, RegisteredClaims: jwt.RegisteredClaims{ID: \"jwt-id\"}}\n\n\tif got := service.buildRefreshLockKey(claims); got != \"refresh_token_lock:12:jwt-id\" {\n\t\tt.Fatalf(\"unexpected refresh lock key: %s\", got)\n\t}\n}\n\n// TestShouldRefreshToken 验证刷新条件判断。\nfunc TestShouldRefreshToken(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\tcfg := &config.Conf{\n\t\tJwt: autoload.JwtConfig{\n\t\t\tRefreshTTL: 30,\n\t\t},\n\t}\n\tservice := NewLoginServiceWithDeps(LoginServiceDeps{\n\t\tConfigProvider: func() *config.Conf { return cfg },\n\t})\n\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tservice.SetCtx(ctx)\n\tclaims := &token.AdminCustomClaims{\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Second)),\n\t\t},\n\t}\n\tprincipal := &AuthPrincipal{Claims: claims}\n\tif !service.shouldRefreshToken(principal) {\n\t\tt.Fatal(\"expected token to require refresh\")\n\t}\n\n\tclaims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(2 * time.Minute))\n\tif service.shouldRefreshToken(principal) {\n\t\tt.Fatal(\"expected token with long remaining ttl to skip refresh\")\n\t}\n}\n\n// TestIsPrincipalValidSkipsFallbackWhenMysqlUnavailable 验证降级模式下不会继续回表。\nfunc TestIsPrincipalValidSkipsFallbackWhenMysqlUnavailable(t *testing.T) {\n\tservice := NewLoginService()\n\tclaims := &token.AdminCustomClaims{\n\t\tAdminUserInfo: token.AdminUserInfo{UserID: 12},\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tID: \"jwt-id\",\n\t\t},\n\t}\n\n\ttokenRevokedCalled := false\n\tservice.blacklistLookupFn = func(_ string) (bool, error) {\n\t\treturn false, errRedisUnavailable\n\t}\n\tservice.tokenRevokedLookupFn = func(_ string) bool {\n\t\ttokenRevokedCalled = true\n\t\treturn false\n\t}\n\tservice.mysqlReadyFn = func() bool { return false }\n\n\tif service.isPrincipalValid(claims) {\n\t\tt.Fatal(\"expected principal to be rejected when redis and mysql are unavailable\")\n\t}\n\tif tokenRevokedCalled {\n\t\tt.Fatal(\"expected database revoke lookup to be skipped\")\n\t}\n}\n\nfunc TestIsPrincipalValidFallsBackToDatabaseWhenMysqlReady(t *testing.T) {\n\tservice := NewLoginService()\n\tclaims := &token.AdminCustomClaims{\n\t\tAdminUserInfo: token.AdminUserInfo{UserID: 12},\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tID: \"jwt-id\",\n\t\t},\n\t}\n\n\ttokenRevokedCalled := false\n\tservice.blacklistLookupFn = func(_ string) (bool, error) {\n\t\treturn false, errRedisUnavailable\n\t}\n\tservice.tokenRevokedLookupFn = func(jwtID string) bool {\n\t\ttokenRevokedCalled = true\n\t\treturn jwtID == \"revoked\"\n\t}\n\tservice.mysqlReadyFn = func() bool { return true }\n\n\tif !service.isPrincipalValid(claims) {\n\t\tt.Fatal(\"expected principal to stay valid when mysql fallback is available\")\n\t}\n\tif !tokenRevokedCalled {\n\t\tt.Fatal(\"expected database revoke lookup to be used\")\n\t}\n}\n\nfunc TestIsPrincipalValidChecksDatabaseWhenRedisMisses(t *testing.T) {\n\tservice := NewLoginService()\n\tclaims := &token.AdminCustomClaims{\n\t\tAdminUserInfo: token.AdminUserInfo{UserID: 12},\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tID: \"jwt-id\",\n\t\t},\n\t}\n\n\ttokenRevokedCalled := false\n\tservice.blacklistLookupFn = func(_ string) (bool, error) {\n\t\treturn false, nil\n\t}\n\tservice.tokenRevokedLookupFn = func(jwtID string) bool {\n\t\ttokenRevokedCalled = true\n\t\treturn jwtID == \"jwt-id\"\n\t}\n\tservice.mysqlReadyFn = func() bool { return true }\n\n\tif service.isPrincipalValid(claims) {\n\t\tt.Fatal(\"expected redis miss to fall back to revoked token lookup\")\n\t}\n\tif !tokenRevokedCalled {\n\t\tt.Fatal(\"expected database revoke lookup to be used on redis miss\")\n\t}\n}\n\nfunc TestIsPrincipalValidRejectsRevokedToken(t *testing.T) {\n\tservice := NewLoginService()\n\tclaims := &token.AdminCustomClaims{\n\t\tAdminUserInfo: token.AdminUserInfo{UserID: 12},\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tID: \"jwt-id\",\n\t\t},\n\t}\n\n\tservice.blacklistLookupFn = func(_ string) (bool, error) {\n\t\treturn false, errors.New(\"redis unavailable\")\n\t}\n\tservice.tokenRevokedLookupFn = func(jwtID string) bool {\n\t\treturn jwtID == \"jwt-id\"\n\t}\n\n\tif service.isPrincipalValid(claims) {\n\t\tt.Fatal(\"expected revoked token to be rejected\")\n\t}\n}\n\nfunc TestValidateUserReturnsDependencyErrorWhenDBUnavailable(t *testing.T) {\n\tservice := NewLoginService()\n\n\tuser, err := service.validateUser(\"missing-user\", \"password\")\n\tif user != nil {\n\t\tt.Fatalf(\"expected nil user, got %#v\", user)\n\t}\n\n\tvar businessErr *e.BusinessError\n\tif !errors.As(err, &businessErr) {\n\t\tt.Fatalf(\"expected business error, got %v\", err)\n\t}\n\tif businessErr.GetCode() != e.ServiceDependencyNotReady {\n\t\tt.Fatalf(\"expected code %d, got %d\", e.ServiceDependencyNotReady, businessErr.GetCode())\n\t}\n}\n\nfunc TestLogoutFallsBackToDatabaseRevocationWhenRedisUnavailable(t *testing.T) {\n\tsecretKey := testkit.SecretKey(\"auth-logout\")\n\tservice := NewLoginServiceWithDeps(LoginServiceDeps{\n\t\tConfigProvider: func() *config.Conf {\n\t\t\treturn &config.Conf{\n\t\t\t\tJwt: autoload.JwtConfig{\n\t\t\t\t\tSecretKey: secretKey,\n\t\t\t\t},\n\t\t\t}\n\t\t},\n\t})\n\n\trevoked := false\n\tservice.markTokensRevokedFn = func(_ context.Context, jwtIDs []string, revokedCode uint8, revokedReason string) error {\n\t\trevoked = true\n\t\tif len(jwtIDs) != 1 || jwtIDs[0] == \"\" {\n\t\t\tt.Fatalf(\"unexpected jwt ids: %#v\", jwtIDs)\n\t\t}\n\t\tif revokedCode != model.RevokedCodeUserLogout || revokedReason == \"\" {\n\t\t\tt.Fatalf(\"unexpected revoke payload: code=%d reason=%s\", revokedCode, revokedReason)\n\t\t}\n\t\treturn nil\n\t}\n\n\tclaims := &token.AdminCustomClaims{\n\t\tAdminUserInfo: token.AdminUserInfo{UserID: 1, Username: \"tester\"},\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),\n\t\t\tIssuer:    global.Issuer,\n\t\t\tIssuedAt:  jwt.NewNumericDate(time.Now()),\n\t\t\tSubject:   global.PcAdminSubject,\n\t\t\tNotBefore: jwt.NewNumericDate(time.Now()),\n\t\t\tID:        \"jwt-logout-test\",\n\t\t},\n\t}\n\taccessToken, err := signTokenForTest(claims, secretKey)\n\tif err != nil {\n\t\tt.Fatalf(\"generate token failed: %v\", err)\n\t}\n\n\tif err := service.Logout(accessToken); err != nil {\n\t\tt.Fatalf(\"expected logout to succeed without redis, got %v\", err)\n\t}\n\tif !revoked {\n\t\tt.Fatal(\"expected database revocation to be invoked\")\n\t}\n}\n\nfunc TestLogoutTreatsRedisWriteFailureAsSuccessAfterDatabaseRevocation(t *testing.T) {\n\tsecretKey := testkit.SecretKey(\"auth-logout\")\n\tservice := NewLoginServiceWithDeps(LoginServiceDeps{\n\t\tConfigProvider: func() *config.Conf {\n\t\t\treturn &config.Conf{\n\t\t\t\tJwt: autoload.JwtConfig{\n\t\t\t\t\tSecretKey: secretKey,\n\t\t\t\t},\n\t\t\t}\n\t\t},\n\t})\n\n\trevoked := false\n\tblacklistWriteCalled := false\n\tservice.markTokensRevokedFn = func(_ context.Context, jwtIDs []string, revokedCode uint8, revokedReason string) error {\n\t\trevoked = true\n\t\tif len(jwtIDs) != 1 || jwtIDs[0] == \"\" {\n\t\t\tt.Fatalf(\"unexpected jwt ids: %#v\", jwtIDs)\n\t\t}\n\t\tif revokedCode != model.RevokedCodeUserLogout || revokedReason == \"\" {\n\t\t\tt.Fatalf(\"unexpected revoke payload: code=%d reason=%s\", revokedCode, revokedReason)\n\t\t}\n\t\treturn nil\n\t}\n\tservice.writeTokenToBlacklistFn = func(jwtID string, remainingTime time.Duration) error {\n\t\tblacklistWriteCalled = true\n\t\tif jwtID == \"\" || remainingTime <= 0 {\n\t\t\tt.Fatalf(\"unexpected blacklist write args: jwtID=%q remaining=%v\", jwtID, remainingTime)\n\t\t}\n\t\treturn errors.New(\"redis write timeout\")\n\t}\n\n\tclaims := &token.AdminCustomClaims{\n\t\tAdminUserInfo: token.AdminUserInfo{UserID: 1, Username: \"tester\"},\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),\n\t\t\tIssuer:    global.Issuer,\n\t\t\tIssuedAt:  jwt.NewNumericDate(time.Now()),\n\t\t\tSubject:   global.PcAdminSubject,\n\t\t\tNotBefore: jwt.NewNumericDate(time.Now()),\n\t\t\tID:        \"jwt-logout-blacklist-fail\",\n\t\t},\n\t}\n\taccessToken, err := signTokenForTest(claims, secretKey)\n\tif err != nil {\n\t\tt.Fatalf(\"generate token failed: %v\", err)\n\t}\n\n\tif err := service.Logout(accessToken); err != nil {\n\t\tt.Fatalf(\"expected logout to degrade to success, got %v\", err)\n\t}\n\tif !revoked {\n\t\tt.Fatal(\"expected database revocation to be invoked\")\n\t}\n\tif !blacklistWriteCalled {\n\t\tt.Fatal(\"expected redis blacklist write to be attempted\")\n\t}\n}\n\n// TestAcquireMemoryLock 验证内存锁可以正常获取并释放。\nfunc TestAcquireMemoryLock(t *testing.T) {\n\tservice := NewLoginService()\n\tunlock := service.acquireMemoryLock(\"lock-key\")\n\tif unlock == nil {\n\t\tt.Fatal(\"expected unlock function\")\n\t}\n\tunlock()\n}\n\nfunc TestNewLoginServiceSharesDefaultRefreshLockStore(t *testing.T) {\n\tfirst := NewLoginService()\n\tsecond := NewLoginService()\n\tif first.refreshLockStore == nil || second.refreshLockStore == nil {\n\t\tt.Fatal(\"expected refresh lock store to be initialized\")\n\t}\n\tif first.refreshLockStore != second.refreshLockStore {\n\t\tt.Fatal(\"expected default refresh lock store to be shared across service instances\")\n\t}\n}\n\nfunc TestNewLoginServiceWithDepsUsesCustomRefreshLockStore(t *testing.T) {\n\tcustomStore := newRefreshTokenLock(100*time.Millisecond, 20*time.Millisecond)\n\tservice := NewLoginServiceWithDeps(LoginServiceDeps{\n\t\tRefreshLockStore: customStore,\n\t})\n\n\tif service.refreshLockStore != customStore {\n\t\tt.Fatal(\"expected custom refresh lock store to be used\")\n\t}\n}\n\nfunc TestAcquireRefreshLockFallsBackToMemoryWhenRedisDisabled(t *testing.T) {\n\tcfg := &config.Conf{\n\t\tRedis: autoload.RedisConfig{\n\t\t\tEnable: false,\n\t\t},\n\t}\n\tservice := NewLoginServiceWithDeps(LoginServiceDeps{\n\t\tConfigProvider: func() *config.Conf { return cfg },\n\t})\n\n\tclaims := &token.AdminCustomClaims{\n\t\tAdminUserInfo: token.AdminUserInfo{UserID: 42},\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tID: \"jwt-id\",\n\t\t},\n\t}\n\n\tunlock := service.acquireRefreshLock(\"refresh-lock:test\", claims)\n\tif unlock == nil {\n\t\tt.Fatal(\"expected memory lock fallback unlock function\")\n\t}\n\tunlock()\n}\n\nfunc TestResolvePrincipalSkipsAutoRefreshWhenMysqlUnavailable(t *testing.T) {\n\tservice := NewLoginService()\n\tclaims := &token.AdminCustomClaims{\n\t\tAdminUserInfo: token.AdminUserInfo{UserID: 12, Username: \"tester\"},\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tID:        \"jwt-id\",\n\t\t\tExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),\n\t\t},\n\t}\n\n\trefreshCalled := false\n\tservice.blacklistLookupFn = func(_ string) (bool, error) { return false, nil }\n\tservice.mysqlReadyFn = func() bool { return false }\n\tservice.tryRefreshPrincipalFn = func(_ *AuthPrincipal) {\n\t\trefreshCalled = true\n\t}\n\n\tprincipal, ok := service.resolvePrincipalFromClaims(claims)\n\tif !ok || principal == nil {\n\t\tt.Fatal(\"expected principal to be resolved\")\n\t}\n\tif refreshCalled {\n\t\tt.Fatal(\"expected auto refresh to be skipped when mysql is unavailable\")\n\t}\n}\n\nfunc signTokenForTest(claims jwt.Claims, secret string) (string, error) {\n\tjwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)\n\treturn jwtToken.SignedString([]byte(secret))\n}\n"
  },
  {
    "path": "internal/service/auth/login_log_helpers.go",
    "content": "package auth\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"time\"\n\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n\t\"github.com/wannanbigpig/gin-layout/pkg/utils/crypto\"\n)\n\n// buildLoginLog 构建登录日志记录。\nfunc (s *LoginService) buildLoginLog(uid uint, username, jwtId, accessToken, refreshToken string, expiresAt time.Time, logInfo LoginLogInfo, loginStatus uint8, failReason string, logType uint8) *model.AdminLoginLogs {\n\tloginLog := model.NewAdminLoginLogs()\n\tloginLog.UID = uid\n\tloginLog.Username = username\n\tloginLog.JwtID = jwtId\n\ts.encryptAndSetToken(loginLog, accessToken, refreshToken, uid)\n\tloginLog.IP = logInfo.IP\n\tloginLog.UserAgent = logInfo.UserAgent\n\tloginLog.OS = logInfo.OS\n\tloginLog.Browser = logInfo.Browser\n\tloginLog.ExecutionTime = logInfo.ExecutionTime\n\tloginLog.LoginStatus = loginStatus\n\tloginLog.LoginFailReason = failReason\n\tloginLog.Type = logType\n\tif !expiresAt.IsZero() {\n\t\tloginLog.TokenExpires = &(utils.FormatDate{Time: expiresAt})\n\t}\n\treturn loginLog\n}\n\n// encryptAndSetToken 加密并设置 token 到登录日志。\nfunc (s *LoginService) encryptAndSetToken(loginLog *model.AdminLoginLogs, accessToken, refreshToken string, uid uint) {\n\tencryptKey := s.currentConfig().Jwt.SecretKey\n\tif accessToken != \"\" {\n\t\tloginLog.AccessToken = s.encryptToken(encryptKey, accessToken, \"access_token\", uid)\n\t\tloginLog.TokenHash = s.calculateTokenHash(accessToken)\n\t}\n\tif refreshToken != \"\" {\n\t\tloginLog.RefreshToken = s.encryptToken(encryptKey, refreshToken, \"refresh_token\", uid)\n\t\tloginLog.RefreshTokenHash = s.calculateTokenHash(refreshToken)\n\t}\n}\n\n// encryptToken 加密 token。\nfunc (s *LoginService) encryptToken(key, token, tokenType string, uid uint) string {\n\tencrypted, err := crypto.Encrypt(key, token)\n\tif err != nil {\n\t\tlog.Logger.Error(\"加密 token 失败\", zap.Error(err), zap.String(\"token_type\", tokenType), zap.Uint(\"user_id\", uid))\n\t\treturn \"\"\n\t}\n\treturn encrypted\n}\n\n// extractErrorMessage 提取简洁的错误消息。\nfunc (s *LoginService) extractErrorMessage(err error) string {\n\tvar businessErr *e.BusinessError\n\tif errors.As(err, &businessErr) {\n\t\treturn businessErr.GetMessage()\n\t}\n\treturn err.Error()\n}\n\n// ExtractErrorMessage 提供给 controller 的错误消息提取入口。\nfunc (s *LoginService) ExtractErrorMessage(err error) string {\n\treturn s.extractErrorMessage(err)\n}\n\n// RecordLoginFailLog 记录登录失败日志。\nfunc (s *LoginService) RecordLoginFailLog(username, failReason string, logInfo LoginLogInfo) {\n\tif !s.currentConfig().Mysql.Enable {\n\t\treturn\n\t}\n\n\tloginLog := s.buildLoginLog(0, username, \"\", \"\", \"\", time.Time{}, logInfo, model.LoginStatusFail, failReason, model.LoginTypeLogin)\n\tgo func() {\n\t\tdefer func() {\n\t\t\tif r := recover(); r != nil {\n\t\t\t\tlogLoginAsyncError(\"记录登录失败日志 panic\",\n\t\t\t\t\tzap.String(\"operation\", \"record_login_fail_log\"),\n\t\t\t\t\tzap.String(\"username\", username),\n\t\t\t\t\tzap.String(\"fail_reason\", failReason),\n\t\t\t\t\tzap.Any(\"recover\", r))\n\t\t\t}\n\t\t}()\n\t\tif err := loginLog.Create(); err != nil {\n\t\t\tlogLoginAsyncError(\"记录登录失败日志出错\",\n\t\t\t\tzap.String(\"operation\", \"record_login_fail_log\"),\n\t\t\t\tzap.String(\"username\", username),\n\t\t\t\tzap.String(\"fail_reason\", failReason),\n\t\t\t\tzap.String(\"ip\", logInfo.IP),\n\t\t\t\tzap.Error(err))\n\t\t}\n\t}()\n}\n\n// calculateTokenHash 计算 Token 的 SHA256 哈希值。\nfunc (s *LoginService) calculateTokenHash(accessToken string) string {\n\thashBytes := sha256.Sum256([]byte(accessToken))\n\treturn hex.EncodeToString(hashBytes[:])\n}\n\nfunc logLoginAsyncError(message string, fields ...zap.Field) {\n\tif log.Logger == nil {\n\t\treturn\n\t}\n\tlog.Logger.Error(message, fields...)\n}\n"
  },
  {
    "path": "internal/service/auth/login_refresh.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/mssola/useragent\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token\"\n)\n\nconst refreshLockRedisTimeout = 2 * time.Second\nconst unknownUserAgentPart = \"Unknown\"\n\n// BuildLoginLogInfo 从请求上下文构建登录日志信息。\nfunc (s *LoginService) BuildLoginLogInfo(c *gin.Context) LoginLogInfo {\n\tuserAgentStr := c.Request.UserAgent()\n\tua := useragent.New(userAgentStr)\n\tos := normalizeUserAgentPart(ua.OS())\n\tbrowser, _ := ua.Browser()\n\tbrowser = normalizeUserAgentPart(browser)\n\n\treturn LoginLogInfo{\n\t\tIP:        c.ClientIP(),\n\t\tUserAgent: userAgentStr,\n\t\tOS:        os,\n\t\tBrowser:   browser,\n\t}\n}\n\nfunc normalizeUserAgentPart(value string) string {\n\tvalue = strings.TrimSpace(value)\n\tif value == \"\" {\n\t\treturn unknownUserAgentPart\n\t}\n\treturn value\n}\n\n// tryRefreshToken 尝试自动刷新 Token。\nfunc (s *LoginService) tryRefreshToken(principal *AuthPrincipal) {\n\ts.ensureRuntimeDeps()\n\tif !s.mysqlReadyFn() {\n\t\treturn\n\t}\n\tif !s.shouldRefreshToken(principal) {\n\t\treturn\n\t}\n\n\tlockKey := s.buildRefreshLockKey(principal.Claims)\n\tunlock := s.acquireRefreshLock(lockKey, principal.Claims)\n\tif unlock == nil {\n\t\treturn\n\t}\n\tdefer unlock()\n\n\ts.doRefreshToken(principal)\n}\n\n// shouldRefreshToken 判断是否需要刷新 token。\nfunc (s *LoginService) shouldRefreshToken(principal *AuthPrincipal) bool {\n\tcfg := s.currentConfig()\n\tif cfg.Jwt.RefreshTTL <= 0 || s.GetCtx() == nil || principal == nil || principal.Claims == nil {\n\t\treturn false\n\t}\n\n\texp, err := principal.Claims.GetExpirationTime()\n\tif err != nil || exp == nil {\n\t\treturn false\n\t}\n\n\trefreshTTL := cfg.Jwt.RefreshTTL * time.Second\n\treturn exp.Time.Sub(time.Now()) < refreshTTL\n}\n\n// buildRefreshLockKey 构建刷新锁 key。\nfunc (s *LoginService) buildRefreshLockKey(claims *token.AdminCustomClaims) string {\n\treturn refreshLockPrefix + strconv.FormatUint(uint64(claims.UserID), 10) + \":\" + claims.ID\n}\n\n// acquireRefreshLock 获取刷新锁。\nfunc (s *LoginService) acquireRefreshLock(lockKey string, claims *token.AdminCustomClaims) func() {\n\tcfg := s.currentConfig()\n\tif !(cfg.Redis.Enable && data.RedisClient() != nil) {\n\t\treturn s.acquireMemoryLock(lockKey)\n\t}\n\n\tunlock, locked, err := s.acquireRedisLock(lockKey)\n\tif err != nil {\n\t\tlog.Logger.Warn(\"获取刷新token Redis锁失败，降级到内存锁\", zap.Error(err), zap.Uint(\"user_id\", claims.UserID), zap.String(\"jwt_id\", claims.ID))\n\t\treturn s.acquireMemoryLock(lockKey)\n\t}\n\tif !locked {\n\t\treturn nil\n\t}\n\treturn unlock\n}\n\n// acquireRedisLock 获取 Redis 分布式锁。\nfunc (s *LoginService) acquireRedisLock(lockKey string) (func(), bool, error) {\n\tredisClient := data.RedisClient()\n\tlockCtx, lockCancel := context.WithTimeout(context.Background(), refreshLockRedisTimeout)\n\tdefer lockCancel()\n\n\tlocked, err := redisClient.SetNX(lockCtx, lockKey, \"1\", refreshLockTTL).Result()\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\tif !locked {\n\t\treturn nil, false, nil\n\t}\n\treturn func() {\n\t\tunlockCtx, unlockCancel := context.WithTimeout(context.Background(), refreshLockRedisTimeout)\n\t\tdefer unlockCancel()\n\t\tif err := redisClient.Del(unlockCtx, lockKey).Err(); err != nil {\n\t\t\tlog.Logger.Warn(\"释放刷新 token Redis 锁失败\", zap.Error(err), zap.String(\"lock_key\", lockKey))\n\t\t}\n\t}, true, nil\n}\n\n// acquireMemoryLock 获取内存锁。\nfunc (s *LoginService) acquireMemoryLock(lockKey string) func() {\n\ts.ensureRuntimeDeps()\n\tmemLock := s.refreshLockStore.getLock(lockKey)\n\tmemLock.Lock()\n\treturn memLock.Unlock\n}\n\n// doRefreshToken 执行刷新 token。\nfunc (s *LoginService) doRefreshToken(principal *AuthPrincipal) {\n\tif principal == nil || principal.Claims == nil {\n\t\treturn\n\t}\n\tlogInfo := s.BuildLoginLogInfo(s.GetCtx())\n\ttokenResponse, err := s.Refresh(principal.UserID, logInfo)\n\tif err != nil {\n\t\tlog.Logger.Warn(\"自动刷新token失败\", zap.Error(err), zap.Uint(\"user_id\", principal.UserID), zap.String(\"jwt_id\", principal.JWTID))\n\t\treturn\n\t}\n\tif tokenResponse == nil {\n\t\treturn\n\t}\n\n\tctx := s.GetCtx()\n\tctx.Writer.Header().Set(\"refresh-access-token\", tokenResponse.AccessToken)\n\tctx.Writer.Header().Set(\"refresh-exp\", strconv.FormatInt(tokenResponse.ExpiresAt, 10))\n}\n"
  },
  {
    "path": "internal/service/auth/login_revoke.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n\t\"go.uber.org/zap\"\n)\n\nconst revokeLogAsyncTimeout = 5 * time.Second\n\n// isTokenRevokedInLog 检查登录日志表中 token 是否被撤销。\nfunc (s *LoginService) isTokenRevokedInLog(jwtId string) bool {\n\tif jwtId == \"\" {\n\t\treturn false\n\t}\n\n\tloginLog := model.NewAdminLoginLogs()\n\terr := loginLog.FindByJwtId(jwtId)\n\tif err != nil {\n\t\tif !errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\tlogRevokeError(\"检查token撤销状态失败\", zap.Error(err), zap.String(\"jwt_id\", jwtId))\n\t\t}\n\t\treturn false\n\t}\n\n\treturn loginLog.IsRevoked == model.IsRevokedYes\n}\n\nfunc (s *LoginService) markTokensRevoked(ctx context.Context, jwtIds []string, revokedCode uint8, revokedReason string) error {\n\tnow := time.Now()\n\trevokedAt := utils.FormatDate{Time: now}\n\n\tloginLog := model.NewAdminLoginLogs()\n\tdb := s.loginLogDB\n\tif db == nil {\n\t\tvar err error\n\t\tdb, err = loginLog.GetDB()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif ctx != nil {\n\t\tdb = db.WithContext(ctx)\n\t}\n\tloginLog.SetDB(db)\n\treturn loginLog.UpdateRevokedStatusByJwtIds(jwtIds, revokedCode, revokedReason, revokedAt)\n}\n\nfunc logRevokeError(message string, fields ...zap.Field) {\n\tif log.Logger == nil {\n\t\treturn\n\t}\n\tlog.Logger.Error(message, fields...)\n}\n"
  },
  {
    "path": "internal/service/auth/login_security.go",
    "content": "package auth\n\nimport (\n\tstderrors \"errors\"\n\t\"math\"\n\t\"strings\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/sys_config\"\n\t\"go.uber.org/zap\"\n)\n\nconst (\n\tdefaultLoginMaxFailures = 5\n\tdefaultLoginLockMinutes = 15\n)\n\ntype loginLockPolicy struct {\n\tEnabled      bool\n\tMaxFailures  int\n\tLockDuration time.Duration\n}\n\n// CheckLoginAllowed 校验账号当前是否允许继续登录。\nfunc (s *LoginService) CheckLoginAllowed(username string) error {\n\treturn s.ensureLoginAllowed(username)\n}\n\n// HandleLoginFailure 统一处理登录失败：记录失败日志，并按策略累加失败计数。\nfunc (s *LoginService) HandleLoginFailure(username, failReason string, logInfo LoginLogInfo, countTowardLock bool) {\n\ts.RecordLoginFailLog(username, failReason, logInfo)\n\tif !countTowardLock {\n\t\treturn\n\t}\n\tif err := s.incrementLoginFailState(username); err != nil {\n\t\tlog.Logger.Warn(\"更新登录失败计数失败\", zap.String(\"username\", username), zap.Error(err))\n\t}\n}\n\nfunc (s *LoginService) ensureLoginAllowed(username string) error {\n\tpolicy := s.loginLockPolicy()\n\tif !policy.Enabled {\n\t\treturn nil\n\t}\n\tusername = strings.TrimSpace(username)\n\tif username == \"\" {\n\t\treturn nil\n\t}\n\n\tstate := model.NewLoginSecurityState()\n\terr := state.FindByUsername(username)\n\tif err != nil {\n\t\tif stderrors.Is(err, gorm.ErrRecordNotFound) || isTableNotFoundErr(err) || e.IsDependencyNotReady(err) {\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\n\tnow := time.Now()\n\tif state.LockUntil == nil || !state.LockUntil.Time.After(now) {\n\t\t// 锁已过期后清空状态，避免残留失败计数影响后续判断。\n\t\tif state.FailCount > 0 || state.LockUntil != nil {\n\t\t\t_ = s.clearLoginFailState(username)\n\t\t}\n\t\treturn nil\n\t}\n\n\tremainingMinutes := int(math.Ceil(state.LockUntil.Time.Sub(now).Minutes()))\n\tif remainingMinutes < 1 {\n\t\tremainingMinutes = 1\n\t}\n\treturn e.NewBusinessErrorWithKey(e.LoginAccountLocked, e.MsgKeyAuthAccountLocked, remainingMinutes)\n}\n\nfunc (s *LoginService) incrementLoginFailState(username string) error {\n\tpolicy := s.loginLockPolicy()\n\tif !policy.Enabled {\n\t\treturn nil\n\t}\n\tusername = strings.TrimSpace(username)\n\tif username == \"\" {\n\t\treturn nil\n\t}\n\n\tstate := model.NewLoginSecurityState()\n\tdb, err := state.GetDB()\n\tif err != nil {\n\t\tif e.IsDependencyNotReady(err) {\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\n\tnow := time.Now()\n\treturn db.Transaction(func(tx *gorm.DB) error {\n\t\tcurrent := model.NewLoginSecurityState()\n\t\tcurrent.SetDB(tx)\n\t\tfindErr := current.FindByUsername(username)\n\t\tif findErr != nil {\n\t\t\tif isTableNotFoundErr(findErr) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif !stderrors.Is(findErr, gorm.ErrRecordNotFound) {\n\t\t\t\treturn findErr\n\t\t\t}\n\t\t\tnext := model.NewLoginSecurityState()\n\t\t\tnext.SetDB(tx)\n\t\t\tnext.Username = username\n\t\t\tapplyLoginFailState(next, now, policy)\n\t\t\treturn next.Save()\n\t\t}\n\n\t\tapplyLoginFailState(current, now, policy)\n\t\treturn current.Save()\n\t})\n}\n\nfunc (s *LoginService) clearLoginFailState(username string) error {\n\tusername = strings.TrimSpace(username)\n\tif username == \"\" {\n\t\treturn nil\n\t}\n\tstate := model.NewLoginSecurityState()\n\tdb, err := state.GetDB()\n\tif err != nil {\n\t\tif e.IsDependencyNotReady(err) {\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\treturn db.Model(state).Where(\"username = ?\", username).Updates(map[string]any{\n\t\t\"fail_count\":     0,\n\t\t\"lock_until\":     nil,\n\t\t\"last_failed_at\": nil,\n\t}).Error\n}\n\nfunc (s *LoginService) loginLockPolicy() loginLockPolicy {\n\tpolicy := loginLockPolicy{\n\t\tEnabled:      sys_config.BoolValue(sys_config.AuthLoginLockEnabledConfigKey, true),\n\t\tMaxFailures:  sys_config.IntValue(sys_config.AuthLoginMaxFailuresConfigKey, defaultLoginMaxFailures),\n\t\tLockDuration: time.Duration(sys_config.IntValue(sys_config.AuthLoginLockMinutesConfigKey, defaultLoginLockMinutes)) * time.Minute,\n\t}\n\tif policy.MaxFailures < 1 {\n\t\tpolicy.MaxFailures = defaultLoginMaxFailures\n\t}\n\tif policy.LockDuration < time.Minute {\n\t\tpolicy.LockDuration = defaultLoginLockMinutes * time.Minute\n\t}\n\treturn policy\n}\n\nfunc (s *LoginService) shouldCountLockFailure(err error) bool {\n\tvar businessErr *e.BusinessError\n\tif !stderrors.As(err, &businessErr) {\n\t\treturn false\n\t}\n\tswitch businessErr.GetCode() {\n\tcase e.UserDoesNotExist, e.UserDisable, e.UserPasswordWrong, e.CaptchaErr:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc applyLoginFailState(state *model.LoginSecurityState, now time.Time, policy loginLockPolicy) {\n\tif state == nil {\n\t\treturn\n\t}\n\tif state.LockUntil != nil && !state.LockUntil.Time.After(now) {\n\t\tstate.FailCount = 0\n\t\tstate.LockUntil = nil\n\t}\n\n\tstate.FailCount++\n\tstate.LastFailedAt = &utils.FormatDate{Time: now}\n\tif int(state.FailCount) >= policy.MaxFailures {\n\t\tstate.LockUntil = &utils.FormatDate{Time: now.Add(policy.LockDuration)}\n\t}\n}\n\nfunc isTableNotFoundErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := strings.ToLower(err.Error())\n\treturn strings.Contains(msg, \"doesn't exist\") || strings.Contains(msg, \"does not exist\") || strings.Contains(msg, \"no such table\")\n}\n"
  },
  {
    "path": "internal/service/auth/login_security_test.go",
    "content": "package auth\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\nfunc TestShouldCountLockFailure(t *testing.T) {\n\tservice := NewLoginService()\n\tcases := []struct {\n\t\tname string\n\t\terr  error\n\t\twant bool\n\t}{\n\t\t{name: \"wrong password\", err: e.NewBusinessError(e.UserPasswordWrong), want: true},\n\t\t{name: \"captcha\", err: e.NewBusinessError(e.CaptchaErr), want: true},\n\t\t{name: \"dependency not ready\", err: e.NewBusinessError(e.ServiceDependencyNotReady), want: false},\n\t\t{name: \"login failed\", err: e.NewBusinessError(e.LoginFailed), want: false},\n\t}\n\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif got := service.shouldCountLockFailure(tc.err); got != tc.want {\n\t\t\t\tt.Fatalf(\"expected %v, got %v\", tc.want, got)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyLoginFailStateLocksWhenThresholdReached(t *testing.T) {\n\tstate := &model.LoginSecurityState{\n\t\tFailCount: 4,\n\t}\n\tnow := time.Now()\n\tpolicy := loginLockPolicy{\n\t\tEnabled:      true,\n\t\tMaxFailures:  5,\n\t\tLockDuration: 15 * time.Minute,\n\t}\n\n\tapplyLoginFailState(state, now, policy)\n\n\tif state.FailCount != 5 {\n\t\tt.Fatalf(\"expected fail count 5, got %d\", state.FailCount)\n\t}\n\tif state.LockUntil == nil || !state.LockUntil.Time.After(now) {\n\t\tt.Fatal(\"expected lock_until to be set\")\n\t}\n\tif state.LastFailedAt == nil || state.LastFailedAt.Time.IsZero() {\n\t\tt.Fatal(\"expected last_failed_at to be set\")\n\t}\n}\n\nfunc TestApplyLoginFailStateResetsExpiredLock(t *testing.T) {\n\tnow := time.Now()\n\tstate := &model.LoginSecurityState{\n\t\tFailCount: 9,\n\t\tLockUntil: &utils.FormatDate{Time: now.Add(-time.Minute)},\n\t}\n\tpolicy := loginLockPolicy{\n\t\tEnabled:      true,\n\t\tMaxFailures:  5,\n\t\tLockDuration: 15 * time.Minute,\n\t}\n\n\tapplyLoginFailState(state, now, policy)\n\n\tif state.FailCount != 1 {\n\t\tt.Fatalf(\"expected expired state to reset then increment to 1, got %d\", state.FailCount)\n\t}\n\tif state.LockUntil != nil {\n\t\tt.Fatal(\"expected no lock while fail count below threshold\")\n\t}\n}\n"
  },
  {
    "path": "internal/service/auth/login_token_ops.go",
    "content": "package auth\n\nimport (\n\t\"errors\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token\"\n\t\"go.uber.org/zap\"\n)\n\n// CheckToken 检查 Token 是否有效。\nfunc (s *LoginService) CheckToken(accessToken string) (*model.AdminUser, bool) {\n\tprincipal, ok := s.ResolvePrincipal(accessToken)\n\tif !ok || principal == nil {\n\t\treturn nil, false\n\t}\n\treturn principal.AdminUser(), true\n}\n\n// ResolvePrincipal 解析并验证当前访问令牌对应的认证主体。\nfunc (s *LoginService) ResolvePrincipal(accessToken string) (*AuthPrincipal, bool) {\n\tclaims, err := s.parseToken(accessToken)\n\tif err != nil {\n\t\treturn nil, false\n\t}\n\treturn s.resolvePrincipalFromClaims(claims)\n}\n\nfunc (s *LoginService) resolvePrincipalFromClaims(claims *token.AdminCustomClaims) (*AuthPrincipal, bool) {\n\ts.ensureRuntimeDeps()\n\tif claims == nil {\n\t\treturn nil, false\n\t}\n\n\texp, err := claims.GetExpirationTime()\n\tif err != nil || exp == nil {\n\t\treturn nil, false\n\t}\n\n\tif !s.isPrincipalValid(claims) {\n\t\treturn nil, false\n\t}\n\n\tprincipal := newAuthPrincipalFromClaims(claims)\n\tif s.mysqlReadyFn() {\n\t\ts.tryRefreshPrincipalFn(principal)\n\t}\n\treturn principal, true\n}\n\n// isPrincipalValid 检查 token 对应主体是否仍然有效。\nfunc (s *LoginService) isPrincipalValid(claims *token.AdminCustomClaims) bool {\n\ts.ensureRuntimeDeps()\n\tif claims == nil {\n\t\treturn false\n\t}\n\tinBlacklist, err := s.blacklistLookupFn(claims.ID)\n\tif err == nil {\n\t\tif inBlacklist {\n\t\t\treturn false\n\t\t}\n\t\tif s.mysqlReadyFn() {\n\t\t\treturn !s.tokenRevokedLookupFn(claims.ID)\n\t\t}\n\t\treturn true\n\t}\n\n\tif !s.mysqlReadyFn() {\n\t\treturn false\n\t}\n\n\tif log.Logger != nil && s.shouldLogRedisFallback(err) {\n\t\tlog.Logger.Warn(\"Redis 黑名单查询失败，回退到数据库撤销状态校验\", zap.Error(err), zap.String(\"jwt_id\", claims.ID))\n\t}\n\treturn !s.tokenRevokedLookupFn(claims.ID)\n}\n\nfunc (s *LoginService) shouldLogRedisFallback(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, errRedisUnavailable) {\n\t\tcfg := s.currentConfig()\n\t\treturn cfg != nil && cfg.Redis.Enable\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "internal/service/auth/login_types.go",
    "content": "package auth\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\tgocache \"github.com/patrickmn/go-cache\"\n)\n\nconst (\n\ttokenTypeBearer   = \"Bearer\"\n\tblacklistPrefix   = \"blacklist:\"\n\trefreshLockPrefix = \"refresh_token_lock:\"\n\trefreshLockTTL    = 5 * time.Second // 锁的过期时间，防止死锁\n\tdefaultTokenTTL   = 24 * time.Hour  // 默认 token 过期时间（当 token_expires 为 NULL 时使用）\n)\n\n// refreshTokenLock 内存锁存储（当Redis未启用时使用）。\ntype refreshTokenLock struct {\n\tmu    sync.RWMutex\n\tttl   time.Duration\n\tlocks *gocache.Cache\n}\n\nfunc newRefreshTokenLock(ttl, cleanupInterval time.Duration) *refreshTokenLock {\n\treturn &refreshTokenLock{\n\t\tttl:   ttl,\n\t\tlocks: gocache.New(ttl, cleanupInterval),\n\t}\n}\n\n// getLock 获取或创建指定 key 的锁。\nfunc (r *refreshTokenLock) getLock(key string) *sync.Mutex {\n\tr.mu.RLock()\n\tif lock, ok := r.locks.Get(key); ok {\n\t\tr.mu.RUnlock()\n\t\treturn lock.(*sync.Mutex)\n\t}\n\tr.mu.RUnlock()\n\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\n\tif lock, ok := r.locks.Get(key); ok {\n\t\treturn lock.(*sync.Mutex)\n\t}\n\n\tnewLock := &sync.Mutex{}\n\t// 让缓存接管过期回收，避免为每个 key 启一个 sleep goroutine 模拟 TTL。\n\tr.locks.Set(key, newLock, r.ttl)\n\n\treturn newLock\n}\n\nvar (\n\tdefaultRefreshLockStoreOnce sync.Once\n\tdefaultRefreshLockStoreInst *refreshTokenLock\n)\n\nfunc defaultRefreshLockStore() *refreshTokenLock {\n\tdefaultRefreshLockStoreOnce.Do(func() {\n\t\tdefaultRefreshLockStoreInst = newRefreshTokenLock(refreshLockTTL, refreshLockTTL)\n\t})\n\treturn defaultRefreshLockStoreInst\n}\n\n// TokenResponse Token响应体。\ntype TokenResponse struct {\n\tAccessToken  string `json:\"access_token\"`\n\tRefreshToken string `json:\"refresh_token\"`\n\tTokenType    string `json:\"token_type\"`\n\tExpiresAt    int64  `json:\"expires_at\"`\n}\n\n// LoginLogInfo 登录日志信息。\ntype LoginLogInfo struct {\n\tIP            string `json:\"ip\"`\n\tUserAgent     string `json:\"user_agent\"`\n\tOS            string `json:\"os\"`\n\tBrowser       string `json:\"browser\"`\n\tExecutionTime int    `json:\"execution_time\"`\n}\n"
  },
  {
    "path": "internal/service/auth/login_types_test.go",
    "content": "package auth\n\nimport (\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestRefreshTokenLockReusesMutexBeforeExpiry(t *testing.T) {\n\tlocker := newRefreshTokenLock(50*time.Millisecond, 10*time.Millisecond)\n\n\tfirst := locker.getLock(\"same-key\")\n\tsecond := locker.getLock(\"same-key\")\n\n\tif first != second {\n\t\tt.Fatal(\"expected the same mutex instance before expiry\")\n\t}\n}\n\nfunc TestRefreshTokenLockCreatesNewMutexAfterExpiry(t *testing.T) {\n\tlocker := newRefreshTokenLock(20*time.Millisecond, 5*time.Millisecond)\n\n\tfirst := locker.getLock(\"expired-key\")\n\ttime.Sleep(60 * time.Millisecond)\n\tsecond := locker.getLock(\"expired-key\")\n\n\tif first == second {\n\t\tt.Fatal(\"expected a new mutex instance after expiry\")\n\t}\n}\n\nfunc TestRefreshTokenLockIsSafeUnderConcurrentAccess(t *testing.T) {\n\tlocker := newRefreshTokenLock(100*time.Millisecond, 20*time.Millisecond)\n\n\tconst workers = 16\n\tresults := make(chan *sync.Mutex, workers)\n\tvar wg sync.WaitGroup\n\n\tfor range workers {\n\t\twg.Add(1)\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tresults <- locker.getLock(\"concurrent-key\")\n\t\t}()\n\t}\n\n\twg.Wait()\n\tclose(results)\n\n\tvar first *sync.Mutex\n\tfor lock := range results {\n\t\tif first == nil {\n\t\t\tfirst = lock\n\t\t\tcontinue\n\t\t}\n\t\tif first != lock {\n\t\t\tt.Fatal(\"expected all goroutines to receive the same mutex instance\")\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/service/auth/principal.go",
    "content": "package auth\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token\"\n)\n\n// AuthPrincipal 表示一次请求中已验证的认证主体。\n//\n// 这里固定采用 claims-first 语义：中间件只保存 JWT claims 中已有的字段快照，\n// 不在请求上下文里缓存完整的 AdminUser 模型，避免每个请求默认回表。\ntype AuthPrincipal struct {\n\tClaims          *token.AdminCustomClaims\n\tJWTID           string\n\tUserID          uint\n\tUsername        string\n\tNickname        string\n\tEmail           string\n\tFullPhoneNumber string\n\tPhoneNumber     string\n\tCountryCode     string\n\tIsSuperAdmin    uint8\n}\n\nfunc newAuthPrincipalFromClaims(claims *token.AdminCustomClaims) *AuthPrincipal {\n\tif claims == nil {\n\t\treturn nil\n\t}\n\n\tprincipal := &AuthPrincipal{\n\t\tUserID:          claims.UserID,\n\t\tUsername:        claims.Username,\n\t\tNickname:        claims.Nickname,\n\t\tEmail:           claims.Email,\n\t\tFullPhoneNumber: claims.FullPhoneNumber,\n\t\tPhoneNumber:     claims.PhoneNumber,\n\t\tCountryCode:     claims.CountryCode,\n\t\tIsSuperAdmin:    claims.IsSuperAdmin,\n\t\tClaims:          claims,\n\t\tJWTID:           claims.ID,\n\t}\n\treturn principal\n}\n\n// AdminUser 将认证主体转换为兼容旧逻辑的轻量用户对象。\n// 返回值只包含 claims 中已有字段，不保证数据库实时状态。\nfunc (p *AuthPrincipal) AdminUser() *model.AdminUser {\n\tif p == nil {\n\t\treturn nil\n\t}\n\treturn &model.AdminUser{\n\t\tContainsDeleteBaseModel: model.ContainsDeleteBaseModel{\n\t\t\tBaseModel: model.BaseModel{ID: p.UserID},\n\t\t},\n\t\tUsername:        p.Username,\n\t\tNickname:        p.Nickname,\n\t\tEmail:           p.Email,\n\t\tFullPhoneNumber: p.FullPhoneNumber,\n\t\tPhoneNumber:     p.PhoneNumber,\n\t\tCountryCode:     p.CountryCode,\n\t\tIsSuperAdmin:    p.IsSuperAdmin,\n\t}\n}\n\n// StoreAuthPrincipal 将认证主体写入上下文。\nfunc StoreAuthPrincipal(c *gin.Context, principal *AuthPrincipal) {\n\tif c == nil || principal == nil {\n\t\treturn\n\t}\n\tc.Set(global.ContextKeyAuthPrincipal, principal)\n\tc.Set(global.ContextKeyUID, principal.UserID)\n}\n\n// GetAuthPrincipal 从上下文中读取认证主体。\nfunc GetAuthPrincipal(c *gin.Context) *AuthPrincipal {\n\tif c == nil {\n\t\treturn nil\n\t}\n\tif value, exists := c.Get(global.ContextKeyAuthPrincipal); exists {\n\t\tif principal, ok := value.(*AuthPrincipal); ok {\n\t\t\treturn principal\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/service/auth/session.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"strings\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n\t\"go.uber.org/zap\"\n)\n\nconst defaultSessionRevokeReason = \"管理员强制下线\"\n\n// ListSessions 分页查询在线会话列表。\nfunc (s *LoginService) ListSessions(params *form.SessionList) *resources.Collection {\n\tquery := query_builder.New().\n\t\tAddEq(\"login_status\", model.LoginStatusSuccess).\n\t\tAddLike(\"username\", params.Username).\n\t\tAddLike(\"ip\", params.IP).\n\t\tAddEq(\"is_revoked\", params.IsRevoked)\n\tif params.UID > 0 {\n\t\tquery.AddEq(\"uid\", params.UID)\n\t}\n\tif params.StartTime != \"\" {\n\t\tquery.AddCondition(\"created_at >= ?\", params.StartTime)\n\t}\n\tif params.EndTime != \"\" {\n\t\tquery.AddCondition(\"created_at <= ?\", params.EndTime)\n\t}\n\tcondition, args := query.Build()\n\n\tloginLog := model.NewAdminLoginLogs()\n\tlistOptionalParams := model.ListOptionalParams{\n\t\tSelectFields: []string{\n\t\t\t\"id\",\n\t\t\t\"uid\",\n\t\t\t\"username\",\n\t\t\t\"jwt_id\",\n\t\t\t\"ip\",\n\t\t\t\"os\",\n\t\t\t\"browser\",\n\t\t\t\"is_revoked\",\n\t\t\t\"revoked_reason\",\n\t\t\t\"revoked_at\",\n\t\t\t\"token_expires\",\n\t\t\t\"created_at\",\n\t\t},\n\t\tOrderBy: \"created_at DESC, id DESC\",\n\t}\n\n\ttransformer := resources.NewSessionTransformer()\n\ttotal, collection, err := model.ListPageE(loginLog, params.Page, params.PerPage, condition, args, listOptionalParams)\n\tif err != nil {\n\t\tlog.Logger.Error(\"查询在线会话列表失败\", zap.Error(err))\n\t\treturn transformer.ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\treturn transformer.ToCollection(params.Page, params.PerPage, total, collection)\n}\n\n// RevokeSession 撤销指定在线会话。\nfunc (s *LoginService) RevokeSession(ctx context.Context, id uint, reason string) error {\n\ts.ensureRuntimeDeps()\n\tloginLog, err := s.loadRevocableSession(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trevokeReason := strings.TrimSpace(reason)\n\tif revokeReason == \"\" {\n\t\trevokeReason = defaultSessionRevokeReason\n\t}\n\n\tif err := s.markTokensRevokedFn(ctx, []string{loginLog.JwtID}, model.RevokedCodeSystemForce, revokeReason); err != nil {\n\t\tlog.Logger.Error(\"撤销在线会话数据库状态失败\", zap.Error(err), zap.Uint(\"id\", id), zap.String(\"jwt_id\", loginLog.JwtID))\n\t\treturn err\n\t}\n\n\tremainingTime := time.Until(loginLog.TokenExpires.Time)\n\tif err := s.writeTokenToBlacklistFn(loginLog.JwtID, remainingTime); err != nil {\n\t\tlog.Logger.Warn(\"Redis 黑名单写入失败，保留数据库撤销状态作为兜底\",\n\t\t\tzap.Error(err),\n\t\t\tzap.Bool(\"redis_unavailable\", errors.Is(err, errRedisUnavailable)),\n\t\t\tzap.Uint(\"id\", id),\n\t\t\tzap.String(\"jwt_id\", loginLog.JwtID))\n\t\treturn nil\n\t}\n\treturn nil\n}\n\nfunc (s *LoginService) loadRevocableSession(id uint) (*model.AdminLoginLogs, error) {\n\tloginLog := model.NewAdminLoginLogs()\n\tif s.loginLogDB != nil {\n\t\tloginLog.SetDB(s.loginLogDB)\n\t}\n\tif err := loginLog.GetById(id); err != nil || loginLog.ID == 0 {\n\t\tif err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\tlog.Logger.Error(\"查询在线会话失败\", zap.Error(err), zap.Uint(\"id\", id))\n\t\t}\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\tif loginLog.LoginStatus != model.LoginStatusSuccess {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"仅成功登录会话允许撤销\")\n\t}\n\tif loginLog.IsRevoked != model.IsRevokedNo {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"会话已撤销\")\n\t}\n\tif loginLog.TokenExpires == nil || !loginLog.TokenExpires.Time.After(time.Now()) {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"会话已过期\")\n\t}\n\tif loginLog.JwtID == \"\" {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"会话缺少 jwt_id\")\n\t}\n\treturn loginLog, nil\n}\n"
  },
  {
    "path": "internal/service/auth/session_test.go",
    "content": "package auth\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\nfunc TestRevokeSessionUpdatesDatabaseWhenRedisUnavailable(t *testing.T) {\n\tdb := newSessionTestDB(t)\n\texpires := utils.FormatDate{Time: time.Now().Add(time.Hour)}\n\tloginLog := model.AdminLoginLogs{\n\t\tUID:          1,\n\t\tUsername:     \"admin\",\n\t\tJwtID:        \"jwt-id\",\n\t\tLoginStatus:  model.LoginStatusSuccess,\n\t\tIsRevoked:    model.IsRevokedNo,\n\t\tTokenExpires: &expires,\n\t}\n\tif err := db.Create(&loginLog).Error; err != nil {\n\t\tt.Fatalf(\"create login log failed: %v\", err)\n\t}\n\n\tservice := NewLoginServiceWithDeps(LoginServiceDeps{\n\t\tLoginLogDB: db,\n\t\tWriteTokenToBlacklist: func(_ string, _ time.Duration) error {\n\t\t\treturn errRedisUnavailable\n\t\t},\n\t})\n\tif err := service.RevokeSession(context.Background(), loginLog.ID, \"force offline\"); err != nil {\n\t\tt.Fatalf(\"RevokeSession returned error: %v\", err)\n\t}\n\n\tvar stored model.AdminLoginLogs\n\tif err := db.First(&stored, loginLog.ID).Error; err != nil {\n\t\tt.Fatalf(\"query login log failed: %v\", err)\n\t}\n\tif stored.IsRevoked != model.IsRevokedYes {\n\t\tt.Fatalf(\"expected session to be revoked, got %d\", stored.IsRevoked)\n\t}\n\tif stored.RevokedReason != \"force offline\" || stored.RevokedAt == nil {\n\t\tt.Fatalf(\"unexpected revoke fields: %#v\", stored)\n\t}\n}\n\nfunc TestRevokeSessionRejectsExpiredSession(t *testing.T) {\n\tdb := newSessionTestDB(t)\n\texpires := utils.FormatDate{Time: time.Now().Add(-time.Minute)}\n\tloginLog := model.AdminLoginLogs{\n\t\tUID:          1,\n\t\tUsername:     \"admin\",\n\t\tJwtID:        \"jwt-id\",\n\t\tLoginStatus:  model.LoginStatusSuccess,\n\t\tIsRevoked:    model.IsRevokedNo,\n\t\tTokenExpires: &expires,\n\t}\n\tif err := db.Create(&loginLog).Error; err != nil {\n\t\tt.Fatalf(\"create login log failed: %v\", err)\n\t}\n\n\tservice := NewLoginServiceWithDeps(LoginServiceDeps{LoginLogDB: db})\n\tif err := service.RevokeSession(context.Background(), loginLog.ID, \"force offline\"); err == nil {\n\t\tt.Fatal(\"expected expired session revoke to fail\")\n\t}\n}\n\nfunc newSessionTestDB(t *testing.T) *gorm.DB {\n\tt.Helper()\n\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite failed: %v\", err)\n\t}\n\tstatement := `CREATE TABLE admin_login_logs (\n\t\tid integer primary key autoincrement,\n\t\tcreated_at datetime,\n\t\tupdated_at datetime,\n\t\tdeleted_at integer not null default 0,\n\t\tuid integer,\n\t\tusername text,\n\t\tjwt_id text,\n\t\taccess_token text,\n\t\trefresh_token text,\n\t\ttoken_hash text,\n\t\trefresh_token_hash text,\n\t\tip text,\n\t\tuser_agent text,\n\t\tos text,\n\t\tbrowser text,\n\t\texecution_time integer,\n\t\tlogin_status integer,\n\t\tlogin_fail_reason text,\n\t\ttype integer,\n\t\tis_revoked integer,\n\t\trevoked_code integer,\n\t\trevoked_reason text,\n\t\trevoked_at datetime,\n\t\ttoken_expires datetime,\n\t\trefresh_expires datetime\n\t)`\n\tif err := db.Exec(statement).Error; err != nil {\n\t\tt.Fatalf(\"create login logs table failed: %v\", err)\n\t}\n\treturn db\n}\n"
  },
  {
    "path": "internal/service/common.go",
    "content": "package service\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"mime/multipart\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/filestorage\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/pkg/utils\"\n\t\"go.uber.org/zap\"\n)\n\n// CommonService 通用服务\ntype CommonService struct {\n\tBase\n}\n\nconst maxUploadFileSize int64 = 10 * 1024 * 1024\n\n// NewCommonService 创建通用服务实例。\nfunc NewCommonService() *CommonService {\n\treturn &CommonService{}\n}\n\n// UploadImages 批量上传图片。\nfunc (s CommonService) UploadImages(files []*multipart.FileHeader, path string) ([]*utils.FileInfo, error) {\n\tfilesInfo := make([]*utils.FileInfo, 0, len(files))\n\tfor _, fileHeader := range files {\n\t\tfile, err := s.UploadImage(fileHeader, true, path)\n\t\tif err != nil {\n\t\t\tlog.Logger.Warn(\"文件上传失败\",\n\t\t\t\tzap.String(\"filename\", fileHeader.Filename),\n\t\t\t\tzap.Error(err),\n\t\t\t)\n\t\t}\n\t\tfilesInfo = append(filesInfo, file)\n\t}\n\n\treturn summarizeImageUploadResults(filesInfo)\n}\n\n// UploadImage 上传单张图片并保存文件记录。\nfunc (s CommonService) UploadImage(fileHeader *multipart.FileHeader, isPublic bool, path string) (*utils.FileInfo, error) {\n\tfileInfo := initUploadResult(fileHeader)\n\tisPublicFlag := visibilityFlag(isPublic)\n\tbasePath := storageBasePath(isPublic)\n\tuploadPath, err := normalizeUploadPath(path)\n\tif err != nil {\n\t\treturn setFileFailure(fileInfo, \"上传目录不合法\", err)\n\t}\n\tdriver, storageConfig, activeDriver, err := NewActiveStorageDriver(context.Background())\n\tif err != nil {\n\t\treturn setFileFailure(fileInfo, \"存储配置异常\", err)\n\t}\n\tuploadDir, err := resolveUploadDestination(basePath, uploadPath)\n\tif err != nil {\n\t\treturn setFileFailure(fileInfo, \"上传目录不合法\", err)\n\t}\n\n\tif fileHeader.Size > maxUploadFileSize {\n\t\treturn setFileFailure(fileInfo, \"文件大小不能大于10M\", nil)\n\t}\n\n\tresult, err := utils.SaveUploadedImageWithUUID(fileHeader, uploadDir)\n\tif err != nil {\n\t\tif errors.Is(err, utils.ErrInvalidImageType) {\n\t\t\treturn setFileFailure(fileInfo, \"仅支持图片格式\", err)\n\t\t}\n\t\treturn setFileFailure(fileInfo, \"文件保存失败\", err)\n\t}\n\tsavedPath := result.Path\n\tfileInfo.Sha256 = result.Sha256\n\n\tabsBasePath, err := filepath.Abs(basePath)\n\tif err != nil {\n\t\tcleanupStoredUpload(result.Path)\n\t\treturn setFileFailure(fileInfo, \"上传路径获取异常\", err)\n\t}\n\trelPath, err := filepath.Rel(absBasePath, result.Path)\n\tif err != nil {\n\t\tcleanupStoredUpload(result.Path)\n\t\treturn setFileFailure(fileInfo, \"上传路径获取异常\", err)\n\t}\n\tresult.Path = relPath\n\tbucket := bucketForDriver(activeDriver, storageConfig, isPublicFlag)\n\tobjectKey := result.Path\n\tetag := result.Sha256\n\tif activeDriver != model.StorageDriverLocal {\n\t\tfile, err := os.Open(savedPath)\n\t\tif err != nil {\n\t\t\tcleanupStoredUpload(savedPath)\n\t\t\treturn setFileFailure(fileInfo, \"读取上传文件失败\", err)\n\t\t}\n\t\tputResult, putErr := driver.Put(context.Background(), filestorage.PutInput{\n\t\t\tBucket:      bucket,\n\t\t\tObjectKey:   objectKey,\n\t\t\tReader:      file,\n\t\t\tSize:        result.Size,\n\t\t\tContentType: result.MimeType,\n\t\t})\n\t\tcloseErr := file.Close()\n\t\tcleanupStoredUpload(savedPath)\n\t\tif putErr != nil {\n\t\t\treturn setFileFailure(fileInfo, \"保存到对象存储失败\", putErr)\n\t\t}\n\t\tif closeErr != nil {\n\t\t\treturn setFileFailure(fileInfo, \"读取上传文件失败\", closeErr)\n\t\t}\n\t\tbucket = putResult.Bucket\n\t\tobjectKey = putResult.ObjectKey\n\t\tif putResult.ETag != \"\" {\n\t\t\tetag = putResult.ETag\n\t\t}\n\t}\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\tif activeDriver == model.StorageDriverLocal {\n\t\t\tcleanupStoredUpload(savedPath)\n\t\t}\n\t\treturn setFileFailure(fileInfo, \"保存文件信息失败\", err)\n\t}\n\tobject, reused, err := ensureFileObject(db, uploadFileObjectInput{\n\t\tStorageDriver: activeDriver,\n\t\tStorageBase:   storageBaseForDriver(activeDriver, isPublic, bucket),\n\t\tBucket:        bucket,\n\t\tStoragePath:   objectKey,\n\t\tObjectKey:     objectKey,\n\t\tSize:          uint(result.Size),\n\t\tHash:          result.Sha256,\n\t\tMimeType:      result.MimeType,\n\t\tETag:          etag,\n\t\tStatus:        model.StorageStatusStored,\n\t})\n\tif err != nil {\n\t\tif activeDriver == model.StorageDriverLocal {\n\t\t\tcleanupStoredUpload(savedPath)\n\t\t}\n\t\treturn setFileFailure(fileInfo, \"保存物理对象失败\", err)\n\t}\n\tif activeDriver == model.StorageDriverLocal && reused {\n\t\tcleanupStoredUpload(savedPath)\n\t}\n\n\tuploadFileModel := model.NewUploadFiles()\n\tuploadFileModel.UID = s.GetAdminUserId()\n\tuploadFileModel.FolderID = 0\n\tuploadFileModel.LogicalPath = \"/\"\n\tuploadFileModel.DisplayName = result.OriginName\n\tuploadFileModel.OriginName = result.OriginName\n\tuploadFileModel.Name = result.Name\n\tuploadFileModel.Path = object.ObjectKey\n\tuploadFileModel.Size = uint(result.Size)\n\tuploadFileModel.Ext = result.Ext\n\tuploadFileModel.Hash = result.Sha256\n\tuploadFileModel.UUID = result.UUID\n\tuploadFileModel.MimeType = result.MimeType\n\tuploadFileModel.FileType = classifyUploadFileType(result.MimeType)\n\tuploadFileModel.IsPublic = isPublicFlag\n\tuploadFileModel.StorageDriver = activeDriver\n\tuploadFileModel.StorageBase = object.StorageBase\n\tuploadFileModel.Bucket = object.Bucket\n\tuploadFileModel.StoragePath = object.StoragePath\n\tuploadFileModel.ObjectKey = object.ObjectKey\n\tuploadFileModel.ETag = object.ETag\n\tuploadFileModel.StorageStatus = model.StorageStatusStored\n\tuploadFileModel.UploadSource = model.UploadSourceBackend\n\tuploadFileModel.UploadScene = \"common\"\n\tuploadFileModel.UploadStatus = model.UploadStatusUploaded\n\tapplyObjectToUploadFile(uploadFileModel, object)\n\n\tif err := uploadFileModel.Create(); err != nil {\n\t\tif activeDriver == model.StorageDriverLocal && !reused {\n\t\t\tcleanupStoredUpload(savedPath)\n\t\t\t_ = db.Delete(&model.UploadFileObject{}, object.ID).Error\n\t\t}\n\t\treturn setFileFailure(fileInfo, \"保存文件信息失败\", err)\n\t}\n\n\tfillFileInfoFromUploadResult(fileInfo, result)\n\treturn fileInfo, nil\n}\n\n// GetFileAccessPath 获取文件访问路径\n// fileUUID: 文件UUID（32位十六进制字符串，不带连字符），用于URL访问\n// checkAuth: 是否检查权限（私有文件需要检查）\n// currentUID: 当前用户ID（用于权限检查，0表示未登录）\ntype FileAccessResult struct {\n\tLocalPath   string\n\tRedirectURL string\n}\n\nfunc (s CommonService) GetFileAccessPath(fileUUID string, checkAuth bool, currentUID uint) (FileAccessResult, error) {\n\tif len(fileUUID) != 32 {\n\t\treturn FileAccessResult{}, e.NewBusinessError(e.FileIdentifierInvalid)\n\t}\n\n\tuploadFile := model.NewUploadFiles()\n\t// 通过UUID查询（更短，适合URL）\n\terr := uploadFile.GetDetail(\"uuid = ?\", fileUUID)\n\tif err != nil {\n\t\treturn FileAccessResult{}, e.NewBusinessError(e.NotFound)\n\t}\n\n\tif uploadFile.IsPublic == global.No {\n\t\tif !checkAuth || currentUID == 0 {\n\t\t\treturn FileAccessResult{}, e.NewBusinessError(e.FilePrivateAuthNeeded)\n\t\t}\n\t\tif uploadFile.UID != currentUID {\n\t\t\treturn FileAccessResult{}, e.NewBusinessError(e.FileAccessDenied)\n\t\t}\n\t}\n\n\tstorageDriver := uploadFile.StorageDriver\n\tstorageBase := uploadFile.StorageBase\n\tbucket := uploadFile.Bucket\n\tobjectKey := firstNonEmpty(uploadFile.ObjectKey, uploadFile.StoragePath, uploadFile.Path)\n\tif uploadFile.FileObjectID > 0 {\n\t\tif db, dbErr := model.GetDB(); dbErr == nil {\n\t\t\tvar object model.UploadFileObject\n\t\t\tif err := db.First(&object, uploadFile.FileObjectID).Error; err == nil {\n\t\t\t\tstorageDriver = object.StorageDriver\n\t\t\t\tstorageBase = object.StorageBase\n\t\t\t\tbucket = object.Bucket\n\t\t\t\tobjectKey = firstNonEmpty(object.ObjectKey, object.StoragePath)\n\t\t\t}\n\t\t}\n\t}\n\n\tif storageDriver != \"\" && storageDriver != model.StorageDriverLocal {\n\t\tdriver, cfg, err := NewStorageDriverByName(context.Background(), storageDriver)\n\t\tif err != nil {\n\t\t\treturn FileAccessResult{}, e.NewBusinessError(e.FileAccessDenied)\n\t\t}\n\t\tttl := time.Duration(cfg.SignedURLTTLSeconds) * time.Second\n\t\tif ttl <= 0 {\n\t\t\tttl = 5 * time.Minute\n\t\t}\n\t\tsignedURL, err := driver.SignedURL(context.Background(), bucket, objectKey, ttl)\n\t\tif err != nil {\n\t\t\treturn FileAccessResult{}, e.NewBusinessError(e.FileAccessDenied)\n\t\t}\n\t\t_ = uploadFile.UpdateById(uploadFile.ID, map[string]any{\"last_accessed_at\": time.Now()})\n\t\treturn FileAccessResult{RedirectURL: signedURL}, nil\n\t}\n\n\tfilePath, err := resolveUploadDestination(firstNonEmpty(storageBase, storageBasePath(uploadFile.IsPublic == global.Yes)), objectKey)\n\tif err != nil {\n\t\treturn FileAccessResult{}, e.NewBusinessError(e.FileAccessDenied)\n\t}\n\t_ = uploadFile.UpdateById(uploadFile.ID, map[string]any{\"last_accessed_at\": time.Now()})\n\treturn FileAccessResult{LocalPath: filePath}, nil\n}\n"
  },
  {
    "path": "internal/service/common_test.go",
    "content": "package service\n\nimport (\n\t\"errors\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\nfunc TestVisibilityFlag(t *testing.T) {\n\tassert.Equal(t, uint8(global.Yes), visibilityFlag(true))\n\tassert.Equal(t, uint8(global.No), visibilityFlag(false))\n}\n\nfunc TestStorageBasePath(t *testing.T) {\n\toriginBasePath := c.Config.BasePath\n\tt.Cleanup(func() {\n\t\tc.Config.BasePath = originBasePath\n\t})\n\n\tc.Config.BasePath = \"/tmp/go-layout\"\n\n\tassert.Equal(t, filepath.Join(\"/tmp/go-layout\", \"storage/public\"), storageBasePath(true))\n\tassert.Equal(t, filepath.Join(\"/tmp/go-layout\", \"storage/private\"), storageBasePath(false))\n}\n\nfunc TestNormalizeUploadPath(t *testing.T) {\n\tpath, err := normalizeUploadPath(\"\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"default\", path)\n\n\tpath, err = normalizeUploadPath(\"avatar\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, \"avatar\", path)\n}\n\nfunc TestBuildFileURL(t *testing.T) {\n\toriginBaseURL := c.Config.BaseURL\n\tt.Cleanup(func() {\n\t\tc.Config.BaseURL = originBaseURL\n\t})\n\n\tc.Config.BaseURL = \"https://example.com/\"\n\tassert.Equal(t, \"https://example.com/admin/v1/file/abc123\", buildFileURL(\"abc123\"))\n\n\tc.Config.BaseURL = \"\"\n\tassert.Equal(t, \"/admin/v1/file/abc123\", buildFileURL(\"abc123\"))\n\tassert.Equal(t, \"\", buildFileURL(\"\"))\n}\n\nfunc TestFillFileInfoFromModel(t *testing.T) {\n\tfileInfo := &utils.FileInfo{OriginName: \"origin.png\"}\n\tuploadFile := &model.UploadFiles{\n\t\tName:     \"stored.png\",\n\t\tPath:     \"avatar/stored.png\",\n\t\tSize:     12,\n\t\tExt:      \".png\",\n\t\tHash:     \"hash\",\n\t\tUUID:     \"uuid123\",\n\t\tMimeType: \"image/png\",\n\t}\n\n\tfillFileInfoFromModel(fileInfo, uploadFile)\n\n\tassert.Equal(t, \"stored.png\", fileInfo.Name)\n\tassert.Equal(t, \"avatar/stored.png\", fileInfo.Path)\n\tassert.Equal(t, int64(12), fileInfo.Size)\n\tassert.Equal(t, \".png\", fileInfo.Ext)\n\tassert.Equal(t, \"hash\", fileInfo.Sha256)\n\tassert.Equal(t, \"uuid123\", fileInfo.UUID)\n\tassert.Equal(t, \"image/png\", fileInfo.MimeType)\n\tassert.Equal(t, global.SUCCESS, fileInfo.Status)\n}\n\nfunc TestFillFileInfoFromUploadResult(t *testing.T) {\n\tfileInfo := &utils.FileInfo{OriginName: \"origin.png\"}\n\tresult := &utils.FileInfo{\n\t\tName:     \"stored.png\",\n\t\tPath:     \"avatar/stored.png\",\n\t\tSize:     12,\n\t\tExt:      \".png\",\n\t\tSha256:   \"hash\",\n\t\tUUID:     \"uuid123\",\n\t\tMimeType: \"image/png\",\n\t}\n\n\tfillFileInfoFromUploadResult(fileInfo, result)\n\n\tassert.Equal(t, \"stored.png\", fileInfo.Name)\n\tassert.Equal(t, \"avatar/stored.png\", fileInfo.Path)\n\tassert.Equal(t, int64(12), fileInfo.Size)\n\tassert.Equal(t, \".png\", fileInfo.Ext)\n\tassert.Equal(t, \"hash\", fileInfo.Sha256)\n\tassert.Equal(t, \"uuid123\", fileInfo.UUID)\n\tassert.Equal(t, \"image/png\", fileInfo.MimeType)\n\tassert.Equal(t, global.SUCCESS, fileInfo.Status)\n}\n\nfunc TestSummarizeImageUploadResults(t *testing.T) {\n\tfilesInfo := []*utils.FileInfo{\n\t\t{Status: global.SUCCESS},\n\t\t{Status: global.ERROR},\n\t}\n\n\tresult, err := summarizeImageUploadResults(filesInfo)\n\tassert.Len(t, result, 2)\n\tassert.Error(t, err)\n\tassert.True(t, IsPartialImageUploadError(err))\n}\n\nfunc TestSummarizeImageUploadResultsAllFailed(t *testing.T) {\n\tfilesInfo := []*utils.FileInfo{\n\t\t{Status: global.ERROR},\n\t\t{Status: global.ERROR},\n\t}\n\n\t_, err := summarizeImageUploadResults(filesInfo)\n\tassert.Error(t, err)\n\tassert.False(t, IsPartialImageUploadError(err))\n}\n\nfunc TestIsPartialImageUploadError(t *testing.T) {\n\tassert.False(t, IsPartialImageUploadError(nil))\n\tassert.False(t, IsPartialImageUploadError(errors.New(\"plain error\")))\n}\n"
  },
  {
    "path": "internal/service/common_upload_helpers.go",
    "content": "package service\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"mime/multipart\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\nconst defaultUploadSubDir = \"default\"\n\nfunc buildFileURL(uuid string) string {\n\tif uuid == \"\" {\n\t\treturn \"\"\n\t}\n\tbaseURL := strings.TrimSuffix(c.GetConfig().BaseURL, \"/\")\n\tif baseURL == \"\" {\n\t\treturn \"/admin/v1/file/\" + uuid\n\t}\n\treturn baseURL + \"/admin/v1/file/\" + uuid\n}\n\nfunc setFileFailure(info *utils.FileInfo, reason string, err error) (*utils.FileInfo, error) {\n\tinfo.FailureReason = reason\n\tinfo.Status = global.ERROR\n\treturn info, err\n}\n\nfunc visibilityFlag(isPublic bool) uint8 {\n\tif isPublic {\n\t\treturn global.Yes\n\t}\n\treturn global.No\n}\n\nfunc storageBasePath(isPublic bool) string {\n\tcfg := c.GetConfig()\n\tif isPublic {\n\t\treturn filepath.Join(cfg.BasePath, \"storage/public\")\n\t}\n\treturn filepath.Join(cfg.BasePath, \"storage/private\")\n}\n\nfunc storageBaseForDriver(driverName string, isPublic bool, bucket string) string {\n\tif driverName == model.StorageDriverLocal {\n\t\treturn storageBasePath(isPublic)\n\t}\n\treturn bucket\n}\n\nfunc normalizeUploadPath(path string) (string, error) {\n\tnormalized := strings.TrimSpace(path)\n\tif normalized == \"\" {\n\t\treturn defaultUploadSubDir, nil\n\t}\n\tnormalized = strings.ReplaceAll(normalized, \"\\\\\", \"/\")\n\tcleaned := filepath.Clean(normalized)\n\tif cleaned == \".\" || cleaned == string(filepath.Separator) {\n\t\treturn defaultUploadSubDir, nil\n\t}\n\tif filepath.IsAbs(cleaned) {\n\t\treturn \"\", fmt.Errorf(\"upload path must be relative\")\n\t}\n\tif cleaned == \"..\" || strings.HasPrefix(cleaned, \"..\"+string(filepath.Separator)) {\n\t\treturn \"\", fmt.Errorf(\"upload path escapes storage root\")\n\t}\n\treturn cleaned, nil\n}\n\nfunc resolveUploadDestination(basePath, uploadPath string) (string, error) {\n\tabsBase, err := filepath.Abs(basePath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"resolve storage base path: %w\", err)\n\t}\n\n\ttargetPath := filepath.Join(absBase, uploadPath)\n\tabsTarget, err := filepath.Abs(targetPath)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"resolve upload target path: %w\", err)\n\t}\n\n\tif absTarget != absBase && !strings.HasPrefix(absTarget, absBase+string(filepath.Separator)) {\n\t\treturn \"\", fmt.Errorf(\"upload target escapes storage root\")\n\t}\n\treturn absTarget, nil\n}\n\nfunc findReusableUploadFile(hash string, isPublic uint8) (*model.UploadFiles, error) {\n\tuploadFile := model.NewUploadFiles()\n\tif err := uploadFile.GetDetail(\"hash = ? AND is_public = ?\", hash, isPublic); err != nil {\n\t\treturn nil, err\n\t}\n\treturn uploadFile, nil\n}\n\nfunc existingUploadFileExists(basePath, relativePath string) bool {\n\tabsolutePath, err := resolveUploadDestination(basePath, relativePath)\n\tif err != nil {\n\t\treturn false\n\t}\n\t_, err = os.Stat(absolutePath)\n\treturn err == nil\n}\n\nfunc fillFileInfoFromModel(fileInfo *utils.FileInfo, uploadFile *model.UploadFiles) {\n\tfileInfo.Path = uploadFile.Path\n\tfileInfo.Name = uploadFile.Name\n\tfileInfo.Size = int64(uploadFile.Size)\n\tfileInfo.Ext = uploadFile.Ext\n\tfileInfo.Sha256 = uploadFile.Hash\n\tfileInfo.UUID = uploadFile.UUID\n\tfileInfo.MimeType = uploadFile.MimeType\n\tfileInfo.URL = buildFileURL(uploadFile.UUID)\n\tfileInfo.Status = global.SUCCESS\n}\n\nfunc fillFileInfoFromUploadResult(fileInfo *utils.FileInfo, result *utils.FileInfo) {\n\tfileInfo.Path = result.Path\n\tfileInfo.Name = result.Name\n\tfileInfo.Size = result.Size\n\tfileInfo.Ext = result.Ext\n\tfileInfo.Sha256 = result.Sha256\n\tfileInfo.UUID = result.UUID\n\tfileInfo.MimeType = result.MimeType\n\tfileInfo.URL = buildFileURL(result.UUID)\n\tfileInfo.Status = global.SUCCESS\n}\n\nfunc classifyUploadFileType(mimeType string) string {\n\tmimeType = strings.ToLower(strings.TrimSpace(mimeType))\n\tswitch {\n\tcase strings.HasPrefix(mimeType, \"image/\"):\n\t\treturn \"image\"\n\tcase mimeType == \"application/pdf\":\n\t\treturn \"pdf\"\n\tcase strings.Contains(mimeType, \"word\") || strings.Contains(mimeType, \"wordprocessingml\"):\n\t\treturn \"word\"\n\tcase strings.Contains(mimeType, \"excel\") || strings.Contains(mimeType, \"spreadsheetml\"):\n\t\treturn \"excel\"\n\tcase strings.Contains(mimeType, \"powerpoint\") || strings.Contains(mimeType, \"presentation\"):\n\t\treturn \"ppt\"\n\tcase strings.Contains(mimeType, \"zip\") || strings.Contains(mimeType, \"rar\") || strings.Contains(mimeType, \"7z\") || strings.Contains(mimeType, \"gzip\") || strings.Contains(mimeType, \"tar\"):\n\t\treturn \"archive\"\n\tcase strings.HasPrefix(mimeType, \"text/\") || mimeType == \"application/json\" || mimeType == \"application/xml\" || mimeType == \"application/yaml\":\n\t\treturn \"text\"\n\tcase strings.HasPrefix(mimeType, \"audio/\"):\n\t\treturn \"audio\"\n\tcase strings.HasPrefix(mimeType, \"video/\"):\n\t\treturn \"video\"\n\tdefault:\n\t\treturn \"other\"\n\t}\n}\n\nfunc cleanupStoredUpload(path string) {\n\tif path == \"\" {\n\t\treturn\n\t}\n\t_ = os.Remove(path)\n}\n\nfunc summarizeImageUploadResults(filesInfo []*utils.FileInfo) ([]*utils.FileInfo, error) {\n\tif len(filesInfo) == 0 {\n\t\treturn filesInfo, nil\n\t}\n\n\tsuccessCount := 0\n\tfor _, item := range filesInfo {\n\t\tif item != nil && item.Status == global.SUCCESS {\n\t\t\tsuccessCount++\n\t\t}\n\t}\n\n\tswitch {\n\tcase successCount == len(filesInfo):\n\t\treturn filesInfo, nil\n\tcase successCount == 0:\n\t\treturn filesInfo, e.NewBusinessError(e.FAILURE)\n\tdefault:\n\t\treturn filesInfo, e.NewBusinessError(e.FileUploadPartialFail)\n\t}\n}\n\n// IsPartialImageUploadError 判断是否属于部分图片上传失败错误。\nfunc IsPartialImageUploadError(err error) bool {\n\tvar businessErr *e.BusinessError\n\treturn errors.As(err, &businessErr) && businessErr.GetCode() == e.FileUploadPartialFail\n}\n\nfunc initUploadResult(fileHeader *multipart.FileHeader) *utils.FileInfo {\n\treturn &utils.FileInfo{\n\t\tOriginName: fileHeader.Filename,\n\t\tSize:       fileHeader.Size,\n\t\tExt:        filepath.Ext(fileHeader.Filename),\n\t\tStatus:     global.ERROR,\n\t}\n}\n"
  },
  {
    "path": "internal/service/common_upload_helpers_test.go",
    "content": "package service\n\nimport \"testing\"\n\nfunc TestNormalizeUploadPathRejectsTraversal(t *testing.T) {\n\tinvalidPaths := []string{\"../secret\", \"../../tmp\", \"/tmp/uploads\", `..\\\\escape`}\n\tfor _, input := range invalidPaths {\n\t\tif _, err := normalizeUploadPath(input); err == nil {\n\t\t\tt.Fatalf(\"expected path %q to be rejected\", input)\n\t\t}\n\t}\n}\n\nfunc TestNormalizeUploadPathKeepsRelativeSubdirs(t *testing.T) {\n\tpath, err := normalizeUploadPath(\"avatars/admin\")\n\tif err != nil {\n\t\tt.Fatalf(\"expected valid relative path, got %v\", err)\n\t}\n\tif path != \"avatars/admin\" {\n\t\tt.Fatalf(\"unexpected normalized path: %q\", path)\n\t}\n}\n"
  },
  {
    "path": "internal/service/dashboard/overview.go",
    "content": "package dashboard\n\nimport (\n\t\"math\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n)\n\ntype Metric struct {\n\tKey     string  `json:\"key\"`\n\tTitle   string  `json:\"title\"`\n\tValue   float64 `json:\"value\"`\n\tSuffix  string  `json:\"suffix\"`\n\tCompare string  `json:\"compare\"`\n\tChange  string  `json:\"change\"`\n\tType    string  `json:\"type\"`\n}\n\ntype ActivityItem struct {\n\tKey   string `json:\"key\"`\n\tTitle string `json:\"title\"`\n\tDesc  string `json:\"desc\"`\n\tTime  string `json:\"time\"`\n\tType  string `json:\"type\"`\n}\n\ntype UserLogin struct {\n\tLastLogin string `json:\"last_login\"`\n\tLastIP    string `json:\"last_ip\"`\n}\n\ntype Overview struct {\n\tMetrics    []Metric       `json:\"metrics\"`\n\tActivities []ActivityItem `json:\"activities\"`\n\tUserLogin  UserLogin      `json:\"user_login\"`\n}\n\ntype OverviewService struct {\n\tservice.Base\n}\n\nfunc NewOverviewService() *OverviewService {\n\treturn &OverviewService{}\n}\n\nfunc (s *OverviewService) Overview() (*Overview, error) {\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tnow := time.Now()\n\ttodayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())\n\ttomorrowStart := todayStart.AddDate(0, 0, 1)\n\tyesterdayStart := todayStart.AddDate(0, 0, -1)\n\n\tactiveUsers, err := countDistinct(db.Table(\"admin_login_logs\").Where(\"deleted_at = 0 AND login_status = ? AND uid > 0 AND created_at >= ? AND created_at < ?\", model.LoginStatusSuccess, todayStart, tomorrowStart), \"uid\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tactiveUsersYesterday, err := countDistinct(db.Table(\"admin_login_logs\").Where(\"deleted_at = 0 AND login_status = ? AND uid > 0 AND created_at >= ? AND created_at < ?\", model.LoginStatusSuccess, yesterdayStart, todayStart), \"uid\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trequestsToday, err := countRows(db.Table(\"request_logs\").Where(\"created_at >= ? AND created_at < ?\", todayStart, tomorrowStart))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trequestsYesterday, err := countRows(db.Table(\"request_logs\").Where(\"created_at >= ? AND created_at < ?\", yesterdayStart, todayStart))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terrorsToday, err := countRows(db.Table(\"request_logs\").Where(\"created_at >= ? AND created_at < ? AND (operation_status <> 0 OR response_status >= 400)\", todayStart, tomorrowStart))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\terrorsYesterday, err := countRows(db.Table(\"request_logs\").Where(\"created_at >= ? AND created_at < ? AND (operation_status <> 0 OR response_status >= 400)\", yesterdayStart, todayStart))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\ttaskCompletion, err := taskCompletionRate(db, todayStart, tomorrowStart)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuserLogin := UserLogin{}\n\tif s.GetAdminUserId() > 0 {\n\t\tvar user model.AdminUser\n\t\tif err := db.Table(\"admin_user\").Select(\"last_login,last_ip\").Where(\"id = ? AND deleted_at = 0\", s.GetAdminUserId()).First(&user).Error; err == nil {\n\t\t\tuserLogin.LastLogin = user.LastLogin.String()\n\t\t\tuserLogin.LastIP = user.LastIp\n\t\t}\n\t}\n\n\treturn &Overview{\n\t\tMetrics: []Metric{\n\t\t\t{Key: \"users\", Title: \"活跃用户\", Value: float64(activeUsers), Compare: \"较昨日\", Change: formatChange(activeUsers, activeUsersYesterday), Type: changeType(activeUsers, activeUsersYesterday)},\n\t\t\t{Key: \"requests\", Title: \"请求总量\", Value: float64(requestsToday), Compare: \"较昨日\", Change: formatChange(requestsToday, requestsYesterday), Type: changeType(requestsToday, requestsYesterday)},\n\t\t\t{Key: \"errors\", Title: \"异常告警\", Value: float64(errorsToday), Compare: \"较昨日\", Change: formatChange(errorsToday, errorsYesterday), Type: inverseChangeType(errorsToday, errorsYesterday)},\n\t\t\t{Key: \"tasks\", Title: \"任务完成率\", Value: taskCompletion, Suffix: \"%\", Compare: \"计划完成\", Change: \"+0.0%\", Type: \"primary\"},\n\t\t},\n\t\tActivities: buildActivities(db),\n\t\tUserLogin:  userLogin,\n\t}, nil\n}\n\nfunc countRows(db *gorm.DB) (int64, error) {\n\tvar count int64\n\tif err := db.Count(&count).Error; err != nil {\n\t\treturn 0, err\n\t}\n\treturn count, nil\n}\n\nfunc countDistinct(db *gorm.DB, field string) (int64, error) {\n\tvar count int64\n\tif err := db.Select(\"COUNT(DISTINCT \" + field + \")\").Scan(&count).Error; err != nil {\n\t\treturn 0, err\n\t}\n\treturn count, nil\n}\n\nfunc taskCompletionRate(db *gorm.DB, start time.Time, end time.Time) (float64, error) {\n\ttotal, err := countRows(db.Table(\"task_runs\").Where(\"created_at >= ? AND created_at < ?\", start, end))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tif total == 0 {\n\t\treturn 100, nil\n\t}\n\tsuccess, err := countRows(db.Table(\"task_runs\").Where(\"created_at >= ? AND created_at < ? AND status = ?\", start, end, model.TaskRunStatusSuccess))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn math.Round(float64(success)*1000/float64(total)) / 10, nil\n}\n\nfunc buildActivities(db *gorm.DB) []ActivityItem {\n\ttype row struct {\n\t\tID              uint\n\t\tOperatorAccount string\n\t\tOperationName   string\n\t\tMethod          string\n\t\tBaseURL         string\n\t\tCreatedAt       time.Time\n\t\tOperationStatus int\n\t}\n\tvar rows []row\n\t_ = db.Table(\"request_logs\").\n\t\tSelect(\"id, operator_account, operation_name, method, base_url, created_at, operation_status\").\n\t\tOrder(\"created_at DESC, id DESC\").\n\t\tLimit(4).\n\t\tScan(&rows).Error\n\n\tactivities := make([]ActivityItem, 0, len(rows))\n\tfor _, item := range rows {\n\t\ttitle := item.OperationName\n\t\tif title == \"\" {\n\t\t\ttitle = item.Method + \" \" + item.BaseURL\n\t\t}\n\t\tactivities = append(activities, ActivityItem{\n\t\t\tKey:   strconv.FormatUint(uint64(item.ID), 10),\n\t\t\tTitle: title,\n\t\t\tDesc:  item.OperatorAccount,\n\t\t\tTime:  item.CreatedAt.Format(\"15:04\"),\n\t\t\tType:  activityType(item.OperationStatus),\n\t\t})\n\t}\n\treturn activities\n}\n\nfunc formatChange(current int64, previous int64) string {\n\tif previous == 0 {\n\t\tif current == 0 {\n\t\t\treturn \"+0.0%\"\n\t\t}\n\t\treturn \"+100.0%\"\n\t}\n\tchange := (float64(current) - float64(previous)) * 100 / float64(previous)\n\tprefix := \"+\"\n\tif change < 0 {\n\t\tprefix = \"\"\n\t}\n\treturn prefix + strconvFormatFloat(change) + \"%\"\n}\n\nfunc strconvFormatFloat(value float64) string {\n\treturn strconv.FormatFloat(math.Round(value*10)/10, 'f', 1, 64)\n}\n\nfunc changeType(current int64, previous int64) string {\n\tif current >= previous {\n\t\treturn \"success\"\n\t}\n\treturn \"warning\"\n}\n\nfunc inverseChangeType(current int64, previous int64) string {\n\tif current <= previous {\n\t\treturn \"success\"\n\t}\n\treturn \"danger\"\n}\n\nfunc activityType(status int) string {\n\tif status == 0 {\n\t\treturn \"success\"\n\t}\n\treturn \"warning\"\n}\n"
  },
  {
    "path": "internal/service/dept/audit_diff.go",
    "content": "package dept\n\nimport (\n\t\"sort\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nvar deptDiffRules = []auditdiff.FieldRule{\n\t{Field: \"id\", Label: \"部门ID\"},\n\t{Field: \"code\", Label: \"部门编码\"},\n\t{Field: \"name\", Label: \"部门名称\"},\n\t{Field: \"pid\", Label: \"上级部门ID\"},\n\t{Field: \"pids\", Label: \"上级路径\"},\n\t{Field: \"level\", Label: \"层级\"},\n\t{Field: \"sort\", Label: \"排序\"},\n\t{Field: \"description\", Label: \"描述\"},\n\t{Field: \"user_number\", Label: \"用户数量\"},\n\t{Field: \"role_ids\", Label: \"角色ID列表\"},\n}\n\nvar deptRoleBindingDiffRules = []auditdiff.FieldRule{\n\t{Field: \"dept_id\", Label: \"部门ID\"},\n\t{Field: \"role_ids\", Label: \"角色ID列表\"},\n}\n\n// CreateWithAuditDiff 新增部门并返回精确 change_diff。\nfunc (s *DeptService) CreateWithAuditDiff(params *form.CreateDept) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tdeptModel, err := s.applyDeptMutation(&deptMutation{\n\t\tName:        params.Name,\n\t\tPid:         params.Pid,\n\t\tDescription: params.Description,\n\t\tSort:        params.Sort,\n\t})\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotDeptByID(deptModel.ID)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\treturn buildDeptDiff(nil, after), nil\n}\n\n// UpdateWithAuditDiff 更新部门并返回精确 change_diff。\nfunc (s *DeptService) UpdateWithAuditDiff(params *form.UpdateDept) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tbefore, err := s.snapshotDeptByID(params.Id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.Update(params); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotDeptByID(params.Id)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\treturn buildDeptDiff(before, after), nil\n}\n\n// DeleteWithAuditDiff 删除部门并返回精确 change_diff。\nfunc (s *DeptService) DeleteWithAuditDiff(id uint) (string, error) {\n\tbefore, err := s.snapshotDeptByID(id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.Delete(id); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn buildDeptDiff(before, nil), nil\n}\n\n// BindRoleWithAuditDiff 绑定部门角色并返回精确 change_diff。\nfunc (s *DeptService) BindRoleWithAuditDiff(params *form.DeptBindRole) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tbefore, err := s.snapshotDeptRoleBinding(params.DeptId)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.BindRole(params); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotDeptRoleBinding(params.DeptId)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\titems := auditdiff.BuildFieldDiff(before, after, deptRoleBindingDiffRules)\n\treturn auditdiff.Marshal(items), nil\n}\n\nfunc (s *DeptService) snapshotDeptByID(id uint) (map[string]any, error) {\n\tdept := model.NewDepartment()\n\tif err := dept.GetById(id); err != nil || dept.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.DepartmentNotFound)\n\t}\n\troleIDs, err := model.NewDeptRoleMap().RoleIdsByDeptIds([]uint{id})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsort.Slice(roleIDs, func(i, j int) bool {\n\t\treturn roleIDs[i] < roleIDs[j]\n\t})\n\treturn map[string]any{\n\t\t\"id\":          dept.ID,\n\t\t\"code\":        dept.Code,\n\t\t\"name\":        dept.Name,\n\t\t\"pid\":         dept.Pid,\n\t\t\"pids\":        dept.Pids,\n\t\t\"level\":       dept.Level,\n\t\t\"sort\":        dept.Sort,\n\t\t\"description\": dept.Description,\n\t\t\"user_number\": dept.UserNumber,\n\t\t\"role_ids\":    roleIDs,\n\t}, nil\n}\n\nfunc (s *DeptService) snapshotDeptRoleBinding(deptID uint) (map[string]any, error) {\n\tdept := model.NewDepartment()\n\tif err := dept.GetById(deptID); err != nil || dept.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.DepartmentNotFound)\n\t}\n\troleIDs, err := model.NewDeptRoleMap().RoleIdsByDeptIds([]uint{deptID})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsort.Slice(roleIDs, func(i, j int) bool {\n\t\treturn roleIDs[i] < roleIDs[j]\n\t})\n\treturn map[string]any{\n\t\t\"dept_id\":  deptID,\n\t\t\"role_ids\": roleIDs,\n\t}, nil\n}\n\nfunc buildDeptDiff(before, after map[string]any) string {\n\titems := auditdiff.BuildFieldDiff(before, after, deptDiffRules)\n\treturn auditdiff.Marshal(items)\n}\n"
  },
  {
    "path": "internal/service/dept/audit_diff_test.go",
    "content": "package dept\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestBuildDeptDiffContainsRoleIDs(t *testing.T) {\n\traw := buildDeptDiff(\n\t\tmap[string]any{\"id\": uint(1), \"role_ids\": []uint{1}},\n\t\tmap[string]any{\"id\": uint(1), \"role_ids\": []uint{1, 2}},\n\t)\n\tvar items []map[string]any\n\tif err := json.Unmarshal([]byte(raw), &items); err != nil {\n\t\tt.Fatalf(\"expected valid json diff, got err=%v raw=%s\", err, raw)\n\t}\n\tif len(items) != 1 {\n\t\tt.Fatalf(\"expected 1 diff item, got %d\", len(items))\n\t}\n\tif items[0][\"field\"] != \"role_ids\" {\n\t\tt.Fatalf(\"expected role_ids diff, got %#v\", items[0][\"field\"])\n\t}\n}\n"
  },
  {
    "path": "internal/service/dept/dept.go",
    "content": "package dept\n\nimport (\n\t\"github.com/samber/lo\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nconst (\n\tmaxDeptLevel = 5\n\tdeptRootPid  = 0\n)\n\n// DeptService 处理部门的增删改查和角色绑定。\ntype DeptService struct {\n\tservice.Base\n}\n\n// NewDeptService 创建部门服务实例。\nfunc NewDeptService() *DeptService {\n\treturn &DeptService{}\n}\n\n// List 返回部门树形列表。\nfunc (s *DeptService) List(params *form.ListDept) any {\n\tcondition, args := s.buildListCondition(params)\n\n\tdeptModel := model.NewDepartment()\n\tdepts, err := model.ListE(deptModel, condition, args, model.ListOptionalParams{\n\t\tOrderBy: \"sort desc, id desc\",\n\t})\n\tif err != nil {\n\t\treturn resources.NewDeptTreeTransformer().BuildTreeByNode(nil, 0)\n\t}\n\n\treturn resources.NewDeptTreeTransformer().BuildTreeByNode(depts, 0)\n}\n\nfunc (s *DeptService) buildListCondition(params *form.ListDept) (string, []any) {\n\treturn query_builder.New().\n\t\tAddLike(\"name\", params.Name).\n\t\tAddEq(\"pid\", params.Pid).\n\t\tBuild()\n}\n\n// Create 新增部门。\nfunc (s *DeptService) Create(params *form.CreateDept) error {\n\t_, err := s.applyDeptMutation(&deptMutation{\n\t\tName:        params.Name,\n\t\tPid:         params.Pid,\n\t\tDescription: params.Description,\n\t\tSort:        params.Sort,\n\t})\n\treturn err\n}\n\n// Update 更新部门。\nfunc (s *DeptService) Update(params *form.UpdateDept) error {\n\t_, err := s.applyDeptMutation(&deptMutation{\n\t\tId:          params.Id,\n\t\tName:        params.Name,\n\t\tPid:         params.Pid,\n\t\tDescription: params.Description,\n\t\tSort:        params.Sort,\n\t})\n\treturn err\n}\n\n// Delete 删除部门。\nfunc (s *DeptService) Delete(id uint) error {\n\tdept := model.NewDepartment()\n\tif err := dept.GetById(id); err != nil || dept.ID == 0 {\n\t\treturn e.NewBusinessError(e.DepartmentNotFound)\n\t}\n\tif access.NewSystemDefaultsService().IsProtectedDepartment(dept) {\n\t\treturn e.NewBusinessError(e.FAILURE)\n\t}\n\tif dept.ChildrenNum > 0 {\n\t\treturn e.NewBusinessError(e.DepartmentHasChildren)\n\t}\n\n\treturn s.executeDeleteTransaction(dept, id)\n}\n\n// Detail 获取部门详情。\nfunc (s *DeptService) Detail(id uint) (any, error) {\n\tdept := model.NewDepartment()\n\tif err := dept.GetAllById(id); err != nil || dept.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.DepartmentNotFound)\n\t}\n\treturn resources.NewDeptTreeTransformer().ToStruct(dept), nil\n}\n\n// BindRole 绑定角色到部门。\nfunc (s *DeptService) BindRole(params *form.DeptBindRole) error {\n\tdeptModel := model.NewDepartment()\n\tif err := deptModel.GetById(params.DeptId); err != nil || deptModel.ID == 0 {\n\t\treturn e.NewBusinessError(e.DepartmentNotFound)\n\t}\n\n\troleIds, err := model.VerifyExistingIDs(model.NewRole(), params.RoleIds)\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.RoleNotFound)\n\t}\n\n\tdb, err := model.NewDepartment().GetDB()\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.FAILURE)\n\t}\n\terr = access.NewPermissionSyncCoordinator().RunAfterCommitWithCode(db, e.FAILURE, func(tx *gorm.DB) error {\n\t\treturn s.updateDeptRole(deptModel.ID, roleIds, tx)\n\t})\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.FAILURE)\n\t}\n\treturn nil\n}\n\n// updateDeptRole 更新部门关联的角色，使用差分算法只变更差异部分。\n// 处理逻辑：\n// 1. 查询部门当前已关联的角色 ID 列表\n// 2. 计算差异：toDelete 需删除，toAdd 需新增\n// 3. 批量删除/新增角色关联\n// 4. 同步部门下所有用户的权限缓存\nfunc (s *DeptService) updateDeptRole(deptId uint, roleIds []uint, tx ...*gorm.DB) error {\n\tdeptRoleMap := model.NewDeptRoleMap()\n\tif len(tx) > 0 {\n\t\tdeptRoleMap.SetDB(tx[0])\n\t}\n\n\t// 查询部门当前已关联的角色 ID 列表\n\texistingIds, err := model.ExtractColumnsByCondition[model.DeptRoleMap, *model.DeptRoleMap, uint](\n\t\tdeptRoleMap,\n\t\t\"role_id\",\n\t\t\"dept_id = ?\",\n\t\tdeptId,\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// 计算差异\n\ttoDelete, toAdd, _ := utils.CalculateChanges(existingIds, roleIds)\n\t// 批量删除差异角色关联\n\tif len(toDelete) > 0 {\n\t\tif err := deptRoleMap.DeleteWhere(\"dept_id = ? AND role_id IN (?)\", []any{deptId, toDelete}...); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// 批量新增角色关联\n\tif len(toAdd) > 0 {\n\t\tnewMappings := lo.Map(toAdd, func(roleId uint, _ int) *model.DeptRoleMap {\n\t\t\treturn &model.DeptRoleMap{RoleId: roleId, DeptId: deptId}\n\t\t})\n\t\tif err := deptRoleMap.CreateBatch(newMappings); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// 同步部门下所有用户的权限缓存\n\tuserIDs, err := s.userIDsByDept(deptId, tx...)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn access.NewPermissionSyncCoordinator().SyncUsers(userIDs, tx...)\n}\n\n// userIDsByDept 查询部门下的所有用户 ID 列表。\nfunc (s *DeptService) userIDsByDept(deptId uint, tx ...*gorm.DB) ([]uint, error) {\n\tdeptMapModel := model.NewAdminUserDeptMap()\n\tif t := access.FirstTx(tx); t != nil {\n\t\tdeptMapModel.SetDB(t)\n\t}\n\treturn deptMapModel.UidsByDeptIds([]uint{deptId})\n}\n"
  },
  {
    "path": "internal/service/dept/dept_mutation.go",
    "content": "package dept\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\tutils2 \"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\n// deptMutation 部门变更参数，用于封装新增/更新部门的请求数据。\ntype deptMutation struct {\n\tId          uint   // 部门 ID，0 表示新增\n\tName        string // 部门名称\n\tPid         uint   // 父部门 ID，0 表示顶级部门\n\tDescription string // 部门描述\n\tSort        uint   // 排序权重\n}\n\n// applyDeptMutation 执行部门变更操作（新增/更新）。\n// 处理逻辑：\n// 1. 验证部门是否存在（更新时）\n// 2. 检查受保护部门（系统默认部门只允许改名称和描述）\n// 3. 验证并构建树形路径（pids, level）\n// 4. 填充部门基础字段\n// 5. 事务保存：部门数据、级联更新子部门 pids、更新子部门数量\nfunc (s *DeptService) applyDeptMutation(params *deptMutation) (*model.Department, error) {\n\tdept := model.NewDepartment()\n\toriginPids := \"0\"\n\toriginPid := uint(0)\n\t// 更新场景：加载现有部门数据，记录原始 pids 用于后续级联判断\n\tif params.Id > 0 {\n\t\tif err := dept.GetById(params.Id); err != nil || dept.ID == 0 {\n\t\t\treturn nil, e.NewBusinessError(e.DepartmentNotFound)\n\t\t}\n\t\toriginPids = dept.Pids\n\t\toriginPid = dept.Pid\n\t}\n\t// 检查是否为受保护部门（系统默认部门只允许修改名称和描述）\n\tif params.Id > 0 && access.NewSystemDefaultsService().IsProtectedDepartment(dept) {\n\t\tif params.Pid != dept.Pid || params.Sort != dept.Sort {\n\t\t\treturn nil, e.NewBusinessError(e.FAILURE)\n\t\t}\n\t}\n\n\t// 处理父部门变更：验证父部门、检测环路、计算层级和路径\n\tif params.Pid > 0 && params.Pid != dept.Pid {\n\t\tparentDept := model.NewDepartment()\n\t\tif err := parentDept.GetById(params.Pid); err != nil || parentDept.ID == 0 {\n\t\t\treturn nil, e.NewBusinessError(e.ParentDeptNotExists)\n\t\t}\n\n\t\t// 环路检测：当前部门若已在父部门的祖先路径上，选择该父部门会形成环\n\t\tif dept.ID > 0 && utils2.WouldCauseCycle(dept.ID, params.Pid, parentDept.Pids) {\n\t\t\treturn nil, e.NewBusinessError(e.ParentDeptInvalid)\n\t\t}\n\n\t\t// 构建新的层级和路径：父层级 +1，pids = 父 pids + 父 ID\n\t\tdept.Level = parentDept.Level + 1\n\t\tif parentDept.Pids == \"0\" || parentDept.Pids == \"\" {\n\t\t\tdept.Pids = fmt.Sprintf(\"%d\", parentDept.ID)\n\t\t} else {\n\t\t\tdept.Pids = fmt.Sprintf(\"%s,%d\", parentDept.Pids, parentDept.ID)\n\t\t}\n\t\tdept.Pid = params.Pid\n\t} else if params.Pid == deptRootPid {\n\t\t// 设置为顶级部门\n\t\tdept.Level = 1\n\t\tdept.Pids = \"0\"\n\t\tdept.Pid = deptRootPid\n\t} else {\n\t\t// 父部门未变更，仅同步 pid 字段\n\t\tdept.Pid = params.Pid\n\t}\n\t// 检查部门层级深度是否超限\n\tif dept.Level > maxDeptLevel {\n\t\treturn nil, e.NewBusinessError(e.MaxDeptDepth)\n\t}\n\n\t// 生成部门 code（为空时）\n\tif dept.Code == \"\" {\n\t\tdept.Code = s.generateDeptCode()\n\t}\n\t// 填充可变更字段\n\tdept.Name = params.Name\n\tdept.Description = params.Description\n\tdept.Sort = params.Sort\n\n\tdb, err := dept.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// 事务执行：保存部门、级联更新子部门 pids、更新子部门数量\n\tif err := access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tdept.SetDB(tx)\n\n\t\tif err := dept.Save(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// pids 变更时，级联更新所有子部门的 pids 路径\n\t\tif dept.Pids != originPids {\n\t\t\tupdateExpr := s.buildPidsUpdateExpr(originPids, dept.Pids)\n\t\t\tdeptModel := model.NewDepartment()\n\t\t\tdeptModel.SetDB(tx)\n\t\t\tif err := deptModel.UpdateChildrenPidsByParent(dept.ID, updateExpr); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// 原父部门的子部门数量减 1\n\t\tif originPid > 0 && originPid != dept.Pid {\n\t\t\tif err := model.UpdateChildrenNum(model.NewDepartment(), originPid, tx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\t// 新父部门的子部门数量加 1\n\t\tif dept.Pid > 0 && dept.Pid != originPid {\n\t\t\tif err := model.UpdateChildrenNum(model.NewDepartment(), dept.Pid, tx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn dept, nil\n}\n\n// generateDeptCode 生成部门唯一编码，格式：dept_{uuid}。\nfunc (s *DeptService) generateDeptCode() string {\n\treturn \"dept_\" + uuid.NewString()\n}\n\n// buildPidsUpdateExpr 构建 SQL CASE 表达式，用于级联更新子部门的 pids 路径。\n// 场景：当某部门的 pids 变更时，其所有子部门的 pids 前缀需要同步更新。\n// 参数：\n//   - originPids: 原始路径\n//   - newPids: 新路径\n//\n// 返回：SQL CASE 表达式字符串\n// 示例：originPids=\"1,2\", newPids=\"1,8\" 时，子部门 \"1,2,3\" → \"1,8,3\"\nfunc (s *DeptService) buildPidsUpdateExpr(originPids, newPids string) string {\n\tif originPids == \"0\" {\n\t\treturn fmt.Sprintf(\n\t\t\t\"CASE WHEN pids = '0' THEN '%s' WHEN pids LIKE '0,%%' THEN CONCAT('%s,', SUBSTRING(pids, 3)) ELSE pids END\",\n\t\t\tnewPids, newPids,\n\t\t)\n\t}\n\n\treturn fmt.Sprintf(\n\t\t\"CASE WHEN pids = '%s' THEN '%s' WHEN pids LIKE '%s,%%' THEN CONCAT('%s,', SUBSTRING(pids, %d)) ELSE pids END\",\n\t\toriginPids, newPids, originPids, newPids, len(originPids)+2,\n\t)\n}\n\n// executeDeleteTransaction 执行部门删除事务。\n// 处理逻辑：\n// 1. 查询部门关联的用户 ID 列表\n// 2. 删除部门 - 角色、用户 - 部门关联\n// 3. 删除部门记录\n// 4. 更新原父部门的子部门数量\n// 5. 同步受影响用户的权限缓存\nfunc (s *DeptService) executeDeleteTransaction(dept *model.Department, id uint) error {\n\tdb, err := dept.GetDB()\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.DepartmentCannotDelete)\n\t}\n\terr = access.NewPermissionSyncCoordinator().RunAfterCommitWithCode(db, e.DepartmentCannotDelete, func(tx *gorm.DB) error {\n\t\tdept.SetDB(tx)\n\n\t\t// 查询部门关联的所有用户 ID，用于后续权限同步\n\t\taffectedUserIDs, err := s.userIDsByDept(id, tx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 删除部门关联的所有角色\n\t\tdeptRoleMap := model.NewDeptRoleMap()\n\t\tdeptRoleMap.SetDB(tx)\n\t\tif err := deptRoleMap.DeleteWhere(\"dept_id = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 删除用户 - 部门关联\n\t\tadminUserDeptMap := model.NewAdminUserDeptMap()\n\t\tadminUserDeptMap.SetDB(tx)\n\t\tif err := adminUserDeptMap.DeleteWhere(\"dept_id = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 删除部门记录\n\t\tparentId := dept.Pid\n\t\tif _, err := dept.DeleteByID(id); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// 更新原父部门的子部门数量（减 1）\n\t\tif parentId > 0 {\n\t\t\tif err := model.UpdateChildrenNum(model.NewDepartment(), parentId, tx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn access.NewPermissionSyncCoordinator().SyncUsers(affectedUserIDs, tx)\n\t})\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.DepartmentCannotDelete)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/service/dept/dept_test.go",
    "content": "package dept\n\nimport \"testing\"\n\nfunc TestGenerateDeptCodeUsesUniqueDefaultPrefix(t *testing.T) {\n\tservice := NewDeptService()\n\n\tfirst := service.generateDeptCode()\n\tsecond := service.generateDeptCode()\n\n\tif first == \"\" || second == \"\" {\n\t\tt.Fatal(\"expected generated dept code\")\n\t}\n\tif first == second {\n\t\tt.Fatalf(\"expected different dept codes, got %s\", first)\n\t}\n\tif first[:5] != \"dept_\" || second[:5] != \"dept_\" {\n\t\tt.Fatalf(\"expected dept_ prefix, got %s and %s\", first, second)\n\t}\n}\n"
  },
  {
    "path": "internal/service/file_object.go",
    "content": "package service\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/filestorage\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\ntype uploadFileObjectInput struct {\n\tStorageDriver string\n\tStorageBase   string\n\tBucket        string\n\tStoragePath   string\n\tObjectKey     string\n\tSize          uint\n\tHash          string\n\tMimeType      string\n\tETag          string\n\tStatus        string\n}\n\nfunc findReusableFileObject(tx *gorm.DB, storageDriver, bucket, hash string) (*model.UploadFileObject, error) {\n\tif hash == \"\" {\n\t\treturn nil, gorm.ErrRecordNotFound\n\t}\n\tbucket = normalizeFileObjectBucket(storageDriver, bucket)\n\tvar object model.UploadFileObject\n\tquery := tx.Where(\"storage_driver = ? AND bucket = ? AND hash = ? AND status = ?\", storageDriver, bucket, hash, model.StorageStatusStored)\n\tif err := query.Order(\"id ASC\").First(&object).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn &object, nil\n}\n\nfunc findFileObjectByID(tx *gorm.DB, id uint) (*model.UploadFileObject, error) {\n\tif id == 0 {\n\t\treturn nil, gorm.ErrRecordNotFound\n\t}\n\tvar object model.UploadFileObject\n\tif err := tx.First(&object, id).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn &object, nil\n}\n\nfunc createFileObject(tx *gorm.DB, input uploadFileObjectInput) (*model.UploadFileObject, error) {\n\tstatus := input.Status\n\tif status == \"\" {\n\t\tstatus = model.StorageStatusStored\n\t}\n\tbucket := normalizeFileObjectBucket(input.StorageDriver, input.Bucket)\n\tobject := &model.UploadFileObject{\n\t\tStorageDriver: input.StorageDriver,\n\t\tStorageBase:   input.StorageBase,\n\t\tBucket:        bucket,\n\t\tStoragePath:   firstNonEmpty(input.StoragePath, input.ObjectKey),\n\t\tObjectKey:     firstNonEmpty(input.ObjectKey, input.StoragePath),\n\t\tSize:          input.Size,\n\t\tHash:          input.Hash,\n\t\tMimeType:      input.MimeType,\n\t\tETag:          input.ETag,\n\t\tStatus:        status,\n\t}\n\tif err := tx.Create(object).Error; err != nil {\n\t\tif existing, findErr := findReusableFileObject(tx, input.StorageDriver, bucket, input.Hash); findErr == nil {\n\t\t\treturn existing, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn object, nil\n}\n\nfunc ensureFileObject(tx *gorm.DB, input uploadFileObjectInput) (*model.UploadFileObject, bool, error) {\n\tif object, err := findReusableFileObject(tx, input.StorageDriver, input.Bucket, input.Hash); err == nil {\n\t\treturn object, true, nil\n\t} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, false, err\n\t}\n\tobject, err := createFileObject(tx, input)\n\treturn object, false, err\n}\n\nfunc normalizeFileObjectBucket(storageDriver, bucket string) string {\n\tif storageDriver == model.StorageDriverLocal {\n\t\treturn \"\"\n\t}\n\treturn bucket\n}\n\nfunc applyObjectToUploadFile(uploadFile *model.UploadFiles, object *model.UploadFileObject) {\n\tif uploadFile == nil || object == nil {\n\t\treturn\n\t}\n\tuploadFile.FileObjectID = object.ID\n\tuploadFile.StorageDriver = object.StorageDriver\n\tuploadFile.StorageBase = object.StorageBase\n\tuploadFile.Bucket = object.Bucket\n\tuploadFile.StoragePath = object.StoragePath\n\tuploadFile.ObjectKey = object.ObjectKey\n\tuploadFile.ETag = object.ETag\n\tuploadFile.StorageStatus = object.Status\n\tif uploadFile.Path == \"\" {\n\t\tuploadFile.Path = object.ObjectKey\n\t}\n\tif uploadFile.Hash == \"\" {\n\t\tuploadFile.Hash = object.Hash\n\t}\n\tif uploadFile.Size == 0 {\n\t\tuploadFile.Size = object.Size\n\t}\n\tif uploadFile.MimeType == \"\" {\n\t\tuploadFile.MimeType = object.MimeType\n\t}\n}\n\nfunc (s *FileResourceService) deletePhysicalObject(object *model.UploadFileObject) error {\n\tif object == nil {\n\t\treturn nil\n\t}\n\tdriverName := firstNonEmpty(object.StorageDriver, model.StorageDriverLocal)\n\tvar driver filestorage.Driver\n\tvar err error\n\tif driverName == model.StorageDriverLocal && object.StorageBase != \"\" {\n\t\tdriver = filestorage.NewLocalDriver(filestorage.LocalConfig{\n\t\t\tPublicBasePath:  object.StorageBase,\n\t\t\tPrivateBasePath: object.StorageBase,\n\t\t}, object.StorageBase, object.StorageBase)\n\t} else {\n\t\tdriver, _, err = s.storageDriverByName(context.Background(), driverName)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\tobjectKey := firstNonEmpty(object.ObjectKey, object.StoragePath)\n\tif objectKey == \"\" {\n\t\treturn nil\n\t}\n\treturn driver.Delete(context.Background(), object.Bucket, objectKey)\n}\n\nfunc (s *FileResourceService) deleteObjectIfUnreferenced(db *gorm.DB, objectID uint) error {\n\tif objectID == 0 {\n\t\treturn nil\n\t}\n\tvar count int64\n\tif err := db.Unscoped().Model(&model.UploadFiles{}).Where(\"file_object_id = ?\", objectID).Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\tif count > 0 {\n\t\treturn nil\n\t}\n\tobject, err := findFileObjectByID(db, objectID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := s.deletePhysicalObject(object); err != nil {\n\t\t_ = db.Model(&model.UploadFileObject{}).Where(\"id = ?\", objectID).Updates(map[string]any{\n\t\t\t\"status\":     model.StorageStatusDeleteFailed,\n\t\t\t\"updated_at\": time.Now(),\n\t\t}).Error\n\t\treturn fmt.Errorf(\"delete physical object: %w\", err)\n\t}\n\treturn db.Delete(&model.UploadFileObject{}, objectID).Error\n}\n"
  },
  {
    "path": "internal/service/file_reference.go",
    "content": "package service\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\ntype FileReferenceService struct {\n\tdb *gorm.DB\n}\n\nfunc NewFileReferenceService(tx ...*gorm.DB) *FileReferenceService {\n\tvar db *gorm.DB\n\tif len(tx) > 0 {\n\t\tdb = tx[0]\n\t}\n\treturn &FileReferenceService{db: db}\n}\n\nfunc (s *FileReferenceService) BindReference(fileURL, ownerType string, ownerID uint, ownerField string) error {\n\tuuid := ExtractFileUUID(fileURL)\n\tif uuid == \"\" || ownerType == \"\" || ownerID == 0 || ownerField == \"\" {\n\t\treturn nil\n\t}\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !db.Migrator().HasTable(&model.UploadFileReference{}) {\n\t\treturn nil\n\t}\n\tvar file model.UploadFiles\n\tif err := db.Where(\"uuid = ? AND deleted_at = 0\", uuid).First(&file).Error; err != nil {\n\t\treturn nil\n\t}\n\trow := model.UploadFileReference{\n\t\tFileID:     file.ID,\n\t\tUUID:       file.UUID,\n\t\tOwnerType:  ownerType,\n\t\tOwnerID:    ownerID,\n\t\tOwnerField: ownerField,\n\t}\n\treturn db.Clauses(clause.OnConflict{\n\t\tColumns:   []clause.Column{{Name: \"owner_type\"}, {Name: \"owner_id\"}, {Name: \"owner_field\"}, {Name: \"file_id\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"uuid\", \"updated_at\"}),\n\t}).Create(&row).Error\n}\n\nfunc (s *FileReferenceService) ReleaseReference(fileURL, ownerType string, ownerID uint, ownerField string) error {\n\tuuid := ExtractFileUUID(fileURL)\n\tif uuid == \"\" {\n\t\treturn nil\n\t}\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !db.Migrator().HasTable(&model.UploadFileReference{}) {\n\t\treturn nil\n\t}\n\treturn db.Where(\"uuid = ? AND owner_type = ? AND owner_id = ? AND owner_field = ?\", uuid, ownerType, ownerID, ownerField).Delete(&model.UploadFileReference{}).Error\n}\n\nfunc (s *FileReferenceService) ReleaseReferencesByOwner(ownerType string, ownerID uint, ownerField string) error {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !db.Migrator().HasTable(&model.UploadFileReference{}) {\n\t\treturn nil\n\t}\n\tquery := db.Where(\"owner_type = ? AND owner_id = ?\", ownerType, ownerID)\n\tif ownerField != \"\" {\n\t\tquery = query.Where(\"owner_field = ?\", ownerField)\n\t}\n\treturn query.Delete(&model.UploadFileReference{}).Error\n}\n\nfunc (s *FileReferenceService) HasActiveReferences(fileID uint) (bool, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif !db.Migrator().HasTable(&model.UploadFileReference{}) {\n\t\treturn false, nil\n\t}\n\tvar count int64\n\tif err := db.Model(&model.UploadFileReference{}).Where(\"file_id = ?\", fileID).Count(&count).Error; err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (s *FileReferenceService) List(params *form.FileReferenceList) *resources.Collection {\n\tquery := buildFileReferenceListQuery(params)\n\tcondition, args := query.Build()\n\tmodelRef := model.NewUploadFileReference()\n\tif s.db != nil {\n\t\tmodelRef.SetDB(s.db)\n\t}\n\tdb, err := s.dbOrDefault()\n\tif err != nil || !db.Migrator().HasTable(&model.UploadFileReference{}) {\n\t\treturn resources.NewFileReferenceTransformer().ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\ttransformer := resources.NewFileReferenceTransformer()\n\ttotal, rows, err := model.ListPageE(modelRef, params.Page, params.PerPage, condition, args, model.ListOptionalParams{OrderBy: \"created_at DESC, id DESC\"})\n\tif err != nil {\n\t\treturn transformer.ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\treturn transformer.ToCollection(params.Page, params.PerPage, total, rows)\n}\n\nfunc buildFileReferenceListQuery(params *form.FileReferenceList) *query_builder.QueryBuilder {\n\tif params == nil {\n\t\treturn query_builder.New()\n\t}\n\tfileID := referenceListFileID(params)\n\tquery := query_builder.New().\n\t\tAddLike(\"uuid\", params.UUID).\n\t\tAddEq(\"owner_type\", params.OwnerType).\n\t\tAddEq(\"owner_field\", params.OwnerField)\n\tif fileID > 0 {\n\t\tquery.AddEq(\"file_id\", fileID)\n\t}\n\tif params.OwnerID > 0 {\n\t\tquery.AddEq(\"owner_id\", params.OwnerID)\n\t}\n\treturn query\n}\n\nfunc referenceListFileID(params *form.FileReferenceList) uint {\n\tif params == nil {\n\t\treturn 0\n\t}\n\tif params.FileID > 0 {\n\t\treturn params.FileID\n\t}\n\treturn params.ID\n}\n\nfunc (s *FileReferenceService) ReferencesByFileID(fileID uint) ([]*model.UploadFileReference, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif !db.Migrator().HasTable(&model.UploadFileReference{}) {\n\t\treturn nil, nil\n\t}\n\tvar rows []*model.UploadFileReference\n\terr = db.Where(\"file_id = ?\", fileID).Order(\"created_at DESC, id DESC\").Find(&rows).Error\n\treturn rows, err\n}\n\nfunc ExtractFileUUID(raw string) string {\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn \"\"\n\t}\n\tparsed, err := url.Parse(raw)\n\tif err == nil && parsed.Path != \"\" {\n\t\traw = parsed.Path\n\t}\n\tparts := strings.Split(strings.TrimRight(raw, \"/\"), \"/\")\n\tif len(parts) == 0 {\n\t\treturn \"\"\n\t}\n\tuuid := parts[len(parts)-1]\n\tif len(uuid) != 32 {\n\t\treturn \"\"\n\t}\n\treturn uuid\n}\n\nfunc (s *FileReferenceService) dbOrDefault() (*gorm.DB, error) {\n\tif s.db != nil {\n\t\treturn s.db, nil\n\t}\n\treturn model.GetDB()\n}\n"
  },
  {
    "path": "internal/service/file_reference_test.go",
    "content": "package service\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nfunc TestExtractFileUUID(t *testing.T) {\n\tuuid := \"1234567890abcdef1234567890abcdef\"\n\tcases := []string{\n\t\t\"/admin/v1/file/\" + uuid,\n\t\t\"https://example.com/admin/v1/file/\" + uuid,\n\t}\n\tfor _, input := range cases {\n\t\tif got := ExtractFileUUID(input); got != uuid {\n\t\t\tt.Fatalf(\"ExtractFileUUID(%q) = %q\", input, got)\n\t\t}\n\t}\n\tif got := ExtractFileUUID(\"/admin/v1/file/not-found\"); got != \"\" {\n\t\tt.Fatalf(\"expected invalid uuid to be empty, got %q\", got)\n\t}\n}\n\nfunc TestReferenceListFileIDUsesIDAlias(t *testing.T) {\n\tgot := referenceListFileID(&form.FileReferenceList{ID: 12})\n\tif got != 12 {\n\t\tt.Fatalf(\"expected id alias to be used as file id, got %d\", got)\n\t}\n}\n\nfunc TestReferenceListFileIDPrefersFileID(t *testing.T) {\n\tgot := referenceListFileID(&form.FileReferenceList{ID: 12, FileID: 34})\n\tif got != 34 {\n\t\tt.Fatalf(\"expected file_id to take precedence, got %d\", got)\n\t}\n}\n\nfunc TestBuildFileReferenceListQuerySkipsZeroOwnerID(t *testing.T) {\n\tcondition, args := buildFileReferenceListQuery(&form.FileReferenceList{ID: 1}).Build()\n\tif condition != \"file_id = ?\" {\n\t\tt.Fatalf(\"expected only file_id condition, got %q\", condition)\n\t}\n\texpectedArgs := []any{uint(1)}\n\tif !reflect.DeepEqual(args, expectedArgs) {\n\t\tt.Fatalf(\"expected args %#v, got %#v\", expectedArgs, args)\n\t}\n}\n\nfunc TestBuildFileReferenceListQueryAddsPositiveOwnerID(t *testing.T) {\n\tcondition, args := buildFileReferenceListQuery(&form.FileReferenceList{ID: 1, OwnerID: 2}).Build()\n\tif condition != \"file_id = ? AND owner_id = ?\" {\n\t\tt.Fatalf(\"expected file_id and owner_id conditions, got %q\", condition)\n\t}\n\texpectedArgs := []any{uint(1), uint(2)}\n\tif !reflect.DeepEqual(args, expectedArgs) {\n\t\tt.Fatalf(\"expected args %#v, got %#v\", expectedArgs, args)\n\t}\n}\n"
  },
  {
    "path": "internal/service/file_resource.go",
    "content": "package service\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/filestorage\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n\t\"go.uber.org/zap\"\n)\n\n// FileResourceService 文件资源管理服务。\ntype FileResourceService struct {\n\tBase\n\tdb                    *gorm.DB\n\tstorageDriverResolver func(context.Context, string) (filestorage.Driver, filestorage.Config, error)\n\tactiveStorageResolver func(context.Context) (filestorage.Driver, filestorage.Config, string, error)\n}\n\n// FileReferencedDeleteError 表示文件仍被业务数据引用，删除需要返回引用来源给前端。\ntype FileReferencedDeleteError struct {\n\tbusinessErr *e.BusinessError\n\tReferences  []*resources.FileReferenceResources\n}\n\nfunc NewFileReferencedDeleteError(references []*resources.FileReferenceResources) *FileReferencedDeleteError {\n\treturn &FileReferencedDeleteError{\n\t\tbusinessErr: e.NewBusinessError(e.FileReferenced),\n\t\tReferences:  references,\n\t}\n}\n\nfunc (err *FileReferencedDeleteError) Error() string {\n\tif err == nil || err.businessErr == nil {\n\t\treturn \"\"\n\t}\n\treturn err.businessErr.Error()\n}\n\nfunc (err *FileReferencedDeleteError) Unwrap() error {\n\tif err == nil {\n\t\treturn nil\n\t}\n\treturn err.businessErr\n}\n\nfunc (err *FileReferencedDeleteError) BusinessError() *e.BusinessError {\n\tif err == nil {\n\t\treturn nil\n\t}\n\treturn err.businessErr\n}\n\n// FileResourceServiceDeps 描述 FileResourceService 可注入依赖。\ntype FileResourceServiceDeps struct {\n\tDB                    *gorm.DB\n\tStorageDriverResolver func(context.Context, string) (filestorage.Driver, filestorage.Config, error)\n\tActiveStorageResolver func(context.Context) (filestorage.Driver, filestorage.Config, string, error)\n}\n\nfunc NewFileResourceService() *FileResourceService {\n\treturn NewFileResourceServiceWithDeps(FileResourceServiceDeps{})\n}\n\nfunc NewFileResourceServiceWithDeps(deps FileResourceServiceDeps) *FileResourceService {\n\treturn &FileResourceService{db: deps.DB, storageDriverResolver: deps.StorageDriverResolver, activeStorageResolver: deps.ActiveStorageResolver}\n}\n\n// List 分页查询文件资源列表。\nfunc (s *FileResourceService) List(params *form.FileResourceList) *resources.Collection {\n\tquery := query_builder.New().\n\t\tAddLike(\"origin_name\", params.OriginName).\n\t\tAddLike(\"uuid\", params.UUID).\n\t\tAddLike(\"mime_type\", params.MimeType).\n\t\tAddEq(\"file_type\", params.FileType).\n\t\tAddEq(\"is_public\", params.IsPublic).\n\t\tAddEq(\"storage_driver\", params.StorageDriver).\n\t\tAddEq(\"storage_status\", params.StorageStatus)\n\tif params.UID > 0 {\n\t\tquery.AddEq(\"uid\", params.UID)\n\t}\n\tif params.FolderID != nil {\n\t\tif params.IncludeSubfolder == global.Yes {\n\t\t\tfolderIDs := s.descendantFolderIDs(*params.FolderID)\n\t\t\tfolderIDs = append(folderIDs, *params.FolderID)\n\t\t\tquery.AddCondition(\"folder_id IN ?\", folderIDs)\n\t\t} else {\n\t\t\tquery.AddEq(\"folder_id\", *params.FolderID)\n\t\t}\n\t}\n\tif params.IsReferenced != nil {\n\t\tif *params.IsReferenced == global.Yes {\n\t\t\tquery.AddCondition(\"EXISTS (SELECT 1 FROM upload_file_references WHERE upload_file_references.file_id = upload_files.id)\")\n\t\t} else {\n\t\t\tquery.AddCondition(\"NOT EXISTS (SELECT 1 FROM upload_file_references WHERE upload_file_references.file_id = upload_files.id)\")\n\t\t}\n\t}\n\tif params.StartTime != \"\" {\n\t\tquery.AddCondition(\"created_at >= ?\", params.StartTime)\n\t}\n\tif params.EndTime != \"\" {\n\t\tquery.AddCondition(\"created_at <= ?\", params.EndTime)\n\t}\n\tcondition, args := query.Build()\n\n\ttransformer := resources.NewFileResourceTransformer()\n\ttotal, collection, err := s.listUploadFiles(params, condition, args)\n\tif err != nil {\n\t\tlog.Logger.Error(\"查询文件资源列表失败\", zap.Error(err))\n\t\treturn transformer.ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\treturn transformer.ToCollection(params.Page, params.PerPage, total, collection)\n}\n\n// Detail 查询文件资源详情。\nfunc (s *FileResourceService) Detail(id uint) (any, error) {\n\tuploadFile, err := s.findByID(id)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trefs, _ := NewFileReferenceService(s.db).ReferencesByFileID(uploadFile.ID)\n\tuploadFile.ReferenceCount = int64(len(refs))\n\ts.fillObjectReuseCounts([]*model.UploadFiles{uploadFile})\n\tresult := resources.NewFileResourceTransformer().ToStruct(uploadFile)\n\tif data := result; data != nil {\n\t\titems := make([]any, 0, len(refs))\n\t\ttransformer := resources.NewFileReferenceTransformer()\n\t\tfor _, ref := range refs {\n\t\t\titems = append(items, transformer.ToStruct(ref))\n\t\t}\n\t\tdata.References = items\n\t}\n\treturn result, nil\n}\n\n// Delete 软删除文件记录。\nfunc (s *FileResourceService) Delete(id uint, deletedBy uint, reason string) error {\n\tuploadFile, err := s.findByID(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\trefs, err := NewFileReferenceService(s.db).ReferencesByFileID(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(refs) > 0 {\n\t\treturn NewFileReferencedDeleteError(buildFileReferenceResources(refs))\n\t}\n\n\tif s.db != nil {\n\t\tuploadFile.SetDB(s.db)\n\t}\n\t_ = uploadFile.UpdateById(id, map[string]any{\"deleted_by\": deletedBy, \"deleted_reason\": reason})\n\trowsAffected, err := uploadFile.DeleteByID(id)\n\tif err != nil {\n\t\tlog.Logger.Error(\"删除文件资源记录失败\", zap.Error(err), zap.Uint(\"id\", id))\n\t\treturn err\n\t}\n\tif rowsAffected == 0 {\n\t\treturn e.NewBusinessError(e.NotFound)\n\t}\n\treturn nil\n}\n\nfunc (s *FileResourceService) Restore(id uint) error {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn err\n\t}\n\tresult := db.Unscoped().Model(&model.UploadFiles{}).Where(\"id = ? AND deleted_at <> 0\", id).Updates(map[string]any{\n\t\t\"deleted_at\":     0,\n\t\t\"deleted_by\":     0,\n\t\t\"deleted_reason\": \"\",\n\t})\n\tif result.Error != nil {\n\t\treturn result.Error\n\t}\n\tif result.RowsAffected == 0 {\n\t\treturn e.NewBusinessError(e.NotFound)\n\t}\n\treturn nil\n}\n\nfunc (s *FileResourceService) Destroy(id uint) error {\n\tuploadFile, err := s.findByIDUnscoped(id)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn err\n\t}\n\tresult := db.Unscoped().Delete(&model.UploadFiles{}, id)\n\tif result.Error != nil {\n\t\treturn result.Error\n\t}\n\tif result.RowsAffected == 0 {\n\t\treturn e.NewBusinessError(e.NotFound)\n\t}\n\t_ = db.Where(\"file_id = ?\", id).Delete(&model.UploadFileReference{}).Error\n\tif uploadFile.FileObjectID > 0 {\n\t\treturn s.deleteObjectIfUnreferenced(db, uploadFile.FileObjectID)\n\t}\n\tif err := s.deletePhysicalFile(uploadFile); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (s *FileResourceService) References(params *form.FileReferenceList) *resources.Collection {\n\treturn NewFileReferenceService(s.db).List(params)\n}\n\nfunc buildFileReferenceResources(refs []*model.UploadFileReference) []*resources.FileReferenceResources {\n\titems := make([]*resources.FileReferenceResources, 0, len(refs))\n\ttransformer := resources.NewFileReferenceTransformer()\n\tfor _, ref := range refs {\n\t\titems = append(items, transformer.ToStruct(ref))\n\t}\n\treturn items\n}\n\nfunc (s *FileResourceService) findByID(id uint) (*model.UploadFiles, error) {\n\tuploadFile := model.NewUploadFiles()\n\tif s.db != nil {\n\t\tuploadFile.SetDB(s.db)\n\t}\n\tif err := uploadFile.GetById(id); err != nil || uploadFile.ID == 0 {\n\t\tif err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\tlog.Logger.Error(\"查询文件资源失败\", zap.Error(err), zap.Uint(\"id\", id))\n\t\t}\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\treturn uploadFile, nil\n}\n\nfunc (s *FileResourceService) findByIDUnscoped(id uint) (*model.UploadFiles, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar uploadFile model.UploadFiles\n\tif err := db.Unscoped().Where(\"id = ?\", id).First(&uploadFile).Error; err != nil || uploadFile.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\treturn &uploadFile, nil\n}\n\nfunc (s *FileResourceService) deletePhysicalFile(uploadFile *model.UploadFiles) error {\n\tif uploadFile == nil {\n\t\treturn nil\n\t}\n\tdriverName := firstNonEmpty(uploadFile.StorageDriver, model.StorageDriverLocal)\n\tvar driver filestorage.Driver\n\tvar err error\n\tif driverName == model.StorageDriverLocal && uploadFile.StorageBase != \"\" {\n\t\tdriver = filestorage.NewLocalDriver(filestorage.LocalConfig{\n\t\t\tPublicBasePath:  uploadFile.StorageBase,\n\t\t\tPrivateBasePath: uploadFile.StorageBase,\n\t\t}, uploadFile.StorageBase, uploadFile.StorageBase)\n\t} else {\n\t\tdriver, _, err = s.storageDriverByName(context.Background(), driverName)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\tobjectKey := firstNonEmpty(uploadFile.ObjectKey, uploadFile.StoragePath, uploadFile.Path)\n\tif objectKey == \"\" {\n\t\treturn nil\n\t}\n\tif err := driver.Delete(context.Background(), uploadFile.Bucket, objectKey); err != nil {\n\t\tlog.Logger.Error(\"删除物理文件失败\", zap.Error(err), zap.Uint(\"id\", uploadFile.ID), zap.String(\"object_key\", objectKey))\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (s *FileResourceService) storageDriverByName(ctx context.Context, driverName string) (filestorage.Driver, filestorage.Config, error) {\n\tif s.storageDriverResolver != nil {\n\t\treturn s.storageDriverResolver(ctx, driverName)\n\t}\n\treturn NewStorageDriverByName(ctx, driverName)\n}\n\nfunc (s *FileResourceService) activeStorageDriver(ctx context.Context) (filestorage.Driver, filestorage.Config, string, error) {\n\tif s.activeStorageResolver != nil {\n\t\treturn s.activeStorageResolver(ctx)\n\t}\n\treturn NewActiveStorageDriver(ctx)\n}\n\nfunc (s *FileResourceService) descendantFolderIDs(folderID uint) []uint {\n\tdb, err := s.dbOrDefault()\n\tif err != nil || folderID == 0 {\n\t\treturn nil\n\t}\n\tvar ids []uint\n\tcurrent := []uint{folderID}\n\tfor len(current) > 0 {\n\t\tvar children []uint\n\t\tif err := db.Model(&model.UploadFileFolder{}).Where(\"parent_id IN ?\", current).Pluck(\"id\", &children).Error; err != nil || len(children) == 0 {\n\t\t\tbreak\n\t\t}\n\t\tids = append(ids, children...)\n\t\tcurrent = children\n\t}\n\treturn ids\n}\n\nfunc (s *FileResourceService) listUploadFiles(params *form.FileResourceList, condition string, args []any) (int64, []*model.UploadFiles, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn 0, nil, err\n\t}\n\tquery := db.Model(&model.UploadFiles{})\n\tisDeleted := uint8(0)\n\tif params.IsDeleted != nil {\n\t\tisDeleted = *params.IsDeleted\n\t}\n\tif isDeleted == global.Yes {\n\t\tquery = query.Unscoped().Where(\"upload_files.deleted_at <> 0\")\n\t}\n\tif condition != \"\" {\n\t\tquery = query.Where(condition, args...)\n\t}\n\tvar total int64\n\tif err := query.Count(&total).Error; err != nil || total == 0 {\n\t\treturn total, nil, err\n\t}\n\tpage, perPage := params.Page, params.PerPage\n\tif page < 1 {\n\t\tpage = 1\n\t}\n\tif perPage < 1 {\n\t\tperPage = global.PerPage\n\t}\n\tvar rows []*model.UploadFiles\n\tif err := query.Order(\"created_at DESC, id DESC\").Offset((page - 1) * perPage).Limit(perPage).Find(&rows).Error; err != nil {\n\t\treturn total, nil, err\n\t}\n\ts.fillReferenceCounts(rows)\n\ts.fillObjectReuseCounts(rows)\n\treturn total, rows, nil\n}\n\nfunc (s *FileResourceService) fillReferenceCounts(rows []*model.UploadFiles) {\n\tif len(rows) == 0 {\n\t\treturn\n\t}\n\tids := make([]uint, 0, len(rows))\n\tfor _, row := range rows {\n\t\tids = append(ids, row.ID)\n\t}\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn\n\t}\n\ttype countRow struct {\n\t\tFileID uint\n\t\tCount  int64\n\t}\n\tvar counts []countRow\n\tif err := db.Model(&model.UploadFileReference{}).Select(\"file_id, COUNT(*) AS count\").Where(\"file_id IN ?\", ids).Group(\"file_id\").Scan(&counts).Error; err != nil {\n\t\treturn\n\t}\n\tcountMap := make(map[uint]int64, len(counts))\n\tfor _, item := range counts {\n\t\tcountMap[item.FileID] = item.Count\n\t}\n\tfor _, row := range rows {\n\t\trow.ReferenceCount = countMap[row.ID]\n\t}\n}\n\nfunc (s *FileResourceService) fillObjectReuseCounts(rows []*model.UploadFiles) {\n\tif len(rows) == 0 {\n\t\treturn\n\t}\n\tobjectIDs := make([]uint, 0, len(rows))\n\tseen := make(map[uint]struct{}, len(rows))\n\tfor _, row := range rows {\n\t\tif row.FileObjectID == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[row.FileObjectID]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[row.FileObjectID] = struct{}{}\n\t\tobjectIDs = append(objectIDs, row.FileObjectID)\n\t}\n\tif len(objectIDs) == 0 {\n\t\treturn\n\t}\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn\n\t}\n\ttype countRow struct {\n\t\tFileObjectID uint\n\t\tCount        int64\n\t}\n\tvar counts []countRow\n\tif err := db.Unscoped().Model(&model.UploadFiles{}).Select(\"file_object_id, COUNT(*) AS count\").Where(\"file_object_id IN ?\", objectIDs).Group(\"file_object_id\").Scan(&counts).Error; err != nil {\n\t\treturn\n\t}\n\tcountMap := make(map[uint]int64, len(counts))\n\tfor _, item := range counts {\n\t\tcountMap[item.FileObjectID] = item.Count\n\t}\n\tvar objects []model.UploadFileObject\n\tif err := db.Where(\"id IN ?\", objectIDs).Find(&objects).Error; err != nil {\n\t\treturn\n\t}\n\tstatusMap := make(map[uint]string, len(objects))\n\tfor _, object := range objects {\n\t\tstatusMap[object.ID] = object.Status\n\t}\n\tfor _, row := range rows {\n\t\trow.ObjectReuseCount = countMap[row.FileObjectID]\n\t\trow.ObjectStatus = statusMap[row.FileObjectID]\n\t}\n}\n\nfunc (s *FileResourceService) markDeleteFailed(id uint) error {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn db.Unscoped().Model(&model.UploadFiles{}).Where(\"id = ?\", id).Updates(map[string]any{\n\t\t\"storage_status\": model.StorageStatusDeleteFailed,\n\t\t\"updated_at\":     time.Now(),\n\t}).Error\n}\n\nfunc (s *FileResourceService) dbOrDefault() (*gorm.DB, error) {\n\tif s.db != nil {\n\t\treturn s.db, nil\n\t}\n\treturn model.GetDB()\n}\n"
  },
  {
    "path": "internal/service/file_resource_folder_upload.go",
    "content": "package service\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime\"\n\t\"mime/multipart\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/filestorage\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\tdateutils \"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n\tfileutils \"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\nfunc (s *FileResourceService) FolderTree() ([]*resources.FileFolderResources, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar folders []*model.UploadFileFolder\n\tif err := db.Order(\"sort ASC, id ASC\").Find(&folders).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tstats, err := s.folderStats()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ttransformer := resources.NewFileFolderTransformer()\n\tnodes := make(map[uint]*resources.FileFolderResources, len(folders))\n\troots := make([]*resources.FileFolderResources, 0)\n\tfor _, folder := range folders {\n\t\tnode := transformer.ToStruct(folder)\n\t\tif stat, ok := stats[folder.ID]; ok {\n\t\t\tnode.FileCount = stat.FileCount\n\t\t\tnode.TotalSize = stat.TotalSize\n\t\t}\n\t\tnodes[folder.ID] = node\n\t\tif folder.ParentID == 0 {\n\t\t\troots = append(roots, node)\n\t\t}\n\t}\n\tfor _, folder := range folders {\n\t\tif folder.ParentID == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tparent := nodes[folder.ParentID]\n\t\tif parent == nil {\n\t\t\troots = append(roots, nodes[folder.ID])\n\t\t\tcontinue\n\t\t}\n\t\tparent.Children = append(parent.Children, nodes[folder.ID])\n\t}\n\treturn roots, nil\n}\n\nfunc (s *FileResourceService) CreateFolder(params *form.FileFolderCreate, uid uint) (*resources.FileFolderResources, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tname, err := normalizeFolderName(params.Name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tparentPath, err := s.folderLogicalPath(params.ParentID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := ensureFolderNameUnique(db, 0, params.ParentID, name); err != nil {\n\t\treturn nil, err\n\t}\n\tfolder := &model.UploadFileFolder{\n\t\tParentID:    params.ParentID,\n\t\tName:        name,\n\t\tLogicalPath: joinLogicalPath(parentPath, name),\n\t\tCreatedBy:   uid,\n\t\tUpdatedBy:   uid,\n\t}\n\tif err := db.Create(folder).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn resources.NewFileFolderTransformer().ToStruct(folder), nil\n}\n\nfunc (s *FileResourceService) UpdateFolder(params *form.FileFolderUpdate, uid uint) (*resources.FileFolderResources, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tname, err := normalizeFolderName(params.Name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar updated model.UploadFileFolder\n\terr = access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tvar folder model.UploadFileFolder\n\t\tif err := tx.First(&folder, params.ID).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := ensureFolderNameUnique(tx, folder.ID, folder.ParentID, name); err != nil {\n\t\t\treturn err\n\t\t}\n\t\toldPath := folder.LogicalPath\n\t\tparentPath, err := folderLogicalPathTx(tx, folder.ParentID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfolder.Name = name\n\t\tfolder.LogicalPath = joinLogicalPath(parentPath, name)\n\t\tfolder.UpdatedBy = uid\n\t\tif err := tx.Save(&folder).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := updateLogicalPathSnapshots(tx, folder.ID, oldPath, folder.LogicalPath); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tupdated = folder\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn resources.NewFileFolderTransformer().ToStruct(&updated), nil\n}\n\nfunc (s *FileResourceService) DeleteFolder(id uint) error {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn err\n\t}\n\tvar childCount int64\n\tif err := db.Model(&model.UploadFileFolder{}).Where(\"parent_id = ?\", id).Count(&childCount).Error; err != nil {\n\t\treturn err\n\t}\n\tif childCount > 0 {\n\t\treturn fmt.Errorf(\"folder is not empty\")\n\t}\n\tvar fileCount int64\n\tif err := db.Model(&model.UploadFiles{}).Where(\"folder_id = ?\", id).Count(&fileCount).Error; err != nil {\n\t\treturn err\n\t}\n\tif fileCount > 0 {\n\t\treturn fmt.Errorf(\"folder is not empty\")\n\t}\n\tresult := db.Delete(&model.UploadFileFolder{}, id)\n\tif result.Error != nil {\n\t\treturn result.Error\n\t}\n\tif result.RowsAffected == 0 {\n\t\treturn gorm.ErrRecordNotFound\n\t}\n\treturn nil\n}\n\nfunc (s *FileResourceService) MoveFolder(params *form.FileFolderMove, uid uint) (*resources.FileFolderResources, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ttargetParentID := params.ParentID\n\tif params.TargetParentID > 0 {\n\t\ttargetParentID = params.TargetParentID\n\t}\n\tvar moved model.UploadFileFolder\n\terr = access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tvar folder model.UploadFileFolder\n\t\tif err := tx.First(&folder, params.ID).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif targetParentID == folder.ID {\n\t\t\treturn fmt.Errorf(\"folder cannot move to itself\")\n\t\t}\n\t\tif targetParentID > 0 {\n\t\t\tparentPath, err := folderLogicalPathTx(tx, targetParentID)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif parentPath == folder.LogicalPath || strings.HasPrefix(parentPath, folder.LogicalPath+\"/\") {\n\t\t\t\treturn fmt.Errorf(\"folder cannot move to descendant\")\n\t\t\t}\n\t\t}\n\t\tif err := ensureFolderNameUnique(tx, folder.ID, targetParentID, folder.Name); err != nil {\n\t\t\treturn err\n\t\t}\n\t\toldPath := folder.LogicalPath\n\t\tparentPath, err := folderLogicalPathTx(tx, targetParentID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfolder.ParentID = targetParentID\n\t\tfolder.LogicalPath = joinLogicalPath(parentPath, folder.Name)\n\t\tfolder.UpdatedBy = uid\n\t\tif err := tx.Save(&folder).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := updateLogicalPathSnapshots(tx, folder.ID, oldPath, folder.LogicalPath); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tmoved = folder\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn resources.NewFileFolderTransformer().ToStruct(&moved), nil\n}\n\nfunc (s *FileResourceService) MoveFiles(params *form.FileMove) (*resources.FileMoveResult, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tlogicalPath, err := s.folderLogicalPath(params.FolderID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := db.Model(&model.UploadFiles{}).Where(\"id IN ?\", params.IDs).Updates(map[string]any{\n\t\t\"folder_id\":    params.FolderID,\n\t\t\"logical_path\": logicalPath,\n\t\t\"updated_at\":   time.Now(),\n\t})\n\tif result.Error != nil {\n\t\treturn nil, result.Error\n\t}\n\ttotal := int64(len(params.IDs))\n\treturn &resources.FileMoveResult{Total: total, Moved: result.RowsAffected, Skipped: total - result.RowsAffected}, nil\n}\n\nfunc (s *FileResourceService) UploadLocal(files []*multipart.FileHeader, params *form.FileLocalUpload, uid uint) ([]*resources.FileResourceResources, error) {\n\titems := make([]*resources.FileResourceResources, 0, len(files))\n\tfor _, file := range files {\n\t\titem, err := s.uploadLocalOne(file, params, uid)\n\t\tif err != nil {\n\t\t\treturn items, err\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (s *FileResourceService) UploadCredential(params *form.FileUploadCredential) (*resources.FileUploadCredentialResources, error) {\n\t_, cfg, activeDriver, err := s.activeStorageDriver(context.Background())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif params.Driver != \"\" {\n\t\tactiveDriver = params.Driver\n\t}\n\tif activeDriver == model.StorageDriverLocal {\n\t\treturn nil, fmt.Errorf(\"local storage does not support direct upload\")\n\t}\n\tbucket := bucketForDriver(activeDriver, cfg, params.IsPublic)\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif object, err := findReusableFileObject(db, activeDriver, bucket, params.Hash); err == nil {\n\t\treturn &resources.FileUploadCredentialResources{\n\t\t\tStorageDriver:   activeDriver,\n\t\t\tDriver:          activeDriver,\n\t\t\tBucket:          object.Bucket,\n\t\t\tObjectKey:       object.ObjectKey,\n\t\t\tReuse:           true,\n\t\t\tFileObjectID:    object.ID,\n\t\t\tSize:            object.Size,\n\t\t\tHash:            object.Hash,\n\t\t\tMimeType:        object.MimeType,\n\t\t\tETag:            object.ETag,\n\t\t\tObjectStatus:    object.Status,\n\t\t\tCompletePayload: buildCredentialCompletePayload(params, activeDriver, object.Bucket, object.ObjectKey, true, object.ID),\n\t\t}, nil\n\t} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, err\n\t}\n\tdriver, _, err := s.storageDriverByName(context.Background(), activeDriver)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfileName := firstNonEmpty(params.FileName, params.OriginName)\n\tif strings.TrimSpace(fileName) == \"\" {\n\t\treturn nil, fmt.Errorf(\"file name is required\")\n\t}\n\tobjectKey := buildUploadObjectKey(fileName)\n\tttl := time.Duration(cfg.SignedURLTTLSeconds) * time.Second\n\tif ttl <= 0 {\n\t\tttl = 5 * time.Minute\n\t}\n\turl, err := driver.SignedURL(context.Background(), bucket, objectKey, ttl)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &resources.FileUploadCredentialResources{\n\t\tStorageDriver:   activeDriver,\n\t\tDriver:          activeDriver,\n\t\tBucket:          bucket,\n\t\tObjectKey:       objectKey,\n\t\tUploadURL:       url,\n\t\tURL:             url,\n\t\tMethod:          \"PUT\",\n\t\tHeaders:         map[string]string{\"Content-Type\": params.MimeType},\n\t\tExpireAt:        dateutils.FormatDate{Time: time.Now().Add(ttl)},\n\t\tReuse:           false,\n\t\tSize:            uint(params.Size),\n\t\tHash:            params.Hash,\n\t\tMimeType:        params.MimeType,\n\t\tCompletePayload: buildCredentialCompletePayload(params, activeDriver, bucket, objectKey, false, 0),\n\t}, nil\n}\n\nfunc buildCredentialCompletePayload(params *form.FileUploadCredential, driver, bucket, objectKey string, reuse bool, fileObjectID uint) map[string]any {\n\treturn map[string]any{\n\t\t\"folder_id\":      params.FolderID,\n\t\t\"reuse\":          reuse,\n\t\t\"file_object_id\": fileObjectID,\n\t\t\"origin_name\":    firstNonEmpty(params.OriginName, params.FileName),\n\t\t\"display_name\":   firstNonEmpty(params.OriginName, params.FileName),\n\t\t\"name\":           filepath.Base(objectKey),\n\t\t\"size\":           params.Size,\n\t\t\"hash\":           params.Hash,\n\t\t\"mime_type\":      params.MimeType,\n\t\t\"is_public\":      params.IsPublic,\n\t\t\"storage_driver\": driver,\n\t\t\"driver\":         driver,\n\t\t\"bucket\":         bucket,\n\t\t\"object_key\":     objectKey,\n\t\t\"upload_scene\":   params.UploadScene,\n\t}\n}\n\nfunc (s *FileResourceService) CompleteDirectUpload(params *form.FileUploadComplete, uid uint) (*resources.FileResourceResources, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tstorageDriver := firstNonEmpty(params.StorageDriver, params.Driver)\n\tif storageDriver == \"\" {\n\t\treturn nil, fmt.Errorf(\"storage driver is required\")\n\t}\n\tif _, _, err := s.storageDriverByName(context.Background(), storageDriver); err != nil {\n\t\treturn nil, err\n\t}\n\tlogicalPath, err := s.folderLogicalPath(params.FolderID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar object *model.UploadFileObject\n\tif params.FileObjectID > 0 {\n\t\tobject, err = findFileObjectByID(db, params.FileObjectID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif object.StorageDriver != storageDriver {\n\t\t\treturn nil, fmt.Errorf(\"file object storage driver mismatch\")\n\t\t}\n\t} else {\n\t\tif strings.TrimSpace(params.ObjectKey) == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"object_key is required\")\n\t\t}\n\t\tobject, _, err = ensureFileObject(db, uploadFileObjectInput{\n\t\t\tStorageDriver: storageDriver,\n\t\t\tStorageBase:   storageBaseForDriver(storageDriver, params.IsPublic == global.Yes, params.Bucket),\n\t\t\tBucket:        params.Bucket,\n\t\t\tStoragePath:   params.ObjectKey,\n\t\t\tObjectKey:     params.ObjectKey,\n\t\t\tSize:          params.Size,\n\t\t\tHash:          params.Hash,\n\t\t\tMimeType:      params.MimeType,\n\t\t\tETag:          params.ETag,\n\t\t\tStatus:        model.StorageStatusStored,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tfileUUID := params.UUID\n\tif fileUUID == \"\" {\n\t\tfileUUID = strings.ReplaceAll(uuid.NewString(), \"-\", \"\")\n\t}\n\tdisplayName := firstNonEmpty(params.DisplayName, params.OriginName)\n\tfileType := params.FileType\n\tif fileType == \"\" {\n\t\tfileType = classifyUploadFileType(params.MimeType)\n\t}\n\tuploadFile := &model.UploadFiles{\n\t\tUID:           uid,\n\t\tFolderID:      params.FolderID,\n\t\tLogicalPath:   logicalPath,\n\t\tDisplayName:   displayName,\n\t\tOriginName:    params.OriginName,\n\t\tName:          firstNonEmpty(params.Name, filepath.Base(params.ObjectKey)),\n\t\tPath:          params.ObjectKey,\n\t\tSize:          params.Size,\n\t\tExt:           firstNonEmpty(params.Ext, filepath.Ext(params.OriginName)),\n\t\tHash:          params.Hash,\n\t\tUUID:          fileUUID,\n\t\tMimeType:      params.MimeType,\n\t\tFileType:      fileType,\n\t\tIsPublic:      params.IsPublic,\n\t\tStorageDriver: storageDriver,\n\t\tStorageBase:   params.Bucket,\n\t\tBucket:        params.Bucket,\n\t\tStoragePath:   params.ObjectKey,\n\t\tObjectKey:     params.ObjectKey,\n\t\tETag:          params.ETag,\n\t\tStorageStatus: model.StorageStatusStored,\n\t\tUploadSource:  model.UploadSourceDirect,\n\t\tUploadScene:   params.UploadScene,\n\t\tUploadStatus:  model.UploadStatusUploaded,\n\t}\n\tapplyObjectToUploadFile(uploadFile, object)\n\tif err := db.Create(uploadFile).Error; err != nil {\n\t\treturn nil, err\n\t}\n\ts.fillObjectReuseCounts([]*model.UploadFiles{uploadFile})\n\treturn resources.NewFileResourceTransformer().ToStruct(uploadFile), nil\n}\n\nfunc (s *FileResourceService) CreateFromReader(ctx context.Context, input ServerGeneratedFileInput) (*model.UploadFiles, error) {\n\tdriver, cfg, activeDriver, err := s.activeStorageDriver(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdata, err := io.ReadAll(input.Reader)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsum := sha256.Sum256(data)\n\thash := hex.EncodeToString(sum[:])\n\tfileUUID := strings.ReplaceAll(uuid.NewString(), \"-\", \"\")\n\text := filepath.Ext(input.OriginName)\n\tobjectKey := buildUploadObjectKey(fileUUID + ext)\n\tbucket := bucketForDriver(activeDriver, cfg, input.IsPublic)\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tobject, reused, err := ensureFileObject(db, uploadFileObjectInput{\n\t\tStorageDriver: activeDriver,\n\t\tStorageBase:   storageBaseForDriver(activeDriver, input.IsPublic == global.Yes, bucket),\n\t\tBucket:        bucket,\n\t\tStoragePath:   objectKey,\n\t\tObjectKey:     objectKey,\n\t\tSize:          uint(len(data)),\n\t\tHash:          hash,\n\t\tMimeType:      input.MimeType,\n\t\tETag:          hash,\n\t\tStatus:        model.StorageStatusStored,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif !reused {\n\t\tputResult, err := driver.Put(ctx, filestorage.PutInput{\n\t\t\tBucket:      bucket,\n\t\t\tObjectKey:   objectKey,\n\t\t\tReader:      bytes.NewReader(data),\n\t\t\tSize:        int64(len(data)),\n\t\t\tContentType: input.MimeType,\n\t\t})\n\t\tif err != nil {\n\t\t\t_ = db.Delete(&model.UploadFileObject{}, object.ID).Error\n\t\t\treturn nil, err\n\t\t}\n\t\tupdates := map[string]any{\n\t\t\t\"bucket\":       putResult.Bucket,\n\t\t\t\"storage_path\": putResult.ObjectKey,\n\t\t\t\"object_key\":   putResult.ObjectKey,\n\t\t\t\"etag\":         firstNonEmpty(putResult.ETag, hash),\n\t\t\t\"updated_at\":   time.Now(),\n\t\t}\n\t\tif err := db.Model(object).Updates(updates).Error; err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tobject.Bucket = putResult.Bucket\n\t\tobject.StoragePath = putResult.ObjectKey\n\t\tobject.ObjectKey = putResult.ObjectKey\n\t\tobject.ETag = firstNonEmpty(putResult.ETag, hash)\n\t}\n\tlogicalPath, err := s.folderLogicalPath(input.FolderID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tuploadFile := &model.UploadFiles{\n\t\tUID:           input.UID,\n\t\tFolderID:      input.FolderID,\n\t\tLogicalPath:   logicalPath,\n\t\tDisplayName:   firstNonEmpty(input.DisplayName, input.OriginName),\n\t\tOriginName:    input.OriginName,\n\t\tName:          fileUUID + ext,\n\t\tPath:          object.ObjectKey,\n\t\tSize:          uint(len(data)),\n\t\tExt:           ext,\n\t\tHash:          hash,\n\t\tUUID:          fileUUID,\n\t\tMimeType:      input.MimeType,\n\t\tFileType:      classifyUploadFileType(input.MimeType),\n\t\tIsPublic:      input.IsPublic,\n\t\tStorageDriver: activeDriver,\n\t\tStorageBase:   object.StorageBase,\n\t\tBucket:        object.Bucket,\n\t\tStoragePath:   object.StoragePath,\n\t\tObjectKey:     object.ObjectKey,\n\t\tETag:          object.ETag,\n\t\tStorageStatus: model.StorageStatusStored,\n\t\tUploadSource:  model.UploadSourceSystem,\n\t\tUploadScene:   input.UploadScene,\n\t\tUploadStatus:  model.UploadStatusUploaded,\n\t}\n\tapplyObjectToUploadFile(uploadFile, object)\n\tif err := db.Create(uploadFile).Error; err != nil {\n\t\treturn nil, err\n\t}\n\treturn uploadFile, nil\n}\n\ntype ServerGeneratedFileInput struct {\n\tUID         uint\n\tFolderID    uint\n\tOriginName  string\n\tDisplayName string\n\tMimeType    string\n\tIsPublic    uint8\n\tUploadScene string\n\tReader      io.Reader\n}\n\nfunc (s *FileResourceService) CreateFromLocalPath(ctx context.Context, path string, input ServerGeneratedFileInput) (*model.UploadFiles, error) {\n\tfile, err := os.Open(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer file.Close()\n\tif input.OriginName == \"\" {\n\t\tinput.OriginName = filepath.Base(path)\n\t}\n\tif input.MimeType == \"\" {\n\t\tinput.MimeType = mime.TypeByExtension(filepath.Ext(path))\n\t}\n\tinput.Reader = file\n\treturn s.CreateFromReader(ctx, input)\n}\n\nfunc (s *FileResourceService) uploadLocalOne(file *multipart.FileHeader, params *form.FileLocalUpload, uid uint) (*resources.FileResourceResources, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tlogicalPath, err := s.folderLogicalPath(params.FolderID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tbasePath := storageBasePath(params.IsPublic == global.Yes)\n\tuploadDir, err := resolveUploadDestination(basePath, filepath.Join(\"file-resource\", time.Now().Format(\"20060102\")))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult, err := fileutils.SaveUploadedFileWithUUID(file, uploadDir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tabsBasePath, err := filepath.Abs(basePath)\n\tif err != nil {\n\t\tcleanupStoredUpload(result.Path)\n\t\treturn nil, err\n\t}\n\trelPath, err := filepath.Rel(absBasePath, result.Path)\n\tif err != nil {\n\t\tcleanupStoredUpload(result.Path)\n\t\treturn nil, err\n\t}\n\trelPath = filepath.ToSlash(relPath)\n\tbucket := bucketForDriver(model.StorageDriverLocal, filestorage.Config{}, params.IsPublic)\n\tobject, reused, err := ensureFileObject(db, uploadFileObjectInput{\n\t\tStorageDriver: model.StorageDriverLocal,\n\t\tStorageBase:   basePath,\n\t\tBucket:        bucket,\n\t\tStoragePath:   relPath,\n\t\tObjectKey:     relPath,\n\t\tSize:          uint(result.Size),\n\t\tHash:          result.Sha256,\n\t\tMimeType:      result.MimeType,\n\t\tETag:          result.Sha256,\n\t\tStatus:        model.StorageStatusStored,\n\t})\n\tif err != nil {\n\t\tcleanupStoredUpload(result.Path)\n\t\treturn nil, err\n\t}\n\tif reused {\n\t\tcleanupStoredUpload(result.Path)\n\t}\n\tuploadFile := &model.UploadFiles{\n\t\tUID:           uid,\n\t\tFolderID:      params.FolderID,\n\t\tLogicalPath:   logicalPath,\n\t\tDisplayName:   result.OriginName,\n\t\tOriginName:    result.OriginName,\n\t\tName:          result.Name,\n\t\tPath:          object.ObjectKey,\n\t\tSize:          uint(result.Size),\n\t\tExt:           result.Ext,\n\t\tHash:          result.Sha256,\n\t\tUUID:          result.UUID,\n\t\tMimeType:      result.MimeType,\n\t\tFileType:      classifyUploadFileType(result.MimeType),\n\t\tIsPublic:      params.IsPublic,\n\t\tStorageDriver: model.StorageDriverLocal,\n\t\tStorageBase:   object.StorageBase,\n\t\tBucket:        object.Bucket,\n\t\tStoragePath:   object.StoragePath,\n\t\tObjectKey:     object.ObjectKey,\n\t\tETag:          object.ETag,\n\t\tStorageStatus: model.StorageStatusStored,\n\t\tUploadSource:  model.UploadSourceBackend,\n\t\tUploadScene:   params.UploadScene,\n\t\tUploadStatus:  model.UploadStatusUploaded,\n\t}\n\tapplyObjectToUploadFile(uploadFile, object)\n\tif err := db.Create(uploadFile).Error; err != nil {\n\t\tif !reused {\n\t\t\tcleanupStoredUpload(result.Path)\n\t\t\t_ = db.Delete(&model.UploadFileObject{}, object.ID).Error\n\t\t}\n\t\treturn nil, err\n\t}\n\ts.fillObjectReuseCounts([]*model.UploadFiles{uploadFile})\n\treturn resources.NewFileResourceTransformer().ToStruct(uploadFile), nil\n}\n\ntype folderStat struct {\n\tFolderID  uint\n\tFileCount int64\n\tTotalSize int64\n}\n\nfunc (s *FileResourceService) folderStats() (map[uint]folderStat, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar rows []folderStat\n\tif err := db.Model(&model.UploadFiles{}).Select(\"folder_id, COUNT(*) AS file_count, COALESCE(SUM(size), 0) AS total_size\").Group(\"folder_id\").Scan(&rows).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make(map[uint]folderStat, len(rows))\n\tfor _, row := range rows {\n\t\tresult[row.FolderID] = row\n\t}\n\treturn result, nil\n}\n\nfunc (s *FileResourceService) folderLogicalPath(folderID uint) (string, error) {\n\tdb, err := s.dbOrDefault()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn folderLogicalPathTx(db, folderID)\n}\n\nfunc folderLogicalPathTx(tx *gorm.DB, folderID uint) (string, error) {\n\tif folderID == 0 {\n\t\treturn \"/\", nil\n\t}\n\tvar folder model.UploadFileFolder\n\tif err := tx.First(&folder, folderID).Error; err != nil {\n\t\treturn \"\", err\n\t}\n\treturn folder.LogicalPath, nil\n}\n\nfunc normalizeFolderName(name string) (string, error) {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" || strings.Contains(name, \"/\") || strings.Contains(name, \"\\\\\") || name == \".\" || name == \"..\" {\n\t\treturn \"\", fmt.Errorf(\"invalid folder name\")\n\t}\n\treturn name, nil\n}\n\nfunc ensureFolderNameUnique(tx *gorm.DB, currentID, parentID uint, name string) error {\n\tquery := tx.Model(&model.UploadFileFolder{}).Where(\"parent_id = ? AND name = ?\", parentID, name)\n\tif currentID > 0 {\n\t\tquery = query.Where(\"id <> ?\", currentID)\n\t}\n\tvar count int64\n\tif err := query.Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\tif count > 0 {\n\t\treturn fmt.Errorf(\"folder name already exists\")\n\t}\n\treturn nil\n}\n\nfunc joinLogicalPath(parentPath, name string) string {\n\tparentPath = strings.TrimRight(strings.TrimSpace(parentPath), \"/\")\n\tif parentPath == \"\" {\n\t\treturn \"/\" + name\n\t}\n\treturn parentPath + \"/\" + name\n}\n\nfunc updateLogicalPathSnapshots(tx *gorm.DB, folderID uint, oldPath, newPath string) error {\n\tif err := tx.Model(&model.UploadFiles{}).Where(\"folder_id = ?\", folderID).Update(\"logical_path\", newPath).Error; err != nil {\n\t\treturn err\n\t}\n\tvar children []model.UploadFileFolder\n\tif err := tx.Where(\"logical_path LIKE ?\", oldPath+\"/%\").Find(&children).Error; err != nil {\n\t\treturn err\n\t}\n\tfor _, child := range children {\n\t\tnextPath := newPath + strings.TrimPrefix(child.LogicalPath, oldPath)\n\t\tif err := tx.Model(&model.UploadFileFolder{}).Where(\"id = ?\", child.ID).Update(\"logical_path\", nextPath).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Model(&model.UploadFiles{}).Where(\"folder_id = ?\", child.ID).Update(\"logical_path\", nextPath).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc buildUploadObjectKey(filename string) string {\n\text := filepath.Ext(filename)\n\treturn \"uploads/\" + time.Now().Format(\"20060102\") + \"/\" + strings.ReplaceAll(uuid.NewString(), \"-\", \"\") + ext\n}\n"
  },
  {
    "path": "internal/service/file_resource_test.go",
    "content": "package service\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\tstderrors \"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/filestorage\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nfunc TestFileResourceDeleteReturnsReferencedError(t *testing.T) {\n\tdb := newFileResourceSQLiteDB(t)\n\tuuid := fmt.Sprintf(\"%032x\", time.Now().UnixNano())\n\toriginName := \"test-referenced-\" + uuid + \".jpg\"\n\n\tfile := model.UploadFiles{\n\t\tOriginName:    originName,\n\t\tName:          uuid + \".jpg\",\n\t\tUUID:          uuid,\n\t\tStorageDriver: model.StorageDriverLocal,\n\t\tStorageStatus: model.StorageStatusStored,\n\t}\n\tif err := db.Create(&file).Error; err != nil {\n\t\tt.Fatalf(\"create upload file failed: %v\", err)\n\t}\n\tref := model.UploadFileReference{\n\t\tFileID:     file.ID,\n\t\tUUID:       file.UUID,\n\t\tOwnerType:  \"admin_user\",\n\t\tOwnerID:    1,\n\t\tOwnerField: \"avatar\",\n\t}\n\tif err := db.Create(&ref).Error; err != nil {\n\t\tt.Fatalf(\"create upload file reference failed: %v\", err)\n\t}\n\n\terr := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db}).Delete(file.ID, 1, \"\")\n\tvar referencedErr *FileReferencedDeleteError\n\tif !stderrors.As(err, &referencedErr) {\n\t\tt.Fatalf(\"expected FileReferencedDeleteError, got %v\", err)\n\t}\n\tbusinessErr := referencedErr.BusinessError()\n\tif businessErr == nil || businessErr.GetCode() != e.FileReferenced {\n\t\tt.Fatalf(\"expected FileReferenced business error, got %#v\", businessErr)\n\t}\n\tif len(referencedErr.References) != 1 {\n\t\tt.Fatalf(\"expected one reference, got %d\", len(referencedErr.References))\n\t}\n\tif referencedErr.References[0].SourceName != \"管理员\" || referencedErr.References[0].FieldName != \"头像\" {\n\t\tt.Fatalf(\"unexpected reference display fields: %#v\", referencedErr.References[0])\n\t}\n\n\tvar stored model.UploadFiles\n\tif err := db.First(&stored, file.ID).Error; err != nil {\n\t\tt.Fatalf(\"query upload file failed: %v\", err)\n\t}\n\tif stored.DeletedAt != 0 {\n\t\tt.Fatalf(\"expected referenced file to remain undeleted, got deleted_at=%d\", stored.DeletedAt)\n\t}\n}\n\nfunc currentConfigFileResourceTestDB(t *testing.T) *gorm.DB {\n\tt.Helper()\n\n\t_, file, _, ok := runtime.Caller(0)\n\tif !ok {\n\t\tt.Fatal(\"resolve test file path failed\")\n\t}\n\tprojectRoot := filepath.Clean(filepath.Join(filepath.Dir(file), \"..\", \"..\"))\n\tif err := config.InitConfig(filepath.Join(projectRoot, \"config.yaml\")); err != nil {\n\t\tt.Fatalf(\"init current config failed: %v\", err)\n\t}\n\tif err := data.InitData(); err != nil {\n\t\tt.Fatalf(\"init current configured data failed: %v\", err)\n\t}\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"get current configured db failed: %v\", err)\n\t}\n\treturn db\n}\n\nfunc cleanupFileResourceTestData(t *testing.T, db *gorm.DB, uuid string) {\n\tt.Helper()\n\n\tif err := db.Where(\"uuid = ?\", uuid).Delete(&model.UploadFileReference{}).Error; err != nil {\n\t\tt.Fatalf(\"cleanup upload file references failed: %v\", err)\n\t}\n\tif err := db.Unscoped().Where(\"uuid = ?\", uuid).Delete(&model.UploadFiles{}).Error; err != nil {\n\t\tt.Fatalf(\"cleanup upload files failed: %v\", err)\n\t}\n}\n\nfunc TestFileResourceMoveFolderRejectsDescendant(t *testing.T) {\n\tdb := newFileResourceSQLiteDB(t)\n\troot := model.UploadFileFolder{Name: \"root\", LogicalPath: \"/root\"}\n\tif err := db.Create(&root).Error; err != nil {\n\t\tt.Fatalf(\"create root folder failed: %v\", err)\n\t}\n\tchild := model.UploadFileFolder{ParentID: root.ID, Name: \"child\", LogicalPath: \"/root/child\"}\n\tif err := db.Create(&child).Error; err != nil {\n\t\tt.Fatalf(\"create child folder failed: %v\", err)\n\t}\n\n\t_, err := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db}).MoveFolder(&form.FileFolderMove{ID: root.ID, ParentID: child.ID}, 1)\n\tif err == nil {\n\t\tt.Fatal(\"expected moving folder to descendant to fail\")\n\t}\n}\n\nfunc TestFileResourceMoveFilesReturnsStatsAndUpdatesLogicalPath(t *testing.T) {\n\tdb := newFileResourceSQLiteDB(t)\n\tfolder := model.UploadFileFolder{Name: \"docs\", LogicalPath: \"/docs\"}\n\tif err := db.Create(&folder).Error; err != nil {\n\t\tt.Fatalf(\"create folder failed: %v\", err)\n\t}\n\tfile := model.UploadFiles{OriginName: \"a.txt\", UUID: fmt.Sprintf(\"%032x\", time.Now().UnixNano()), LogicalPath: \"/\", StorageDriver: model.StorageDriverLocal, StorageStatus: model.StorageStatusStored}\n\tif err := db.Create(&file).Error; err != nil {\n\t\tt.Fatalf(\"create upload file failed: %v\", err)\n\t}\n\n\tresult, err := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db}).MoveFiles(&form.FileMove{IDs: []uint{file.ID, file.ID + 1000}, FolderID: folder.ID})\n\tif err != nil {\n\t\tt.Fatalf(\"move files failed: %v\", err)\n\t}\n\tif result.Total != 2 || result.Moved != 1 || result.Skipped != 1 {\n\t\tt.Fatalf(\"unexpected move stats: %#v\", result)\n\t}\n\tvar stored model.UploadFiles\n\tif err := db.First(&stored, file.ID).Error; err != nil {\n\t\tt.Fatalf(\"query moved file failed: %v\", err)\n\t}\n\tif stored.FolderID != folder.ID || stored.LogicalPath != \"/docs\" {\n\t\tt.Fatalf(\"unexpected moved file folder/path: folder=%d path=%q\", stored.FolderID, stored.LogicalPath)\n\t}\n}\n\nfunc TestFileResourceUploadLocalRecordsLocationFields(t *testing.T) {\n\tdb := newFileResourceSQLiteDB(t)\n\tconfig.Config.BasePath = t.TempDir()\n\tfolder := model.UploadFileFolder{Name: \"images\", LogicalPath: \"/images\"}\n\tif err := db.Create(&folder).Error; err != nil {\n\t\tt.Fatalf(\"create folder failed: %v\", err)\n\t}\n\theader := newMultipartFileHeader(t, \"hello.txt\", []byte(\"hello\"))\n\n\tresult, err := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db}).UploadLocal([]*multipart.FileHeader{header}, &form.FileLocalUpload{FolderID: folder.ID, IsPublic: global.Yes, UploadScene: \"test\"}, 9)\n\tif err != nil {\n\t\tt.Fatalf(\"upload local failed: %v\", err)\n\t}\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"expected one upload result, got %d\", len(result))\n\t}\n\tvar stored model.UploadFiles\n\tif err := db.First(&stored, result[0].ID).Error; err != nil {\n\t\tt.Fatalf(\"query uploaded file failed: %v\", err)\n\t}\n\tif stored.FolderID != folder.ID || stored.LogicalPath != \"/images\" || stored.DisplayName != \"hello.txt\" {\n\t\tt.Fatalf(\"unexpected logical fields: %#v\", stored)\n\t}\n\tif stored.StorageDriver != model.StorageDriverLocal || stored.StorageBase == \"\" || stored.StoragePath == \"\" || stored.ObjectKey == \"\" {\n\t\tt.Fatalf(\"unexpected storage fields: %#v\", stored)\n\t}\n\tif stored.UploadSource != model.UploadSourceBackend || stored.UploadScene != \"test\" || stored.UploadStatus != model.UploadStatusUploaded {\n\t\tt.Fatalf(\"unexpected upload fields: %#v\", stored)\n\t}\n}\n\nfunc TestFileResourceUploadLocalReusesPhysicalObject(t *testing.T) {\n\tdb := newFileResourceSQLiteDB(t)\n\tconfig.Config.BasePath = t.TempDir()\n\tservice := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db})\n\tcontent := []byte(\"same content\")\n\n\tfirst, err := service.UploadLocal([]*multipart.FileHeader{newMultipartFileHeader(t, \"first.txt\", content)}, &form.FileLocalUpload{IsPublic: global.Yes}, 1)\n\tif err != nil {\n\t\tt.Fatalf(\"first upload failed: %v\", err)\n\t}\n\tsecond, err := service.UploadLocal([]*multipart.FileHeader{newMultipartFileHeader(t, \"second.txt\", content)}, &form.FileLocalUpload{IsPublic: global.No}, 2)\n\tif err != nil {\n\t\tt.Fatalf(\"second upload failed: %v\", err)\n\t}\n\tif first[0].FileObjectID == 0 || first[0].FileObjectID != second[0].FileObjectID {\n\t\tt.Fatalf(\"expected same file object, first=%d second=%d\", first[0].FileObjectID, second[0].FileObjectID)\n\t}\n\tif first[0].ObjectKey != second[0].ObjectKey {\n\t\tt.Fatalf(\"expected same object key, first=%q second=%q\", first[0].ObjectKey, second[0].ObjectKey)\n\t}\n\tvar objectCount int64\n\tif err := db.Model(&model.UploadFileObject{}).Count(&objectCount).Error; err != nil {\n\t\tt.Fatalf(\"count objects failed: %v\", err)\n\t}\n\tif objectCount != 1 {\n\t\tt.Fatalf(\"expected one physical object, got %d\", objectCount)\n\t}\n\tstoredPath := filepath.Join(config.Config.BasePath, \"storage/public\", filepath.FromSlash(first[0].ObjectKey))\n\tif _, err := os.Stat(storedPath); err != nil {\n\t\tt.Fatalf(\"expected reused physical file to exist: %v\", err)\n\t}\n}\n\nfunc TestFileResourceDestroyKeepsPhysicalObjectUntilLastRecord(t *testing.T) {\n\tdb := newFileResourceSQLiteDB(t)\n\tbasePath := t.TempDir()\n\tobjectKey := \"objects/shared.txt\"\n\tphysicalPath := filepath.Join(basePath, filepath.FromSlash(objectKey))\n\tif err := os.MkdirAll(filepath.Dir(physicalPath), 0o755); err != nil {\n\t\tt.Fatalf(\"create physical dir failed: %v\", err)\n\t}\n\tif err := os.WriteFile(physicalPath, []byte(\"shared\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write physical file failed: %v\", err)\n\t}\n\tobject := model.UploadFileObject{\n\t\tStorageDriver: model.StorageDriverLocal,\n\t\tStorageBase:   basePath,\n\t\tBucket:        \"public\",\n\t\tStoragePath:   objectKey,\n\t\tObjectKey:     objectKey,\n\t\tHash:          strings.Repeat(\"a\", 64),\n\t\tStatus:        model.StorageStatusStored,\n\t}\n\tif err := db.Create(&object).Error; err != nil {\n\t\tt.Fatalf(\"create object failed: %v\", err)\n\t}\n\tfirst := model.UploadFiles{FileObjectID: object.ID, UUID: fmt.Sprintf(\"%032x\", time.Now().UnixNano()), StorageDriver: model.StorageDriverLocal, StorageStatus: model.StorageStatusStored}\n\tsecond := model.UploadFiles{FileObjectID: object.ID, UUID: fmt.Sprintf(\"%032x\", time.Now().UnixNano()+1), StorageDriver: model.StorageDriverLocal, StorageStatus: model.StorageStatusStored}\n\tif err := db.Create(&first).Error; err != nil {\n\t\tt.Fatalf(\"create first file failed: %v\", err)\n\t}\n\tif err := db.Create(&second).Error; err != nil {\n\t\tt.Fatalf(\"create second file failed: %v\", err)\n\t}\n\tservice := NewFileResourceServiceWithDeps(FileResourceServiceDeps{DB: db})\n\tif err := service.Destroy(first.ID); err != nil {\n\t\tt.Fatalf(\"destroy first failed: %v\", err)\n\t}\n\tif _, err := os.Stat(physicalPath); err != nil {\n\t\tt.Fatalf(\"expected physical file to remain after non-last destroy: %v\", err)\n\t}\n\tvar objectCount int64\n\tif err := db.Model(&model.UploadFileObject{}).Where(\"id = ?\", object.ID).Count(&objectCount).Error; err != nil {\n\t\tt.Fatalf(\"count object failed: %v\", err)\n\t}\n\tif objectCount != 1 {\n\t\tt.Fatalf(\"expected object to remain, got %d\", objectCount)\n\t}\n\tif err := service.Destroy(second.ID); err != nil {\n\t\tt.Fatalf(\"destroy second failed: %v\", err)\n\t}\n\tif _, err := os.Stat(physicalPath); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"expected physical file removed after last destroy, err=%v\", err)\n\t}\n\tif err := db.Model(&model.UploadFileObject{}).Where(\"id = ?\", object.ID).Count(&objectCount).Error; err != nil {\n\t\tt.Fatalf(\"count object after destroy failed: %v\", err)\n\t}\n\tif objectCount != 0 {\n\t\tt.Fatalf(\"expected object removed, got %d\", objectCount)\n\t}\n}\n\nfunc TestFileResourceUploadCredentialReturnsReuse(t *testing.T) {\n\tdb := newFileResourceSQLiteDB(t)\n\thash := strings.Repeat(\"b\", 64)\n\tobject := model.UploadFileObject{\n\t\tStorageDriver: model.StorageDriverS3,\n\t\tBucket:        \"assets\",\n\t\tStoragePath:   \"uploads/existing.txt\",\n\t\tObjectKey:     \"uploads/existing.txt\",\n\t\tSize:          12,\n\t\tHash:          hash,\n\t\tMimeType:      \"text/plain\",\n\t\tETag:          \"etag-1\",\n\t\tStatus:        model.StorageStatusStored,\n\t}\n\tif err := db.Create(&object).Error; err != nil {\n\t\tt.Fatalf(\"create object failed: %v\", err)\n\t}\n\tservice := NewFileResourceServiceWithDeps(FileResourceServiceDeps{\n\t\tDB: db,\n\t\tActiveStorageResolver: func(context.Context) (filestorage.Driver, filestorage.Config, string, error) {\n\t\t\treturn fakeFileResourceDriver{name: model.StorageDriverLocal}, filestorage.Config{\n\t\t\t\tS3: filestorage.S3Config{Bucket: \"assets\"},\n\t\t\t}, model.StorageDriverLocal, nil\n\t\t},\n\t})\n\tresult, err := service.UploadCredential(&form.FileUploadCredential{\n\t\tDriver:   model.StorageDriverS3,\n\t\tHash:     hash,\n\t\tMimeType: \"text/plain\",\n\t\tSize:     12,\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"upload credential failed: %v\", err)\n\t}\n\tif !result.Reuse || result.FileObjectID != object.ID || result.ObjectKey != object.ObjectKey {\n\t\tt.Fatalf(\"unexpected reuse credential: %#v\", result)\n\t}\n\tif result.UploadURL != \"\" || result.Method != \"\" {\n\t\tt.Fatalf(\"reuse credential should not require direct upload: %#v\", result)\n\t}\n}\n\nfunc TestFileResourceDeletePhysicalUsesFileStorageDriver(t *testing.T) {\n\tcalls := make([]string, 0, 1)\n\tservice := NewFileResourceServiceWithDeps(FileResourceServiceDeps{\n\t\tStorageDriverResolver: func(_ context.Context, driverName string) (filestorage.Driver, filestorage.Config, error) {\n\t\t\tcalls = append(calls, driverName)\n\t\t\treturn fakeFileResourceDriver{name: driverName}, filestorage.Config{}, nil\n\t\t},\n\t})\n\terr := service.deletePhysicalFile(&model.UploadFiles{\n\t\tStorageDriver: model.StorageDriverS3,\n\t\tBucket:        \"bucket\",\n\t\tObjectKey:     \"old/object.txt\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"delete physical file failed: %v\", err)\n\t}\n\tif len(calls) != 1 || calls[0] != model.StorageDriverS3 {\n\t\tt.Fatalf(\"expected resolver to use file storage driver, got %#v\", calls)\n\t}\n}\n\nfunc newFileResourceSQLiteDB(t *testing.T) *gorm.DB {\n\tt.Helper()\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite failed: %v\", err)\n\t}\n\texecFileResourceTestSchema(t, db)\n\treturn db\n}\n\nfunc execFileResourceTestSchema(t *testing.T, db *gorm.DB) {\n\tt.Helper()\n\tstatements := []string{\n\t\t`CREATE TABLE upload_file_folders (\n\t\t\tid integer primary key autoincrement,\n\t\t\tparent_id integer not null default 0,\n\t\t\tname text not null default '',\n\t\t\tlogical_path text not null default '/',\n\t\t\tsort integer not null default 0,\n\t\t\tcreated_by integer not null default 0,\n\t\t\tupdated_by integer not null default 0,\n\t\t\tcreated_at datetime,\n\t\t\tupdated_at datetime,\n\t\t\tdeleted_at integer not null default 0\n\t\t)`,\n\t\t`CREATE TABLE upload_files (\n\t\t\tid integer primary key autoincrement,\n\t\t\tfile_object_id integer not null default 0,\n\t\t\tuid integer not null default 0,\n\t\t\tfolder_id integer not null default 0,\n\t\t\tlogical_path text not null default '/',\n\t\t\tdisplay_name text not null default '',\n\t\t\torigin_name text not null default '',\n\t\t\tname text not null default '',\n\t\t\tpath text not null default '',\n\t\t\tsize integer not null default 0,\n\t\t\text text not null default '',\n\t\t\thash text not null default '',\n\t\t\tuuid text not null default '',\n\t\t\tmime_type text not null default '',\n\t\t\tfile_type text not null default '',\n\t\t\tis_public integer not null default 0,\n\t\t\tstorage_driver text not null default 'local',\n\t\t\tstorage_base text not null default '',\n\t\t\tbucket text not null default '',\n\t\t\tstorage_path text not null default '',\n\t\t\tobject_key text not null default '',\n\t\t\tetag text not null default '',\n\t\t\tstorage_status text not null default 'stored',\n\t\t\tupload_source text not null default '',\n\t\t\tupload_scene text not null default '',\n\t\t\tupload_status text not null default '',\n\t\t\tlast_accessed_at datetime,\n\t\t\tdeleted_by integer not null default 0,\n\t\t\tdeleted_reason text not null default '',\n\t\t\tcreated_at datetime,\n\t\t\tupdated_at datetime,\n\t\t\tdeleted_at integer not null default 0\n\t\t)`,\n\t\t`CREATE TABLE upload_file_objects (\n\t\t\tid integer primary key autoincrement,\n\t\t\tstorage_driver text not null default 'local',\n\t\t\tstorage_base text not null default '',\n\t\t\tbucket text not null default '',\n\t\t\tstorage_path text not null default '',\n\t\t\tobject_key text not null default '',\n\t\t\tsize integer not null default 0,\n\t\t\thash text not null default '',\n\t\t\tmime_type text not null default '',\n\t\t\tetag text not null default '',\n\t\t\tstatus text not null default 'stored',\n\t\t\tcreated_at datetime,\n\t\t\tupdated_at datetime\n\t\t)`,\n\t\t`CREATE TABLE upload_file_references (\n\t\t\tid integer primary key autoincrement,\n\t\t\tfile_id integer not null default 0,\n\t\t\tuuid text not null default '',\n\t\t\towner_type text not null default '',\n\t\t\towner_id integer not null default 0,\n\t\t\towner_field text not null default '',\n\t\t\tcreated_at datetime,\n\t\t\tupdated_at datetime\n\t\t)`,\n\t}\n\tfor _, statement := range statements {\n\t\tif err := db.Exec(statement).Error; err != nil {\n\t\t\tt.Fatalf(\"create test schema failed: %v\", err)\n\t\t}\n\t}\n}\n\nfunc newMultipartFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader {\n\tt.Helper()\n\tbody := &bytes.Buffer{}\n\twriter := multipart.NewWriter(body)\n\tpart, err := writer.CreateFormFile(\"files\", filename)\n\tif err != nil {\n\t\tt.Fatalf(\"create multipart file failed: %v\", err)\n\t}\n\tif _, err := part.Write(content); err != nil {\n\t\tt.Fatalf(\"write multipart file failed: %v\", err)\n\t}\n\tif err := writer.Close(); err != nil {\n\t\tt.Fatalf(\"close multipart writer failed: %v\", err)\n\t}\n\treq, err := http.NewRequest(http.MethodPost, \"/\", body)\n\tif err != nil {\n\t\tt.Fatalf(\"create request failed: %v\", err)\n\t}\n\treq.Header.Set(\"Content-Type\", writer.FormDataContentType())\n\tif err := req.ParseMultipartForm(1024); err != nil {\n\t\tt.Fatalf(\"parse multipart form failed: %v\", err)\n\t}\n\treturn req.MultipartForm.File[\"files\"][0]\n}\n\ntype fakeFileResourceDriver struct {\n\tname string\n}\n\nfunc (d fakeFileResourceDriver) Name() string { return d.name }\n\nfunc (d fakeFileResourceDriver) Put(context.Context, filestorage.PutInput) (filestorage.PutResult, error) {\n\treturn filestorage.PutResult{}, nil\n}\n\nfunc (d fakeFileResourceDriver) Open(context.Context, string, string) (io.ReadCloser, error) {\n\treturn io.NopCloser(bytes.NewReader(nil)), nil\n}\n\nfunc (d fakeFileResourceDriver) Exists(context.Context, string, string) (bool, error) {\n\treturn true, nil\n}\n\nfunc (d fakeFileResourceDriver) Delete(context.Context, string, string) error {\n\treturn nil\n}\n\nfunc (d fakeFileResourceDriver) URL(string, string, bool) string {\n\treturn \"\"\n}\n\nfunc (d fakeFileResourceDriver) SignedURL(context.Context, string, string, time.Duration) (string, error) {\n\treturn \"\", nil\n}\n"
  },
  {
    "path": "internal/service/i18n_text.go",
    "content": "package service\n\nimport (\n\t\"strings\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/i18n\"\n)\n\n// NormalizeLocaleTextMap 规范化多语言文本输入，保留未来可扩展语言。\nfunc NormalizeLocaleTextMap(data map[string]string) map[string]string {\n\tresult := make(map[string]string, len(data))\n\tfor locale, text := range data {\n\t\tnormalizedLocale := NormalizeLocaleKey(locale)\n\t\ttrimmedText := strings.TrimSpace(text)\n\t\tif normalizedLocale == \"\" || trimmedText == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tresult[normalizedLocale] = trimmedText\n\t}\n\treturn result\n}\n\n// NormalizeLocaleKey 统一 zh/en 的历史写法，并保留其他语言原值。\nfunc NormalizeLocaleKey(locale string) string {\n\ttrimmed := strings.TrimSpace(strings.ReplaceAll(locale, \"_\", \"-\"))\n\tif trimmed == \"\" {\n\t\treturn \"\"\n\t}\n\n\tlower := strings.ToLower(trimmed)\n\tswitch {\n\tcase strings.HasPrefix(lower, \"zh\"):\n\t\treturn i18n.LocaleZhCN\n\tcase strings.HasPrefix(lower, \"en\"):\n\t\treturn i18n.LocaleEnUS\n\tdefault:\n\t\treturn trimmed\n\t}\n}\n\n// LocalePriority 返回读路径使用的语言优先级。\nfunc LocalePriority(locale string) []string {\n\tcandidates := []string{\n\t\tNormalizeLocaleKey(locale),\n\t\ti18n.DefaultLocale,\n\t\ti18n.LocaleEnUS,\n\t}\n\tresult := make([]string, 0, len(candidates))\n\tseen := make(map[string]struct{}, len(candidates))\n\tfor _, candidate := range candidates {\n\t\tif candidate == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[candidate]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[candidate] = struct{}{}\n\t\tresult = append(result, candidate)\n\t}\n\treturn result\n}\n\n// ResolveLocaleText 根据优先级解析展示文本。\nfunc ResolveLocaleText(translations map[string]string, locale string) string {\n\tfor _, candidate := range LocalePriority(locale) {\n\t\tif text := strings.TrimSpace(translations[candidate]); text != \"\" {\n\t\t\treturn text\n\t\t}\n\t}\n\tfor _, text := range translations {\n\t\tif trimmed := strings.TrimSpace(text); trimmed != \"\" {\n\t\t\treturn trimmed\n\t\t}\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/service/menu/audit_diff.go",
    "content": "package menu\n\nimport (\n\t\"sort\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nvar menuDiffRules = []auditdiff.FieldRule{\n\t{Field: \"id\", Label: \"菜单ID\"},\n\t{Field: \"icon\", Label: \"图标\"},\n\t{Field: \"title_i18n\", Label: \"标题\"},\n\t{Field: \"code\", Label: \"权限标识\"},\n\t{Field: \"path\", Label: \"路由路径\"},\n\t{Field: \"full_path\", Label: \"完整路径\"},\n\t{Field: \"name\", Label: \"路由名称\"},\n\t{Field: \"component\", Label: \"组件\"},\n\t{\n\t\tField: \"status\",\n\t\tLabel: \"状态\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"禁用\",\n\t\t\t\"1\": \"启用\",\n\t\t},\n\t},\n\t{\n\t\tField: \"type\",\n\t\tLabel: \"类型\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"1\": \"目录\",\n\t\t\t\"2\": \"菜单\",\n\t\t\t\"3\": \"按钮\",\n\t\t},\n\t},\n\t{\n\t\tField: \"is_show\",\n\t\tLabel: \"显示\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"否\",\n\t\t\t\"1\": \"是\",\n\t\t},\n\t},\n\t{\n\t\tField: \"is_auth\",\n\t\tLabel: \"鉴权\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"否\",\n\t\t\t\"1\": \"是\",\n\t\t},\n\t},\n\t{\n\t\tField: \"is_new_window\",\n\t\tLabel: \"新窗口\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"否\",\n\t\t\t\"1\": \"是\",\n\t\t},\n\t},\n\t{\n\t\tField: \"is_external_links\",\n\t\tLabel: \"外链\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"否\",\n\t\t\t\"1\": \"是\",\n\t\t},\n\t},\n\t{Field: \"sort\", Label: \"排序\"},\n\t{Field: \"pid\", Label: \"上级菜单ID\"},\n\t{Field: \"pids\", Label: \"上级路径\"},\n\t{Field: \"level\", Label: \"层级\"},\n\t{Field: \"redirect\", Label: \"重定向\"},\n\t{Field: \"animate_enter\", Label: \"进入动画\"},\n\t{Field: \"animate_leave\", Label: \"离开动画\"},\n\t{Field: \"animate_duration\", Label: \"动画时长\"},\n\t{Field: \"description\", Label: \"描述\"},\n\t{Field: \"api_list\", Label: \"接口ID列表\"},\n}\n\n// CreateWithAuditDiff 新增菜单并返回精确 change_diff。\nfunc (s *MenuService) CreateWithAuditDiff(params *form.CreateMenu, _ string) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tmenuModel, err := s.applyMenuMutation(&menuMutation{\n\t\tIcon:            params.Icon,\n\t\tTitleI18n:       params.TitleI18n,\n\t\tCode:            params.Code,\n\t\tPath:            params.Path,\n\t\tName:            params.Name,\n\t\tAnimateEnter:    params.AnimateEnter,\n\t\tAnimateLeave:    params.AnimateLeave,\n\t\tAnimateDuration: params.AnimateDuration,\n\t\tIsShow:          params.IsShow,\n\t\tIsAuth:          params.IsAuth,\n\t\tIsNewWindow:     params.IsNewWindow,\n\t\tSort:            params.Sort,\n\t\tType:            params.Type,\n\t\tPid:             params.Pid,\n\t\tDescription:     params.Description,\n\t\tApiList:         params.ApiList,\n\t\tComponent:       params.Component,\n\t\tStatus:          params.Status,\n\t\tRedirect:        params.Redirect,\n\t\tIsExternalLinks: params.IsExternalLinks,\n\t})\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotMenuByID(menuModel.ID)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\treturn buildMenuDiff(nil, after), nil\n}\n\n// UpdateWithAuditDiff 更新菜单并返回精确 change_diff。\nfunc (s *MenuService) UpdateWithAuditDiff(params *form.UpdateMenu, _ string) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tbefore, err := s.snapshotMenuByID(params.Id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif _, err := s.applyMenuMutation(&menuMutation{\n\t\tId:              params.Id,\n\t\tIcon:            params.Icon,\n\t\tTitleI18n:       params.TitleI18n,\n\t\tCode:            params.Code,\n\t\tPath:            params.Path,\n\t\tName:            params.Name,\n\t\tAnimateEnter:    params.AnimateEnter,\n\t\tAnimateLeave:    params.AnimateLeave,\n\t\tAnimateDuration: params.AnimateDuration,\n\t\tIsShow:          params.IsShow,\n\t\tIsAuth:          params.IsAuth,\n\t\tIsNewWindow:     params.IsNewWindow,\n\t\tSort:            params.Sort,\n\t\tType:            params.Type,\n\t\tPid:             params.Pid,\n\t\tDescription:     params.Description,\n\t\tApiList:         params.ApiList,\n\t\tComponent:       params.Component,\n\t\tStatus:          params.Status,\n\t\tRedirect:        params.Redirect,\n\t\tIsExternalLinks: params.IsExternalLinks,\n\t}); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotMenuByID(params.Id)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\treturn buildMenuDiff(before, after), nil\n}\n\n// DeleteWithAuditDiff 删除菜单并返回精确 change_diff。\nfunc (s *MenuService) DeleteWithAuditDiff(id uint) (string, error) {\n\tbefore, err := s.snapshotMenuByID(id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.Delete(id); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn buildMenuDiff(before, nil), nil\n}\n\nfunc (s *MenuService) snapshotMenuByID(id uint) (map[string]any, error) {\n\tmenuModel := model.NewMenu()\n\tif err := menuModel.GetById(id); err != nil || menuModel.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.MenuNotFound)\n\t}\n\ttitleI18n, err := model.NewMenuI18n().LocaleTitleMapByMenuID(menuModel.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tapiIDs, err := model.NewMenuApiMap().ApiIdsByMenuId(menuModel.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsort.Slice(apiIDs, func(i, j int) bool {\n\t\treturn apiIDs[i] < apiIDs[j]\n\t})\n\treturn map[string]any{\n\t\t\"id\":                menuModel.ID,\n\t\t\"icon\":              menuModel.Icon,\n\t\t\"title_i18n\":        titleI18n,\n\t\t\"code\":              menuModel.Code,\n\t\t\"path\":              menuModel.Path,\n\t\t\"full_path\":         menuModel.FullPath,\n\t\t\"name\":              menuModel.Name,\n\t\t\"component\":         menuModel.Component,\n\t\t\"status\":            menuModel.Status,\n\t\t\"type\":              menuModel.Type,\n\t\t\"is_show\":           menuModel.IsShow,\n\t\t\"is_auth\":           menuModel.IsAuth,\n\t\t\"is_new_window\":     menuModel.IsNewWindow,\n\t\t\"is_external_links\": menuModel.IsExternalLinks,\n\t\t\"sort\":              menuModel.Sort,\n\t\t\"pid\":               menuModel.Pid,\n\t\t\"pids\":              menuModel.Pids,\n\t\t\"level\":             menuModel.Level,\n\t\t\"redirect\":          menuModel.Redirect,\n\t\t\"animate_enter\":     menuModel.AnimateEnter,\n\t\t\"animate_leave\":     menuModel.AnimateLeave,\n\t\t\"animate_duration\":  menuModel.AnimateDuration,\n\t\t\"description\":       menuModel.Description,\n\t\t\"api_list\":          apiIDs,\n\t}, nil\n}\n\nfunc buildMenuDiff(before, after map[string]any) string {\n\titems := auditdiff.BuildFieldDiff(before, after, menuDiffRules)\n\treturn auditdiff.Marshal(items)\n}\n"
  },
  {
    "path": "internal/service/menu/audit_diff_test.go",
    "content": "package menu\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestBuildMenuDiffIncludesTypeDisplay(t *testing.T) {\n\traw := buildMenuDiff(\n\t\tmap[string]any{\"type\": uint8(1)},\n\t\tmap[string]any{\"type\": uint8(2)},\n\t)\n\tvar items []map[string]any\n\tif err := json.Unmarshal([]byte(raw), &items); err != nil {\n\t\tt.Fatalf(\"expected valid json diff, got err=%v raw=%s\", err, raw)\n\t}\n\tif len(items) != 1 {\n\t\tt.Fatalf(\"expected 1 diff item, got %d\", len(items))\n\t}\n\tif items[0][\"before_display\"] != \"目录\" || items[0][\"after_display\"] != \"菜单\" {\n\t\tt.Fatalf(\"unexpected type display mapping: %#v\", items[0])\n\t}\n}\n"
  },
  {
    "path": "internal/service/menu/menu.go",
    "content": "package menu\n\nimport \"github.com/wannanbigpig/gin-layout/internal/service\"\n\nconst (\n\tmenuRootPid   = \"0\"\n\tmenuRootLevel = 1\n\tmaxMenuLevel  = 4 // 最多4层菜单\n\tallStatus     = 2\n\trootPath      = \"/\"\n)\n\n// MenuService 菜单服务\ntype MenuService struct {\n\tservice.Base\n}\n\n// NewMenuService 创建菜单服务实例\nfunc NewMenuService() *MenuService {\n\treturn &MenuService{}\n}\n"
  },
  {
    "path": "internal/service/menu/menu_edit.go",
    "content": "package menu\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/samber/lo\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/i18n\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n\tutils2 \"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\n// Create 新增菜单。\nfunc (s *MenuService) Create(params *form.CreateMenu, _ string) error {\n\t_, err := s.applyMenuMutation(&menuMutation{\n\t\tIcon:            params.Icon,\n\t\tTitleI18n:       params.TitleI18n,\n\t\tCode:            params.Code,\n\t\tPath:            params.Path,\n\t\tName:            params.Name,\n\t\tAnimateEnter:    params.AnimateEnter,\n\t\tAnimateLeave:    params.AnimateLeave,\n\t\tAnimateDuration: params.AnimateDuration,\n\t\tIsShow:          params.IsShow,\n\t\tIsAuth:          params.IsAuth,\n\t\tIsNewWindow:     params.IsNewWindow,\n\t\tSort:            params.Sort,\n\t\tType:            params.Type,\n\t\tPid:             params.Pid,\n\t\tDescription:     params.Description,\n\t\tApiList:         params.ApiList,\n\t\tComponent:       params.Component,\n\t\tStatus:          params.Status,\n\t\tRedirect:        params.Redirect,\n\t\tIsExternalLinks: params.IsExternalLinks,\n\t})\n\treturn err\n}\n\n// Update 更新菜单。\nfunc (s *MenuService) Update(params *form.UpdateMenu, _ string) error {\n\t_, err := s.applyMenuMutation(&menuMutation{\n\t\tId:              params.Id,\n\t\tIcon:            params.Icon,\n\t\tTitleI18n:       params.TitleI18n,\n\t\tCode:            params.Code,\n\t\tPath:            params.Path,\n\t\tName:            params.Name,\n\t\tAnimateEnter:    params.AnimateEnter,\n\t\tAnimateLeave:    params.AnimateLeave,\n\t\tAnimateDuration: params.AnimateDuration,\n\t\tIsShow:          params.IsShow,\n\t\tIsAuth:          params.IsAuth,\n\t\tIsNewWindow:     params.IsNewWindow,\n\t\tSort:            params.Sort,\n\t\tType:            params.Type,\n\t\tPid:             params.Pid,\n\t\tDescription:     params.Description,\n\t\tApiList:         params.ApiList,\n\t\tComponent:       params.Component,\n\t\tStatus:          params.Status,\n\t\tRedirect:        params.Redirect,\n\t\tIsExternalLinks: params.IsExternalLinks,\n\t})\n\treturn err\n}\n\n// menuMutation 菜单变更参数，用于封装新增/更新菜单的请求数据。\ntype menuMutation struct {\n\tId              uint   // 菜单 ID，0 表示新增\n\tIcon            string // 图标\n\tTitleI18n       map[string]string\n\tCode            string  // 权限标识\n\tPath            string  // 路径\n\tName            string  // 路由名称\n\tAnimateEnter    string  // 进入动画\n\tAnimateLeave    string  // 离开动画\n\tAnimateDuration float32 // 动画时长\n\tIsShow          uint8   // 是否显示\n\tIsAuth          uint8   // 是否鉴权\n\tIsNewWindow     uint8   // 是否新窗口打开\n\tSort            uint    // 排序权重\n\tType            uint8   // 菜单类型（目录/菜单/按钮）\n\tPid             uint    // 父菜单 ID\n\tDescription     string  // 描述\n\tApiList         []uint  // 关联的 API ID 列表\n\tComponent       string  // 组件路径\n\tStatus          uint8   // 状态\n\tRedirect        string  // 重定向路径\n\tIsExternalLinks uint8   // 是否外链\n}\n\n// menuEditContext 菜单编辑上下文，保存更新前的状态用于级联判断。\ntype menuEditContext struct {\n\toriginPids     string // 原始路径\n\toriginPid      uint   // 原始父 ID\n\toriginFullPath string // 原始完整路径\n\texcludeId      uint   // 排除的当前菜单 ID\n}\n\n// applyMenuMutation 执行菜单变更操作（新增/更新）。\n// 处理逻辑：\n// 1. 验证菜单是否存在（更新时）\n// 2. 构建树形层级（pids, level, full_path）\n// 3. 检查层级深度\n// 4. 填充菜单字段\n// 5. 验证唯一字段（code, name, path）\n// 6. 验证 API 列表\n// 7. 事务保存：菜单数据、级联更新子菜单、更新子菜单数量、同步菜单权限\nfunc (s *MenuService) applyMenuMutation(params *menuMutation) (*model.Menu, error) {\n\tmenu, editContext, err := s.prepareMutationContext(params)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 1) 规范化标题输入\n\tif err := s.normalizeMenuTitles(params); err != nil {\n\t\treturn nil, err\n\t}\n\t// 2) 构建树形层级并验证合法性\n\tif err := s.resolveMenuHierarchy(menu, params); err != nil {\n\t\treturn nil, err\n\t}\n\t// 3) 检查层级深度\n\tif menu.Level > maxMenuLevel {\n\t\treturn nil, e.NewBusinessError(e.MaxMenuDepth)\n\t}\n\n\t// 4) 填充字段并验证唯一性\n\ts.assignMenuFields(menu, params)\n\tif err := s.validateUniqueFields(menu, params, editContext.excludeId); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 5) 规范化关联 API 列表（仅保留有效且去重后的 ID）\n\tif err := s.normalizeMenuAPIList(params); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 6) 持久化及事务后处理\n\tif err := s.executeEditTransaction(menu, params.ApiList, params.TitleI18n, editContext); err != nil {\n\t\treturn nil, err\n\t}\n\treturn menu, nil\n}\n\n// prepareMutationContext 根据新增/更新场景初始化菜单模型与编辑上下文。\nfunc (s *MenuService) prepareMutationContext(params *menuMutation) (*model.Menu, *menuEditContext, error) {\n\tmenu := model.NewMenu()\n\teditContext := newMenuEditContext()\n\tif params.Id == 0 {\n\t\treturn menu, editContext, nil\n\t}\n\n\t// 更新场景：加载原菜单，供唯一性校验与子树级联更新使用。\n\tif err := menu.GetById(params.Id); err != nil || menu.ID == 0 {\n\t\treturn nil, nil, e.NewBusinessError(e.MenuNotFound)\n\t}\n\teditContext.originPids = menu.Pids\n\teditContext.originPid = menu.Pid\n\teditContext.originFullPath = menu.FullPath\n\teditContext.excludeId = params.Id\n\treturn menu, editContext, nil\n}\n\nfunc newMenuEditContext() *menuEditContext {\n\treturn &menuEditContext{\n\t\toriginPids:     menuRootPid,\n\t\toriginPid:      0,\n\t\toriginFullPath: \"\",\n\t\texcludeId:      0,\n\t}\n}\n\n// normalizeMenuAPIList 校验 API ID 是否存在，并仅保留有效去重结果。\nfunc (s *MenuService) normalizeMenuAPIList(params *menuMutation) error {\n\tif len(params.ApiList) == 0 {\n\t\treturn nil\n\t}\n\n\tapis, err := model.NewApi().FindByIds(params.ApiList)\n\tif err != nil {\n\t\treturn err\n\t}\n\tparams.ApiList = lo.Map(apis, func(api model.Api, _ int) uint {\n\t\treturn api.ID\n\t})\n\treturn nil\n}\n\n// normalizeMenuTitles 规范化菜单标题输入，要求中英至少一种语言非空。\nfunc (s *MenuService) normalizeMenuTitles(params *menuMutation) error {\n\tnormalized := make(map[string]string, len(params.TitleI18n))\n\tfor locale, title := range params.TitleI18n {\n\t\tnormalizedLocale := i18n.NormalizeLocale(locale)\n\t\tif !isSupportedMenuLocale(normalizedLocale) {\n\t\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t\t}\n\t\ttrimmedTitle := strings.TrimSpace(title)\n\t\tif trimmedTitle == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tnormalized[normalizedLocale] = trimmedTitle\n\t}\n\n\tzhTitle := strings.TrimSpace(normalized[i18n.LocaleZhCN])\n\tenTitle := strings.TrimSpace(normalized[i18n.LocaleEnUS])\n\tif zhTitle == \"\" && enTitle == \"\" {\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\tparams.TitleI18n = normalized\n\treturn nil\n}\n\nfunc isSupportedMenuLocale(locale string) bool {\n\treturn locale == i18n.LocaleZhCN || locale == i18n.LocaleEnUS\n}\n\n// resolveMenuHierarchy 统一处理菜单层级、父级合法性和 full_path 计算，避免主流程在多个小函数间跳转。\n// 处理逻辑：\n// 1. 判断是否需要更新父级信息（pid 变更或 path 变更）\n// 2. 验证父菜单是否存在且不是按钮类型\n// 3. 检测环路\n// 4. 计算新的 level、pids、full_path\nfunc (s *MenuService) resolveMenuHierarchy(menu *model.Menu, params *menuMutation) error {\n\tneedRefreshByParent := (params.Pid > 0 && params.Pid != menu.Pid) ||\n\t\t(params.Pid > 0 && params.Path != menu.Path)\n\tif !needRefreshByParent {\n\t\t// 无需更新父级信息，仅处理顶级菜单场景\n\t\tif params.Pid == 0 {\n\t\t\tmenu.Level = menuRootLevel\n\t\t\tmenu.Pids = menuRootPid\n\t\t\tmenu.FullPath = s.buildFullPath(params.Path, rootPath, params.Type)\n\t\t}\n\t\tmenu.Pid = params.Pid\n\t\treturn nil\n\t}\n\n\t// 验证父菜单是否存在\n\tparentMenu := model.NewMenu()\n\tif err := parentMenu.GetById(params.Pid); err != nil || parentMenu.ID == 0 {\n\t\treturn e.NewBusinessError(e.ParentMenuNotExists)\n\t}\n\t// 父菜单不能是按钮类型\n\tif parentMenu.Type == model.BUTTON {\n\t\treturn e.NewBusinessError(e.ParentMenuTypeInvalid)\n\t}\n\t// 环路检测\n\tif utils2.WouldCauseCycle(menu.ID, params.Pid, parentMenu.Pids) {\n\t\treturn e.NewBusinessError(e.ParentMenuInvalid)\n\t}\n\n\t// 计算新的层级和路径\n\tmenu.Level = parentMenu.Level + 1\n\tmenu.Pids = s.buildPids(parentMenu.Pids, parentMenu.ID)\n\tmenu.FullPath = s.buildFullPath(params.Path, parentMenu.FullPath, params.Type)\n\tmenu.Pid = params.Pid\n\treturn nil\n}\n\n// buildPids 构建子节点的 pids 路径：父 pids + 父 ID。\nfunc (s *MenuService) buildPids(parentPids string, parentID uint) string {\n\treturn strings.TrimPrefix(fmt.Sprintf(\"%s,%d\", parentPids, parentID), \",\")\n}\n\n// buildFullPath 构建菜单的完整路径。\n// 规则：\n// 1. 按钮类型无 full_path\n// 2. 已有完整路径前缀（/、http、https）则直接使用\n// 3. 否则拼接父路径 + 当前路径\nfunc (s *MenuService) buildFullPath(path, parentPath string, menuType uint8) string {\n\tif menuType == model.BUTTON {\n\t\treturn \"\"\n\t}\n\tif parentPath == \"\" {\n\t\tparentPath = rootPath\n\t}\n\tif strings.HasPrefix(path, rootPath) ||\n\t\tstrings.HasPrefix(path, \"https://\") ||\n\t\tstrings.HasPrefix(path, \"http://\") {\n\t\treturn path\n\t}\n\tif !strings.HasSuffix(parentPath, \"/\") {\n\t\tparentPath += \"/\"\n\t}\n\treturn parentPath + path\n}\n\n// assignMenuFields 填充菜单模型字段。\nfunc (s *MenuService) assignMenuFields(menu *model.Menu, params *menuMutation) {\n\tmenu.Icon = params.Icon\n\tmenu.Pid = params.Pid\n\tmenu.Code = params.Code\n\tmenu.Path = params.Path\n\tmenu.Name = params.Name\n\tmenu.Component = params.Component\n\tmenu.Status = params.Status\n\tmenu.Redirect = params.Redirect\n\tmenu.AnimateEnter = params.AnimateEnter\n\tmenu.AnimateLeave = params.AnimateLeave\n\tmenu.AnimateDuration = params.AnimateDuration\n\tmenu.IsShow = params.IsShow\n\tmenu.IsAuth = params.IsAuth\n\tmenu.IsNewWindow = params.IsNewWindow\n\tmenu.Sort = params.Sort\n\tmenu.Type = params.Type\n\tmenu.Description = params.Description\n\tmenu.IsExternalLinks = params.IsExternalLinks\n\t// 按钮类型无 full_path\n\tif params.Type == model.BUTTON {\n\t\tmenu.FullPath = \"\"\n\t}\n}\n\n// validateUniqueFields 验证菜单唯一字段：code、name、full_path。\nfunc (s *MenuService) validateUniqueFields(menu *model.Menu, params *menuMutation, excludeId uint) error {\n\t// 验证 code 唯一性\n\tcodeExists, err := menu.ExistsExcludeId(\"code\", params.Code, excludeId)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif params.Code != \"\" && codeExists {\n\t\treturn e.NewBusinessError(e.MenuCodeExists)\n\t}\n\n\t// 验证 name 唯一性\n\tnameExists, err := menu.ExistsExcludeId(\"name\", params.Name, excludeId)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif params.Name != \"\" && nameExists {\n\t\treturn e.NewBusinessError(e.MenuRouteNameExists)\n\t}\n\n\t// 验证 full_path 唯一性（按钮类型除外）\n\tif params.Type != model.BUTTON && menu.Path != \"\" {\n\t\tpathExists, err := menu.ExistsExcludeId(\"full_path\", menu.FullPath, excludeId)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif pathExists {\n\t\t\treturn e.NewBusinessError(e.MenuPathExists)\n\t\t}\n\t}\n\treturn nil\n}\n\n// executeEditTransaction 执行菜单编辑事务。\n// 处理逻辑：\n// 1. 保存菜单数据\n// 2. 级联更新子菜单的 level、pids、full_path\n// 3. 更新原父菜单和新父菜单的子菜单数量\n// 4. 更新菜单关联的 API 权限\n// 5. 同步受影响用户的权限缓存\nfunc (s *MenuService) executeEditTransaction(menu *model.Menu, apiList []uint, titleI18n map[string]string, editContext *menuEditContext) error {\n\tdb, err := menu.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\t// 保存菜单数据\n\t\tif err := s.persistMenu(menu, tx); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := model.NewMenuI18n().UpsertMenuTitles(menu.ID, titleI18n, tx); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// 级联更新子菜单\n\t\tif err := s.updateChildrenLevels(menu, editContext.originPids, editContext.originFullPath, tx); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := s.updateParentChildrenNum(menu, editContext, tx); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// 更新菜单关联的 API 权限\n\t\treturn s.updateMenuPermissions(menu, apiList, tx)\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\t// 同步受影响用户的权限缓存\n\treturn access.NewPermissionSyncCoordinator().SyncUsersAffectedByMenus([]uint{menu.ID})\n}\n\n// persistMenu 持久化菜单数据。\nfunc (s *MenuService) persistMenu(menu *model.Menu, tx *gorm.DB) error {\n\tmenu.SetDB(tx)\n\treturn menu.Save()\n}\n\n// updateParentChildrenNum 在父节点变更后刷新原父与新父的 children_num。\nfunc (s *MenuService) updateParentChildrenNum(menu *model.Menu, editContext *menuEditContext, tx *gorm.DB) error {\n\t// 原父节点的子节点数减一\n\tif editContext.originPid > 0 && editContext.originPid != menu.Pid {\n\t\tif err := model.UpdateChildrenNum(model.NewMenu(), editContext.originPid, tx); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\t// 新父节点的子节点数加一\n\tif menu.Pid > 0 && menu.Pid != editContext.originPid {\n\t\tif err := model.UpdateChildrenNum(model.NewMenu(), menu.Pid, tx); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// updateChildrenLevels 更新子菜单的层级信息（pids, level, full_path）。\n// 当菜单的 pids 或 full_path 变更时，需要级联更新所有子代菜单。\nfunc (s *MenuService) updateChildrenLevels(menu *model.Menu, originPids string, originFullPath string, tx *gorm.DB) error {\n\t// pids 和 full_path 都未变更，无需级联更新\n\tif menu.Pids == originPids && menu.FullPath == originFullPath {\n\t\treturn nil\n\t}\n\n\t// 查询所有子代菜单\n\tdescendantModel := model.NewMenu()\n\tdescendantModel.SetDB(tx)\n\tdescendants, err := descendantModel.FindDescendantsById(menu.ID)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(descendants) == 0 {\n\t\treturn nil\n\t}\n\n\t// 按 pid 分组，便于递归重建\n\tchildrenByPID := s.groupDescendantsByPID(descendants)\n\treturn s.rebuildMenuDescendants(tx, menu, childrenByPID)\n}\n\n// groupDescendantsByPID 按父节点分组子菜单。\n// 这里必须使用索引取址，避免 range 临时变量取址导致所有指针指向同一对象。\nfunc (s *MenuService) groupDescendantsByPID(descendants []model.Menu) map[uint][]*model.Menu {\n\tchildrenByPID := make(map[uint][]*model.Menu, len(descendants))\n\tfor i := range descendants {\n\t\tchild := &descendants[i]\n\t\tchildrenByPID[child.Pid] = append(childrenByPID[child.Pid], child)\n\t}\n\treturn childrenByPID\n}\n\n// rebuildMenuDescendants 递归重建子代菜单的层级信息。\nfunc (s *MenuService) rebuildMenuDescendants(tx *gorm.DB, parent *model.Menu, childrenByPID map[uint][]*model.Menu) error {\n\tmenuModel := model.NewMenu()\n\tmenuModel.SetDB(tx)\n\tfor _, child := range childrenByPID[parent.ID] {\n\t\t// 重建子菜单的 pids、level、full_path\n\t\tchild.Pids = s.buildPids(parent.Pids, parent.ID)\n\t\tchild.Level = parent.Level + 1\n\t\tchild.FullPath = s.buildFullPath(child.Path, parent.FullPath, child.Type)\n\t\tif child.Type == model.BUTTON {\n\t\t\tchild.FullPath = \"\"\n\t\t}\n\t\t// 批量更新数据库\n\t\tif err := menuModel.UpdateById(child.ID, map[string]any{\n\t\t\t\"pids\":      child.Pids,\n\t\t\t\"level\":     child.Level,\n\t\t\t\"full_path\": child.FullPath,\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// 递归处理下一级子菜单\n\t\tif err := s.rebuildMenuDescendants(tx, child, childrenByPID); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// updateMenuPermissions 更新菜单关联的 API 权限。\n// 使用差分算法：计算需要删除和新增的 API ID，只变更差异部分。\nfunc (s *MenuService) updateMenuPermissions(menu *model.Menu, apiList []uint, tx ...*gorm.DB) error {\n\tmenuApiMap := model.NewMenuApiMap()\n\tif len(tx) > 0 {\n\t\tmenuApiMap.SetDB(tx[0])\n\t}\n\n\t// 查询菜单当前已关联的 API ID 列表\n\texistingMaps, err := model.ListE(menuApiMap, \"menu_id = ?\", []any{menu.ID}, model.ListOptionalParams{\n\t\tSelectFields: []string{\"api_id\"},\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\texistingIDs := lo.Map(existingMaps, func(m *model.MenuApiMap, _ int) uint {\n\t\treturn m.ApiId\n\t})\n\tapiList = lo.Uniq(apiList)\n\t// 计算差异\n\ttoDelete, toAdd := lo.Difference(existingIDs, apiList)\n\n\t// 删除差异 API 关联\n\tif len(toDelete) > 0 {\n\t\tif err := menuApiMap.DeleteWhere(\"menu_id = ? AND api_id IN (?)\", []any{menu.ID, toDelete}...); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif len(toAdd) == 0 {\n\t\treturn nil\n\t}\n\n\t// 新增 API 关联\n\tnewMappings := lo.Map(toAdd, func(apiID uint, _ int) *model.MenuApiMap {\n\t\treturn &model.MenuApiMap{MenuId: menu.ID, ApiId: apiID}\n\t})\n\treturn menuApiMap.CreateBatch(newMappings)\n}\n\n// UpdateAllMenuPermissions 批量更新所有菜单的权限到 Casbin。\nfunc (s *MenuService) UpdateAllMenuPermissions() error {\n\treturn access.NewPermissionSyncCoordinator().SyncAll()\n}\n"
  },
  {
    "path": "internal/service/menu/menu_query.go",
    "content": "package menu\n\nimport (\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/i18n\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// ListPage 分页查询菜单列表\nfunc (s *MenuService) ListPage(params *form.ListMenu) *resources.Collection {\n\tcondition, args := s.buildListCondition(params, false)\n\tmenu := model.NewMenu()\n\ttotal, collection, err := model.ListPageE(menu, params.Page, params.PerPage, condition, args)\n\tif err != nil {\n\t\treturn resources.NewMenuTransformer().ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\treturn resources.NewMenuTransformer().ToCollectionWithTitles(params.Page, params.PerPage, total, collection, nil)\n}\n\n// List 查询菜单树形列表\nfunc (s *MenuService) List(params *form.ListMenu, locale string) any {\n\tcondition, args := s.buildListCondition(params, true)\n\tmenus, err := model.ListE(model.NewMenu(), condition, args, model.ListOptionalParams{\n\t\tOrderBy: \"sort desc, id desc\",\n\t})\n\tif err != nil {\n\t\treturn resources.BuildMenuTree(nil, 0, nil)\n\t}\n\tlocaleTitles, err := s.loadLocalizedTitles(menus, locale)\n\tif err != nil {\n\t\treturn resources.BuildMenuTree(menus, 0, nil)\n\t}\n\treturn resources.BuildMenuTree(menus, 0, localeTitles)\n}\n\n// Delete 删除菜单\nfunc (s *MenuService) Delete(id uint) error {\n\tmenu := model.NewMenu()\n\tif err := menu.GetById(id); err != nil || menu.ID == 0 {\n\t\treturn e.NewBusinessError(e.MenuNotFound)\n\t}\n\tif menu.ChildrenNum > 0 {\n\t\treturn e.NewBusinessError(e.MenuHasChildren)\n\t}\n\n\tdb, err := menu.GetDB()\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.MenuCannotDelete)\n\t}\n\taffectedUserIDs, err := access.NewAffectedUsersResolver().Resolve(access.PermissionChangeScope{MenuIDs: []uint{id}})\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.MenuCannotDelete)\n\t}\n\tcoordinator := access.NewPermissionSyncCoordinator()\n\terr = access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tmenu.SetDB(tx)\n\t\tparentID := menu.Pid\n\t\tmenuApiMap := model.NewMenuApiMap()\n\t\tmenuApiMap.SetDB(tx)\n\t\tif err := menuApiMap.DeleteWhere(\"menu_id = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\t\troleMenuMap := model.NewRoleMenuMap()\n\t\troleMenuMap.SetDB(tx)\n\t\tif err := roleMenuMap.DeleteWhere(\"menu_id = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, deleteErr := menu.DeleteByID(id); deleteErr != nil {\n\t\t\treturn deleteErr\n\t\t}\n\t\tif err := model.NewMenuI18n().DeleteByMenuIDs([]uint{id}, tx); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif parentID > 0 {\n\t\t\tif err := model.UpdateChildrenNum(model.NewMenu(), parentID, tx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn coordinator.SyncUsers(affectedUserIDs, tx)\n\t})\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.MenuCannotDelete)\n\t}\n\treturn coordinator.ReloadPolicyCacheWithCode(e.MenuCannotDelete)\n}\n\n// Detail 获取菜单详情\nfunc (s *MenuService) Detail(id uint, _ string) (any, error) {\n\tmenu := model.NewMenu()\n\tif err := menu.GetAllById(id); err != nil || menu.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.MenuNotFound)\n\t}\n\ttitleI18n, err := model.NewMenuI18n().LocaleTitleMapByMenuID(menu.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn resources.NewMenuTransformer().ToStructWithTitles(menu, \"\", titleI18n), nil\n}\n\nfunc (s *MenuService) buildListCondition(params *form.ListMenu, includeStatus bool) (string, []any) {\n\tqb := query_builder.New()\n\tif params.Keyword != \"\" {\n\t\tqb.AddCondition(\"(id IN (SELECT menu_id FROM menu_i18n WHERE title like ?) OR path like ? OR code = ?)\", \"%\"+params.Keyword+\"%\", \"%\"+params.Keyword+\"%\", params.Keyword)\n\t}\n\tqb.AddEq(\"is_auth\", params.IsAuth)\n\tif includeStatus && params.Status != nil && *params.Status != allStatus {\n\t\tqb.AddEq(\"status\", params.Status)\n\t}\n\treturn qb.Build()\n}\n\nfunc (s *MenuService) loadLocalizedTitles(menus []*model.Menu, locale string) (map[uint]string, error) {\n\tmenuIDs := make([]uint, 0, len(menus))\n\tfor _, menu := range menus {\n\t\tif menu == nil {\n\t\t\tcontinue\n\t\t}\n\t\tmenuIDs = append(menuIDs, menu.ID)\n\t}\n\tif len(menuIDs) == 0 {\n\t\treturn map[uint]string{}, nil\n\t}\n\treturn model.NewMenuI18n().LocalizedTitleMapByMenuIDs(menuIDs, menuLocalePriority(locale))\n}\n\nfunc menuLocalePriority(locale string) []string {\n\treturn []string{\n\t\ti18n.NormalizeLocale(locale),\n\t\ti18n.LocaleZhCN,\n\t\ti18n.LocaleEnUS,\n\t}\n}\n"
  },
  {
    "path": "internal/service/menu/menu_test.go",
    "content": "package menu\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/i18n\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nfunc TestMenuBuildListCondition(t *testing.T) {\n\tisAuth := int8(1)\n\tstatus := int8(1)\n\tparams := &form.ListMenu{\n\t\tKeyword: \"dashboard\",\n\t\tIsAuth:  &isAuth,\n\t\tStatus:  &status,\n\t}\n\n\tcondition, args := NewMenuService().buildListCondition(params, true)\n\texpected := \"(id IN (SELECT menu_id FROM menu_i18n WHERE title like ?) OR path like ? OR code = ?) AND is_auth = ? AND status = ?\"\n\tif condition != expected {\n\t\tt.Fatalf(\"unexpected condition: %s\", condition)\n\t}\n\tif len(args) != 5 {\n\t\tt.Fatalf(\"unexpected args len: %d\", len(args))\n\t}\n}\n\nfunc TestAssembleFullPath(t *testing.T) {\n\tservice := NewMenuService()\n\tif got := service.buildFullPath(\"users\", \"/admin\", model.MENU); got != \"/admin/users\" {\n\t\tt.Fatalf(\"unexpected full path: %s\", got)\n\t}\n\tif got := service.buildFullPath(\"https://example.com\", \"/admin\", model.MENU); got != \"https://example.com\" {\n\t\tt.Fatalf(\"unexpected external path: %s\", got)\n\t}\n\tif got := service.buildFullPath(\"button\", \"/admin\", model.BUTTON); got != \"\" {\n\t\tt.Fatalf(\"expected empty path for button, got %s\", got)\n\t}\n}\n\nfunc TestBuildPids(t *testing.T) {\n\tservice := NewMenuService()\n\tif got := service.buildPids(\"0,1\", 10); got != \"0,1,10\" {\n\t\tt.Fatalf(\"unexpected pids: %s\", got)\n\t}\n\tif got := service.buildPids(\"\", 10); got != \"10\" {\n\t\tt.Fatalf(\"unexpected root pids: %s\", got)\n\t}\n}\n\nfunc TestMenuLocalePriority(t *testing.T) {\n\tpriorities := menuLocalePriority(\"ja-JP\")\n\tif len(priorities) != 3 {\n\t\tt.Fatalf(\"unexpected priorities length: %d\", len(priorities))\n\t}\n\tif priorities[0] != i18n.LocaleZhCN || priorities[2] != i18n.LocaleEnUS {\n\t\tt.Fatalf(\"unexpected priorities: %+v\", priorities)\n\t}\n}\n"
  },
  {
    "path": "internal/service/role/audit_diff.go",
    "content": "package role\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nvar roleDiffRules = []auditdiff.FieldRule{\n\t{Field: \"id\", Label: \"角色ID\"},\n\t{Field: \"code\", Label: \"角色编码\"},\n\t{Field: \"name\", Label: \"角色名称\"},\n\t{Field: \"description\", Label: \"描述\"},\n\t{\n\t\tField: \"status\",\n\t\tLabel: \"状态\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"禁用\",\n\t\t\t\"1\": \"启用\",\n\t\t},\n\t},\n\t{Field: \"pid\", Label: \"上级角色ID\"},\n\t{Field: \"pids\", Label: \"上级路径\"},\n\t{Field: \"level\", Label: \"层级\"},\n\t{Field: \"sort\", Label: \"排序\"},\n\t{Field: \"menu_list\", Label: \"菜单ID列表\"},\n}\n\n// CreateWithAuditDiff 新增角色并返回精确 change_diff。\nfunc (s *RoleService) CreateWithAuditDiff(params *form.CreateRole) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tpayload := *params\n\tpayload.Code = strings.TrimSpace(payload.Code)\n\tif payload.Code == \"\" {\n\t\tpayload.Code = s.generateRoleCode()\n\t}\n\tif err := s.Create(&payload); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotRoleByCode(payload.Code)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\treturn buildRoleDiff(nil, after), nil\n}\n\n// UpdateWithAuditDiff 更新角色并返回精确 change_diff。\nfunc (s *RoleService) UpdateWithAuditDiff(params *form.UpdateRole) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tbefore, err := s.snapshotRoleByID(params.Id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.Update(params); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotRoleByID(params.Id)\n\tif err != nil {\n\t\treturn auditdiff.Marshal(nil), nil\n\t}\n\treturn buildRoleDiff(before, after), nil\n}\n\n// DeleteWithAuditDiff 删除角色并返回精确 change_diff。\nfunc (s *RoleService) DeleteWithAuditDiff(id uint) (string, error) {\n\tbefore, err := s.snapshotRoleByID(id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.Delete(id); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn buildRoleDiff(before, nil), nil\n}\n\nfunc (s *RoleService) snapshotRoleByCode(code string) (map[string]any, error) {\n\trole := model.NewRole()\n\tif err := role.FindByCode(strings.TrimSpace(code)); err != nil || role.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.RoleNotFound)\n\t}\n\treturn s.snapshotRoleByID(role.ID)\n}\n\nfunc (s *RoleService) snapshotRoleByID(id uint) (map[string]any, error) {\n\trole := model.NewRole()\n\tif err := role.GetById(id); err != nil || role.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.RoleNotFound)\n\t}\n\tmenuIDs, err := model.NewRoleMenuMap().MenuIdsByRoleIds([]uint{id})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsort.Slice(menuIDs, func(i, j int) bool {\n\t\treturn menuIDs[i] < menuIDs[j]\n\t})\n\treturn map[string]any{\n\t\t\"id\":          role.ID,\n\t\t\"code\":        role.Code,\n\t\t\"name\":        role.Name,\n\t\t\"description\": role.Description,\n\t\t\"status\":      role.Status,\n\t\t\"pid\":         role.Pid,\n\t\t\"pids\":        role.Pids,\n\t\t\"level\":       role.Level,\n\t\t\"sort\":        role.Sort,\n\t\t\"menu_list\":   menuIDs,\n\t}, nil\n}\n\nfunc buildRoleDiff(before, after map[string]any) string {\n\titems := auditdiff.BuildFieldDiff(before, after, roleDiffRules)\n\treturn auditdiff.Marshal(items)\n}\n"
  },
  {
    "path": "internal/service/role/audit_diff_test.go",
    "content": "package role\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestBuildRoleDiffIncludesStatusDisplay(t *testing.T) {\n\traw := buildRoleDiff(\n\t\tmap[string]any{\"status\": uint8(0)},\n\t\tmap[string]any{\"status\": uint8(1)},\n\t)\n\tvar items []map[string]any\n\tif err := json.Unmarshal([]byte(raw), &items); err != nil {\n\t\tt.Fatalf(\"expected valid json diff, got err=%v raw=%s\", err, raw)\n\t}\n\tif len(items) != 1 {\n\t\tt.Fatalf(\"expected 1 diff item, got %d\", len(items))\n\t}\n\tif items[0][\"before_display\"] != \"禁用\" || items[0][\"after_display\"] != \"启用\" {\n\t\tt.Fatalf(\"unexpected status display mapping: %#v\", items[0])\n\t}\n}\n"
  },
  {
    "path": "internal/service/role/role.go",
    "content": "package role\n\nimport (\n\t\"strconv\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nconst (\n\tmaxRoleLevel      = 2\n\tmaxChildrenPerTop = 5\n)\n\n// RoleService 角色服务。\ntype RoleService struct {\n\tservice.Base\n}\n\n// NewRoleService 创建角色服务实例。\nfunc NewRoleService() *RoleService {\n\treturn &RoleService{}\n}\n\n// List 分页查询角色列表。\nfunc (s *RoleService) List(params *form.RoleList) interface{} {\n\tcondition, args := s.buildListCondition(params)\n\n\troleModel := model.NewRole()\n\ttotal, collection, err := model.ListPageE(\n\t\troleModel,\n\t\tparams.Page,\n\t\tparams.PerPage,\n\t\tcondition,\n\t\targs,\n\t\tmodel.ListOptionalParams{OrderBy: \"sort desc, id desc\"},\n\t)\n\tif err != nil {\n\t\treturn resources.ToRawCollection(params.Page, params.PerPage, 0, make([]*model.Role, 0))\n\t}\n\n\treturn resources.ToRawCollection(params.Page, params.PerPage, total, collection)\n}\n\nfunc (s *RoleService) buildListCondition(params *form.RoleList) (string, []any) {\n\treturn query_builder.New().\n\t\tAddLike(\"name\", params.Name).\n\t\tAddEq(\"status\", params.Status).\n\t\tAddEq(\"pid\", params.Pid).\n\t\tBuild()\n}\n\n// Create 新增角色。\nfunc (s *RoleService) Create(params *form.CreateRole) error {\n\treturn s.applyRoleMutation(&roleMutation{\n\t\tCode:        params.Code,\n\t\tName:        params.Name,\n\t\tDescription: params.Description,\n\t\tStatus:      params.Status,\n\t\tPid:         params.Pid,\n\t\tSort:        params.Sort,\n\t\tMenuList:    params.MenuList,\n\t})\n}\n\n// Update 更新角色。\nfunc (s *RoleService) Update(params *form.UpdateRole) error {\n\treturn s.applyRoleMutation(&roleMutation{\n\t\tId:          params.Id,\n\t\tName:        params.Name,\n\t\tDescription: params.Description,\n\t\tStatus:      params.Status,\n\t\tPid:         params.Pid,\n\t\tSort:        params.Sort,\n\t\tMenuList:    params.MenuList,\n\t})\n}\n\n// Delete 删除角色。\nfunc (s *RoleService) Delete(id uint) error {\n\trole := model.NewRole()\n\tif err := role.GetById(id); err != nil || role.ID == 0 {\n\t\treturn e.NewBusinessError(e.RoleNotFound)\n\t}\n\tif access.NewSystemDefaultsService().IsProtectedRole(role) {\n\t\treturn e.NewBusinessError(e.SuperAdminCannotDelete)\n\t}\n\tif role.ChildrenNum > 0 {\n\t\treturn e.NewBusinessError(e.RoleHasChildren)\n\t}\n\n\treturn s.executeDeleteTransaction(role, id)\n}\n\n// Detail 获取角色详情。\nfunc (s *RoleService) Detail(id uint) (any, error) {\n\trole := model.NewRole()\n\tif err := role.GetAllById(id); err != nil || role.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.RoleNotFound)\n\t}\n\treturn resources.NewRoleTransformer().ToStruct(role), nil\n}\n\n// GetRoleMenus 获取角色的所有菜单标识列表。\n// 调用跨包方法 access.UserPermissionSyncService.RoleMenuIDs 获取菜单 ID，再转换为字符串列表。\nfunc (s *RoleService) GetRoleMenus(roleId uint) ([]string, error) {\n\trole := model.NewRole()\n\tif err := role.GetById(roleId); err != nil || role.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.RoleNotFound)\n\t}\n\n\tmenuIDs, err := access.NewUserPermissionSyncService().RoleMenuIDs([]uint{roleId})\n\tif err != nil {\n\t\treturn nil, e.NewBusinessError(e.FAILURE)\n\t}\n\n\tresult := make([]string, 0, len(menuIDs))\n\tfor _, menuID := range menuIDs {\n\t\tresult = append(result, strconv.FormatUint(uint64(menuID), 10))\n\t}\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/service/role/role_mutation.go",
    "content": "package role\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/samber/lo\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\tutils2 \"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\n// roleMutation 角色变更参数，用于封装新增/更新角色的请求数据。\ntype roleMutation struct {\n\tId          uint   // 角色 ID，0 表示新增\n\tCode        string // 角色编码，新增时为空则自动生成\n\tName        string // 角色名称\n\tDescription string // 角色描述\n\tStatus      uint8  // 角色状态\n\tPid         uint   // 父角色 ID，0 表示顶级角色\n\tSort        uint   // 排序权重\n\tMenuList    []uint // 关联的菜单 ID 列表\n}\n\n// applyRoleMutation 执行角色变更操作（新增/更新）。\n// 处理逻辑：\n// 1. 验证角色是否存在（更新时）\n// 2. 检查受保护角色（系统默认角色不可修改）\n// 3. 验证并构建树形路径（pids, level）\n// 4. 填充角色基础字段\n// 5. 验证菜单列表\n// 6. 事务保存：角色数据、级联更新子角色 pids、更新子角色数量、同步菜单关联\n// 7. 同步受影响角色的用户权限缓存\nfunc (s *RoleService) applyRoleMutation(params *roleMutation) error {\n\trole := model.NewRole()\n\toriginPids := \"0\"\n\toriginPid := uint(0)\n\t// 更新场景：加载现有角色数据，记录原始 pids 用于后续级联判断\n\tif params.Id > 0 {\n\t\tif err := role.GetById(params.Id); err != nil || role.ID == 0 {\n\t\t\treturn e.NewBusinessError(e.RoleNotFound)\n\t\t}\n\t\toriginPids = role.Pids\n\t\toriginPid = role.Pid\n\t}\n\t// 检查是否为受保护角色（系统默认角色不可修改）\n\tif params.Id > 0 && access.NewSystemDefaultsService().IsProtectedRole(role) {\n\t\treturn e.NewBusinessError(e.SuperAdminCannotModify)\n\t}\n\n\t// 处理父角色变更：验证父角色、检测环路、计算层级和路径\n\tif params.Pid > 0 && params.Pid != role.Pid {\n\t\tparentRole := model.NewRole()\n\t\tif err := parentRole.GetById(params.Pid); err != nil || parentRole.ID == 0 {\n\t\t\treturn e.NewBusinessError(e.ParentRoleNotExists)\n\t\t}\n\n\t\t// 环路检测：当前角色若已在父角色的祖先路径上，选择该父角色会形成环\n\t\tif role.ID > 0 && utils2.WouldCauseCycle(role.ID, params.Pid, parentRole.Pids) {\n\t\t\treturn e.NewBusinessError(e.ParentRoleInvalid)\n\t\t}\n\n\t\t// 限制顶级角色的子角色数量\n\t\tif parentRole.Pid == 0 && (role.ID == 0 || role.Pid != params.Pid) && parentRole.ChildrenNum >= maxChildrenPerTop {\n\t\t\treturn e.NewBusinessError(e.MaxChildRoles)\n\t\t}\n\n\t\t// 构建新的层级和路径：父层级 +1，pids = 父 pids + 父 ID\n\t\trole.Level = parentRole.Level + 1\n\t\tif parentRole.Pids == \"0\" || parentRole.Pids == \"\" {\n\t\t\trole.Pids = fmt.Sprintf(\"%d\", parentRole.ID)\n\t\t} else {\n\t\t\trole.Pids = fmt.Sprintf(\"%s,%d\", parentRole.Pids, parentRole.ID)\n\t\t}\n\t\trole.Pid = params.Pid\n\t} else if params.Pid == 0 {\n\t\t// 设置为顶级角色\n\t\trole.Level = 1\n\t\trole.Pids = \"0\"\n\t\trole.Pid = 0\n\t} else {\n\t\t// 父角色未变更，仅同步 pid 字段\n\t\trole.Pid = params.Pid\n\t}\n\t// 检查角色层级深度是否超限\n\tif role.Level > maxRoleLevel {\n\t\treturn e.NewBusinessError(e.MaxRoleDepth)\n\t}\n\n\t// 新增角色时生成 code\n\tif params.Id == 0 {\n\t\tif params.Code != \"\" {\n\t\t\trole.Code = params.Code\n\t\t} else if role.Code == \"\" {\n\t\t\trole.Code = s.generateRoleCode()\n\t\t}\n\t}\n\t// 填充可变更字段\n\trole.Name = params.Name\n\trole.Description = params.Description\n\trole.Status = params.Status\n\trole.Sort = params.Sort\n\n\t// 验证所有菜单 ID 是否存在\n\tmenuList, err := model.VerifyExistingIDs(model.NewMenu(), params.MenuList)\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.MenuNotFound)\n\t}\n\n\tdb, err := role.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\t// 事务执行：保存角色、级联更新、菜单同步\n\terr = access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\trole.SetDB(tx)\n\n\t\tif err := role.Save(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// pids 变更时，级联更新所有子角色的 pids 路径\n\t\tif role.Pids != originPids {\n\t\t\tupdateExpr := s.buildPidsUpdateExpr(originPids, role.Pids)\n\t\t\troleModel := model.NewRole()\n\t\t\troleModel.SetDB(tx)\n\t\t\tif err := roleModel.UpdateChildrenPidsByParent(role.ID, updateExpr); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// 原父角色的子角色数量减 1\n\t\tif originPid > 0 && originPid != role.Pid {\n\t\t\tif err := model.UpdateChildrenNum(model.NewRole(), originPid, tx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\t// 新父角色的子角色数量加 1\n\t\tif role.Pid > 0 && role.Pid != originPid {\n\t\t\tif err := model.UpdateChildrenNum(model.NewRole(), role.Pid, tx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn s.updateRoleMenu(role.ID, menuList, tx)\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\t// 同步受影响角色的用户权限缓存\n\treturn access.NewPermissionSyncCoordinator().SyncUsersAffectedByRoles([]uint{role.ID})\n}\n\n// generateRoleCode 生成角色唯一编码，格式：role_{uuid}。\nfunc (s *RoleService) generateRoleCode() string {\n\treturn \"role_\" + uuid.NewString()\n}\n\n// buildPidsUpdateExpr 构建 SQL CASE 表达式，用于级联更新子角色的 pids 路径。\n// 场景：当某角色的 pids 变更时，其所有子角色的 pids 前缀需要同步更新。\n// 参数：\n//   - originPids: 原始路径\n//   - newPids: 新路径\n//\n// 返回：SQL CASE 表达式字符串\n// 示例：originPids=\"1,2\", newPids=\"1,8\" 时，子角色 \"1,2,3\" → \"1,8,3\"\nfunc (s *RoleService) buildPidsUpdateExpr(originPids, newPids string) string {\n\tif originPids == \"0\" {\n\t\treturn fmt.Sprintf(\n\t\t\t\"CASE WHEN pids = '0' THEN '%s' WHEN pids LIKE '0,%%' THEN CONCAT('%s,', SUBSTRING(pids, 3)) ELSE pids END\",\n\t\t\tnewPids, newPids,\n\t\t)\n\t}\n\n\treturn fmt.Sprintf(\n\t\t\"CASE WHEN pids = '%s' THEN '%s' WHEN pids LIKE '%s,%%' THEN CONCAT('%s,', SUBSTRING(pids, %d)) ELSE pids END\",\n\t\toriginPids, newPids, originPids, newPids, len(originPids)+2,\n\t)\n}\n\n// updateRoleMenu 更新角色的菜单关联关系。\n// 使用差分算法：计算需要删除和新增的菜单 ID，只变更差异部分。\n// 参数：\n//   - roleId: 角色 ID\n//   - menuList: 目标菜单 ID 列表\n//   - tx: 可选的事务 DB 实例\nfunc (s *RoleService) updateRoleMenu(roleId uint, menuList []uint, tx ...*gorm.DB) error {\n\troleMenuMap := model.NewRoleMenuMap()\n\tif len(tx) > 0 {\n\t\troleMenuMap.SetDB(tx[0])\n\t}\n\n\t// 查询角色当前已关联的菜单 ID 列表\n\texistingIds, err := model.ExtractColumnsByCondition[model.RoleMenuMap, *model.RoleMenuMap, uint](roleMenuMap, \"menu_id\", \"role_id = ?\", roleId)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// 计算差异：toDelete 需删除，toAdd 需新增\n\ttoDelete, toAdd, _ := utils.CalculateChanges(existingIds, menuList)\n\t// 批量删除差异菜单关联\n\tif len(toDelete) > 0 {\n\t\tif err := roleMenuMap.DeleteWhere(\"role_id = ? AND menu_id IN (?)\", []any{roleId, toDelete}...); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// 批量新增菜单关联\n\tif len(toAdd) > 0 {\n\t\tnewMappings := lo.Map(toAdd, func(menuId uint, _ int) *model.RoleMenuMap {\n\t\t\treturn &model.RoleMenuMap{RoleId: roleId, MenuId: menuId}\n\t\t})\n\t\tif err := roleMenuMap.CreateBatch(newMappings); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// executeDeleteTransaction 执行角色删除事务。\n// 处理逻辑：\n// 1. 删除角色 - 菜单关联\n// 2. 删除角色记录\n// 3. 更新原父角色的子角色数量\n// 4. 同步受影响用户的权限缓存\nfunc (s *RoleService) executeDeleteTransaction(role *model.Role, id uint) error {\n\tdb, err := role.GetDB()\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.RoleCannotDelete)\n\t}\n\taffectedUserIDs, err := access.NewAffectedUsersResolver().Resolve(access.PermissionChangeScope{RoleIDs: []uint{id}})\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.RoleCannotDelete)\n\t}\n\tcoordinator := access.NewPermissionSyncCoordinator()\n\terr = access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\trole.SetDB(tx)\n\n\t\t// 删除角色关联的所有菜单\n\t\troleMenuMap := model.NewRoleMenuMap()\n\t\troleMenuMap.SetDB(tx)\n\t\tif err := roleMenuMap.DeleteWhere(\"role_id = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tadminUserRoleMap := model.NewAdminUserRoleMap()\n\t\tadminUserRoleMap.SetDB(tx)\n\t\tif err := adminUserRoleMap.DeleteWhere(\"role_id = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdeptRoleMap := model.NewDeptRoleMap()\n\t\tdeptRoleMap.SetDB(tx)\n\t\tif err := deptRoleMap.DeleteWhere(\"role_id = ?\", id); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// 删除角色记录\n\t\tparentId := role.Pid\n\t\tif _, err := role.DeleteByID(id); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// 更新原父角色的子角色数量（减 1）\n\t\tif parentId > 0 {\n\t\t\tif err := model.UpdateChildrenNum(model.NewRole(), parentId, tx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn coordinator.SyncUsers(affectedUserIDs, tx)\n\t})\n\tif err != nil {\n\t\treturn e.NewBusinessError(e.RoleCannotDelete)\n\t}\n\t// 同步受影响用户的权限缓存\n\treturn coordinator.ReloadPolicyCacheWithCode(e.RoleCannotDelete)\n}\n"
  },
  {
    "path": "internal/service/role/role_test.go",
    "content": "package role\n\nimport \"testing\"\n\nfunc TestGenerateRoleCodeUsesUniqueDefaultPrefix(t *testing.T) {\n\tservice := NewRoleService()\n\n\tfirst := service.generateRoleCode()\n\tsecond := service.generateRoleCode()\n\n\tif first == \"\" || second == \"\" {\n\t\tt.Fatal(\"expected generated role code\")\n\t}\n\tif first == second {\n\t\tt.Fatalf(\"expected different role codes, got %s\", first)\n\t}\n\tif first[:5] != \"role_\" || second[:5] != \"role_\" {\n\t\tt.Fatalf(\"expected role_ prefix, got %s and %s\", first, second)\n\t}\n}\n"
  },
  {
    "path": "internal/service/storage_config.go",
    "content": "package service\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/filestorage\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/i18n\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"gorm.io/gorm\"\n)\n\nconst (\n\tStorageActiveDriverConfigKey = \"storage.active_driver\"\n\tStorageConfigConfigKey       = \"storage.config\"\n\tstorageConfigManageTab       = \"storage\"\n)\n\ntype StorageConfigService struct{}\n\nfunc NewStorageConfigService() *StorageConfigService {\n\treturn &StorageConfigService{}\n}\n\ntype StorageSettings struct {\n\tActiveDriver string             `json:\"active_driver\"`\n\tConfig       filestorage.Config `json:\"config\"`\n}\n\nfunc (s *StorageConfigService) Get(maskSensitive bool) (StorageSettings, error) {\n\tsettings := StorageSettings{ActiveDriver: model.StorageDriverLocal, Config: filestorage.DefaultConfig()}\n\tif value, err := storageSysConfigValue(StorageActiveDriverConfigKey); err == nil && strings.TrimSpace(value) != \"\" {\n\t\tsettings.ActiveDriver = strings.TrimSpace(value)\n\t}\n\tif value, err := storageSysConfigValue(StorageConfigConfigKey); err == nil && strings.TrimSpace(value) != \"\" {\n\t\t_ = json.Unmarshal([]byte(value), &settings.Config)\n\t}\n\tif settings.Config.SignedURLTTLSeconds <= 0 {\n\t\tsettings.Config.SignedURLTTLSeconds = 300\n\t}\n\tif settings.Config.MaxFileSizeMB <= 0 {\n\t\tsettings.Config.MaxFileSizeMB = 10\n\t}\n\tapplyLocalStorageDefaults(&settings)\n\tif maskSensitive {\n\t\tmaskStorageSettings(&settings)\n\t}\n\treturn settings, nil\n}\n\nfunc (s *StorageConfigService) Save(next StorageSettings) error {\n\tif err := validateStorageDriver(next.ActiveDriver); err != nil {\n\t\treturn err\n\t}\n\tcurrent, _ := s.Get(false)\n\tmergeSensitiveStorageConfig(&next.Config, current.Config)\n\tpayload, err := json.Marshal(next.Config)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\terr = access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tif err := upsertStorageSysConfig(tx, StorageActiveDriverConfigKey, next.ActiveDriver, model.SysConfigValueTypeString, 0, \"当前启用的文件存储驱动\", map[string]string{\n\t\t\ti18n.LocaleZhCN: \"当前存储驱动\",\n\t\t\ti18n.LocaleEnUS: \"Active Storage Driver\",\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn upsertStorageSysConfig(tx, StorageConfigConfigKey, string(payload), model.SysConfigValueTypeJSON, 1, \"文件存储配置\", map[string]string{\n\t\t\ti18n.LocaleZhCN: \"文件存储配置\",\n\t\t\ti18n.LocaleEnUS: \"File Storage Config\",\n\t\t})\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc storageSysConfigValue(key string) (string, error) {\n\tconfigModel := model.NewSysConfig()\n\tif err := configModel.FindByKey(key); err != nil {\n\t\treturn \"\", err\n\t}\n\tif configModel.Status != global.Yes {\n\t\treturn \"\", gorm.ErrRecordNotFound\n\t}\n\treturn configModel.ConfigValue, nil\n}\n\nfunc (s *StorageConfigService) Test(ctx context.Context, settings StorageSettings) error {\n\tdriver, err := buildStorageDriver(ctx, settings.ActiveDriver, settings.Config)\n\tif err != nil {\n\t\treturn err\n\t}\n\tkey := \"storage-test/\" + time.Now().Format(\"20060102150405\") + \".txt\"\n\tbucket := bucketForDriver(settings.ActiveDriver, settings.Config, global.Yes)\n\tif _, err := driver.Put(ctx, filestorage.PutInput{Bucket: bucket, ObjectKey: key, Reader: strings.NewReader(\"ok\"), Size: 2, ContentType: \"text/plain\"}); err != nil {\n\t\treturn err\n\t}\n\texists, err := driver.Exists(ctx, bucket, key)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn fmt.Errorf(\"storage object not found after put\")\n\t}\n\tif _, err := driver.SignedURL(ctx, bucket, key, time.Minute); err != nil {\n\t\treturn err\n\t}\n\treturn driver.Delete(ctx, bucket, key)\n}\n\nfunc NewActiveStorageDriver(ctx context.Context) (filestorage.Driver, filestorage.Config, string, error) {\n\tsettings, err := NewStorageConfigService().Get(false)\n\tif err != nil {\n\t\treturn nil, filestorage.Config{}, \"\", err\n\t}\n\tdriver, err := buildStorageDriver(ctx, settings.ActiveDriver, settings.Config)\n\treturn driver, settings.Config, settings.ActiveDriver, err\n}\n\nfunc NewStorageDriverByName(ctx context.Context, driverName string) (filestorage.Driver, filestorage.Config, error) {\n\tsettings, err := NewStorageConfigService().Get(false)\n\tif err != nil {\n\t\treturn nil, filestorage.Config{}, err\n\t}\n\tdriver, err := buildStorageDriver(ctx, driverName, settings.Config)\n\treturn driver, settings.Config, err\n}\n\nfunc buildStorageDriver(ctx context.Context, driverName string, config filestorage.Config) (filestorage.Driver, error) {\n\tif err := validateStorageDriver(driverName); err != nil {\n\t\treturn nil, err\n\t}\n\tswitch driverName {\n\tcase model.StorageDriverLocal:\n\t\treturn filestorage.NewLocalDriver(config.Local, storageBasePath(true), storageBasePath(false)), nil\n\tcase model.StorageDriverAliyunOSS:\n\t\tif strings.TrimSpace(config.AliyunOSS.Bucket) == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"aliyun_oss.bucket is required\")\n\t\t}\n\t\treturn filestorage.NewAliyunOSSDriver(config.AliyunOSS), nil\n\tcase model.StorageDriverS3:\n\t\tif strings.TrimSpace(config.S3.Bucket) == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"s3.bucket is required\")\n\t\t}\n\t\treturn filestorage.NewS3Driver(ctx, config.S3)\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported storage driver: %s\", driverName)\n\t}\n}\n\nfunc bucketForDriver(driverName string, config filestorage.Config, isPublic uint8) string {\n\tswitch driverName {\n\tcase model.StorageDriverAliyunOSS:\n\t\treturn config.AliyunOSS.Bucket\n\tcase model.StorageDriverS3:\n\t\treturn config.S3.Bucket\n\tdefault:\n\t\tif isPublic == global.Yes {\n\t\t\treturn \"public\"\n\t\t}\n\t\treturn \"private\"\n\t}\n}\n\nfunc validateStorageDriver(driverName string) error {\n\tswitch driverName {\n\tcase model.StorageDriverLocal, model.StorageDriverAliyunOSS, model.StorageDriverS3:\n\t\treturn nil\n\tdefault:\n\t\treturn fmt.Errorf(\"unsupported storage driver: %s\", driverName)\n\t}\n}\n\nfunc upsertStorageSysConfig(tx *gorm.DB, key, value, valueType string, sensitive uint8, remark string, names map[string]string) error {\n\tconfigModel := model.NewSysConfig()\n\tconfigModel.SetDB(tx)\n\terr := configModel.FindByKey(key)\n\tif err != nil && err != gorm.ErrRecordNotFound {\n\t\treturn err\n\t}\n\tif err == gorm.ErrRecordNotFound {\n\t\tconfigModel.ConfigKey = key\n\t}\n\tconfigModel.ConfigValue = value\n\tconfigModel.ValueType = valueType\n\tconfigModel.GroupCode = \"storage\"\n\tconfigModel.IsSystem = 1\n\tconfigModel.IsSensitive = sensitive\n\tconfigModel.IsVisible = 0\n\tconfigModel.ManageTab = storageConfigManageTab\n\tconfigModel.Status = 1\n\tconfigModel.Sort = 90\n\tconfigModel.Remark = remark\n\tif err := tx.Save(configModel).Error; err != nil {\n\t\treturn err\n\t}\n\treturn model.NewSysConfigI18n().UpsertConfigNames(configModel.ID, names, tx)\n}\n\nfunc maskStorageSettings(settings *StorageSettings) {\n\tsettings.Config.AliyunOSS.AccessKeyID = maskIfNotEmpty(settings.Config.AliyunOSS.AccessKeyID)\n\tsettings.Config.AliyunOSS.AccessKeySecret = maskIfNotEmpty(settings.Config.AliyunOSS.AccessKeySecret)\n\tsettings.Config.S3.AccessKeyID = maskIfNotEmpty(settings.Config.S3.AccessKeyID)\n\tsettings.Config.S3.SecretAccessKey = maskIfNotEmpty(settings.Config.S3.SecretAccessKey)\n}\n\nfunc mergeSensitiveStorageConfig(next *filestorage.Config, current filestorage.Config) {\n\tif shouldKeepSecret(next.AliyunOSS.AccessKeyID) {\n\t\tnext.AliyunOSS.AccessKeyID = current.AliyunOSS.AccessKeyID\n\t}\n\tif shouldKeepSecret(next.AliyunOSS.AccessKeySecret) {\n\t\tnext.AliyunOSS.AccessKeySecret = current.AliyunOSS.AccessKeySecret\n\t}\n\tif shouldKeepSecret(next.S3.AccessKeyID) {\n\t\tnext.S3.AccessKeyID = current.S3.AccessKeyID\n\t}\n\tif shouldKeepSecret(next.S3.SecretAccessKey) {\n\t\tnext.S3.SecretAccessKey = current.S3.SecretAccessKey\n\t}\n}\n\nfunc applyLocalStorageDefaults(settings *StorageSettings) {\n\tif settings == nil {\n\t\treturn\n\t}\n\tcfg := c.GetConfig()\n\tif cfg == nil {\n\t\treturn\n\t}\n\tbasePath := filepath.Join(cfg.BasePath, \"storage\")\n\tif strings.TrimSpace(settings.Config.Local.BasePath) == \"\" {\n\t\tsettings.Config.Local.BasePath = basePath\n\t}\n\tif strings.TrimSpace(settings.Config.Local.PublicBasePath) == \"\" {\n\t\tsettings.Config.Local.PublicBasePath = filepath.Join(basePath, \"public\")\n\t}\n\tif strings.TrimSpace(settings.Config.Local.PrivateBasePath) == \"\" {\n\t\tsettings.Config.Local.PrivateBasePath = filepath.Join(basePath, \"private\")\n\t}\n}\n\nfunc maskIfNotEmpty(value string) string {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn \"\"\n\t}\n\treturn filestorage.MaskPlaceholder\n}\n\nfunc shouldKeepSecret(value string) bool {\n\tvalue = strings.TrimSpace(value)\n\treturn value == \"\" || value == filestorage.MaskPlaceholder\n}\n\nfunc firstNonEmpty(values ...string) string {\n\tfor _, value := range values {\n\t\tif strings.TrimSpace(value) != \"\" {\n\t\t\treturn value\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc ensureLocalStorageDirs() error {\n\tcfg := c.GetConfig()\n\tif cfg == nil {\n\t\treturn nil\n\t}\n\tfor _, path := range []string{filepath.Join(cfg.BasePath, \"storage/public\"), filepath.Join(cfg.BasePath, \"storage/private\")} {\n\t\tif err := os.MkdirAll(path, 0o755); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/service/sys_base.go",
    "content": "package service\n\nimport \"github.com/gin-gonic/gin\"\n\n// Base 为服务层提供上下文和当前管理员信息。\ntype Base struct {\n\tctx         *gin.Context\n\tadminUserId uint\n}\n\n// SetAdminUserId 设置管理员ID\nfunc (b *Base) SetAdminUserId(userId uint) {\n\tb.adminUserId = userId\n}\n\n// GetAdminUserId 获取管理员ID\nfunc (b *Base) GetAdminUserId() uint {\n\treturn b.adminUserId\n}\n\n// SetCtx 设置上下文\nfunc (b *Base) SetCtx(c *gin.Context) {\n\tb.ctx = c\n}\n\n// GetCtx 获取上下文\nfunc (b *Base) GetCtx() *gin.Context {\n\treturn b.ctx\n}\n"
  },
  {
    "path": "internal/service/sys_config/audit_diff.go",
    "content": "package sys_config\n\nimport (\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nvar sysConfigDiffRules = []auditdiff.FieldRule{\n\t{Field: \"config_key\", Label: \"参数键名\"},\n\t{Field: \"config_name_i18n\", Label: \"参数名称\"},\n\t{Field: \"config_value\", Label: \"参数值\"},\n\t{\n\t\tField: \"value_type\",\n\t\tLabel: \"值类型\",\n\t\tValueLabels: map[string]string{\n\t\t\tmodel.SysConfigValueTypeString: \"字符串\",\n\t\t\tmodel.SysConfigValueTypeNumber: \"数字\",\n\t\t\tmodel.SysConfigValueTypeBool:   \"布尔\",\n\t\t\tmodel.SysConfigValueTypeJSON:   \"JSON\",\n\t\t},\n\t},\n\t{Field: \"group_code\", Label: \"参数分组\"},\n\t{\n\t\tField: \"is_sensitive\",\n\t\tLabel: \"敏感参数\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"否\",\n\t\t\t\"1\": \"是\",\n\t\t},\n\t},\n\t{\n\t\tField: \"is_visible\",\n\t\tLabel: \"系统参数页展示\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"否\",\n\t\t\t\"1\": \"是\",\n\t\t},\n\t},\n\t{Field: \"manage_tab\", Label: \"专属配置Tab\"},\n\t{\n\t\tField: \"status\",\n\t\tLabel: \"状态\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"禁用\",\n\t\t\t\"1\": \"启用\",\n\t\t},\n\t},\n\t{Field: \"sort\", Label: \"排序\"},\n\t{Field: \"remark\", Label: \"备注\"},\n}\n\n// CreateWithAuditDiff 创建系统参数并返回字段级 change_diff JSON。\nfunc (s *SysConfigService) CreateWithAuditDiff(params *form.CreateSysConfig) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tif err := s.applyMutation(0, &params.SysConfigPayload); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotConfigByKey(params.ConfigKey)\n\tif err != nil {\n\t\treturn \"\", nil\n\t}\n\treturn buildSysConfigDiffJSON(nil, after), nil\n}\n\n// UpdateWithAuditDiff 更新系统参数并返回字段级 change_diff JSON。\nfunc (s *SysConfigService) UpdateWithAuditDiff(params *form.UpdateSysConfig) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tbefore, err := s.snapshotConfigByID(params.Id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.applyMutation(params.Id, &params.SysConfigPayload); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotConfigByID(params.Id)\n\tif err != nil {\n\t\treturn \"\", nil\n\t}\n\treturn buildSysConfigDiffJSON(before, after), nil\n}\n\n// DeleteWithAuditDiff 删除系统参数并返回字段级 change_diff JSON。\nfunc (s *SysConfigService) DeleteWithAuditDiff(id uint) (string, error) {\n\tbefore, err := s.snapshotConfigByID(id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.deleteConfig(id); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn buildSysConfigDiffJSON(before, nil), nil\n}\n\nfunc (s *SysConfigService) deleteConfig(id uint) error {\n\tconfig := model.NewSysConfig()\n\tif err := config.GetById(id); err != nil || config.ID == 0 {\n\t\treturn e.NewBusinessError(e.NotFound)\n\t}\n\tif config.IsProtected() {\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\tdb, err := config.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tconfig.SetDB(tx)\n\t\tif _, deleteErr := config.DeleteByID(id); deleteErr != nil {\n\t\t\treturn deleteErr\n\t\t}\n\t\treturn model.NewSysConfigI18n().DeleteByConfigIDs([]uint{id}, tx)\n\t}); err != nil {\n\t\treturn err\n\t}\n\treturn s.RefreshCache()\n}\n\nfunc (s *SysConfigService) snapshotConfigByID(id uint) (map[string]any, error) {\n\tconfig := model.NewSysConfig()\n\tif err := config.GetById(id); err != nil || config.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\treturn snapshotConfig(config)\n}\n\nfunc (s *SysConfigService) snapshotConfigByKey(key string) (map[string]any, error) {\n\tconfig := model.NewSysConfig()\n\tif err := config.FindByKey(strings.TrimSpace(key)); err != nil {\n\t\treturn nil, err\n\t}\n\treturn snapshotConfig(config)\n}\n\nfunc snapshotConfig(config *model.SysConfig) (map[string]any, error) {\n\tif config == nil || config.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\tnameI18n, err := model.NewSysConfigI18n().LocaleNameMapByConfigID(config.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tconfigValue := config.ConfigValue\n\tif config.IsSensitive == 1 {\n\t\tconfigValue = maskedConfigValue\n\t}\n\treturn map[string]any{\n\t\t\"config_key\":       config.ConfigKey,\n\t\t\"config_name_i18n\": nameI18n,\n\t\t\"config_value\":     configValue,\n\t\t\"value_type\":       model.NormalizeValueType(config.ValueType),\n\t\t\"group_code\":       config.GroupCode,\n\t\t\"is_sensitive\":     config.IsSensitive,\n\t\t\"is_visible\":       config.IsVisible,\n\t\t\"manage_tab\":       config.ManageTab,\n\t\t\"status\":           config.Status,\n\t\t\"sort\":             config.Sort,\n\t\t\"remark\":           config.Remark,\n\t}, nil\n}\n\nfunc buildSysConfigDiffJSON(before, after map[string]any) string {\n\treturn auditdiff.Marshal(auditdiff.BuildFieldDiff(before, after, sysConfigDiffRules))\n}\n"
  },
  {
    "path": "internal/service/sys_config/audit_request_body.go",
    "content": "package sys_config\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// MaskedAuditRequestBody builds a request-body snapshot for request logs.\n// It only overrides the generic body mask when the target config is sensitive.\nfunc (s *SysConfigService) MaskedAuditRequestBody(id uint, params *form.SysConfigPayload) string {\n\tif params == nil {\n\t\treturn \"\"\n\t}\n\tif !s.shouldMaskConfigMutationValue(id, params) {\n\t\treturn \"\"\n\t}\n\n\tpayload := map[string]any{\n\t\t\"config_key\":       strings.TrimSpace(params.ConfigKey),\n\t\t\"config_name_i18n\": params.ConfigNameI18n,\n\t\t\"config_value\":     maskedConfigValue,\n\t\t\"value_type\":       model.NormalizeValueType(params.ValueType),\n\t\t\"group_code\":       strings.TrimSpace(params.GroupCode),\n\t\t\"is_sensitive\":     params.IsSensitive,\n\t\t\"is_visible\":       params.IsVisible,\n\t\t\"manage_tab\":       strings.TrimSpace(params.ManageTab),\n\t\t\"status\":           params.Status,\n\t\t\"sort\":             params.Sort,\n\t\t\"remark\":           strings.TrimSpace(params.Remark),\n\t}\n\tif id > 0 {\n\t\tpayload[\"id\"] = id\n\t}\n\n\traw, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn string(raw)\n}\n\nfunc (s *SysConfigService) shouldMaskConfigMutationValue(id uint, params *form.SysConfigPayload) bool {\n\tif params != nil && params.IsSensitive != nil && *params.IsSensitive == 1 {\n\t\treturn true\n\t}\n\tif id == 0 {\n\t\treturn false\n\t}\n\n\tconfig := model.NewSysConfig()\n\tif err := config.GetById(id); err != nil || config.ID == 0 {\n\t\treturn false\n\t}\n\treturn config.IsSensitive == 1\n}\n"
  },
  {
    "path": "internal/service/sys_config/cache.go",
    "content": "package sys_config\n\nimport (\n\t\"sync\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\ntype ConfigCacheItem struct {\n\tConfigKey    string `json:\"config_key\"`\n\tConfigValue  string `json:\"config_value\"`\n\tValueType    string `json:\"value_type\"`\n\tGroupCode    string `json:\"group_code\"`\n\tIsSensitive  uint8  `json:\"is_sensitive\"`\n}\n\nvar runtimeCache = struct {\n\tsync.RWMutex\n\tloaded bool\n\titems  map[string]ConfigCacheItem\n}{\n\titems: make(map[string]ConfigCacheItem),\n}\n\nfunc replaceCache(configs []model.SysConfig) {\n\tnext := make(map[string]ConfigCacheItem, len(configs))\n\tfor _, config := range configs {\n\t\tnext[config.ConfigKey] = ConfigCacheItem{\n\t\t\tConfigKey:   config.ConfigKey,\n\t\t\tConfigValue: config.ConfigValue,\n\t\t\tValueType:   config.ValueType,\n\t\t\tGroupCode:   config.GroupCode,\n\t\t\tIsSensitive: config.IsSensitive,\n\t\t}\n\t}\n\n\truntimeCache.Lock()\n\tdefer runtimeCache.Unlock()\n\truntimeCache.items = next\n\truntimeCache.loaded = true\n}\n\nfunc getCachedValue(key string) (ConfigCacheItem, bool) {\n\truntimeCache.RLock()\n\tdefer runtimeCache.RUnlock()\n\titem, ok := runtimeCache.items[key]\n\treturn item, ok\n}\n\nfunc cacheLoaded() bool {\n\truntimeCache.RLock()\n\tdefer runtimeCache.RUnlock()\n\treturn runtimeCache.loaded\n}\n"
  },
  {
    "path": "internal/service/sys_config/cache_sync.go",
    "content": "package sys_config\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/redis/go-redis/v9\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"go.uber.org/zap\"\n)\n\nconst (\n\tsysConfigCacheSyncChannel = \"sys_config:cache:refresh\"\n\tsysConfigCacheSyncTimeout = 3 * time.Second\n)\n\ntype sysConfigCacheSyncPayload struct {\n\tSource    string `json:\"source\"`\n\tTimestamp int64  `json:\"timestamp\"`\n}\n\nvar (\n\tsysConfigCacheSyncSourceID = buildSysConfigCacheSyncSourceID()\n\tsysConfigSubscriberState   = struct {\n\t\tmu      sync.Mutex\n\t\tstarted bool\n\t}{}\n)\n\nfunc notifySysConfigCacheRefreshed() {\n\tensureSysConfigCacheSyncSubscriber()\n\n\tcfg := config.GetConfig()\n\tif cfg == nil || !cfg.Redis.Enable {\n\t\treturn\n\t}\n\tclient := data.RedisClient()\n\tif client == nil {\n\t\treturn\n\t}\n\n\tpayload, err := json.Marshal(sysConfigCacheSyncPayload{\n\t\tSource:    sysConfigCacheSyncSourceID,\n\t\tTimestamp: time.Now().Unix(),\n\t})\n\tif err != nil {\n\t\treturn\n\t}\n\n\tctx, cancel := context.WithTimeout(context.Background(), sysConfigCacheSyncTimeout)\n\tdefer cancel()\n\tif err := client.Publish(ctx, sysConfigCacheSyncChannel, payload).Err(); err != nil && log.Logger != nil {\n\t\tlog.Logger.Warn(\"发布系统参数缓存刷新通知失败\", zap.Error(err))\n\t}\n}\n\nfunc ensureSysConfigCacheSyncSubscriber() {\n\tcfg := config.GetConfig()\n\tif cfg == nil || !cfg.Redis.Enable {\n\t\treturn\n\t}\n\tclient := data.RedisClient()\n\tif client == nil {\n\t\treturn\n\t}\n\n\tsysConfigSubscriberState.mu.Lock()\n\tif sysConfigSubscriberState.started {\n\t\tsysConfigSubscriberState.mu.Unlock()\n\t\treturn\n\t}\n\tsysConfigSubscriberState.started = true\n\tsysConfigSubscriberState.mu.Unlock()\n\n\tgo runSysConfigCacheSyncSubscriber(client)\n}\n\nfunc runSysConfigCacheSyncSubscriber(client *redis.Client) {\n\tctx := context.Background()\n\tpubsub := client.Subscribe(ctx, sysConfigCacheSyncChannel)\n\tdefer func() {\n\t\t_ = pubsub.Close()\n\t\tsysConfigSubscriberState.mu.Lock()\n\t\tsysConfigSubscriberState.started = false\n\t\tsysConfigSubscriberState.mu.Unlock()\n\t}()\n\n\tif _, err := pubsub.Receive(ctx); err != nil {\n\t\tif log.Logger != nil {\n\t\t\tlog.Logger.Warn(\"订阅系统参数缓存刷新通道失败\", zap.Error(err))\n\t\t}\n\t\treturn\n\t}\n\n\tfor {\n\t\tmessage, err := pubsub.ReceiveMessage(ctx)\n\t\tif err != nil {\n\t\t\tif log.Logger != nil {\n\t\t\t\tlog.Logger.Warn(\"系统参数缓存刷新订阅中断\", zap.Error(err))\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tpayload, ok := decodeSysConfigCacheSyncPayload(message.Payload)\n\t\tif !ok || payload.Source == sysConfigCacheSyncSourceID {\n\t\t\tcontinue\n\t\t}\n\t\tif err := NewSysConfigService().refreshCache(false); err != nil && log.Logger != nil {\n\t\t\tlog.Logger.Warn(\"处理系统参数缓存刷新通知失败\", zap.Error(err))\n\t\t}\n\t}\n}\n\nfunc decodeSysConfigCacheSyncPayload(raw string) (sysConfigCacheSyncPayload, bool) {\n\tpayload := sysConfigCacheSyncPayload{}\n\traw = strings.TrimSpace(raw)\n\tif raw == \"\" {\n\t\treturn payload, true\n\t}\n\tif err := json.Unmarshal([]byte(raw), &payload); err != nil {\n\t\treturn payload, false\n\t}\n\treturn payload, true\n}\n\nfunc buildSysConfigCacheSyncSourceID() string {\n\thost, err := os.Hostname()\n\tif err != nil {\n\t\thost = \"unknown\"\n\t}\n\treturn host + \":\" + strconv.Itoa(os.Getpid())\n}\n"
  },
  {
    "path": "internal/service/sys_config/cache_sync_test.go",
    "content": "package sys_config\n\nimport \"testing\"\n\nfunc TestDecodeSysConfigCacheSyncPayload(t *testing.T) {\n\tpayload, ok := decodeSysConfigCacheSyncPayload(`{\"source\":\"node-1\",\"timestamp\":123}`)\n\tif !ok {\n\t\tt.Fatal(\"expected payload to decode\")\n\t}\n\tif payload.Source != \"node-1\" || payload.Timestamp != 123 {\n\t\tt.Fatalf(\"unexpected payload: %+v\", payload)\n\t}\n}\n\nfunc TestDecodeSysConfigCacheSyncPayloadRejectsInvalidJSON(t *testing.T) {\n\t_, ok := decodeSysConfigCacheSyncPayload(\"{invalid\")\n\tif ok {\n\t\tt.Fatal(\"expected invalid payload to be rejected\")\n\t}\n}\n"
  },
  {
    "path": "internal/service/sys_config/runtime_audit.go",
    "content": "package sys_config\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/sensitive\"\n)\n\nconst AuditSensitiveFieldsConfigKey = \"audit.sensitive_fields\"\n\nvar loadSensitiveFieldsConfigFn = sensitive.LoadSensitiveFieldsConfig\n\n// WarmupRuntimeConfigIfAvailable 在表存在时预热系统参数缓存和运行时配置。\nfunc (s *SysConfigService) WarmupRuntimeConfigIfAvailable() error {\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !db.Migrator().HasTable(\"sys_config\") {\n\t\treturn nil\n\t}\n\tif err := s.refreshCache(false); err != nil {\n\t\treturn err\n\t}\n\tensureSysConfigCacheSyncSubscriber()\n\treturn nil\n}\n\nfunc applyRuntimeConfig(configs []model.SysConfig) error {\n\tconfig, err := resolveAuditSensitiveFieldsConfig(configs)\n\tif err != nil {\n\t\treturn err\n\t}\n\tloadSensitiveFieldsConfigFn(config)\n\treturn nil\n}\n\nfunc resolveAuditSensitiveFieldsConfig(configs []model.SysConfig) (sensitive.SensitiveFieldsConfig, error) {\n\tdefaultConfig := sensitive.DefaultSensitiveFieldsConfig()\n\tfor _, item := range configs {\n\t\tif item.ConfigKey != AuditSensitiveFieldsConfigKey {\n\t\t\tcontinue\n\t\t}\n\t\tif model.NormalizeValueType(item.ValueType) != model.SysConfigValueTypeJSON {\n\t\t\treturn defaultConfig, fmt.Errorf(\"%s value_type must be json\", AuditSensitiveFieldsConfigKey)\n\t\t}\n\t\traw := strings.TrimSpace(item.ConfigValue)\n\t\tif raw == \"\" {\n\t\t\treturn defaultConfig, nil\n\t\t}\n\n\t\tvar runtimeConfig sensitive.SensitiveFieldsConfig\n\t\tif err := json.Unmarshal([]byte(raw), &runtimeConfig); err != nil {\n\t\t\treturn defaultConfig, fmt.Errorf(\"decode %s failed: %w\", AuditSensitiveFieldsConfigKey, err)\n\t\t}\n\t\treturn normalizeSensitiveFieldsConfig(runtimeConfig), nil\n\t}\n\treturn defaultConfig, nil\n}\n\nfunc normalizeSensitiveFieldsConfig(config sensitive.SensitiveFieldsConfig) sensitive.SensitiveFieldsConfig {\n\treturn sensitive.SensitiveFieldsConfig{\n\t\tCommon:         normalizeStringList(config.Common),\n\t\tRequestHeader:  normalizeStringList(config.RequestHeader),\n\t\tRequestBody:    normalizeStringList(config.RequestBody),\n\t\tResponseHeader: normalizeStringList(config.ResponseHeader),\n\t\tResponseBody:   normalizeStringList(config.ResponseBody),\n\t}\n}\n\nfunc normalizeStringList(input []string) []string {\n\tif len(input) == 0 {\n\t\treturn []string{}\n\t}\n\tresult := make([]string, 0, len(input))\n\tseen := make(map[string]struct{}, len(input))\n\tfor _, item := range input {\n\t\ttrimmed := strings.ToLower(strings.TrimSpace(item))\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[trimmed]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[trimmed] = struct{}{}\n\t\tresult = append(result, trimmed)\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "internal/service/sys_config/runtime_audit_test.go",
    "content": "package sys_config\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/sensitive\"\n)\n\nfunc TestResolveAuditSensitiveFieldsConfigReturnsDefaultWhenMissing(t *testing.T) {\n\tgot, err := resolveAuditSensitiveFieldsConfig(nil)\n\tif err != nil {\n\t\tt.Fatalf(\"resolveAuditSensitiveFieldsConfig returned error: %v\", err)\n\t}\n\tdefaultConfig := sensitive.DefaultSensitiveFieldsConfig()\n\tif len(got.Common) != len(defaultConfig.Common) {\n\t\tt.Fatalf(\"expected default common fields, got %#v\", got.Common)\n\t}\n}\n\nfunc TestResolveAuditSensitiveFieldsConfigNormalizesValues(t *testing.T) {\n\tgot, err := resolveAuditSensitiveFieldsConfig([]model.SysConfig{\n\t\t{\n\t\t\tConfigKey:   AuditSensitiveFieldsConfigKey,\n\t\t\tValueType:   model.SysConfigValueTypeJSON,\n\t\t\tConfigValue: `{\"common\":[\" Password \",\"token\",\"password\"],\"request_body\":[\" Phone \",\"phone\"],\"response_header\":[\" Set-Cookie \"]}`,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"resolveAuditSensitiveFieldsConfig returned error: %v\", err)\n\t}\n\n\tassertStringSliceEqual(t, got.Common, []string{\"password\", \"token\"})\n\tassertStringSliceEqual(t, got.RequestBody, []string{\"phone\"})\n\tassertStringSliceEqual(t, got.ResponseHeader, []string{\"set-cookie\"})\n}\n\nfunc TestResolveAuditSensitiveFieldsConfigRejectsNonJSONValueType(t *testing.T) {\n\t_, err := resolveAuditSensitiveFieldsConfig([]model.SysConfig{\n\t\t{\n\t\t\tConfigKey:   AuditSensitiveFieldsConfigKey,\n\t\t\tValueType:   model.SysConfigValueTypeString,\n\t\t\tConfigValue: `{\"common\":[\"password\"]}`,\n\t\t},\n\t})\n\tif err == nil {\n\t\tt.Fatal(\"expected non-json audit config to fail\")\n\t}\n}\n\nfunc TestApplyRuntimeConfigLoadsSensitiveManager(t *testing.T) {\n\tprevious := loadSensitiveFieldsConfigFn\n\tt.Cleanup(func() {\n\t\tloadSensitiveFieldsConfigFn = previous\n\t})\n\n\tvar captured sensitive.SensitiveFieldsConfig\n\tloadSensitiveFieldsConfigFn = func(config sensitive.SensitiveFieldsConfig) {\n\t\tcaptured = config\n\t}\n\n\terr := applyRuntimeConfig([]model.SysConfig{\n\t\t{\n\t\t\tConfigKey:   AuditSensitiveFieldsConfigKey,\n\t\t\tValueType:   model.SysConfigValueTypeJSON,\n\t\t\tConfigValue: `{\"common\":[\"password\"],\"request_header\":[\"authorization\"]}`,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"applyRuntimeConfig returned error: %v\", err)\n\t}\n\n\tassertStringSliceEqual(t, captured.Common, []string{\"password\"})\n\tassertStringSliceEqual(t, captured.RequestHeader, []string{\"authorization\"})\n}\n\nfunc assertStringSliceEqual(t *testing.T, got []string, want []string) {\n\tt.Helper()\n\tif len(got) != len(want) {\n\t\tt.Fatalf(\"unexpected slice length: got=%#v want=%#v\", got, want)\n\t}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"unexpected slice content: got=%#v want=%#v\", got, want)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/service/sys_config/sys_config.go",
    "content": "package sys_config\n\nimport (\n\t\"encoding/json\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// SysConfigService 系统参数服务。\ntype SysConfigService struct {\n\tservice.Base\n}\n\nconst maskedConfigValue = \"******\"\n\nfunc NewSysConfigService() *SysConfigService {\n\treturn &SysConfigService{}\n}\n\nfunc (s *SysConfigService) List(params *form.SysConfigList, locale string) *resources.Collection {\n\tcondition, args := s.buildListCondition(params)\n\tconfigModel := model.NewSysConfig()\n\ttotal, collection, err := model.ListPageE(configModel, params.Page, params.PerPage, condition, args, model.ListOptionalParams{\n\t\tOrderBy: \"sort desc, id desc\",\n\t})\n\tif err != nil {\n\t\treturn resources.NewSysConfigTransformer().ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\ts.maskSensitiveValues(collection)\n\ts.fillLocalizedNames(collection, locale)\n\treturn resources.NewSysConfigTransformer().ToCollection(params.Page, params.PerPage, total, collection)\n}\n\nfunc (s *SysConfigService) Detail(id uint, locale string) (any, error) {\n\tconfig := model.NewSysConfig()\n\tif err := config.GetById(id); err != nil || config.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\ttranslations, err := model.NewSysConfigI18n().LocaleNameMapByConfigID(config.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tconfig.ConfigNameI18n = translations\n\tconfig.ConfigName = service.ResolveLocaleText(translations, locale)\n\t// 详情接口用于编辑回填，敏感参数保留真实值；列表和 PublicValue 继续脱敏。\n\treturn resources.NewSysConfigTransformer().ToStruct(config), nil\n}\n\nfunc (s *SysConfigService) Create(params *form.CreateSysConfig) error {\n\t_, err := s.CreateWithAuditDiff(params)\n\treturn err\n}\n\nfunc (s *SysConfigService) Update(params *form.UpdateSysConfig) error {\n\t_, err := s.UpdateWithAuditDiff(params)\n\treturn err\n}\n\nfunc (s *SysConfigService) Delete(id uint) error {\n\t_, err := s.DeleteWithAuditDiff(id)\n\treturn err\n}\n\nfunc (s *SysConfigService) Value(key string) (ConfigCacheItem, error) {\n\tkey = strings.TrimSpace(key)\n\tif key == \"\" {\n\t\treturn ConfigCacheItem{}, e.NewBusinessError(e.InvalidParameter)\n\t}\n\tif !cacheLoaded() {\n\t\tif err := s.RefreshCache(); err != nil {\n\t\t\treturn ConfigCacheItem{}, err\n\t\t}\n\t}\n\tif item, ok := getCachedValue(key); ok {\n\t\treturn item, nil\n\t}\n\treturn ConfigCacheItem{}, e.NewBusinessError(e.NotFound)\n}\n\n// PublicValue 对外暴露的系统参数值接口，敏感参数自动脱敏。\nfunc (s *SysConfigService) PublicValue(key string) (ConfigCacheItem, error) {\n\titem, err := s.Value(key)\n\tif err != nil {\n\t\treturn item, err\n\t}\n\tif item.IsSensitive == 1 {\n\t\titem.ConfigValue = maskedConfigValue\n\t}\n\treturn item, nil\n}\n\n// RefreshCache 刷新本进程参数缓存；加载失败时保留旧缓存。\nfunc (s *SysConfigService) RefreshCache() error {\n\treturn s.refreshCache(true)\n}\n\nfunc (s *SysConfigService) refreshCache(notify bool) error {\n\tconfigs, err := model.NewSysConfig().EnabledConfigs()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := applyRuntimeConfig(configs); err != nil {\n\t\treturn err\n\t}\n\treplaceCache(configs)\n\tif notify {\n\t\tnotifySysConfigCacheRefreshed()\n\t}\n\treturn nil\n}\n\nfunc (s *SysConfigService) applyMutation(id uint, params *form.SysConfigPayload) error {\n\tparams.ConfigKey = strings.TrimSpace(params.ConfigKey)\n\tparams.ConfigNameI18n = service.NormalizeLocaleTextMap(params.ConfigNameI18n)\n\tparams.GroupCode = strings.TrimSpace(params.GroupCode)\n\tparams.ValueType = model.NormalizeValueType(params.ValueType)\n\tparams.ManageTab = strings.TrimSpace(params.ManageTab)\n\tparams.Remark = strings.TrimSpace(params.Remark)\n\tif params.GroupCode == \"\" {\n\t\tparams.GroupCode = \"default\"\n\t}\n\tif len(params.ConfigNameI18n) == 0 {\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\n\tconfig := model.NewSysConfig()\n\tif id > 0 {\n\t\tif err := config.GetById(id); err != nil || config.ID == 0 {\n\t\t\treturn e.NewBusinessError(e.NotFound)\n\t\t}\n\t\t// 系统内置参数禁止修改稳定字段。\n\t\tif config.IsProtected() {\n\t\t\tif params.ConfigKey != config.ConfigKey {\n\t\t\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t\t\t}\n\t\t\tif model.NormalizeValueType(params.ValueType) != config.ValueType {\n\t\t\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t\t\t}\n\t\t\tif strings.TrimSpace(params.GroupCode) != config.GroupCode {\n\t\t\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t\t\t}\n\t\t\t// 敏感标记禁止降级。\n\t\t\tif params.IsSensitive != nil && *params.IsSensitive == 0 && config.IsSensitive == 1 {\n\t\t\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t\t\t}\n\t\t}\n\t}\n\tconfigValue := resolveMutationConfigValue(config, params.ConfigValue)\n\tif err := validateConfigValue(params.ValueType, configValue); err != nil {\n\t\treturn err\n\t}\n\tif exists, err := model.NewSysConfig().ExistsByKeyExcludeID(params.ConfigKey, id); err != nil {\n\t\treturn err\n\t} else if exists {\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\n\tconfig.ConfigKey = params.ConfigKey\n\tconfig.ConfigValue = configValue\n\tconfig.ValueType = params.ValueType\n\tconfig.GroupCode = params.GroupCode\n\tconfig.IsSensitive = valueOrDefault(params.IsSensitive, config.IsSensitive)\n\tconfig.IsVisible = valueOrDefault(params.IsVisible, defaultVisible(config.IsVisible, id))\n\tconfig.ManageTab = params.ManageTab\n\tconfig.Status = valueOrDefault(params.Status, defaultStatus(config.Status, id))\n\tconfig.Sort = params.Sort\n\tconfig.Remark = params.Remark\n\n\tdb, err := config.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tconfig.SetDB(tx)\n\t\tif saveErr := config.Save(); saveErr != nil {\n\t\t\treturn saveErr\n\t\t}\n\t\treturn model.NewSysConfigI18n().UpsertConfigNames(config.ID, params.ConfigNameI18n, tx)\n\t}); err != nil {\n\t\treturn err\n\t}\n\treturn s.RefreshCache()\n}\n\nfunc resolveMutationConfigValue(existing *model.SysConfig, incoming string) string {\n\tif existing != nil && existing.ID > 0 && existing.IsSensitive == 1 && strings.TrimSpace(incoming) == maskedConfigValue {\n\t\treturn existing.ConfigValue\n\t}\n\treturn incoming\n}\n\nfunc (s *SysConfigService) buildListCondition(params *form.SysConfigList) (string, []any) {\n\tqb := query_builder.New().\n\t\tAddLike(\"config_key\", params.ConfigKey).\n\t\tAddEq(\"group_code\", params.GroupCode).\n\t\tAddEq(\"value_type\", params.ValueType).\n\t\tAddEq(\"manage_tab\", params.ManageTab).\n\t\tAddEq(\"status\", params.Status)\n\tif params.IsVisible != nil {\n\t\tqb.AddEq(\"is_visible\", params.IsVisible)\n\t} else if params.IncludeHidden == nil || *params.IncludeHidden == 0 {\n\t\tqb.AddEq(\"is_visible\", uint8(1))\n\t}\n\tif keyword := strings.TrimSpace(params.ConfigName); keyword != \"\" {\n\t\tqb.AddCondition(\"id IN (SELECT config_id FROM sys_config_i18n WHERE config_name like ?)\", \"%\"+keyword+\"%\")\n\t}\n\treturn qb.Build()\n}\n\nfunc (s *SysConfigService) fillLocalizedNames(configs []*model.SysConfig, locale string) {\n\tids := make([]uint, 0, len(configs))\n\tfor _, config := range configs {\n\t\tif config == nil {\n\t\t\tcontinue\n\t\t}\n\t\tids = append(ids, config.ID)\n\t}\n\tif len(ids) == 0 {\n\t\treturn\n\t}\n\tnameMap, err := model.NewSysConfigI18n().LocalizedNameMapByConfigIDs(ids, service.LocalePriority(locale))\n\tif err != nil {\n\t\treturn\n\t}\n\tfor _, config := range configs {\n\t\tif config == nil {\n\t\t\tcontinue\n\t\t}\n\t\tconfig.ConfigName = nameMap[config.ID]\n\t}\n}\n\nfunc (s *SysConfigService) maskSensitiveValues(configs []*model.SysConfig) {\n\tfor _, config := range configs {\n\t\tif config == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif config.IsSensitive == 1 {\n\t\t\tconfig.ConfigValue = maskedConfigValue\n\t\t}\n\t}\n}\n\nfunc validateConfigValue(valueType, value string) error {\n\tvalue = strings.TrimSpace(value)\n\tswitch valueType {\n\tcase model.SysConfigValueTypeString:\n\t\treturn nil\n\tcase model.SysConfigValueTypeNumber:\n\t\tif _, err := strconv.ParseFloat(value, 64); err != nil {\n\t\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t\t}\n\tcase model.SysConfigValueTypeBool:\n\t\tif _, err := strconv.ParseBool(value); err != nil {\n\t\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t\t}\n\tcase model.SysConfigValueTypeJSON:\n\t\tif !json.Valid([]byte(value)) {\n\t\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t\t}\n\tdefault:\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\treturn nil\n}\n\nfunc valueOrDefault(value *uint8, fallback uint8) uint8 {\n\tif value == nil {\n\t\treturn fallback\n\t}\n\treturn *value\n}\n\nfunc defaultVisible(current uint8, id uint) uint8 {\n\tif id == 0 && current == 0 {\n\t\treturn 1\n\t}\n\treturn current\n}\n\nfunc defaultStatus(current uint8, id uint) uint8 {\n\tif id == 0 && current == 0 {\n\t\treturn 1\n\t}\n\treturn current\n}\n"
  },
  {
    "path": "internal/service/sys_config/sys_config_mask_test.go",
    "content": "package sys_config\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nfunc TestMaskSensitiveValues(t *testing.T) {\n\tservice := NewSysConfigService()\n\tconfigs := []*model.SysConfig{\n\t\t{\n\t\t\tConfigKey:   AuthLoginLockEnabledConfigKey,\n\t\t\tConfigValue: \"true\",\n\t\t\tIsSensitive: 0,\n\t\t},\n\t\t{\n\t\t\tConfigKey:   \"audit.sensitive_fields\",\n\t\t\tConfigValue: `{\"common\":[\"password\"]}`,\n\t\t\tIsSensitive: 1,\n\t\t},\n\t}\n\n\tservice.maskSensitiveValues(configs)\n\n\tif configs[0].ConfigValue != \"true\" {\n\t\tt.Fatalf(\"expected non-sensitive value unchanged, got %q\", configs[0].ConfigValue)\n\t}\n\tif configs[1].ConfigValue != maskedConfigValue {\n\t\tt.Fatalf(\"expected sensitive value masked, got %q\", configs[1].ConfigValue)\n\t}\n}\n\nfunc TestSysConfigDetailResourceKeepsSensitiveConfigValue(t *testing.T) {\n\tconfig := &model.SysConfig{\n\t\tConfigKey:   \"audit.sensitive_fields\",\n\t\tConfigValue: `{\"common\":[\"password\"]}`,\n\t\tIsSensitive: 1,\n\t}\n\n\tdetail := resources.NewSysConfigTransformer().ToStruct(config)\n\n\tif detail.ConfigValue != config.ConfigValue {\n\t\tt.Fatalf(\"expected detail value to remain unmasked, got %q\", detail.ConfigValue)\n\t}\n}\n\nfunc TestMaskedAuditRequestBodyMasksSensitiveConfigValue(t *testing.T) {\n\tisSensitive := uint8(1)\n\tisVisible := uint8(0)\n\tstatus := uint8(1)\n\traw := NewSysConfigService().MaskedAuditRequestBody(0, &form.SysConfigPayload{\n\t\tConfigKey:      \"secret.demo\",\n\t\tConfigNameI18n: map[string]string{\"zh-CN\": \"密钥\"},\n\t\tConfigValue:    \"plain-secret\",\n\t\tValueType:      model.SysConfigValueTypeString,\n\t\tIsSensitive:    &isSensitive,\n\t\tIsVisible:      &isVisible,\n\t\tManageTab:      \"audit_mask\",\n\t\tStatus:         &status,\n\t})\n\n\tif raw == \"\" {\n\t\tt.Fatal(\"expected masked audit request body\")\n\t}\n\tif strings.Contains(raw, \"plain-secret\") {\n\t\tt.Fatalf(\"expected raw secret to be masked, got %s\", raw)\n\t}\n\tif !strings.Contains(raw, maskedConfigValue) {\n\t\tt.Fatalf(\"expected masked value in audit request body, got %s\", raw)\n\t}\n\tif !strings.Contains(raw, `\"is_visible\":0`) {\n\t\tt.Fatalf(\"expected is_visible in audit request body, got %s\", raw)\n\t}\n\tif !strings.Contains(raw, `\"manage_tab\":\"audit_mask\"`) {\n\t\tt.Fatalf(\"expected manage_tab in audit request body, got %s\", raw)\n\t}\n}\n\nfunc TestMaskedAuditRequestBodySkipsNonSensitiveConfig(t *testing.T) {\n\tisSensitive := uint8(0)\n\traw := NewSysConfigService().MaskedAuditRequestBody(0, &form.SysConfigPayload{\n\t\tConfigKey:   \"feature.demo\",\n\t\tConfigValue: \"visible\",\n\t\tValueType:   model.SysConfigValueTypeString,\n\t\tIsSensitive: &isSensitive,\n\t})\n\tif raw != \"\" {\n\t\tt.Fatalf(\"expected no override for non-sensitive config, got %s\", raw)\n\t}\n}\n\nfunc TestResolveMutationConfigValueKeepsSensitiveValueForMaskedPlaceholder(t *testing.T) {\n\texisting := &model.SysConfig{\n\t\tConfigValue: \"real-secret\",\n\t\tIsSensitive: 1,\n\t}\n\texisting.ID = 1\n\n\tgot := resolveMutationConfigValue(existing, maskedConfigValue)\n\n\tif got != \"real-secret\" {\n\t\tt.Fatalf(\"expected existing sensitive value to be kept, got %q\", got)\n\t}\n}\n\nfunc TestResolveMutationConfigValueUsesIncomingValueWhenChanged(t *testing.T) {\n\texisting := &model.SysConfig{\n\t\tConfigValue: \"real-secret\",\n\t\tIsSensitive: 1,\n\t}\n\texisting.ID = 1\n\n\tgot := resolveMutationConfigValue(existing, \"new-secret\")\n\n\tif got != \"new-secret\" {\n\t\tt.Fatalf(\"expected incoming value, got %q\", got)\n\t}\n}\n\nfunc TestResolveMutationConfigValuePreservesStringWhitespace(t *testing.T) {\n\tgot := resolveMutationConfigValue(&model.SysConfig{}, \"  display value  \")\n\n\tif got != \"  display value  \" {\n\t\tt.Fatalf(\"expected incoming whitespace to be preserved, got %q\", got)\n\t}\n}\n\nfunc TestBuildSysConfigDiffIncludesVisibilityFields(t *testing.T) {\n\tbefore := map[string]any{\n\t\t\"config_key\":       \"audit.sensitive_fields\",\n\t\t\"config_name_i18n\": map[string]string{\"zh-CN\": \"请求日志脱敏配置\"},\n\t\t\"config_value\":     \"before\",\n\t\t\"value_type\":       model.SysConfigValueTypeJSON,\n\t\t\"group_code\":       \"audit\",\n\t\t\"is_sensitive\":     uint8(1),\n\t\t\"is_visible\":       uint8(1),\n\t\t\"manage_tab\":       \"\",\n\t\t\"status\":           uint8(1),\n\t\t\"sort\":             uint(95),\n\t\t\"remark\":           \"before\",\n\t}\n\tafter := map[string]any{\n\t\t\"config_key\":       \"audit.sensitive_fields\",\n\t\t\"config_name_i18n\": map[string]string{\"zh-CN\": \"请求日志脱敏配置\"},\n\t\t\"config_value\":     maskedConfigValue,\n\t\t\"value_type\":       model.SysConfigValueTypeJSON,\n\t\t\"group_code\":       \"audit\",\n\t\t\"is_sensitive\":     uint8(1),\n\t\t\"is_visible\":       uint8(0),\n\t\t\"manage_tab\":       \"audit_mask\",\n\t\t\"status\":           uint8(1),\n\t\t\"sort\":             uint(95),\n\t\t\"remark\":           \"before\",\n\t}\n\n\traw := buildSysConfigDiffJSON(before, after)\n\tif !strings.Contains(raw, `\"field\":\"is_visible\"`) {\n\t\tt.Fatalf(\"expected diff to include is_visible, got %s\", raw)\n\t}\n\tif !strings.Contains(raw, `\"field\":\"manage_tab\"`) {\n\t\tt.Fatalf(\"expected diff to include manage_tab, got %s\", raw)\n\t}\n}\n"
  },
  {
    "path": "internal/service/sys_config/typed_value.go",
    "content": "package sys_config\n\nimport \"strconv\"\n\nconst (\n\tTaskCronDemoEnabledConfigKey = \"task.cron_demo_enabled\"\n\n\tAuthLoginLockEnabledConfigKey = \"auth.login_lock_enabled\"\n\tAuthLoginMaxFailuresConfigKey = \"auth.login_max_failures\"\n\tAuthLoginLockMinutesConfigKey = \"auth.login_lock_minutes\"\n)\n\n// BoolValue 读取 bool 类型系统参数；读取失败或解析失败时返回 fallback。\nfunc BoolValue(key string, fallback bool) bool {\n\titem, err := NewSysConfigService().Value(key)\n\tif err != nil {\n\t\treturn fallback\n\t}\n\tvalue, err := strconv.ParseBool(item.ConfigValue)\n\tif err != nil {\n\t\treturn fallback\n\t}\n\treturn value\n}\n\n// IntValue 读取 int 类型系统参数；读取失败或解析失败时返回 fallback。\nfunc IntValue(key string, fallback int) int {\n\titem, err := NewSysConfigService().Value(key)\n\tif err != nil {\n\t\treturn fallback\n\t}\n\tvalue, err := strconv.Atoi(item.ConfigValue)\n\tif err != nil {\n\t\treturn fallback\n\t}\n\treturn value\n}\n"
  },
  {
    "path": "internal/service/sys_config/typed_value_test.go",
    "content": "package sys_config\n\nimport (\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\nfunc TestBoolValueFallbackWhenMissing(t *testing.T) {\n\tif got := BoolValue(\"missing.key\", true); !got {\n\t\tt.Fatal(\"expected fallback true when config is missing\")\n\t}\n\tif got := BoolValue(\"missing.key\", false); got {\n\t\tt.Fatal(\"expected fallback false when config is missing\")\n\t}\n}\n\nfunc TestBoolValueFromCache(t *testing.T) {\n\trestore := setRuntimeCacheForTest([]model.SysConfig{\n\t\t{ConfigKey: AuthLoginLockEnabledConfigKey, ConfigValue: \"true\", Status: 1},\n\t})\n\tt.Cleanup(restore)\n\n\tif !BoolValue(AuthLoginLockEnabledConfigKey, false) {\n\t\tt.Fatal(\"expected bool value from cache\")\n\t}\n}\n\nfunc TestIntValueFromCache(t *testing.T) {\n\trestore := setRuntimeCacheForTest([]model.SysConfig{\n\t\t{ConfigKey: AuthLoginMaxFailuresConfigKey, ConfigValue: \"7\", Status: 1},\n\t})\n\tt.Cleanup(restore)\n\n\tif got := IntValue(AuthLoginMaxFailuresConfigKey, 5); got != 7 {\n\t\tt.Fatalf(\"expected int value 7, got %d\", got)\n\t}\n}\n\nfunc TestIntValueFallbackWhenInvalid(t *testing.T) {\n\trestore := setRuntimeCacheForTest([]model.SysConfig{\n\t\t{ConfigKey: AuthLoginLockMinutesConfigKey, ConfigValue: \"invalid\", Status: 1},\n\t})\n\tt.Cleanup(restore)\n\n\tif got := IntValue(AuthLoginLockMinutesConfigKey, 15); got != 15 {\n\t\tt.Fatalf(\"expected fallback int value 15, got %d\", got)\n\t}\n}\n\nfunc setRuntimeCacheForTest(configs []model.SysConfig) func() {\n\truntimeCache.Lock()\n\tprevLoaded := runtimeCache.loaded\n\tprevItems := runtimeCache.items\n\truntimeCache.Unlock()\n\n\treplaceCache(configs)\n\n\treturn func() {\n\t\truntimeCache.Lock()\n\t\truntimeCache.loaded = prevLoaded\n\t\truntimeCache.items = prevItems\n\t\truntimeCache.Unlock()\n\t}\n}\n"
  },
  {
    "path": "internal/service/sys_dict/audit_diff.go",
    "content": "package sys_dict\n\nimport (\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nvar sysDictTypeDiffRules = []auditdiff.FieldRule{\n\t{Field: \"type_code\", Label: \"字典类型编码\"},\n\t{Field: \"type_name_i18n\", Label: \"字典类型名称\"},\n\t{\n\t\tField: \"status\",\n\t\tLabel: \"状态\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"禁用\",\n\t\t\t\"1\": \"启用\",\n\t\t},\n\t},\n\t{Field: \"sort\", Label: \"排序\"},\n\t{Field: \"remark\", Label: \"备注\"},\n}\n\nvar sysDictItemDiffRules = []auditdiff.FieldRule{\n\t{Field: \"type_code\", Label: \"字典类型编码\"},\n\t{Field: \"label_i18n\", Label: \"字典标签\"},\n\t{Field: \"value\", Label: \"字典值\"},\n\t{Field: \"color\", Label: \"颜色\"},\n\t{Field: \"tag_type\", Label: \"标签类型\"},\n\t{\n\t\tField: \"is_default\",\n\t\tLabel: \"默认项\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"否\",\n\t\t\t\"1\": \"是\",\n\t\t},\n\t},\n\t{\n\t\tField: \"status\",\n\t\tLabel: \"状态\",\n\t\tValueLabels: map[string]string{\n\t\t\t\"0\": \"禁用\",\n\t\t\t\"1\": \"启用\",\n\t\t},\n\t},\n\t{Field: \"sort\", Label: \"排序\"},\n\t{Field: \"remark\", Label: \"备注\"},\n}\n\n// CreateTypeWithAuditDiff 创建字典类型并返回字段级 change_diff JSON。\nfunc (s *SysDictService) CreateTypeWithAuditDiff(params *form.CreateSysDictType) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tif err := s.applyTypeMutation(0, &params.SysDictTypePayload); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotTypeByTypeCode(params.TypeCode)\n\tif err != nil {\n\t\treturn \"\", nil\n\t}\n\treturn buildSysDictTypeDiffJSON(nil, after), nil\n}\n\n// UpdateTypeWithAuditDiff 更新字典类型并返回字段级 change_diff JSON。\nfunc (s *SysDictService) UpdateTypeWithAuditDiff(params *form.UpdateSysDictType) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tbefore, err := s.snapshotTypeByID(params.Id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.applyTypeMutation(params.Id, &params.SysDictTypePayload); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotTypeByID(params.Id)\n\tif err != nil {\n\t\treturn \"\", nil\n\t}\n\treturn buildSysDictTypeDiffJSON(before, after), nil\n}\n\n// DeleteTypeWithAuditDiff 删除字典类型并返回字段级 change_diff JSON。\nfunc (s *SysDictService) DeleteTypeWithAuditDiff(id uint) (string, error) {\n\tbefore, err := s.snapshotTypeByID(id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.deleteType(id); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn buildSysDictTypeDiffJSON(before, nil), nil\n}\n\n// CreateItemWithAuditDiff 创建字典项并返回字段级 change_diff JSON。\nfunc (s *SysDictService) CreateItemWithAuditDiff(params *form.CreateSysDictItem) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tif err := s.applyItemMutation(0, &params.SysDictItemPayload); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotItemByTypeValue(params.TypeCode, params.Value)\n\tif err != nil {\n\t\treturn \"\", nil\n\t}\n\treturn buildSysDictItemDiffJSON(nil, after), nil\n}\n\n// UpdateItemWithAuditDiff 更新字典项并返回字段级 change_diff JSON。\nfunc (s *SysDictService) UpdateItemWithAuditDiff(params *form.UpdateSysDictItem) (string, error) {\n\tif params == nil {\n\t\treturn \"\", e.NewBusinessError(e.InvalidParameter)\n\t}\n\tbefore, err := s.snapshotItemByID(params.Id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.applyItemMutation(params.Id, &params.SysDictItemPayload); err != nil {\n\t\treturn \"\", err\n\t}\n\tafter, err := s.snapshotItemByID(params.Id)\n\tif err != nil {\n\t\treturn \"\", nil\n\t}\n\treturn buildSysDictItemDiffJSON(before, after), nil\n}\n\n// DeleteItemWithAuditDiff 删除字典项并返回字段级 change_diff JSON。\nfunc (s *SysDictService) DeleteItemWithAuditDiff(id uint) (string, error) {\n\tbefore, err := s.snapshotItemByID(id)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := s.deleteItem(id); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn buildSysDictItemDiffJSON(before, nil), nil\n}\n\nfunc (s *SysDictService) deleteType(id uint) error {\n\tdictType := model.NewSysDictType()\n\tif err := dictType.GetById(id); err != nil || dictType.ID == 0 {\n\t\treturn e.NewBusinessError(e.NotFound)\n\t}\n\tif dictType.IsProtected() {\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\tcount, err := model.NewSysDictItem().CountByTypeCode(dictType.TypeCode)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif count > 0 {\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\tdb, err := dictType.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tdictType.SetDB(tx)\n\t\tif _, deleteErr := dictType.DeleteByID(id); deleteErr != nil {\n\t\t\treturn deleteErr\n\t\t}\n\t\treturn model.NewSysDictTypeI18n().DeleteByTypeIDs([]uint{id}, tx)\n\t})\n}\n\nfunc (s *SysDictService) deleteItem(id uint) error {\n\titem := model.NewSysDictItem()\n\tif err := item.GetById(id); err != nil || item.ID == 0 {\n\t\treturn e.NewBusinessError(e.NotFound)\n\t}\n\tif item.IsProtected() {\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\tdb, err := item.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\titem.SetDB(tx)\n\t\tif _, deleteErr := item.DeleteByID(id); deleteErr != nil {\n\t\t\treturn deleteErr\n\t\t}\n\t\treturn model.NewSysDictItemI18n().DeleteByItemIDs([]uint{id}, tx)\n\t})\n}\n\nfunc (s *SysDictService) snapshotTypeByID(id uint) (map[string]any, error) {\n\tdictType := model.NewSysDictType()\n\tif err := dictType.GetById(id); err != nil || dictType.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\treturn snapshotDictType(dictType)\n}\n\nfunc (s *SysDictService) snapshotTypeByTypeCode(typeCode string) (map[string]any, error) {\n\tdictType := model.NewSysDictType()\n\tif err := dictType.FindByTypeCode(strings.TrimSpace(typeCode)); err != nil {\n\t\treturn nil, err\n\t}\n\treturn snapshotDictType(dictType)\n}\n\nfunc snapshotDictType(dictType *model.SysDictType) (map[string]any, error) {\n\tif dictType == nil || dictType.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\ttypeNameI18n, err := model.NewSysDictTypeI18n().LocaleNameMapByTypeID(dictType.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn map[string]any{\n\t\t\"type_code\":      dictType.TypeCode,\n\t\t\"type_name_i18n\": typeNameI18n,\n\t\t\"status\":         dictType.Status,\n\t\t\"sort\":           dictType.Sort,\n\t\t\"remark\":         dictType.Remark,\n\t}, nil\n}\n\nfunc (s *SysDictService) snapshotItemByID(id uint) (map[string]any, error) {\n\titem := model.NewSysDictItem()\n\tif err := item.GetById(id); err != nil || item.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\treturn snapshotDictItem(item)\n}\n\nfunc (s *SysDictService) snapshotItemByTypeValue(typeCode, value string) (map[string]any, error) {\n\titem := model.NewSysDictItem()\n\tif err := item.FindByTypeCodeAndValue(strings.TrimSpace(typeCode), strings.TrimSpace(value)); err != nil {\n\t\treturn nil, err\n\t}\n\treturn snapshotDictItem(item)\n}\n\nfunc snapshotDictItem(item *model.SysDictItem) (map[string]any, error) {\n\tif item == nil || item.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\tlabelI18n, err := model.NewSysDictItemI18n().LocaleLabelMapByItemID(item.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn map[string]any{\n\t\t\"type_code\":  item.TypeCode,\n\t\t\"label_i18n\": labelI18n,\n\t\t\"value\":      item.Value,\n\t\t\"color\":      item.Color,\n\t\t\"tag_type\":   item.TagType,\n\t\t\"is_default\": item.IsDefault,\n\t\t\"status\":     item.Status,\n\t\t\"sort\":       item.Sort,\n\t\t\"remark\":     item.Remark,\n\t}, nil\n}\n\nfunc buildSysDictTypeDiffJSON(before, after map[string]any) string {\n\treturn auditdiff.Marshal(auditdiff.BuildFieldDiff(before, after, sysDictTypeDiffRules))\n}\n\nfunc buildSysDictItemDiffJSON(before, after map[string]any) string {\n\treturn auditdiff.Marshal(auditdiff.BuildFieldDiff(before, after, sysDictItemDiffRules))\n}\n"
  },
  {
    "path": "internal/service/sys_dict/sys_dict.go",
    "content": "package sys_dict\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\n// SysDictService 系统字典服务。\ntype SysDictService struct {\n\tservice.Base\n}\n\nfunc NewSysDictService() *SysDictService {\n\treturn &SysDictService{}\n}\n\nfunc (s *SysDictService) TypeList(params *form.SysDictTypeList, locale string) *resources.Collection {\n\tcondition, args := s.buildTypeCondition(params)\n\ttotal, collection, err := model.ListPageE(model.NewSysDictType(), params.Page, params.PerPage, condition, args, model.ListOptionalParams{\n\t\tOrderBy: \"sort desc, id desc\",\n\t})\n\tif err != nil {\n\t\treturn resources.NewSysDictTypeTransformer().ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\ts.fillLocalizedTypeNames(collection, locale)\n\treturn resources.NewSysDictTypeTransformer().ToCollection(params.Page, params.PerPage, total, collection)\n}\n\nfunc (s *SysDictService) TypeDetail(id uint, locale string) (any, error) {\n\tdictType := model.NewSysDictType()\n\tif err := dictType.GetById(id); err != nil || dictType.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\ttranslations, err := model.NewSysDictTypeI18n().LocaleNameMapByTypeID(dictType.ID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdictType.TypeNameI18n = translations\n\tdictType.TypeName = service.ResolveLocaleText(translations, locale)\n\treturn resources.NewSysDictTypeTransformer().ToStruct(dictType), nil\n}\n\nfunc (s *SysDictService) CreateType(params *form.CreateSysDictType) error {\n\t_, err := s.CreateTypeWithAuditDiff(params)\n\treturn err\n}\n\nfunc (s *SysDictService) UpdateType(params *form.UpdateSysDictType) error {\n\t_, err := s.UpdateTypeWithAuditDiff(params)\n\treturn err\n}\n\nfunc (s *SysDictService) DeleteType(id uint) error {\n\t_, err := s.DeleteTypeWithAuditDiff(id)\n\treturn err\n}\n\nfunc (s *SysDictService) ItemList(params *form.SysDictItemList, locale string) *resources.Collection {\n\tcondition, args := s.buildItemCondition(params)\n\ttotal, collection, err := model.ListPageE(model.NewSysDictItem(), params.Page, params.PerPage, condition, args, model.ListOptionalParams{\n\t\tOrderBy: \"sort desc, id asc\",\n\t})\n\tif err != nil {\n\t\treturn resources.NewSysDictItemTransformer().ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\ts.fillLocalizedItemLabels(collection, locale)\n\treturn resources.NewSysDictItemTransformer().ToCollection(params.Page, params.PerPage, total, collection)\n}\n\nfunc (s *SysDictService) CreateItem(params *form.CreateSysDictItem) error {\n\t_, err := s.CreateItemWithAuditDiff(params)\n\treturn err\n}\n\nfunc (s *SysDictService) UpdateItem(params *form.UpdateSysDictItem) error {\n\t_, err := s.UpdateItemWithAuditDiff(params)\n\treturn err\n}\n\nfunc (s *SysDictService) DeleteItem(id uint) error {\n\t_, err := s.DeleteItemWithAuditDiff(id)\n\treturn err\n}\n\nfunc (s *SysDictService) Options(typeCode string, locale string) ([]resources.SysDictOptionResources, error) {\n\ttypeCode = strings.TrimSpace(typeCode)\n\tif typeCode == \"\" {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter)\n\t}\n\tif err := s.ensureEnabledType(typeCode); err != nil {\n\t\treturn nil, err\n\t}\n\titems, err := model.NewSysDictItem().EnabledItemsByTypeCode(typeCode)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ts.fillLocalizedItemLabelsFromSlice(items, locale)\n\treturn resources.ToSysDictOptions(items), nil\n}\n\nfunc (s *SysDictService) applyTypeMutation(id uint, params *form.SysDictTypePayload) error {\n\tparams.TypeCode = strings.TrimSpace(params.TypeCode)\n\tparams.TypeNameI18n = service.NormalizeLocaleTextMap(params.TypeNameI18n)\n\tparams.Remark = strings.TrimSpace(params.Remark)\n\tif len(params.TypeNameI18n) == 0 {\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\n\tdictType := model.NewSysDictType()\n\tif id > 0 {\n\t\tif err := dictType.GetById(id); err != nil || dictType.ID == 0 {\n\t\t\treturn e.NewBusinessError(e.NotFound)\n\t\t}\n\t\tif dictType.IsProtected() && dictType.TypeCode != params.TypeCode {\n\t\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t\t}\n\t}\n\tif exists, err := model.NewSysDictType().ExistsByTypeCodeExcludeID(params.TypeCode, id); err != nil {\n\t\treturn err\n\t} else if exists {\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\n\tdictType.TypeCode = params.TypeCode\n\tdictType.Status = valueOrDefault(params.Status, defaultStatus(dictType.Status, id))\n\tdictType.Sort = params.Sort\n\tdictType.Remark = params.Remark\n\tdb, err := dictType.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\tdictType.SetDB(tx)\n\t\tif saveErr := dictType.Save(); saveErr != nil {\n\t\t\treturn saveErr\n\t\t}\n\t\treturn model.NewSysDictTypeI18n().UpsertTypeNames(dictType.ID, params.TypeNameI18n, tx)\n\t})\n}\n\nfunc (s *SysDictService) applyItemMutation(id uint, params *form.SysDictItemPayload) error {\n\tparams.TypeCode = strings.TrimSpace(params.TypeCode)\n\tparams.LabelI18n = service.NormalizeLocaleTextMap(params.LabelI18n)\n\tparams.Value = strings.TrimSpace(params.Value)\n\tparams.Color = strings.TrimSpace(params.Color)\n\tparams.TagType = strings.TrimSpace(params.TagType)\n\tparams.Remark = strings.TrimSpace(params.Remark)\n\tif len(params.LabelI18n) == 0 {\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\tif err := s.ensureTypeExists(params.TypeCode); err != nil {\n\t\treturn err\n\t}\n\n\titem := model.NewSysDictItem()\n\tif id > 0 {\n\t\tif err := item.GetById(id); err != nil || item.ID == 0 {\n\t\t\treturn e.NewBusinessError(e.NotFound)\n\t\t}\n\t\tif item.IsProtected() && (item.TypeCode != params.TypeCode || item.Value != params.Value) {\n\t\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t\t}\n\t}\n\tif exists, err := model.NewSysDictItem().ExistsByValueExcludeID(params.TypeCode, params.Value, id); err != nil {\n\t\treturn err\n\t} else if exists {\n\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t}\n\n\titem.TypeCode = params.TypeCode\n\titem.Value = params.Value\n\titem.Color = params.Color\n\titem.TagType = params.TagType\n\titem.IsDefault = valueOrDefault(params.IsDefault, item.IsDefault)\n\titem.Status = valueOrDefault(params.Status, defaultStatus(item.Status, id))\n\titem.Sort = params.Sort\n\titem.Remark = params.Remark\n\n\tdb, err := item.GetDB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn access.RunInTransaction(db, func(tx *gorm.DB) error {\n\t\titem.SetDB(tx)\n\t\tif item.IsDefault == 1 {\n\t\t\tif err := tx.Model(model.NewSysDictItem()).\n\t\t\t\tWhere(\"type_code = ? AND id <> ? AND deleted_at = 0\", item.TypeCode, item.ID).\n\t\t\t\tUpdate(\"is_default\", 0).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif err := item.Save(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn model.NewSysDictItemI18n().UpsertLabels(item.ID, params.LabelI18n, tx)\n\t})\n}\n\nfunc (s *SysDictService) ensureTypeExists(typeCode string) error {\n\tdictType := model.NewSysDictType()\n\tif err := dictType.FindByTypeCode(typeCode); err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn e.NewBusinessError(e.InvalidParameter)\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (s *SysDictService) ensureEnabledType(typeCode string) error {\n\tdictType := model.NewSysDictType()\n\tif err := dictType.FindByTypeCode(typeCode); err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn e.NewBusinessError(e.NotFound)\n\t\t}\n\t\treturn err\n\t}\n\tif dictType.Status != 1 {\n\t\treturn e.NewBusinessError(e.NotFound)\n\t}\n\treturn nil\n}\n\nfunc (s *SysDictService) buildTypeCondition(params *form.SysDictTypeList) (string, []any) {\n\tqb := query_builder.New().\n\t\tAddLike(\"type_code\", params.TypeCode).\n\t\tAddEq(\"status\", params.Status)\n\tif keyword := strings.TrimSpace(params.TypeName); keyword != \"\" {\n\t\tqb.AddCondition(\"id IN (SELECT dict_type_id FROM sys_dict_type_i18n WHERE type_name like ?)\", \"%\"+keyword+\"%\")\n\t}\n\treturn qb.Build()\n}\n\nfunc (s *SysDictService) buildItemCondition(params *form.SysDictItemList) (string, []any) {\n\tqb := query_builder.New().\n\t\tAddEq(\"type_code\", params.TypeCode).\n\t\tAddLike(\"value\", params.Value).\n\t\tAddEq(\"status\", params.Status)\n\tif keyword := strings.TrimSpace(params.Label); keyword != \"\" {\n\t\tqb.AddCondition(\"id IN (SELECT dict_item_id FROM sys_dict_item_i18n WHERE label like ?)\", \"%\"+keyword+\"%\")\n\t}\n\treturn qb.Build()\n}\n\nfunc (s *SysDictService) fillLocalizedTypeNames(items []*model.SysDictType, locale string) {\n\tids := make([]uint, 0, len(items))\n\tfor _, item := range items {\n\t\tif item == nil {\n\t\t\tcontinue\n\t\t}\n\t\tids = append(ids, item.ID)\n\t}\n\tif len(ids) == 0 {\n\t\treturn\n\t}\n\tnameMap, err := model.NewSysDictTypeI18n().LocalizedNameMapByTypeIDs(ids, service.LocalePriority(locale))\n\tif err != nil {\n\t\treturn\n\t}\n\tfor _, item := range items {\n\t\tif item == nil {\n\t\t\tcontinue\n\t\t}\n\t\titem.TypeName = nameMap[item.ID]\n\t}\n}\n\nfunc (s *SysDictService) fillLocalizedItemLabels(items []*model.SysDictItem, locale string) {\n\tids := make([]uint, 0, len(items))\n\tfor _, item := range items {\n\t\tif item == nil {\n\t\t\tcontinue\n\t\t}\n\t\tids = append(ids, item.ID)\n\t}\n\tif len(ids) == 0 {\n\t\treturn\n\t}\n\tlabelMap, err := model.NewSysDictItemI18n().LocalizedLabelMapByItemIDs(ids, service.LocalePriority(locale))\n\tif err != nil {\n\t\treturn\n\t}\n\tfor _, item := range items {\n\t\tif item == nil {\n\t\t\tcontinue\n\t\t}\n\t\titem.Label = labelMap[item.ID]\n\t}\n}\n\nfunc (s *SysDictService) fillLocalizedItemLabelsFromSlice(items []model.SysDictItem, locale string) {\n\tids := make([]uint, 0, len(items))\n\tfor _, item := range items {\n\t\tids = append(ids, item.ID)\n\t}\n\tif len(ids) == 0 {\n\t\treturn\n\t}\n\tlabelMap, err := model.NewSysDictItemI18n().LocalizedLabelMapByItemIDs(ids, service.LocalePriority(locale))\n\tif err != nil {\n\t\treturn\n\t}\n\tfor i := range items {\n\t\titems[i].Label = labelMap[items[i].ID]\n\t}\n}\n\nfunc valueOrDefault(value *uint8, fallback uint8) uint8 {\n\tif value == nil {\n\t\treturn fallback\n\t}\n\treturn *value\n}\n\nfunc defaultStatus(current uint8, id uint) uint8 {\n\tif id == 0 && current == 0 {\n\t\treturn 1\n\t}\n\treturn current\n}\n"
  },
  {
    "path": "internal/service/system/init.go",
    "content": "package system\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/routers\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service/access\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n\t\"github.com/wannanbigpig/gin-layout/pkg/utils\"\n)\n\nconst (\n\tdefaultSort      = 100\n\tdefaultIsAuth    = 0\n\tdefaultGroupCode = \"other\"\n)\n\n// InitApiRoutes 初始化API路由\nfunc InitApiRoutes() error {\n\t// 检查数据库连接\n\tif err := checkDatabaseConnection(); err != nil {\n\t\treturn err\n\t}\n\n\t// 初始化验证器\n\tif err := validator.InitValidatorTrans(\"zh\"); err != nil {\n\t\treturn fmt.Errorf(\"初始化验证器失败: %w\", err)\n\t}\n\n\trouteTree := routers.AppRouteTree()\n\tengine, err := routers.SetRoutersWithTree(routeTree)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"初始化路由失败: %w\", err)\n\t}\n\tapiMap := routers.CollectRouteMeta(routeTree)\n\n\t// 构建API数据\n\tapiData := buildApiData(engine.Routes(), apiMap)\n\n\t// 保存API数据\n\tif err := saveApiData(apiData); err != nil {\n\t\treturn fmt.Errorf(\"保存API数据失败: %w\", err)\n\t}\n\tif err := access.NewMenuAPIDefaultsService().Sync(); err != nil {\n\t\treturn fmt.Errorf(\"同步默认菜单接口关系失败: %w\", err)\n\t}\n\n\treturn access.NewSystemDefaultsService().Ensure()\n}\n\n// RebuildUserPermissions 按数据库关系全量重建用户最终 API 权限。\nfunc RebuildUserPermissions() error {\n\t// 检查数据库连接\n\tif err := checkDatabaseConnection(); err != nil {\n\t\treturn err\n\t}\n\n\t// 在方案A中，菜单-API 关系以数据库关系表为准，这里改为全量重建用户最终 API 权限\n\tif err := access.NewMenuAPIDefaultsService().Sync(); err != nil {\n\t\treturn err\n\t}\n\tif err := access.NewSystemDefaultsService().Ensure(); err != nil {\n\t\treturn err\n\t}\n\treturn access.NewUserPermissionSyncService().SyncAllUsers()\n}\n\n// buildApiData 构建API数据\nfunc buildApiData(routes []gin.RouteInfo, apiMap routers.RouteMetaMap) []map[string]any {\n\tdate := time.Now().Format(time.DateTime)\n\tapiData := make([]map[string]any, 0, len(routes))\n\n\tfor _, route := range routes {\n\t\tcode := utils.MD5(route.Method + \"_\" + route.Path)\n\t\tname := route.Path\n\t\tisAuth := defaultIsAuth\n\t\tdesc := \"\"\n\t\tgroupCode := defaultGroupCode\n\n\t\tif val, ok := apiMap[code]; ok {\n\t\t\tname = val.Title\n\t\t\tisAuth = int(val.Auth)\n\t\t\tdesc = val.Desc\n\t\t\tgroupCode = val.GroupCode\n\t\t}\n\n\t\tapiData = append(apiData, map[string]any{\n\t\t\t\"code\":         code,\n\t\t\t\"name\":         name,\n\t\t\t\"route\":        route.Path,\n\t\t\t\"method\":       route.Method,\n\t\t\t\"func\":         extractHandlerName(route.Handler),\n\t\t\t\"func_path\":    route.Handler,\n\t\t\t\"is_auth\":      isAuth,\n\t\t\t\"description\":  desc,\n\t\t\t\"sort\":         defaultSort,\n\t\t\t\"is_effective\": 1,\n\t\t\t\"created_at\":   date,\n\t\t\t\"updated_at\":   date,\n\t\t\t\"group_code\":   groupCode,\n\t\t})\n\t}\n\n\treturn apiData\n}\n\n// extractHandlerName 提取处理器名称\nfunc extractHandlerName(handler string) string {\n\tparts := strings.Split(handler, \".\")\n\tif len(parts) == 0 {\n\t\treturn handler\n\t}\n\thandlerName := parts[len(parts)-1]\n\t// 移除方法接收器的后缀 \"-fm\"\n\treturn strings.TrimSuffix(handlerName, \"-fm\")\n}\n\n// saveApiData 保存API数据到数据库\nfunc saveApiData(apiData []map[string]any) error {\n\tapiModel := model.NewApi()\n\tdate := time.Now().Format(time.DateTime)\n\tif err := apiModel.InitRegisters(apiData, date); err != nil {\n\t\treturn err\n\t}\n\treturn access.NewApiRouteCacheService().RefreshCache()\n}\n\n// checkDatabaseConnection 检查数据库连接\nfunc checkDatabaseConnection() error {\n\tdb := data.MysqlDB()\n\tif db == nil {\n\t\treturn fmt.Errorf(\"数据库连接未初始化，请检查配置\")\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"获取数据库连接失败: %w\", err)\n\t}\n\n\tif err := sqlDB.Ping(); err != nil {\n\t\treturn fmt.Errorf(\"数据库连接测试失败: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/service/system/migration_runner.go",
    "content": "package system\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/golang-migrate/migrate/v4\"\n)\n\n// ResolveMigrationsPath 解析迁移目录绝对路径。\nfunc ResolveMigrationsPath() (string, error) {\n\treturn getMigrationsPath()\n}\n\n// NewMigrator 创建默认迁移执行器（自动解析迁移目录）。\nfunc NewMigrator() (*migrate.Migrate, error) {\n\treturn NewResetService().createMigrateInstance()\n}\n\n// NewMigratorWithPath 创建指定迁移目录的迁移执行器。\nfunc NewMigratorWithPath(path string) (*migrate.Migrate, error) {\n\ttrimmedPath := strings.TrimSpace(strings.TrimPrefix(path, \"file://\"))\n\tif trimmedPath == \"\" {\n\t\treturn nil, fmt.Errorf(\"迁移目录不能为空\")\n\t}\n\n\tabsPath, err := filepath.Abs(trimmedPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析迁移目录失败: %w\", err)\n\t}\n\n\tdbURL := NewResetService().buildDatabaseURL()\n\tm, err := migrate.New(fmt.Sprintf(\"file://%s\", absPath), dbURL)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建迁移实例失败: %w\", err)\n\t}\n\treturn m, nil\n}\n"
  },
  {
    "path": "internal/service/system/reset.go",
    "content": "package system\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/golang-migrate/migrate/v4\"\n\t_ \"github.com/golang-migrate/migrate/v4/database/mysql\"\n\t_ \"github.com/golang-migrate/migrate/v4/source/file\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n)\n\nconst migrationsPathEnvKey = \"GO_LAYOUT_MIGRATIONS_PATH\"\n\n// ResetService 保留历史入口，对外统一暴露系统维护能力。\n// 当前不持有状态，仅为兼容旧调用保留。\ntype ResetService struct {\n\t// configProvider 提供运行时配置读取入口。\n\tconfigProvider func() *config.Conf\n}\n\n// NewResetService 创建兼容旧调用的系统维护服务。\nfunc NewResetService() *ResetService {\n\treturn NewResetServiceWithDeps(ResetServiceDeps{})\n}\n\n// ResetServiceDeps 描述 ResetService 可注入依赖。\ntype ResetServiceDeps struct {\n\t// ConfigProvider 自定义配置读取函数。\n\tConfigProvider func() *config.Conf\n}\n\n// NewResetServiceWithDeps 创建带依赖注入的系统维护服务实例。\nfunc NewResetServiceWithDeps(deps ResetServiceDeps) *ResetService {\n\ts := &ResetService{\n\t\tconfigProvider: deps.ConfigProvider,\n\t}\n\ts.ensureRuntimeDeps()\n\treturn s\n}\n\nfunc (s *ResetService) ensureRuntimeDeps() {\n\tif s.configProvider == nil {\n\t\ts.configProvider = config.GetConfig\n\t}\n}\n\nfunc (s *ResetService) currentConfig() *config.Conf {\n\ts.ensureRuntimeDeps()\n\treturn config.GetConfigFrom(s.configProvider)\n}\n\n// ResetSystemData 兼容旧入口，实际执行日常清理任务。\nfunc (s *ResetService) ResetSystemData() error {\n\treturn s.cleanupExpiredSystemData()\n}\n\n// ReinitializeSystemData 兼容旧入口，实际执行系统重建任务。\nfunc (s *ResetService) ReinitializeSystemData() error {\n\treturn s.reinitializeSystemData()\n}\n\n// ResetSystemData 清理过期日志与已撤销 token 记录。\nfunc ResetSystemData() error {\n\treturn NewResetService().ResetSystemData()\n}\n\n// ReinitializeSystemData 重新初始化系统数据。\nfunc ReinitializeSystemData() error {\n\treturn NewResetService().ReinitializeSystemData()\n}\n\nfunc (s *ResetService) cleanupExpiredSystemData() error {\n\tdb := data.MysqlDB()\n\tif db == nil {\n\t\terr := model.ErrDBUninitialized\n\t\tif initErr := data.MysqlInitError(); initErr != nil {\n\t\t\terr = fmt.Errorf(\"%w: %v\", model.ErrDBUninitialized, initErr)\n\t\t}\n\t\tlog.Logger.Error(\"数据库连接未初始化\", zap.Error(err))\n\t\treturn err\n\t}\n\n\tthirtyDaysAgo := time.Now().AddDate(0, 0, -30)\n\tlog.Logger.Info(\"开始执行系统日常清理\", zap.String(\"cutoff_date\", thirtyDaysAgo.Format(\"2006-01-02 15:04:05\")))\n\n\tvar deletedRequestLogs, deletedLoginLogs, deletedRevokedTokens int64\n\n\trequestLogs := model.NewRequestLogs()\n\tresult := db.Model(requestLogs).\n\t\tWhere(\"created_at < ?\", thirtyDaysAgo).\n\t\tDelete(requestLogs)\n\tif result.Error != nil {\n\t\tlog.Logger.Error(\"清理请求日志失败\", zap.Error(result.Error))\n\t} else {\n\t\tdeletedRequestLogs = result.RowsAffected\n\t\tlog.Logger.Info(\"清理请求日志完成\", zap.Int64(\"deleted_count\", deletedRequestLogs))\n\t}\n\n\tloginLogs := model.NewAdminLoginLogs()\n\tresult = db.Model(loginLogs).\n\t\tWhere(\"created_at < ?\", thirtyDaysAgo).\n\t\tDelete(loginLogs)\n\tif result.Error != nil {\n\t\tlog.Logger.Error(\"清理登录日志失败\", zap.Error(result.Error))\n\t} else {\n\t\tdeletedLoginLogs = result.RowsAffected\n\t\tlog.Logger.Info(\"清理登录日志完成\", zap.Int64(\"deleted_count\", deletedLoginLogs))\n\t}\n\n\tresult = db.Model(loginLogs).\n\t\tWhere(\"is_revoked = 1 AND revoked_at < ?\", thirtyDaysAgo).\n\t\tDelete(loginLogs)\n\tif result.Error != nil {\n\t\tlog.Logger.Error(\"清理已撤销Token失败\", zap.Error(result.Error))\n\t} else {\n\t\tdeletedRevokedTokens = result.RowsAffected\n\t\tlog.Logger.Info(\"清理已撤销Token完成\", zap.Int64(\"deleted_count\", deletedRevokedTokens))\n\t}\n\n\tlog.Logger.Info(\"系统日常清理完成\",\n\t\tzap.Int64(\"deleted_request_logs\", deletedRequestLogs),\n\t\tzap.Int64(\"deleted_login_logs\", deletedLoginLogs),\n\t\tzap.Int64(\"deleted_revoked_tokens\", deletedRevokedTokens),\n\t)\n\treturn nil\n}\n\nfunc (s *ResetService) reinitializeSystemData() error {\n\tlog.Logger.Info(\"开始重新初始化系统数据\")\n\n\tif err := s.rollbackMigrations(); err != nil {\n\t\tlog.Logger.Error(\"回滚迁移失败\", zap.Error(err))\n\t\treturn fmt.Errorf(\"回滚迁移失败: %w\", err)\n\t}\n\tlog.Logger.Info(\"回滚迁移完成\")\n\n\tif err := s.runMigrations(); err != nil {\n\t\tlog.Logger.Error(\"执行迁移失败\", zap.Error(err))\n\t\treturn fmt.Errorf(\"执行迁移失败: %w\", err)\n\t}\n\tlog.Logger.Info(\"执行迁移完成\")\n\n\tif err := initAPIRoutes(); err != nil {\n\t\tlog.Logger.Error(\"初始化API路由失败\", zap.Error(err))\n\t\treturn fmt.Errorf(\"初始化API路由失败: %w\", err)\n\t}\n\tlog.Logger.Info(\"初始化API路由完成\")\n\n\tif err := rebuildUserPermissions(); err != nil {\n\t\tlog.Logger.Error(\"重建用户最终 API 权限失败\", zap.Error(err))\n\t\treturn fmt.Errorf(\"重建用户最终 API 权限失败: %w\", err)\n\t}\n\tlog.Logger.Info(\"重建用户最终 API 权限完成\")\n\n\tlog.Logger.Info(\"系统数据重新初始化完成\")\n\treturn nil\n}\n\nfunc (s *ResetService) rollbackMigrations() error {\n\tm, err := s.createMigrateInstance()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer m.Close()\n\n\tif err := m.Down(); err != nil && err != migrate.ErrNoChange {\n\t\tvar dirtyErr migrate.ErrDirty\n\t\tif errors.As(err, &dirtyErr) {\n\t\t\tlog.Logger.Warn(\"检测到 dirty 迁移状态，尝试自动修复并重试回滚\", zap.Uint(\"version\", uint(dirtyErr.Version)))\n\t\t\tif forceErr := m.Force(int(dirtyErr.Version)); forceErr != nil {\n\t\t\t\treturn fmt.Errorf(\"自动修复 dirty 状态失败: %w\", forceErr)\n\t\t\t}\n\t\t\tif retryErr := m.Down(); retryErr != nil && retryErr != migrate.ErrNoChange {\n\t\t\t\treturn retryErr\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (s *ResetService) runMigrations() error {\n\tm, err := s.createMigrateInstance()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer m.Close()\n\n\tif err := m.Up(); err != nil && err != migrate.ErrNoChange {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (s *ResetService) createMigrateInstance() (*migrate.Migrate, error) {\n\tmigrationsPath, err := getMigrationsPath()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"获取迁移文件路径失败: %w\", err)\n\t}\n\n\tdbURL := s.buildDatabaseURL()\n\tm, err := migrate.New(\n\t\tfmt.Sprintf(\"file://%s\", migrationsPath),\n\t\tdbURL,\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建迁移实例失败: %w\", err)\n\t}\n\treturn m, nil\n}\n\nfunc getMigrationsPath() (string, error) {\n\tpossiblePaths := []string{}\n\n\tif envPath := strings.TrimSpace(os.Getenv(migrationsPathEnvKey)); envPath != \"\" {\n\t\tpossiblePaths = append(possiblePaths, strings.TrimPrefix(envPath, \"file://\"))\n\t}\n\n\tif config.V != nil {\n\t\tconfigPath := strings.TrimSpace(config.V.ConfigFileUsed())\n\t\tif configPath != \"\" {\n\t\t\tpossiblePaths = append(possiblePaths, filepath.Join(filepath.Dir(configPath), \"data\", \"migrations\"))\n\t\t}\n\t}\n\tif executablePath, err := os.Executable(); err == nil {\n\t\tpossiblePaths = append(possiblePaths, filepath.Join(filepath.Dir(executablePath), \"data\", \"migrations\"))\n\t}\n\tif _, currentFile, _, ok := runtime.Caller(0); ok {\n\t\tpossiblePaths = append(possiblePaths, filepath.Join(filepath.Dir(currentFile), \"..\", \"..\", \"..\", \"data\", \"migrations\"))\n\t}\n\tpossiblePaths = append(possiblePaths,\n\t\t\"data/migrations\",\n\t\t\"./data/migrations\",\n\t\t\"../data/migrations\",\n\t\t\"../../data/migrations\",\n\t)\n\n\tseen := make(map[string]struct{}, len(possiblePaths))\n\tfor _, path := range possiblePaths {\n\t\tabsPath, err := filepath.Abs(path)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seen[absPath]; ok {\n\t\t\tcontinue\n\t\t}\n\t\tseen[absPath] = struct{}{}\n\t\tmatches, err := filepath.Glob(filepath.Join(absPath, \"*.up.sql\"))\n\t\tif err == nil && len(matches) > 0 {\n\t\t\treturn absPath, nil\n\t\t}\n\t}\n\n\treturn \"\", fmt.Errorf(\"未找到迁移文件目录，请确保 data/migrations 目录存在，或通过环境变量 %s 指定路径\", migrationsPathEnvKey)\n}\n\nfunc (s *ResetService) buildDatabaseURL() string {\n\tcfg := s.currentConfig().Mysql\n\treturn fmt.Sprintf(\"mysql://%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local\",\n\t\tcfg.Username,\n\t\tcfg.Password,\n\t\tcfg.Host,\n\t\tcfg.Port,\n\t\tcfg.Database,\n\t)\n}\n\nfunc initAPIRoutes() error {\n\treturn InitApiRoutes()\n}\n\nfunc rebuildUserPermissions() error {\n\treturn RebuildUserPermissions()\n}\n"
  },
  {
    "path": "internal/service/system/reset_path_test.go",
    "content": "package system\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\nfunc TestGetMigrationsPathPrefersEnvPath(t *testing.T) {\n\ttempDir := t.TempDir()\n\tmigrationsDir := filepath.Join(tempDir, \"migrations\")\n\tif err := os.MkdirAll(migrationsDir, 0o755); err != nil {\n\t\tt.Fatalf(\"create migrations dir failed: %v\", err)\n\t}\n\n\t// 只要存在一个 *.up.sql 即视为有效迁移目录。\n\tupFile := filepath.Join(migrationsDir, \"000001_init.up.sql\")\n\tif err := os.WriteFile(upFile, []byte(\"SELECT 1;\"), 0o644); err != nil {\n\t\tt.Fatalf(\"write migration file failed: %v\", err)\n\t}\n\n\tt.Setenv(migrationsPathEnvKey, migrationsDir)\n\n\tgot, err := getMigrationsPath()\n\tif err != nil {\n\t\tt.Fatalf(\"expected migrations path from env, got error: %v\", err)\n\t}\n\tif got != migrationsDir {\n\t\tt.Fatalf(\"expected %s, got %s\", migrationsDir, got)\n\t}\n}\n"
  },
  {
    "path": "internal/service/system/reset_test.go",
    "content": "package system\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/config/autoload\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\nfunc TestResetSystemDataReturnsErrorWhenMysqlUnavailable(t *testing.T) {\n\tt.Cleanup(func() {\n\t\tif err := data.CloseMysql(); err != nil {\n\t\t\tt.Fatalf(\"close mysql on cleanup: %v\", err)\n\t\t}\n\t})\n\tif err := data.CloseMysql(); err != nil {\n\t\tt.Fatalf(\"close mysql: %v\", err)\n\t}\n\n\terr := NewResetService().ResetSystemData()\n\tif !errors.Is(err, model.ErrDBUninitialized) {\n\t\tt.Fatalf(\"expected %v, got %v\", model.ErrDBUninitialized, err)\n\t}\n}\n\nfunc TestBuildDatabaseURLUsesInjectedConfig(t *testing.T) {\n\tservice := NewResetServiceWithDeps(ResetServiceDeps{\n\t\tConfigProvider: func() *config.Conf {\n\t\t\treturn &config.Conf{\n\t\t\t\tMysql: autoload.MysqlConfig{\n\t\t\t\t\tHost:     \"127.0.0.1\",\n\t\t\t\t\tPort:     3307,\n\t\t\t\t\tDatabase: \"demo\",\n\t\t\t\t\tUsername: \"tester\",\n\t\t\t\t\tPassword: \"secret\",\n\t\t\t\t},\n\t\t\t}\n\t\t},\n\t})\n\n\tgot := service.buildDatabaseURL()\n\twant := \"mysql://tester:secret@tcp(127.0.0.1:3307)/demo?charset=utf8mb4&parseTime=True&loc=Local\"\n\tif got != want {\n\t\tt.Fatalf(\"expected %s, got %s\", want, got)\n\t}\n}\n"
  },
  {
    "path": "internal/service/taskcenter/action.go",
    "content": "package taskcenter\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"gorm.io/gorm\"\n\n\ttaskcron \"github.com/wannanbigpig/gin-layout/internal/cron\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n\t\"go.uber.org/zap\"\n)\n\nvar loadTaskRunByID = func(runID uint) (*model.TaskRun, error) {\n\trunModel := model.NewTaskRun()\n\tif err := runModel.GetById(runID); err != nil {\n\t\treturn nil, err\n\t}\n\treturn runModel, nil\n}\n\nvar loadTaskDefinitionByCode = func(taskCode string) (*model.TaskDefinition, error) {\n\tdefinition := model.NewTaskDefinition()\n\tif err := definition.GetDetail(\"code = ? AND deleted_at = 0\", taskCode); err != nil {\n\t\treturn nil, err\n\t}\n\treturn definition, nil\n}\n\nvar executeCronHandler = func(ctx context.Context, handler string, payload map[string]any) error {\n\treturn taskcron.ExecuteHandler(ctx, handler, payload)\n}\n\n// TriggerTask 手动触发任务（支持 async 与 cron）。\nfunc (s *TaskCenterService) TriggerTask(ctx context.Context, params *form.TaskTriggerForm, triggerUserID uint, triggerAccount string) (map[string]any, error) {\n\ttaskCode := strings.TrimSpace(params.TaskCode)\n\tif taskCode == \"\" {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"task_code 不能为空\")\n\t}\n\n\tdefinition, err := loadTaskDefinitionByCode(taskCode)\n\tif err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t\t}\n\t\treturn nil, e.NewBusinessError(e.ServerErr, \"读取任务定义失败\")\n\t}\n\tif definition.Status != 1 {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"任务已停用，无法触发\")\n\t}\n\tif definition.AllowManual != 1 {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"任务不允许手动触发\")\n\t}\n\tif definition.IsHighRisk == model.TaskHighRisk && strings.TrimSpace(params.Confirm) == \"\" {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"高危任务触发需要确认\")\n\t}\n\n\tswitch definition.Kind {\n\tcase model.TaskKindAsync:\n\t\treturn s.triggerAsyncTask(ctx, definition, params, triggerUserID, triggerAccount)\n\tcase model.TaskKindCron:\n\t\treturn s.triggerCronTask(ctx, definition, params, triggerUserID, triggerAccount)\n\tdefault:\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"当前任务类型不支持手动触发\")\n\t}\n}\n\nfunc (s *TaskCenterService) triggerAsyncTask(ctx context.Context, definition *model.TaskDefinition, params *form.TaskTriggerForm, triggerUserID uint, triggerAccount string) (map[string]any, error) {\n\tqueueName := strings.TrimSpace(params.Queue)\n\tif queueName == \"\" {\n\t\tqueueName = strings.TrimSpace(definition.Queue)\n\t}\n\tif queueName == \"\" {\n\t\tqueueName = queue.DefaultQueue\n\t}\n\ttaskID := strings.TrimSpace(params.TaskID)\n\tif taskID == \"\" {\n\t\ttaskID = \"manual:\" + uuid.NewString()\n\t}\n\n\tpayload := map[string]any{}\n\tif params.Payload != nil {\n\t\tpayload = params.Payload\n\t}\n\trawPayload, marshalErr := json.Marshal(payload)\n\tif marshalErr != nil {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"payload 不是合法 JSON 对象\")\n\t}\n\n\trecorder := NewRunRecorder()\n\trun, err := recorder.Enqueue(ctx, RunStart{\n\t\tTaskCode:       definition.Code,\n\t\tKind:           definition.Kind,\n\t\tSource:         model.TaskSourceManual,\n\t\tSourceID:       taskID,\n\t\tQueue:          queueName,\n\t\tPayload:        rawPayload,\n\t\tTriggerUserID:  triggerUserID,\n\t\tTriggerAccount: triggerAccount,\n\t\tTriggerConfirm: strings.TrimSpace(params.Confirm),\n\t\tTriggerReason:  strings.TrimSpace(params.Reason),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tjobInfo, publishErr := queue.PublishJSON(ctx, definition.Code, queueName, payload, queue.WithTaskID(taskID))\n\tif publishErr != nil {\n\t\tfinishErr := recorder.Finish(ctx, run, RunFinish{\n\t\t\tStatus: model.TaskRunStatusFailed,\n\t\t\tError:  publishErr,\n\t\t})\n\t\tif finishErr != nil {\n\t\t\tlog.Logger.Warn(\"手动触发任务失败后更新执行记录失败\",\n\t\t\t\tzap.Uint(\"run_id\", run.ID),\n\t\t\t\tzap.Error(finishErr))\n\t\t}\n\t\tif errors.Is(publishErr, queue.ErrPublisherUnavailable) {\n\t\t\treturn nil, e.NewBusinessError(e.ServiceDependencyNotReady, \"队列未启用或未就绪\")\n\t\t}\n\t\treturn nil, e.NewBusinessError(e.ServerErr, \"任务触发失败\")\n\t}\n\n\treturn map[string]any{\n\t\t\"run_id\":  run.ID,\n\t\t\"task_id\": jobInfo.ID,\n\t\t\"queue\":   jobInfo.Queue,\n\t\t\"type\":    jobInfo.Type,\n\t}, nil\n}\n\nfunc (s *TaskCenterService) triggerCronTask(ctx context.Context, definition *model.TaskDefinition, params *form.TaskTriggerForm, triggerUserID uint, triggerAccount string) (map[string]any, error) {\n\tif strings.TrimSpace(definition.Handler) == \"\" {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"cron 任务处理器未配置\")\n\t}\n\n\ttaskID := strings.TrimSpace(params.TaskID)\n\tif taskID == \"\" {\n\t\ttaskID = \"manual:\" + uuid.NewString()\n\t}\n\n\tpayload := map[string]any{}\n\tif params.Payload != nil {\n\t\tpayload = params.Payload\n\t}\n\trawPayload, marshalErr := json.Marshal(payload)\n\tif marshalErr != nil {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"payload 不是合法 JSON 对象\")\n\t}\n\n\trun, err := NewRunRecorder().Start(ctx, RunStart{\n\t\tTaskCode:       definition.Code,\n\t\tKind:           model.TaskKindCron,\n\t\tSource:         model.TaskSourceManual,\n\t\tSourceID:       taskID,\n\t\tCronSpec:       definition.CronSpec,\n\t\tPayload:        rawPayload,\n\t\tTriggerUserID:  triggerUserID,\n\t\tTriggerAccount: triggerAccount,\n\t\tTriggerConfirm: strings.TrimSpace(params.Confirm),\n\t\tTriggerReason:  strings.TrimSpace(params.Reason),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\texecErr := executeCronHandler(ctx, definition.Handler, payload)\n\tif finishErr := NewRunRecorder().Finish(ctx, run, RunFinish{Error: execErr}); finishErr != nil {\n\t\tlog.Logger.Warn(\"手动触发 cron 任务后更新执行记录失败\",\n\t\t\tzap.Uint(\"run_id\", run.ID),\n\t\t\tzap.String(\"task_code\", definition.Code),\n\t\t\tzap.Error(finishErr))\n\t\treturn nil, e.NewBusinessError(e.ServerErr, \"任务触发后更新执行记录失败\")\n\t}\n\n\tif execErr != nil {\n\t\treturn nil, e.NewBusinessError(e.ServerErr, \"任务触发失败\")\n\t}\n\n\treturn map[string]any{\n\t\t\"run_id\":  run.ID,\n\t\t\"task_id\": taskID,\n\t\t\"queue\":   \"\",\n\t\t\"type\":    definition.Code,\n\t}, nil\n}\n\n// RetryTask 重试失败任务，重试时会创建一条新的执行记录。\nfunc (s *TaskCenterService) RetryTask(ctx context.Context, runID uint, triggerUserID uint, triggerAccount string) (map[string]any, error) {\n\trunModel, err := loadTaskRunByID(runID)\n\tif err != nil || runModel == nil || runModel.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\tif runModel.Status != model.TaskRunStatusFailed {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"仅失败任务允许重试\")\n\t}\n\tif strings.TrimSpace(runModel.TaskCode) == \"\" {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"任务编码为空，无法重试\")\n\t}\n\n\t// 校验当前任务定义是否允许重试。\n\tdefinition, err := loadTaskDefinitionByCode(runModel.TaskCode)\n\tif err != nil {\n\t\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\t\treturn nil, e.NewBusinessError(e.NotFound, \"任务定义不存在\")\n\t\t}\n\t\treturn nil, e.NewBusinessError(e.ServerErr, \"读取任务定义失败\")\n\t}\n\tif definition.Status != 1 {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"任务已停用，无法重试\")\n\t}\n\tif definition.AllowRetry != 1 {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"任务不允许重试\")\n\t}\n\tif definition.Kind != runModel.Kind {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"任务类型与当前定义不一致，无法重试\")\n\t}\n\n\tpayloadAny := map[string]any{}\n\tif strings.TrimSpace(runModel.Payload) != \"\" {\n\t\tif err := json.Unmarshal([]byte(runModel.Payload), &payloadAny); err != nil {\n\t\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"任务 payload 非法，无法重试\")\n\t\t}\n\t}\n\n\ttaskID := fmt.Sprintf(\"retry:%d:%d:%s\", runModel.ID, time.Now().UnixMilli(), uuid.NewString())\n\tqueueName := strings.TrimSpace(runModel.Queue)\n\tif queueName == \"\" {\n\t\tqueueName = queue.DefaultQueue\n\t}\n\trawPayload, _ := json.Marshal(payloadAny)\n\n\trecorder := NewRunRecorder()\n\tretryRun, err := recorder.Enqueue(ctx, RunStart{\n\t\tTaskCode:       runModel.TaskCode,\n\t\tKind:           runModel.Kind,\n\t\tSource:         model.TaskSourceManual,\n\t\tSourceID:       taskID,\n\t\tQueue:          queueName,\n\t\tAttempt:        1,\n\t\tMaxRetry:       runModel.MaxRetry,\n\t\tPayload:        rawPayload,\n\t\tTriggerUserID:  triggerUserID,\n\t\tTriggerAccount: triggerAccount,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\toptions := []queue.JobOption{queue.WithTaskID(taskID)}\n\tif runModel.MaxRetry > 0 {\n\t\toptions = append(options, queue.WithMaxRetry(runModel.MaxRetry))\n\t}\n\tjobInfo, publishErr := queue.PublishJSON(ctx, runModel.TaskCode, queueName, payloadAny, options...)\n\tif publishErr != nil {\n\t\tfinishErr := recorder.Finish(ctx, retryRun, RunFinish{\n\t\t\tStatus: model.TaskRunStatusFailed,\n\t\t\tError:  publishErr,\n\t\t})\n\t\tif finishErr != nil {\n\t\t\tlog.Logger.Warn(\"重试任务发布失败后更新执行记录失败\",\n\t\t\t\tzap.Uint(\"run_id\", retryRun.ID),\n\t\t\t\tzap.Error(finishErr))\n\t\t}\n\t\tif errors.Is(publishErr, queue.ErrPublisherUnavailable) {\n\t\t\treturn nil, e.NewBusinessError(e.ServiceDependencyNotReady, \"队列未启用或未就绪\")\n\t\t}\n\t\treturn nil, e.NewBusinessError(e.ServerErr, \"任务重试失败\")\n\t}\n\n\treturn map[string]any{\n\t\t\"run_id\":         retryRun.ID,\n\t\t\"task_id\":        jobInfo.ID,\n\t\t\"queue\":          jobInfo.Queue,\n\t\t\"type\":           jobInfo.Type,\n\t\t\"retry_from_run\": runModel.ID,\n\t}, nil\n}\n\n// CancelTask 取消待执行或执行中的异步任务。\nfunc (s *TaskCenterService) CancelTask(ctx context.Context, runID uint, triggerUserID uint, triggerAccount string, cancelReason string) (map[string]any, error) {\n\trunModel, err := loadTaskRunByID(runID)\n\tif err != nil || runModel == nil || runModel.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\n\tif runModel.Kind != model.TaskKindAsync {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"仅异步任务支持取消\")\n\t}\n\tif strings.TrimSpace(runModel.SourceID) == \"\" {\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"任务缺少 source_id，无法取消\")\n\t}\n\tswitch runModel.Status {\n\tcase model.TaskRunStatusSuccess, model.TaskRunStatusFailed, model.TaskRunStatusCanceled:\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"已结束任务不支持取消\")\n\tcase model.TaskRunStatusPending, model.TaskRunStatusRetrying, model.TaskRunStatusRunning:\n\t\t// 允许取消\n\tdefault:\n\t\treturn nil, e.NewBusinessError(e.InvalidParameter, \"当前任务状态不支持取消\")\n\t}\n\n\tqueueName := strings.TrimSpace(runModel.Queue)\n\tif queueName == \"\" {\n\t\tqueueName = queue.DefaultQueue\n\t}\n\n\tvar cancelErr error\n\tif runModel.Status == model.TaskRunStatusRunning {\n\t\tcancelErr = queue.CancelProcessing(ctx, runModel.SourceID)\n\t} else {\n\t\tcancelErr = queue.DeleteTask(ctx, queueName, runModel.SourceID)\n\t}\n\tif cancelErr != nil {\n\t\tif errors.Is(cancelErr, queue.ErrInspectorUnavailable) {\n\t\t\treturn nil, e.NewBusinessError(e.ServiceDependencyNotReady, \"队列未启用或未就绪\")\n\t\t}\n\t\tif errors.Is(cancelErr, queue.ErrTaskNotFound) || errors.Is(cancelErr, queue.ErrQueueNotFound) {\n\t\t\treturn nil, e.NewBusinessError(e.NotFound, \"队列任务不存在或已结束\")\n\t\t}\n\t\treturn nil, e.NewBusinessError(e.ServerErr, \"任务取消失败\")\n\t}\n\n\trunModel.Status = model.TaskRunStatusCanceled\n\tif err := NewRunRecorder().Finish(ctx, runModel, RunFinish{\n\t\tStatus:            model.TaskRunStatusCanceled,\n\t\tCanceledBy:        triggerUserID,\n\t\tCanceledByAccount: triggerAccount,\n\t\tCancelReason:      strings.TrimSpace(cancelReason),\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := map[string]any{\n\t\t\"run_id\":              runModel.ID,\n\t\t\"task_id\":             runModel.SourceID,\n\t\t\"status\":              model.TaskRunStatusCanceled,\n\t\t\"canceled_by\":         triggerUserID,\n\t\t\"canceled_by_account\": triggerAccount,\n\t}\n\tif strings.TrimSpace(cancelReason) != \"\" {\n\t\tresult[\"cancel_reason\"] = strings.TrimSpace(cancelReason)\n\t}\n\treturn result, nil\n}\n"
  },
  {
    "path": "internal/service/taskcenter/action_test.go",
    "content": "package taskcenter\n\nimport (\n\t\"context\"\n\tstderrors \"errors\"\n\t\"testing\"\n\n\ttaskcron \"github.com/wannanbigpig/gin-layout/internal/cron\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/queue\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\ntype stubPublisher struct {\n\tinfo queue.JobInfo\n\terr  error\n}\n\nfunc (s *stubPublisher) Enqueue(ctx context.Context, job queue.Job) (queue.JobInfo, error) {\n\t_ = ctx\n\t_ = job\n\tif s.err != nil {\n\t\treturn queue.JobInfo{}, s.err\n\t}\n\treturn s.info, nil\n}\n\ntype fakeActionRecorder struct {\n\tenqueueInputs []RunStart\n\tstartInputs   []RunStart\n\tfinishInputs  []RunFinish\n}\n\ntype stubInspector struct {\n\tdeleteTaskErr error\n\tcancelErr     error\n\tdeleted       []struct {\n\t\tqueue  string\n\t\ttaskID string\n\t}\n\tcanceled []string\n}\n\nfunc (f *fakeActionRecorder) Enqueue(ctx context.Context, input RunStart) (*model.TaskRun, error) {\n\t_ = ctx\n\tf.enqueueInputs = append(f.enqueueInputs, input)\n\treturn &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.enqueueInputs))}, TaskCode: input.TaskCode}, nil\n}\n\nfunc (f *fakeActionRecorder) Start(ctx context.Context, input RunStart) (*model.TaskRun, error) {\n\t_ = ctx\n\tf.startInputs = append(f.startInputs, input)\n\treturn &model.TaskRun{BaseModel: model.BaseModel{ID: uint(len(f.startInputs))}, TaskCode: input.TaskCode}, nil\n}\n\nfunc (f *fakeActionRecorder) Finish(ctx context.Context, run *model.TaskRun, input RunFinish) error {\n\t_ = ctx\n\t_ = run\n\tf.finishInputs = append(f.finishInputs, input)\n\treturn nil\n}\n\nfunc (s *stubInspector) DeleteTask(ctx context.Context, queueName, taskID string) error {\n\t_ = ctx\n\ts.deleted = append(s.deleted, struct {\n\t\tqueue  string\n\t\ttaskID string\n\t}{queue: queueName, taskID: taskID})\n\treturn s.deleteTaskErr\n}\n\nfunc (s *stubInspector) CancelProcessing(ctx context.Context, taskID string) error {\n\t_ = ctx\n\ts.canceled = append(s.canceled, taskID)\n\treturn s.cancelErr\n}\n\nfunc TestTriggerTaskSuccess(t *testing.T) {\n\trestoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {\n\t\treturn &model.TaskDefinition{\n\t\t\tCode:        taskCode,\n\t\t\tKind:        model.TaskKindAsync,\n\t\t\tStatus:      1,\n\t\t\tAllowManual: 1,\n\t\t}, nil\n\t})\n\tdefer restoreTaskDefinition()\n\n\trestorePublisher := queue.SetPublisherForTesting(&stubPublisher{\n\t\tinfo: queue.JobInfo{ID: \"task-1\", Queue: \"default\", Type: \"demo:send\"},\n\t})\n\tdefer restorePublisher()\n\n\tfakeRecorder := &fakeActionRecorder{}\n\trestoreRecorder := SetRecorderForTesting(fakeRecorder)\n\tdefer restoreRecorder()\n\n\tsvc := NewTaskCenterService()\n\tresult, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{\n\t\tTaskCode: \"demo:send\",\n\t\tQueue:    \"default\",\n\t\tPayload:  map[string]any{\"name\": \"codex\"},\n\t}, 1, \"tester\")\n\tif err != nil {\n\t\tt.Fatalf(\"TriggerTask returned error: %v\", err)\n\t}\n\n\tif result[\"task_id\"] != \"task-1\" {\n\t\tt.Fatalf(\"unexpected task id: %#v\", result[\"task_id\"])\n\t}\n\tif len(fakeRecorder.enqueueInputs) != 1 {\n\t\tt.Fatalf(\"expected enqueue record called once, got %d\", len(fakeRecorder.enqueueInputs))\n\t}\n\tif len(fakeRecorder.finishInputs) != 0 {\n\t\tt.Fatalf(\"did not expect finish record on success, got %d\", len(fakeRecorder.finishInputs))\n\t}\n}\n\nfunc TestTriggerTaskReturnsDependencyNotReadyWhenPublisherUnavailable(t *testing.T) {\n\trestoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {\n\t\treturn &model.TaskDefinition{\n\t\t\tCode:        taskCode,\n\t\t\tKind:        model.TaskKindAsync,\n\t\t\tStatus:      1,\n\t\t\tAllowManual: 1,\n\t\t}, nil\n\t})\n\tdefer restoreTaskDefinition()\n\n\trestorePublisher := queue.SetPublisherForTesting(nil)\n\tdefer restorePublisher()\n\n\tfakeRecorder := &fakeActionRecorder{}\n\trestoreRecorder := SetRecorderForTesting(fakeRecorder)\n\tdefer restoreRecorder()\n\n\tsvc := NewTaskCenterService()\n\t_, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{\n\t\tTaskCode: \"demo:send\",\n\t\tQueue:    \"default\",\n\t}, 1, \"tester\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error when publisher unavailable\")\n\t}\n\n\tvar be *e.BusinessError\n\tif !stderrors.As(err, &be) {\n\t\tt.Fatalf(\"expected business error, got %T\", err)\n\t}\n\tif be.GetCode() != e.ServiceDependencyNotReady {\n\t\tt.Fatalf(\"expected code %d, got %d\", e.ServiceDependencyNotReady, be.GetCode())\n\t}\n\tif len(fakeRecorder.finishInputs) != 1 {\n\t\tt.Fatalf(\"expected finish record called once, got %d\", len(fakeRecorder.finishInputs))\n\t}\n}\n\nfunc TestTriggerTaskCronSuccess(t *testing.T) {\n\trestoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {\n\t\treturn &model.TaskDefinition{\n\t\t\tCode:        taskCode,\n\t\t\tKind:        model.TaskKindCron,\n\t\t\tHandler:     taskcron.HandlerCronDemo,\n\t\t\tStatus:      1,\n\t\t\tAllowManual: 1,\n\t\t}, nil\n\t})\n\tdefer restoreTaskDefinition()\n\n\tvar calledHandler string\n\trestoreExecutor := setCronExecutorForTest(func(ctx context.Context, handler string, payload map[string]any) error {\n\t\t_ = ctx\n\t\t_ = payload\n\t\tcalledHandler = handler\n\t\treturn nil\n\t})\n\tdefer restoreExecutor()\n\n\tfakeRecorder := &fakeActionRecorder{}\n\trestoreRecorder := SetRecorderForTesting(fakeRecorder)\n\tdefer restoreRecorder()\n\n\tsvc := NewTaskCenterService()\n\tresult, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{\n\t\tTaskCode: \"cron:demo\",\n\t\tPayload:  map[string]any{\"source\": \"test\"},\n\t}, 1, \"tester\")\n\tif err != nil {\n\t\tt.Fatalf(\"TriggerTask returned error: %v\", err)\n\t}\n\n\tif calledHandler != taskcron.HandlerCronDemo {\n\t\tt.Fatalf(\"unexpected cron handler: %s\", calledHandler)\n\t}\n\tif result[\"run_id\"] != uint(1) {\n\t\tt.Fatalf(\"unexpected run_id: %#v\", result[\"run_id\"])\n\t}\n\tif len(fakeRecorder.startInputs) != 1 {\n\t\tt.Fatalf(\"expected start record called once, got %d\", len(fakeRecorder.startInputs))\n\t}\n\tif len(fakeRecorder.finishInputs) != 1 {\n\t\tt.Fatalf(\"expected finish record called once, got %d\", len(fakeRecorder.finishInputs))\n\t}\n\tif fakeRecorder.finishInputs[0].Error != nil {\n\t\tt.Fatalf(\"expected cron run success, got error: %v\", fakeRecorder.finishInputs[0].Error)\n\t}\n}\n\nfunc TestTriggerTaskReturnsErrorWhenManualNotAllowed(t *testing.T) {\n\trestoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {\n\t\treturn &model.TaskDefinition{\n\t\t\tCode:        taskCode,\n\t\t\tKind:        model.TaskKindCron,\n\t\t\tStatus:      1,\n\t\t\tAllowManual: 0,\n\t\t}, nil\n\t})\n\tdefer restoreTaskDefinition()\n\n\tsvc := NewTaskCenterService()\n\t_, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{\n\t\tTaskCode: \"cron:demo\",\n\t}, 1, \"tester\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error when task does not allow manual trigger\")\n\t}\n\n\tvar be *e.BusinessError\n\tif !stderrors.As(err, &be) {\n\t\tt.Fatalf(\"expected business error, got %T\", err)\n\t}\n\tif be.GetCode() != e.InvalidParameter {\n\t\tt.Fatalf(\"expected code %d, got %d\", e.InvalidParameter, be.GetCode())\n\t}\n}\n\nfunc TestTriggerTaskHighRiskRequiresConfirm(t *testing.T) {\n\trestoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {\n\t\treturn &model.TaskDefinition{\n\t\t\tCode:        taskCode,\n\t\t\tKind:        model.TaskKindAsync,\n\t\t\tStatus:      1,\n\t\t\tAllowManual: 1,\n\t\t\tIsHighRisk:  model.TaskHighRisk,\n\t\t}, nil\n\t})\n\tdefer restoreTaskDefinition()\n\n\tsvc := NewTaskCenterService()\n\t_, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{\n\t\tTaskCode: \"demo:send\",\n\t}, 1, \"tester\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error when high-risk task confirm is empty\")\n\t}\n\n\tvar be *e.BusinessError\n\tif !stderrors.As(err, &be) {\n\t\tt.Fatalf(\"expected business error, got %T\", err)\n\t}\n\tif be.GetCode() != e.InvalidParameter {\n\t\tt.Fatalf(\"expected code %d, got %d\", e.InvalidParameter, be.GetCode())\n\t}\n}\n\nfunc TestTriggerTaskRecordsConfirmAndReason(t *testing.T) {\n\trestoreTaskDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {\n\t\treturn &model.TaskDefinition{\n\t\t\tCode:        taskCode,\n\t\t\tKind:        model.TaskKindAsync,\n\t\t\tStatus:      1,\n\t\t\tAllowManual: 1,\n\t\t\tIsHighRisk:  model.TaskHighRisk,\n\t\t}, nil\n\t})\n\tdefer restoreTaskDefinition()\n\n\trestorePublisher := queue.SetPublisherForTesting(&stubPublisher{\n\t\tinfo: queue.JobInfo{ID: \"task-1\", Queue: \"default\", Type: \"demo:send\"},\n\t})\n\tdefer restorePublisher()\n\n\tfakeRecorder := &fakeActionRecorder{}\n\trestoreRecorder := SetRecorderForTesting(fakeRecorder)\n\tdefer restoreRecorder()\n\n\tsvc := NewTaskCenterService()\n\t_, err := svc.TriggerTask(context.Background(), &form.TaskTriggerForm{\n\t\tTaskCode: \"demo:send\",\n\t\tConfirm:  \"CONFIRM\",\n\t\tReason:   \"manual high-risk operation\",\n\t}, 1, \"tester\")\n\tif err != nil {\n\t\tt.Fatalf(\"TriggerTask returned error: %v\", err)\n\t}\n\n\tif len(fakeRecorder.enqueueInputs) != 1 {\n\t\tt.Fatalf(\"expected enqueue record called once, got %d\", len(fakeRecorder.enqueueInputs))\n\t}\n\tif fakeRecorder.enqueueInputs[0].TriggerConfirm != \"CONFIRM\" || fakeRecorder.enqueueInputs[0].TriggerReason != \"manual high-risk operation\" {\n\t\tt.Fatalf(\"unexpected trigger audit meta: %#v\", fakeRecorder.enqueueInputs[0])\n\t}\n}\n\nfunc TestRetryTaskRespectsCurrentDefinition(t *testing.T) {\n\trestoreRun := setTaskRunLoaderForTest(func(runID uint) (*model.TaskRun, error) {\n\t\treturn &model.TaskRun{\n\t\t\tBaseModel: model.BaseModel{ID: runID},\n\t\t\tTaskCode:  \"demo:send\",\n\t\t\tKind:      model.TaskKindAsync,\n\t\t\tQueue:     \"default\",\n\t\t\tStatus:    model.TaskRunStatusFailed,\n\t\t\tPayload:   `{\"name\":\"codex\"}`,\n\t\t}, nil\n\t})\n\tdefer restoreRun()\n\n\trestoreDefinition := setTaskDefinitionLoaderForTest(func(taskCode string) (*model.TaskDefinition, error) {\n\t\treturn &model.TaskDefinition{\n\t\t\tCode:       taskCode,\n\t\t\tKind:       model.TaskKindAsync,\n\t\t\tStatus:     model.TaskStatusEnabled,\n\t\t\tAllowRetry: 0,\n\t\t}, nil\n\t})\n\tdefer restoreDefinition()\n\n\tsvc := NewTaskCenterService()\n\t_, err := svc.RetryTask(context.Background(), 101, 1, \"tester\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error when current definition disallows retry\")\n\t}\n\n\tvar be *e.BusinessError\n\tif !stderrors.As(err, &be) {\n\t\tt.Fatalf(\"expected business error, got %T\", err)\n\t}\n\tif be.GetCode() != e.InvalidParameter {\n\t\tt.Fatalf(\"expected code %d, got %d\", e.InvalidParameter, be.GetCode())\n\t}\n}\n\nfunc TestCancelTaskDeletesPendingTaskAndMarksRunCanceled(t *testing.T) {\n\trestoreLoader := setTaskRunLoaderForTest(func(runID uint) (*model.TaskRun, error) {\n\t\treturn &model.TaskRun{\n\t\t\tBaseModel: model.BaseModel{ID: runID},\n\t\t\tTaskCode:  \"demo:send\",\n\t\t\tKind:      model.TaskKindAsync,\n\t\t\tSourceID:  \"task-1\",\n\t\t\tQueue:     \"default\",\n\t\t\tStatus:    model.TaskRunStatusPending,\n\t\t}, nil\n\t})\n\tdefer restoreLoader()\n\n\tfakeRecorder := &fakeActionRecorder{}\n\trestoreRecorder := SetRecorderForTesting(fakeRecorder)\n\tdefer restoreRecorder()\n\n\tinspector := &stubInspector{}\n\trestoreInspector := queue.SetInspectorForTesting(inspector)\n\tdefer restoreInspector()\n\n\tsvc := NewTaskCenterService()\n\tresult, err := svc.CancelTask(context.Background(), 101, 1, \"tester\", \"manual cancel\")\n\tif err != nil {\n\t\tt.Fatalf(\"CancelTask returned error: %v\", err)\n\t}\n\tif result[\"status\"] != model.TaskRunStatusCanceled {\n\t\tt.Fatalf(\"unexpected cancel status: %#v\", result[\"status\"])\n\t}\n\tif len(inspector.deleted) != 1 || inspector.deleted[0].taskID != \"task-1\" {\n\t\tt.Fatalf(\"unexpected deleted tasks: %#v\", inspector.deleted)\n\t}\n\tif len(fakeRecorder.finishInputs) != 1 || fakeRecorder.finishInputs[0].Status != model.TaskRunStatusCanceled {\n\t\tt.Fatalf(\"expected recorder finish with canceled status, got %#v\", fakeRecorder.finishInputs)\n\t}\n\tif fakeRecorder.finishInputs[0].CanceledBy != 1 || fakeRecorder.finishInputs[0].CanceledByAccount != \"tester\" || fakeRecorder.finishInputs[0].CancelReason != \"manual cancel\" {\n\t\tt.Fatalf(\"unexpected cancel meta: %#v\", fakeRecorder.finishInputs[0])\n\t}\n}\n\nfunc TestCancelTaskReturnsDependencyNotReadyWhenInspectorUnavailable(t *testing.T) {\n\trestoreLoader := setTaskRunLoaderForTest(func(runID uint) (*model.TaskRun, error) {\n\t\treturn &model.TaskRun{\n\t\t\tBaseModel: model.BaseModel{ID: runID},\n\t\t\tTaskCode:  \"demo:send\",\n\t\t\tKind:      model.TaskKindAsync,\n\t\t\tSourceID:  \"task-1\",\n\t\t\tQueue:     \"default\",\n\t\t\tStatus:    model.TaskRunStatusPending,\n\t\t}, nil\n\t})\n\tdefer restoreLoader()\n\n\trestoreInspector := queue.SetInspectorForTesting(nil)\n\tdefer restoreInspector()\n\n\tsvc := NewTaskCenterService()\n\t_, err := svc.CancelTask(context.Background(), 101, 1, \"tester\", \"\")\n\tif err == nil {\n\t\tt.Fatal(\"expected error when inspector unavailable\")\n\t}\n\n\tvar be *e.BusinessError\n\tif !stderrors.As(err, &be) {\n\t\tt.Fatalf(\"expected business error, got %T\", err)\n\t}\n\tif be.GetCode() != e.ServiceDependencyNotReady {\n\t\tt.Fatalf(\"expected code %d, got %d\", e.ServiceDependencyNotReady, be.GetCode())\n\t}\n}\n\nfunc setTaskRunLoaderForTest(loader func(runID uint) (*model.TaskRun, error)) func() {\n\tprevious := loadTaskRunByID\n\tloadTaskRunByID = loader\n\treturn func() {\n\t\tloadTaskRunByID = previous\n\t}\n}\n\nfunc setTaskDefinitionLoaderForTest(loader func(taskCode string) (*model.TaskDefinition, error)) func() {\n\tprevious := loadTaskDefinitionByCode\n\tloadTaskDefinitionByCode = loader\n\treturn func() {\n\t\tloadTaskDefinitionByCode = previous\n\t}\n}\n\nfunc setCronExecutorForTest(executor func(ctx context.Context, handler string, payload map[string]any) error) func() {\n\tprevious := executeCronHandler\n\texecuteCronHandler = executor\n\treturn func() {\n\t\texecuteCronHandler = previous\n\t}\n}\n"
  },
  {
    "path": "internal/service/taskcenter/audit_diff.go",
    "content": "package taskcenter\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/auditdiff\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nvar taskRunStatusLabels = map[string]string{\n\tmodel.TaskRunStatusPending:  \"待执行\",\n\tmodel.TaskRunStatusRunning:  \"执行中\",\n\tmodel.TaskRunStatusSuccess:  \"成功\",\n\tmodel.TaskRunStatusFailed:   \"失败\",\n\tmodel.TaskRunStatusCanceled: \"已取消\",\n\tmodel.TaskRunStatusRetrying: \"重试中\",\n}\n\nvar taskActionDiffRules = []auditdiff.FieldRule{\n\t{Field: \"action\", Label: \"操作类型\"},\n\t{Field: \"task_code\", Label: \"任务编码\"},\n\t{Field: \"run_id\", Label: \"执行记录ID\"},\n\t{Field: \"status\", Label: \"执行状态\", ValueLabels: taskRunStatusLabels},\n\t{Field: \"queue\", Label: \"队列\"},\n\t{Field: \"task_id\", Label: \"任务ID\"},\n\t{Field: \"retry_from_run\", Label: \"重试来源执行记录ID\"},\n\t{Field: \"payload_keys\", Label: \"Payload字段\"},\n\t{Field: \"confirm\", Label: \"确认信息\"},\n\t{Field: \"reason\", Label: \"操作原因\"},\n\t{Field: \"canceled_by\", Label: \"取消人ID\"},\n\t{Field: \"canceled_by_account\", Label: \"取消人账号\"},\n\t{Field: \"cancel_reason\", Label: \"取消原因\"},\n}\n\n// TaskRunAuditSnapshot 描述任务执行记录审计快照。\ntype TaskRunAuditSnapshot struct {\n\tRunID     uint\n\tTaskCode  string\n\tStatus    string\n\tQueue     string\n\tSourceID  string\n\tKind      string\n\tMaxRetry  int\n\tAttempt   int\n\tHasRecord bool\n}\n\nfunc BuildTriggerAuditDiff(params *form.TaskTriggerForm, result map[string]any) string {\n\tafter := map[string]any{\n\t\t\"action\":       \"trigger\",\n\t\t\"task_code\":    strings.TrimSpace(params.TaskCode),\n\t\t\"run_id\":       result[\"run_id\"],\n\t\t\"queue\":        result[\"queue\"],\n\t\t\"task_id\":      result[\"task_id\"],\n\t\t\"payload_keys\": payloadKeys(params.Payload),\n\t}\n\tif strings.TrimSpace(params.Confirm) != \"\" {\n\t\tafter[\"confirm\"] = strings.TrimSpace(params.Confirm)\n\t}\n\tif strings.TrimSpace(params.Reason) != \"\" {\n\t\tafter[\"reason\"] = strings.TrimSpace(params.Reason)\n\t}\n\titems := auditdiff.BuildFieldDiff(nil, after, taskActionDiffRules)\n\treturn auditdiff.Marshal(items)\n}\n\nfunc BuildRetryAuditDiff(before *TaskRunAuditSnapshot, result map[string]any) string {\n\tbeforeState := map[string]any{}\n\tif before != nil && before.HasRecord {\n\t\tbeforeState[\"action\"] = \"retry\"\n\t\tbeforeState[\"task_code\"] = before.TaskCode\n\t\tbeforeState[\"run_id\"] = before.RunID\n\t\tbeforeState[\"status\"] = before.Status\n\t\tbeforeState[\"queue\"] = before.Queue\n\t}\n\n\tafter := map[string]any{\n\t\t\"action\":         \"retry\",\n\t\t\"task_code\":      beforeState[\"task_code\"],\n\t\t\"run_id\":         result[\"run_id\"],\n\t\t\"status\":         model.TaskRunStatusPending,\n\t\t\"queue\":          result[\"queue\"],\n\t\t\"task_id\":        result[\"task_id\"],\n\t\t\"retry_from_run\": result[\"retry_from_run\"],\n\t}\n\titems := auditdiff.BuildFieldDiff(beforeState, after, taskActionDiffRules)\n\treturn auditdiff.Marshal(items)\n}\n\nfunc BuildCancelAuditDiff(before *TaskRunAuditSnapshot, result map[string]any) string {\n\tbeforeState := map[string]any{}\n\tif before != nil && before.HasRecord {\n\t\tbeforeState[\"action\"] = \"cancel\"\n\t\tbeforeState[\"task_code\"] = before.TaskCode\n\t\tbeforeState[\"run_id\"] = before.RunID\n\t\tbeforeState[\"status\"] = before.Status\n\t\tbeforeState[\"queue\"] = before.Queue\n\t\tbeforeState[\"task_id\"] = before.SourceID\n\t}\n\n\tafter := map[string]any{\n\t\t\"action\":    \"cancel\",\n\t\t\"task_code\": beforeState[\"task_code\"],\n\t\t\"run_id\":    result[\"run_id\"],\n\t\t\"status\":    result[\"status\"],\n\t\t\"queue\":     beforeState[\"queue\"],\n\t\t\"task_id\":   result[\"task_id\"],\n\t}\n\tif result[\"canceled_by\"] != nil {\n\t\tafter[\"canceled_by\"] = result[\"canceled_by\"]\n\t}\n\tif result[\"canceled_by_account\"] != nil {\n\t\tafter[\"canceled_by_account\"] = result[\"canceled_by_account\"]\n\t}\n\tif result[\"cancel_reason\"] != nil {\n\t\tafter[\"cancel_reason\"] = result[\"cancel_reason\"]\n\t}\n\titems := auditdiff.BuildFieldDiff(beforeState, after, taskActionDiffRules)\n\treturn auditdiff.Marshal(items)\n}\n\nfunc payloadKeys(payload map[string]any) []string {\n\tif len(payload) == 0 {\n\t\treturn []string{}\n\t}\n\tkeys := make([]string, 0, len(payload))\n\tfor key := range payload {\n\t\ttrimmed := strings.TrimSpace(key)\n\t\tif trimmed == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tkeys = append(keys, trimmed)\n\t}\n\tsort.Strings(keys)\n\treturn keys\n}\n"
  },
  {
    "path": "internal/service/taskcenter/audit_diff_test.go",
    "content": "package taskcenter\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n)\n\nfunc TestBuildCancelAuditDiffContainsStatusMapping(t *testing.T) {\n\traw := BuildCancelAuditDiff(&TaskRunAuditSnapshot{\n\t\tRunID:     101,\n\t\tTaskCode:  \"demo:send\",\n\t\tStatus:    model.TaskRunStatusRunning,\n\t\tQueue:     \"critical\",\n\t\tSourceID:  \"task-abc\",\n\t\tHasRecord: true,\n\t}, map[string]any{\n\t\t\"run_id\":              uint(101),\n\t\t\"task_id\":             \"task-abc\",\n\t\t\"status\":              model.TaskRunStatusCanceled,\n\t\t\"canceled_by\":         uint(1),\n\t\t\"canceled_by_account\": \"tester\",\n\t\t\"cancel_reason\":       \"manual cancel\",\n\t})\n\n\tvar items []map[string]any\n\tif err := json.Unmarshal([]byte(raw), &items); err != nil {\n\t\tt.Fatalf(\"expected valid json diff, got err=%v raw=%s\", err, raw)\n\t}\n\n\tfoundStatus := false\n\tfoundAccount := false\n\tfoundReason := false\n\tfor _, item := range items {\n\t\tswitch item[\"field\"] {\n\t\tcase \"status\":\n\t\t\tfoundStatus = true\n\t\t\tif item[\"before_display\"] != \"执行中\" || item[\"after_display\"] != \"已取消\" {\n\t\t\t\tt.Fatalf(\"unexpected status display mapping: %#v\", item)\n\t\t\t}\n\t\tcase \"canceled_by_account\":\n\t\t\tfoundAccount = true\n\t\t\tif item[\"after\"] != \"tester\" {\n\t\t\t\tt.Fatalf(\"unexpected canceled_by_account item: %#v\", item)\n\t\t\t}\n\t\tcase \"cancel_reason\":\n\t\t\tfoundReason = true\n\t\t\tif item[\"after\"] != \"manual cancel\" {\n\t\t\t\tt.Fatalf(\"unexpected cancel_reason item: %#v\", item)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundStatus {\n\t\tt.Fatalf(\"expected status diff item, got %#v\", items)\n\t}\n\tif !foundAccount || !foundReason {\n\t\tt.Fatalf(\"expected cancel audit meta items, got %#v\", items)\n\t}\n}\n\nfunc TestBuildTriggerAuditDiffContainsPayloadKeys(t *testing.T) {\n\traw := BuildTriggerAuditDiff(&form.TaskTriggerForm{\n\t\tTaskCode: \"cron:demo\",\n\t\tConfirm:  \"CONFIRM\",\n\t\tReason:   \"manual run\",\n\t\tPayload: map[string]any{\n\t\t\t\"z\": 1,\n\t\t\t\"a\": \"x\",\n\t\t},\n\t}, map[string]any{\n\t\t\"run_id\":  uint(1),\n\t\t\"task_id\": \"manual:abc\",\n\t\t\"queue\":   \"default\",\n\t})\n\n\tvar items []map[string]any\n\tif err := json.Unmarshal([]byte(raw), &items); err != nil {\n\t\tt.Fatalf(\"expected valid json diff, got err=%v raw=%s\", err, raw)\n\t}\n\n\tfoundPayloadKeys := false\n\tfoundConfirm := false\n\tfoundReason := false\n\tfor _, item := range items {\n\t\tswitch item[\"field\"] {\n\t\tcase \"payload_keys\":\n\t\t\tfoundPayloadKeys = true\n\t\t\tafter, ok := item[\"after\"].([]any)\n\t\t\tif !ok || len(after) != 2 {\n\t\t\t\tt.Fatalf(\"unexpected payload_keys after value: %#v\", item[\"after\"])\n\t\t\t}\n\t\t\tif after[0] != \"a\" || after[1] != \"z\" {\n\t\t\t\tt.Fatalf(\"expected sorted payload keys [a z], got %#v\", after)\n\t\t\t}\n\t\tcase \"confirm\":\n\t\t\tfoundConfirm = true\n\t\t\tif item[\"after\"] != \"CONFIRM\" {\n\t\t\t\tt.Fatalf(\"unexpected confirm item: %#v\", item)\n\t\t\t}\n\t\tcase \"reason\":\n\t\t\tfoundReason = true\n\t\t\tif item[\"after\"] != \"manual run\" {\n\t\t\t\tt.Fatalf(\"unexpected reason item: %#v\", item)\n\t\t\t}\n\t\t}\n\t}\n\tif !foundPayloadKeys {\n\t\tt.Fatalf(\"expected payload_keys diff item, got %#v\", items)\n\t}\n\tif !foundConfirm || !foundReason {\n\t\tt.Fatalf(\"expected trigger audit meta items, got %#v\", items)\n\t}\n}\n"
  },
  {
    "path": "internal/service/taskcenter/list_helpers.go",
    "content": "package taskcenter\n\nimport \"github.com/wannanbigpig/gin-layout/internal/pkg/query_builder\"\n\ntype listQuery struct {\n\t*query_builder.QueryBuilder\n}\n\nfunc newListQuery() *listQuery {\n\treturn &listQuery{QueryBuilder: query_builder.New()}\n}\n\nfunc (q *listQuery) addEq(field string, value any) *listQuery {\n\tq.QueryBuilder.AddEq(field, value)\n\treturn q\n}\n\nfunc (q *listQuery) addLike(field, value string) *listQuery {\n\tq.QueryBuilder.AddLike(field, value)\n\treturn q\n}\n\nfunc (q *listQuery) addCreatedAtRange(startTime, endTime string) *listQuery {\n\tif startTime != \"\" {\n\t\tq.QueryBuilder.AddCondition(\"created_at >= ?\", startTime)\n\t}\n\tif endTime != \"\" {\n\t\tq.QueryBuilder.AddCondition(\"created_at <= ?\", endTime)\n\t}\n\treturn q\n}\n"
  },
  {
    "path": "internal/service/taskcenter/query.go",
    "content": "package taskcenter\n\nimport (\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/resources\"\n\t\"github.com/wannanbigpig/gin-layout/internal/service\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator/form\"\n\t\"go.uber.org/zap\"\n)\n\n// TaskCenterService 任务中心查询服务。\ntype TaskCenterService struct {\n\tservice.Base\n\tdb *gorm.DB\n}\n\n// TaskCenterServiceDeps 描述 TaskCenterService 可注入依赖。\ntype TaskCenterServiceDeps struct {\n\tDB *gorm.DB\n}\n\nfunc NewTaskCenterService() *TaskCenterService {\n\treturn NewTaskCenterServiceWithDeps(TaskCenterServiceDeps{})\n}\n\nfunc NewTaskCenterServiceWithDeps(deps TaskCenterServiceDeps) *TaskCenterService {\n\treturn &TaskCenterService{db: deps.DB}\n}\n\n// ListTaskDefinitions 分页查询任务定义列表。\nfunc (s *TaskCenterService) ListTaskDefinitions(params *form.TaskDefinitionList) *resources.Collection {\n\tquery := newListQuery().\n\t\taddLike(\"code\", params.Code).\n\t\taddLike(\"name\", params.Name).\n\t\taddEq(\"kind\", params.Kind).\n\t\taddEq(\"status\", params.Status).\n\t\taddEq(\"allow_manual\", params.AllowManual).\n\t\taddEq(\"allow_retry\", params.AllowRetry).\n\t\taddEq(\"is_high_risk\", params.IsHighRisk)\n\tcondition, args := query.Build()\n\n\tdefinitionModel := model.NewTaskDefinition()\n\tlistOptionalParams := model.ListOptionalParams{\n\t\tSelectFields: []string{\n\t\t\t\"id\",\n\t\t\t\"code\",\n\t\t\t\"name\",\n\t\t\t\"kind\",\n\t\t\t\"queue\",\n\t\t\t\"cron_spec\",\n\t\t\t\"handler\",\n\t\t\t\"status\",\n\t\t\t\"allow_manual\",\n\t\t\t\"allow_retry\",\n\t\t\t\"is_high_risk\",\n\t\t\t\"remark\",\n\t\t\t\"created_at\",\n\t\t\t\"updated_at\",\n\t\t},\n\t\tOrderBy: \"id DESC\",\n\t}\n\n\ttransformer := resources.NewTaskDefinitionTransformer()\n\ttotal, collection, err := model.ListPageE(definitionModel, params.Page, params.PerPage, condition, args, listOptionalParams)\n\tif err != nil {\n\t\tlog.Logger.Error(\"查询任务定义列表失败\", zap.Error(err))\n\t\treturn transformer.ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\treturn transformer.ToCollection(params.Page, params.PerPage, total, collection)\n}\n\n// ListTaskRuns 分页查询任务执行记录列表。\nfunc (s *TaskCenterService) ListTaskRuns(params *form.TaskRunList) *resources.Collection {\n\tquery := newListQuery().\n\t\taddLike(\"task_code\", params.TaskCode).\n\t\taddEq(\"kind\", params.Kind).\n\t\taddEq(\"source\", params.Source).\n\t\taddLike(\"source_id\", params.SourceID).\n\t\taddEq(\"status\", params.Status).\n\t\taddCreatedAtRange(params.StartTime, params.EndTime)\n\tcondition, args := query.Build()\n\n\trunModel := model.NewTaskRun()\n\tlistOptionalParams := model.ListOptionalParams{\n\t\tSelectFields: []string{\n\t\t\t\"id\",\n\t\t\t\"task_code\",\n\t\t\t\"kind\",\n\t\t\t\"source\",\n\t\t\t\"source_id\",\n\t\t\t\"queue\",\n\t\t\t\"status\",\n\t\t\t\"attempt\",\n\t\t\t\"max_retry\",\n\t\t\t\"error_message\",\n\t\t\t\"duration_ms\",\n\t\t\t\"started_at\",\n\t\t\t\"finished_at\",\n\t\t\t\"created_at\",\n\t\t\t\"trigger_user_id\",\n\t\t\t\"trigger_account\",\n\t\t},\n\t\tOrderBy: \"created_at DESC, id DESC\",\n\t}\n\n\ttransformer := resources.NewTaskRunTransformer()\n\ttotal, collection, err := model.ListPageE(runModel, params.Page, params.PerPage, condition, args, listOptionalParams)\n\tif err != nil {\n\t\tlog.Logger.Error(\"查询任务执行记录列表失败\", zap.Error(err))\n\t\treturn transformer.ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\treturn transformer.ToCollection(params.Page, params.PerPage, total, collection)\n}\n\n// TaskRunDetail 获取任务执行记录详情。\nfunc (s *TaskCenterService) TaskRunDetail(id uint) (any, error) {\n\ttaskRun := model.NewTaskRun()\n\tif s.db != nil {\n\t\ttaskRun.SetDB(s.db)\n\t}\n\tif err := taskRun.GetById(id); err != nil || taskRun.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\treturn resources.NewTaskRunTransformer().ToStruct(taskRun), nil\n}\n\n// TaskRunEvents 查询任务执行事件列表。\nfunc (s *TaskCenterService) TaskRunEvents(runID uint) ([]*resources.TaskRunEventResources, error) {\n\teventModel := model.NewTaskRunEvent()\n\tif s.db != nil {\n\t\tvar events []*model.TaskRunEvent\n\t\terr := s.db.Select([]string{\n\t\t\t\"id\",\n\t\t\t\"run_id\",\n\t\t\t\"event_type\",\n\t\t\t\"message\",\n\t\t\t\"meta\",\n\t\t\t\"created_at\",\n\t\t}).Where(\"run_id = ?\", runID).Order(\"created_at ASC, id ASC\").Find(&events).Error\n\t\tif err != nil {\n\t\t\tlog.Logger.Error(\"查询任务执行事件失败\", zap.Error(err), zap.Uint(\"run_id\", runID))\n\t\t\treturn nil, err\n\t\t}\n\t\treturn buildTaskRunEventResources(events), nil\n\t}\n\tlistOptionalParams := model.ListOptionalParams{\n\t\tSelectFields: []string{\"id\", \"run_id\", \"event_type\", \"message\", \"meta\", \"created_at\"},\n\t\tOrderBy:      \"created_at ASC, id ASC\",\n\t}\n\n\tevents, err := model.ListE(eventModel, \"run_id = ?\", []any{runID}, listOptionalParams)\n\tif err != nil {\n\t\tlog.Logger.Error(\"查询任务执行事件失败\", zap.Error(err), zap.Uint(\"run_id\", runID))\n\t\treturn nil, err\n\t}\n\treturn buildTaskRunEventResources(events), nil\n}\n\nfunc buildTaskRunEventResources(events []*model.TaskRunEvent) []*resources.TaskRunEventResources {\n\ttransformer := resources.NewTaskRunEventTransformer()\n\tresponse := make([]*resources.TaskRunEventResources, 0, len(events))\n\tfor _, item := range events {\n\t\tresponse = append(response, transformer.ToStruct(item))\n\t}\n\treturn response\n}\n\n// TaskRunAuditSnapshot 查询任务执行记录的审计快照（用于构建 change_diff）。\nfunc (s *TaskCenterService) TaskRunAuditSnapshot(id uint) (*TaskRunAuditSnapshot, error) {\n\ttaskRun, err := loadTaskRunByID(id)\n\tif err != nil || taskRun == nil || taskRun.ID == 0 {\n\t\treturn nil, e.NewBusinessError(e.NotFound)\n\t}\n\treturn &TaskRunAuditSnapshot{\n\t\tRunID:     taskRun.ID,\n\t\tTaskCode:  taskRun.TaskCode,\n\t\tStatus:    taskRun.Status,\n\t\tQueue:     taskRun.Queue,\n\t\tSourceID:  taskRun.SourceID,\n\t\tKind:      taskRun.Kind,\n\t\tMaxRetry:  taskRun.MaxRetry,\n\t\tAttempt:   taskRun.Attempt,\n\t\tHasRecord: true,\n\t}, nil\n}\n\n// ListCronTaskStates 分页查询定时任务最近状态列表。\nfunc (s *TaskCenterService) ListCronTaskStates(params *form.CronTaskStateList) *resources.Collection {\n\tquery := newListQuery().\n\t\taddLike(\"task_code\", params.TaskCode).\n\t\taddEq(\"last_status\", params.LastStatus)\n\tcondition, args := query.Build()\n\n\tstateModel := model.NewCronTaskState()\n\tlistOptionalParams := model.ListOptionalParams{\n\t\tSelectFields: []string{\n\t\t\t\"id\",\n\t\t\t\"task_code\",\n\t\t\t\"cron_spec\",\n\t\t\t\"last_run_id\",\n\t\t\t\"last_status\",\n\t\t\t\"last_started_at\",\n\t\t\t\"last_finished_at\",\n\t\t\t\"next_run_at\",\n\t\t\t\"last_error\",\n\t\t\t\"updated_at\",\n\t\t},\n\t\tOrderBy: \"updated_at DESC, id DESC\",\n\t}\n\n\ttransformer := resources.NewCronTaskStateTransformer()\n\ttotal, collection, err := model.ListPageE(stateModel, params.Page, params.PerPage, condition, args, listOptionalParams)\n\tif err != nil {\n\t\tlog.Logger.Error(\"查询定时任务最近状态列表失败\", zap.Error(err))\n\t\treturn transformer.ToCollection(params.Page, params.PerPage, 0, nil)\n\t}\n\treturn transformer.ToCollection(params.Page, params.PerPage, total, collection)\n}\n"
  },
  {
    "path": "internal/service/taskcenter/recorder.go",
    "content": "package taskcenter\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils\"\n)\n\nconst (\n\tmaxPayloadBytes = 64 * 1024\n\tmaxErrorBytes   = 8 * 1024\n)\n\n// RunStart 描述一次任务开始执行时的上下文。\ntype RunStart struct {\n\tTaskCode       string\n\tKind           string\n\tSource         string\n\tSourceID       string\n\tQueue          string\n\tCronSpec       string\n\tAttempt        int\n\tMaxRetry       int\n\tPayload        []byte\n\tTriggerUserID  uint\n\tTriggerAccount string\n\tTriggerConfirm string\n\tTriggerReason  string\n}\n\n// RunFinish 描述一次任务执行结束时的结果。\ntype RunFinish struct {\n\tStatus            string\n\tError             error\n\tNextRunAt         *time.Time\n\tCanceledBy        uint\n\tCanceledByAccount string\n\tCancelReason      string\n}\n\n// Recorder 持久化任务执行记录。\ntype Recorder interface {\n\tEnqueue(ctx context.Context, input RunStart) (*model.TaskRun, error)\n\tStart(ctx context.Context, input RunStart) (*model.TaskRun, error)\n\tFinish(ctx context.Context, run *model.TaskRun, input RunFinish) error\n}\n\ntype recorder struct {\n\tdb *gorm.DB\n}\n\nvar (\n\tfactoryMu       sync.RWMutex\n\trecorderFactory = func() Recorder {\n\t\treturn NewRecorder()\n\t}\n)\n\n// NewRecorder 创建使用全局数据库连接的任务执行记录器。\nfunc NewRecorder() Recorder {\n\treturn &recorder{}\n}\n\n// NewRecorderWithDB 创建使用指定数据库连接的任务执行记录器，主要用于测试。\nfunc NewRecorderWithDB(db *gorm.DB) Recorder {\n\treturn &recorder{db: db}\n}\n\n// NewRunRecorder 返回当前默认记录器。\nfunc NewRunRecorder() Recorder {\n\tfactoryMu.RLock()\n\tdefer factoryMu.RUnlock()\n\treturn recorderFactory()\n}\n\n// SetRecorderForTesting 临时替换默认记录器。\nfunc SetRecorderForTesting(next Recorder) func() {\n\tfactoryMu.Lock()\n\tprevious := recorderFactory\n\trecorderFactory = func() Recorder {\n\t\treturn next\n\t}\n\tfactoryMu.Unlock()\n\n\treturn func() {\n\t\tfactoryMu.Lock()\n\t\trecorderFactory = previous\n\t\tfactoryMu.Unlock()\n\t}\n}\n\nfunc (r *recorder) Start(ctx context.Context, input RunStart) (*model.TaskRun, error) {\n\tif strings.TrimSpace(input.TaskCode) == \"\" {\n\t\treturn nil, errors.New(\"task code is required\")\n\t}\n\tif input.Kind == \"\" {\n\t\tinput.Kind = model.TaskKindAsync\n\t}\n\tif input.Source == \"\" {\n\t\tinput.Source = model.TaskSourceQueue\n\t}\n\n\tdb, err := r.dbWithContext(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tnow := utils.FormatDate{Time: time.Now()}\n\trun := model.NewTaskRun()\n\trun.TaskCode = input.TaskCode\n\trun.Kind = input.Kind\n\trun.Source = input.Source\n\trun.SourceID = input.SourceID\n\trun.Queue = input.Queue\n\trun.TriggerUserID = input.TriggerUserID\n\trun.TriggerAccount = input.TriggerAccount\n\trun.Status = model.TaskRunStatusRunning\n\trun.Attempt = input.Attempt\n\trun.MaxRetry = input.MaxRetry\n\trun.Payload = truncateBytes(input.Payload, maxPayloadBytes)\n\trun.StartedAt = &now\n\n\terr = db.Transaction(func(tx *gorm.DB) error {\n\t\t// 如果存在同 source_id 的 pending/retrying 记录（例如手动触发后进入 worker），就复用该记录并推进状态。\n\t\tif input.SourceID != \"\" {\n\t\t\tvar existing model.TaskRun\n\t\t\tfindErr := tx.Where(\"task_code = ? AND source_id = ? AND status IN ?\",\n\t\t\t\tinput.TaskCode, input.SourceID, []string{model.TaskRunStatusPending, model.TaskRunStatusRetrying}).\n\t\t\t\tOrder(\"id DESC\").First(&existing).Error\n\t\t\tif findErr == nil {\n\t\t\t\trun = &existing\n\t\t\t\trun.Kind = input.Kind\n\t\t\t\trun.Source = input.Source\n\t\t\t\trun.Queue = input.Queue\n\t\t\t\trun.Status = model.TaskRunStatusRunning\n\t\t\t\trun.Attempt = input.Attempt\n\t\t\t\trun.MaxRetry = input.MaxRetry\n\t\t\t\trun.Payload = truncateBytes(input.Payload, maxPayloadBytes)\n\t\t\t\trun.StartedAt = &now\n\t\t\t\tif err := tx.Model(&model.TaskRun{}).Where(\"id = ?\", run.ID).Updates(map[string]any{\n\t\t\t\t\t\"kind\":       run.Kind,\n\t\t\t\t\t\"source\":     run.Source,\n\t\t\t\t\t\"queue\":      run.Queue,\n\t\t\t\t\t\"status\":     run.Status,\n\t\t\t\t\t\"attempt\":    run.Attempt,\n\t\t\t\t\t\"max_retry\":  run.MaxRetry,\n\t\t\t\t\t\"payload\":    run.Payload,\n\t\t\t\t\t\"started_at\": run.StartedAt,\n\t\t\t\t}).Error; err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif err := tx.Create(newEvent(run.ID, model.TaskEventStart, \"task started\", inputMeta(input))).Error; err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tif input.Source == model.TaskSourceCron {\n\t\t\t\t\treturn upsertCronState(tx, run, input.CronSpec, \"\", nil)\n\t\t\t\t}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif !errors.Is(findErr, gorm.ErrRecordNotFound) {\n\t\t\t\treturn findErr\n\t\t\t}\n\t\t}\n\n\t\tif err := tx.Create(run).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Create(newEvent(run.ID, model.TaskEventStart, \"task started\", inputMeta(input))).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif input.Source == model.TaskSourceCron {\n\t\t\treturn upsertCronState(tx, run, input.CronSpec, \"\", nil)\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn run, nil\n}\n\nfunc (r *recorder) Enqueue(ctx context.Context, input RunStart) (*model.TaskRun, error) {\n\tif strings.TrimSpace(input.TaskCode) == \"\" {\n\t\treturn nil, errors.New(\"task code is required\")\n\t}\n\tif input.Kind == \"\" {\n\t\tinput.Kind = model.TaskKindAsync\n\t}\n\tif input.Source == \"\" {\n\t\tinput.Source = model.TaskSourceQueue\n\t}\n\n\tdb, err := r.dbWithContext(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trun := model.NewTaskRun()\n\trun.TaskCode = input.TaskCode\n\trun.Kind = input.Kind\n\trun.Source = input.Source\n\trun.SourceID = input.SourceID\n\trun.Queue = input.Queue\n\trun.TriggerUserID = input.TriggerUserID\n\trun.TriggerAccount = input.TriggerAccount\n\trun.Status = model.TaskRunStatusPending\n\trun.Attempt = input.Attempt\n\trun.MaxRetry = input.MaxRetry\n\trun.Payload = truncateBytes(input.Payload, maxPayloadBytes)\n\n\tif err := db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Create(run).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn tx.Create(newEvent(run.ID, model.TaskEventEnqueue, \"task enqueued\", inputMeta(input))).Error\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\treturn run, nil\n}\n\nfunc (r *recorder) Finish(ctx context.Context, run *model.TaskRun, input RunFinish) error {\n\tif run == nil || run.ID == 0 {\n\t\treturn nil\n\t}\n\tstatus := input.Status\n\tif status == \"\" {\n\t\tstatus = model.TaskRunStatusSuccess\n\t\tif input.Error != nil {\n\t\t\tstatus = model.TaskRunStatusFailed\n\t\t}\n\t}\n\n\tdb, err := r.dbWithContext(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfinishedAt := utils.FormatDate{Time: time.Now()}\n\terrorMessage := \"\"\n\teventType, eventMessage := resolveFinishEvent(status)\n\tif input.Error != nil {\n\t\terrorMessage = truncateString(input.Error.Error(), maxErrorBytes)\n\t\teventType = model.TaskEventFail\n\t\teventMessage = errorMessage\n\t}\n\tinput.Status = status\n\n\tdurationMS := float64(0)\n\tif run.StartedAt != nil && !run.StartedAt.IsZero() {\n\t\tduration := finishedAt.Time.Sub(run.StartedAt.Time)\n\t\tdurationMS = float64(duration.Nanoseconds()) / 1000000.0\n\t\tdurationMS = float64(int(durationMS*10000+0.5)) / 10000.0\n\t}\n\n\trun.Status = status\n\trun.ErrorMessage = errorMessage\n\trun.FinishedAt = &finishedAt\n\trun.DurationMS = durationMS\n\n\treturn db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Model(&model.TaskRun{}).Where(\"id = ?\", run.ID).Updates(map[string]any{\n\t\t\t\"status\":        run.Status,\n\t\t\t\"error_message\": run.ErrorMessage,\n\t\t\t\"finished_at\":   run.FinishedAt,\n\t\t\t\"duration_ms\":   run.DurationMS,\n\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Create(newEvent(run.ID, eventType, eventMessage, finishMeta(input))).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif run.Source == model.TaskSourceCron {\n\t\t\treturn upsertCronState(tx, run, \"\", errorMessage, input.NextRunAt)\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc resolveFinishEvent(status string) (eventType string, message string) {\n\tswitch status {\n\tcase model.TaskRunStatusFailed:\n\t\treturn model.TaskEventFail, \"task failed\"\n\tcase model.TaskRunStatusCanceled:\n\t\treturn model.TaskEventCancel, \"task canceled\"\n\tcase model.TaskRunStatusRetrying:\n\t\treturn model.TaskEventRetry, \"task retrying\"\n\tdefault:\n\t\treturn model.TaskEventSuccess, \"task succeeded\"\n\t}\n}\n\nfunc (r *recorder) dbWithContext(ctx context.Context) (*gorm.DB, error) {\n\tif ctx == nil {\n\t\tctx = context.Background()\n\t}\n\tif r.db != nil {\n\t\treturn r.db.WithContext(ctx), nil\n\t}\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn db.WithContext(ctx), nil\n}\n\nfunc newEvent(runID uint, eventType, message string, meta map[string]any) *model.TaskRunEvent {\n\treturn &model.TaskRunEvent{\n\t\tRunID:     runID,\n\t\tEventType: eventType,\n\t\tMessage:   truncateString(message, maxErrorBytes),\n\t\tMeta:      marshalMeta(meta),\n\t}\n}\n\nfunc inputMeta(input RunStart) map[string]any {\n\tmeta := map[string]any{\n\t\t\"kind\":            input.Kind,\n\t\t\"source\":          input.Source,\n\t\t\"source_id\":       input.SourceID,\n\t\t\"queue\":           input.Queue,\n\t\t\"attempt\":         input.Attempt,\n\t\t\"max_retry\":       input.MaxRetry,\n\t\t\"trigger_user_id\": input.TriggerUserID,\n\t\t\"cron_spec\":       input.CronSpec,\n\t}\n\tif strings.TrimSpace(input.TriggerAccount) != \"\" {\n\t\tmeta[\"trigger_account\"] = strings.TrimSpace(input.TriggerAccount)\n\t}\n\tif strings.TrimSpace(input.TriggerConfirm) != \"\" {\n\t\tmeta[\"trigger_confirm\"] = strings.TrimSpace(input.TriggerConfirm)\n\t}\n\tif strings.TrimSpace(input.TriggerReason) != \"\" {\n\t\tmeta[\"trigger_reason\"] = strings.TrimSpace(input.TriggerReason)\n\t}\n\treturn meta\n}\n\nfunc finishMeta(input RunFinish) map[string]any {\n\tmeta := map[string]any{\n\t\t\"status\": input.Status,\n\t}\n\tif input.NextRunAt != nil {\n\t\tmeta[\"next_run_at\"] = input.NextRunAt.Format(\"2006-01-02 15:04:05\")\n\t}\n\tif input.CanceledBy > 0 {\n\t\tmeta[\"canceled_by\"] = input.CanceledBy\n\t}\n\tif strings.TrimSpace(input.CanceledByAccount) != \"\" {\n\t\tmeta[\"canceled_by_account\"] = strings.TrimSpace(input.CanceledByAccount)\n\t}\n\tif input.CancelReason != \"\" {\n\t\tmeta[\"cancel_reason\"] = strings.TrimSpace(input.CancelReason)\n\t}\n\treturn meta\n}\n\nfunc marshalMeta(meta map[string]any) string {\n\tif len(meta) == 0 {\n\t\treturn \"{}\"\n\t}\n\traw, err := json.Marshal(meta)\n\tif err != nil {\n\t\treturn \"{}\"\n\t}\n\treturn string(raw)\n}\n\nfunc upsertCronState(tx *gorm.DB, run *model.TaskRun, cronSpec string, lastError string, nextRunAt *time.Time) error {\n\tif run == nil || run.TaskCode == \"\" {\n\t\treturn nil\n\t}\n\n\tstate := model.NewCronTaskState()\n\tstate.TaskCode = run.TaskCode\n\tstate.CronSpec = cronSpec\n\tstate.LastRunID = run.ID\n\tstate.LastStatus = run.Status\n\tstate.LastStartedAt = run.StartedAt\n\tstate.LastFinishedAt = run.FinishedAt\n\tstate.LastError = truncateString(lastError, maxErrorBytes)\n\tif nextRunAt != nil {\n\t\tstate.NextRunAt = &utils.FormatDate{Time: *nextRunAt}\n\t}\n\n\tassignments := []string{\n\t\t\"last_run_id\",\n\t\t\"last_status\",\n\t\t\"last_started_at\",\n\t\t\"last_finished_at\",\n\t\t\"next_run_at\",\n\t\t\"last_error\",\n\t\t\"updated_at\",\n\t}\n\tif cronSpec != \"\" {\n\t\tassignments = append(assignments, \"cron_spec\")\n\t}\n\n\treturn tx.Clauses(clause.OnConflict{\n\t\tColumns:   []clause.Column{{Name: \"task_code\"}},\n\t\tDoUpdates: clause.AssignmentColumns(assignments),\n\t}).Create(state).Error\n}\n\nfunc truncateBytes(raw []byte, limit int) string {\n\tif len(raw) == 0 {\n\t\treturn \"\"\n\t}\n\tif len(raw) <= limit {\n\t\treturn string(raw)\n\t}\n\treturn string(raw[:limit]) + \"...(truncated)\"\n}\n\nfunc truncateString(raw string, limit int) string {\n\tif raw == \"\" || len(raw) <= limit {\n\t\treturn raw\n\t}\n\treturn raw[:limit] + \"...(truncated)\"\n}\n"
  },
  {
    "path": "internal/service/taskcenter/recorder_test.go",
    "content": "package taskcenter\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"gorm.io/driver/sqlite\"\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n)\n\nfunc TestRecorderStartAndFinishSuccess(t *testing.T) {\n\tdb := newTaskCenterTestDB(t)\n\trecorder := NewRecorderWithDB(db)\n\n\trun, err := recorder.Start(context.Background(), RunStart{\n\t\tTaskCode: \"demo:send\",\n\t\tKind:     model.TaskKindAsync,\n\t\tSource:   model.TaskSourceQueue,\n\t\tSourceID: \"task-1\",\n\t\tQueue:    \"default\",\n\t\tAttempt:  1,\n\t\tMaxRetry: 3,\n\t\tPayload:  []byte(`{\"name\":\"codex\"}`),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Start returned error: %v\", err)\n\t}\n\tif run.ID == 0 {\n\t\tt.Fatal(\"expected run id to be assigned\")\n\t}\n\n\tif err := recorder.Finish(context.Background(), run, RunFinish{}); err != nil {\n\t\tt.Fatalf(\"Finish returned error: %v\", err)\n\t}\n\n\tvar stored model.TaskRun\n\tif err := db.First(&stored, run.ID).Error; err != nil {\n\t\tt.Fatalf(\"query task run failed: %v\", err)\n\t}\n\tif stored.Status != model.TaskRunStatusSuccess {\n\t\tt.Fatalf(\"unexpected status: %s\", stored.Status)\n\t}\n\tif stored.Payload != `{\"name\":\"codex\"}` {\n\t\tt.Fatalf(\"unexpected payload: %s\", stored.Payload)\n\t}\n\n\tvar count int64\n\tif err := db.Model(&model.TaskRunEvent{}).Where(\"run_id = ?\", run.ID).Count(&count).Error; err != nil {\n\t\tt.Fatalf(\"count events failed: %v\", err)\n\t}\n\tif count != 2 {\n\t\tt.Fatalf(\"expected 2 events, got %d\", count)\n\t}\n}\n\nfunc TestRecorderFinishFailureUpdatesCronState(t *testing.T) {\n\tdb := newTaskCenterTestDB(t)\n\trecorder := NewRecorderWithDB(db)\n\n\trun, err := recorder.Start(context.Background(), RunStart{\n\t\tTaskCode: \"cron:demo\",\n\t\tKind:     model.TaskKindCron,\n\t\tSource:   model.TaskSourceCron,\n\t\tSourceID: \"demo\",\n\t\tCronSpec: \"0/5 * * * * *\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Start returned error: %v\", err)\n\t}\n\n\ttaskErr := errors.New(\"boom\")\n\tif err := recorder.Finish(context.Background(), run, RunFinish{Error: taskErr}); err != nil {\n\t\tt.Fatalf(\"Finish returned error: %v\", err)\n\t}\n\n\tvar stored model.TaskRun\n\tif err := db.First(&stored, run.ID).Error; err != nil {\n\t\tt.Fatalf(\"query task run failed: %v\", err)\n\t}\n\tif stored.Status != model.TaskRunStatusFailed {\n\t\tt.Fatalf(\"unexpected status: %s\", stored.Status)\n\t}\n\tif stored.ErrorMessage != \"boom\" {\n\t\tt.Fatalf(\"unexpected error message: %s\", stored.ErrorMessage)\n\t}\n\n\tvar state model.CronTaskState\n\tif err := db.Where(\"task_code = ?\", \"cron:demo\").First(&state).Error; err != nil {\n\t\tt.Fatalf(\"query cron state failed: %v\", err)\n\t}\n\tif state.LastRunID != run.ID || state.LastStatus != model.TaskRunStatusFailed {\n\t\tt.Fatalf(\"unexpected cron state: %#v\", state)\n\t}\n\tif state.LastError != \"boom\" {\n\t\tt.Fatalf(\"unexpected cron state error: %s\", state.LastError)\n\t}\n\n\tvar event model.TaskRunEvent\n\tif err := db.Where(\"run_id = ? AND event_type = ?\", run.ID, model.TaskEventFail).First(&event).Error; err != nil {\n\t\tt.Fatalf(\"query fail event failed: %v\", err)\n\t}\n\tvar meta map[string]any\n\tif err := json.Unmarshal([]byte(event.Meta), &meta); err != nil {\n\t\tt.Fatalf(\"unmarshal fail meta failed: %v\", err)\n\t}\n\tif meta[\"status\"] != model.TaskRunStatusFailed {\n\t\tt.Fatalf(\"unexpected fail meta: %#v\", meta)\n\t}\n}\n\nfunc TestRecorderFinishCancelWritesOperatorMeta(t *testing.T) {\n\tdb := newTaskCenterTestDB(t)\n\trecorder := NewRecorderWithDB(db)\n\n\trun, err := recorder.Enqueue(context.Background(), RunStart{\n\t\tTaskCode:       \"demo:send\",\n\t\tKind:           model.TaskKindAsync,\n\t\tSource:         model.TaskSourceManual,\n\t\tSourceID:       \"task-1\",\n\t\tTriggerUserID:  7,\n\t\tTriggerAccount: \"starter\",\n\t\tTriggerConfirm: \"CONFIRM\",\n\t\tTriggerReason:  \"manual run\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Enqueue returned error: %v\", err)\n\t}\n\n\tif err := recorder.Finish(context.Background(), run, RunFinish{\n\t\tStatus:            model.TaskRunStatusCanceled,\n\t\tCanceledBy:        9,\n\t\tCanceledByAccount: \"operator\",\n\t\tCancelReason:      \"manual cancel\",\n\t}); err != nil {\n\t\tt.Fatalf(\"Finish returned error: %v\", err)\n\t}\n\n\tvar event model.TaskRunEvent\n\tif err := db.Where(\"run_id = ? AND event_type = ?\", run.ID, model.TaskEventCancel).First(&event).Error; err != nil {\n\t\tt.Fatalf(\"query cancel event failed: %v\", err)\n\t}\n\tvar meta map[string]any\n\tif err := json.Unmarshal([]byte(event.Meta), &meta); err != nil {\n\t\tt.Fatalf(\"unmarshal cancel meta failed: %v\", err)\n\t}\n\tif meta[\"canceled_by_account\"] != \"operator\" || meta[\"cancel_reason\"] != \"manual cancel\" {\n\t\tt.Fatalf(\"unexpected cancel meta: %#v\", meta)\n\t}\n\n\tvar enqueueEvent model.TaskRunEvent\n\tif err := db.Where(\"run_id = ? AND event_type = ?\", run.ID, model.TaskEventEnqueue).First(&enqueueEvent).Error; err != nil {\n\t\tt.Fatalf(\"query enqueue event failed: %v\", err)\n\t}\n\tvar enqueueMeta map[string]any\n\tif err := json.Unmarshal([]byte(enqueueEvent.Meta), &enqueueMeta); err != nil {\n\t\tt.Fatalf(\"unmarshal enqueue meta failed: %v\", err)\n\t}\n\tif enqueueMeta[\"trigger_account\"] != \"starter\" || enqueueMeta[\"trigger_confirm\"] != \"CONFIRM\" || enqueueMeta[\"trigger_reason\"] != \"manual run\" {\n\t\tt.Fatalf(\"unexpected enqueue meta: %#v\", enqueueMeta)\n\t}\n}\n\nfunc TestTaskRunEventsReturnsCreatedAtAscending(t *testing.T) {\n\tdb := newTaskCenterTestDB(t)\n\tevents := []model.TaskRunEvent{\n\t\t{RunID: 7, EventType: model.TaskEventStart, Message: \"start\"},\n\t\t{RunID: 7, EventType: model.TaskEventSuccess, Message: \"success\"},\n\t}\n\tfor i := range events {\n\t\tif err := db.Create(&events[i]).Error; err != nil {\n\t\t\tt.Fatalf(\"create event failed: %v\", err)\n\t\t}\n\t}\n\n\tresult, err := NewTaskCenterServiceWithDeps(TaskCenterServiceDeps{DB: db}).TaskRunEvents(7)\n\tif err != nil {\n\t\tt.Fatalf(\"TaskRunEvents returned error: %v\", err)\n\t}\n\tif len(result) != 2 {\n\t\tt.Fatalf(\"expected 2 events, got %d\", len(result))\n\t}\n\tif result[0].EventType != model.TaskEventStart || result[1].EventType != model.TaskEventSuccess {\n\t\tt.Fatalf(\"unexpected event order: %#v\", result)\n\t}\n}\n\nfunc newTaskCenterTestDB(t *testing.T) *gorm.DB {\n\tt.Helper()\n\n\tdb, err := gorm.Open(sqlite.Open(\":memory:\"), &gorm.Config{})\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite failed: %v\", err)\n\t}\n\tstatements := []string{\n\t\t`CREATE TABLE task_runs (\n\t\t\tid integer primary key autoincrement,\n\t\t\tcreated_at datetime,\n\t\t\tupdated_at datetime,\n\t\t\ttask_code text,\n\t\t\tkind text,\n\t\t\tsource text,\n\t\t\tsource_id text,\n\t\t\tqueue text,\n\t\t\ttrigger_user_id integer,\n\t\t\ttrigger_account text,\n\t\t\tstatus text,\n\t\t\tattempt integer,\n\t\t\tmax_retry integer,\n\t\t\tpayload text,\n\t\t\terror_message text,\n\t\t\tstarted_at datetime,\n\t\t\tfinished_at datetime,\n\t\t\tduration_ms real\n\t\t)`,\n\t\t`CREATE TABLE task_run_events (\n\t\t\tid integer primary key autoincrement,\n\t\t\tcreated_at datetime,\n\t\t\tupdated_at datetime,\n\t\t\trun_id integer,\n\t\t\tevent_type text,\n\t\t\tmessage text,\n\t\t\tmeta text\n\t\t)`,\n\t\t`CREATE TABLE cron_task_states (\n\t\t\tid integer primary key autoincrement,\n\t\t\tcreated_at datetime,\n\t\t\tupdated_at datetime,\n\t\t\ttask_code text unique,\n\t\t\tcron_spec text,\n\t\t\tlast_run_id integer,\n\t\t\tlast_status text,\n\t\t\tlast_started_at datetime,\n\t\t\tlast_finished_at datetime,\n\t\t\tnext_run_at datetime,\n\t\t\tlast_error text\n\t\t)`,\n\t}\n\tfor _, statement := range statements {\n\t\tif err := db.Exec(statement).Error; err != nil {\n\t\t\tt.Fatalf(\"create test table failed: %v\", err)\n\t\t}\n\t}\n\treturn db\n}\n"
  },
  {
    "path": "internal/validator/binding.go",
    "content": "package validator\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\tut \"github.com/go-playground/universal-translator\"\n\t\"github.com/go-playground/validator/v10\"\n\n\terrcode \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\tr \"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n)\n\nconst (\n\teofErrorPattern         = `^multipart:nextpart:eof$`\n\ttypeConvertErrorPattern = `parsing .*?: invalid syntax`\n)\n\nvar (\n\teofRegex         = regexp.MustCompile(eofErrorPattern)\n\ttypeConvertRegex = regexp.MustCompile(typeConvertErrorPattern)\n)\n\n// ResponseError 处理错误并返回给前端。\nfunc ResponseError(c *gin.Context, err error) {\n\tvar errs validator.ValidationErrors\n\tif errors.As(err, &errs) {\n\t\thandleValidationError(c, errs)\n\t} else {\n\t\thandleBindingError(c, err)\n\t}\n}\n\nfunc handleValidationError(c *gin.Context, errs validator.ValidationErrors) {\n\tprimary := validatorRuntime.translatorForRequest(c)\n\tfallback := validatorRuntime.fallbackTranslator(primary)\n\tfor _, fieldErr := range errs {\n\t\tmessage := translateFieldError(fieldErr, primary, fallback)\n\t\tr.Resp().FailCode(c, errcode.InvalidParameter, message)\n\t\treturn\n\t}\n}\n\nfunc translateFieldError(fieldErr validator.FieldError, primary, fallback ut.Translator) string {\n\tif primary != nil {\n\t\tif translated := fieldErr.Translate(primary); translated != \"\" && translated != fieldErr.Error() {\n\t\t\treturn translated\n\t\t}\n\t}\n\tif fallback != nil {\n\t\tif translated := fieldErr.Translate(fallback); translated != \"\" && translated != fieldErr.Error() {\n\t\t\treturn translated\n\t\t}\n\t}\n\treturn fieldErr.Error()\n}\n\nfunc handleBindingError(c *gin.Context, err error) {\n\tvar typeErr *json.UnmarshalTypeError\n\tvar syntaxErr *json.SyntaxError\n\tswitch {\n\tcase errors.As(err, &typeErr):\n\t\tr.Resp().FailCode(c, errcode.InvalidParameter)\n\tcase errors.As(err, &syntaxErr):\n\t\tr.Resp().FailCode(c, errcode.InvalidParameter)\n\tdefault:\n\t\terrStr := err.Error()\n\t\tswitch {\n\t\tcase isEOFError(errStr):\n\t\t\tr.Resp().FailCode(c, errcode.InvalidParameter)\n\t\tcase isConvertError(errStr):\n\t\t\tr.Resp().FailCode(c, errcode.InvalidParameter)\n\t\tdefault:\n\t\t\tr.Resp().FailCode(c, errcode.InvalidParameter)\n\t\t}\n\t}\n}\n\nfunc isEOFError(errStr string) bool {\n\tif len(errStr) == 0 {\n\t\treturn false\n\t}\n\tif errStr[0] == ' ' || errStr[len(errStr)-1] == ' ' {\n\t\treturn eofRegex.MatchString(strings.TrimSpace(errStr))\n\t}\n\treturn eofRegex.MatchString(errStr)\n}\n\nfunc isConvertError(errStr string) bool {\n\tif len(errStr) == 0 {\n\t\treturn false\n\t}\n\tif errStr[0] == ' ' || errStr[len(errStr)-1] == ' ' {\n\t\treturn typeConvertRegex.MatchString(strings.TrimSpace(errStr))\n\t}\n\treturn typeConvertRegex.MatchString(errStr)\n}\n\n// CheckParams 检查请求参数。\nfunc CheckParams(c *gin.Context, obj interface{}, bindFunc func(obj interface{}) error) error {\n\tif err := bindFunc(obj); err != nil {\n\t\tResponseError(c, err)\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// CheckQueryParams 检查 GET 请求的查询参数。\nfunc CheckQueryParams(c *gin.Context, obj interface{}) error {\n\treturn CheckParams(c, obj, c.ShouldBindQuery)\n}\n\n// CheckPostParams 检查 POST 请求的参数。\nfunc CheckPostParams(c *gin.Context, obj interface{}) error {\n\treturn CheckParams(c, obj, c.ShouldBind)\n}\n"
  },
  {
    "path": "internal/validator/binding_i18n_test.go",
    "content": "package validator\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n)\n\ntype phonePayload struct {\n\tPhone string `json:\"phone\" form:\"phone\" label:\"手机号\" binding:\"required,phone_number\"`\n}\n\ntype validationResult struct {\n\tCode int    `json:\"code\"`\n\tMsg  string `json:\"msg\"`\n}\n\nfunc TestValidationErrorUsesRequestLocale(t *testing.T) {\n\tresetValidatorRuntimeForI18nTest(t)\n\tif err := InitValidatorTrans(\"zh\"); err != nil {\n\t\tt.Fatalf(\"init validator: %v\", err)\n\t}\n\n\tzhMsg := validatePayloadAndReadMessage(t, \"zh-CN\")\n\tif zhMsg != \"手机号格式不正确\" {\n\t\tt.Fatalf(\"expected zh translation, got %q\", zhMsg)\n\t}\n\n\tenMsg := validatePayloadAndReadMessage(t, \"en-US\")\n\tif enMsg != \"手机号 format is invalid\" {\n\t\tt.Fatalf(\"expected en translation, got %q\", enMsg)\n\t}\n}\n\nfunc TestValidationErrorFallsBackToDefaultTranslator(t *testing.T) {\n\tresetValidatorRuntimeForI18nTest(t)\n\tif err := InitValidatorTrans(\"invalid-locale\"); err != nil {\n\t\tt.Fatalf(\"init validator fallback: %v\", err)\n\t}\n\n\tmsg := validatePayloadAndReadMessage(t, \"fr-FR\")\n\tif msg != \"手机号格式不正确\" {\n\t\tt.Fatalf(\"expected fallback zh translation, got %q\", msg)\n\t}\n}\n\nfunc validatePayloadAndReadMessage(t *testing.T, locale string) string {\n\tt.Helper()\n\tgin.SetMode(gin.TestMode)\n\n\tbody := strings.NewReader(`{\"phone\":\"123\"}`)\n\treq := httptest.NewRequest(http.MethodPost, \"/demo\", body)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"Accept-Language\", locale)\n\n\trecorder := httptest.NewRecorder()\n\tctx, _ := gin.CreateTestContext(recorder)\n\tctx.Request = req\n\tctx.Set(global.ContextKeyRequestStartTime, time.Now())\n\tctx.Set(global.ContextKeyRequestID, \"validator-i18n\")\n\tctx.Set(global.ContextKeyLocale, locale)\n\n\tpayload := &phonePayload{}\n\tif err := CheckPostParams(ctx, payload); err == nil {\n\t\tt.Fatal(\"expected validation error\")\n\t}\n\n\tvar result validationResult\n\tif err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {\n\t\tt.Fatalf(\"unmarshal response: %v\", err)\n\t}\n\treturn result.Msg\n}\n\nfunc resetValidatorRuntimeForI18nTest(t *testing.T) {\n\tt.Helper()\n\tvalidatorRuntime = newValidatorRuntime()\n\tregexCache = sync.Map{}\n}\n"
  },
  {
    "path": "internal/validator/form/admin_user.go",
    "content": "package form\n\ntype adminUserEditableFields struct {\n\tUsername *string `form:\"username\" json:\"username\" label:\"用户名\" binding:\"omitempty,min=3,max=20,regexp=^[a-zA-Z0-9_]+$\"`\n\tNickname *string `form:\"nickname\" json:\"nickname\" label:\"昵称\" binding:\"omitempty\"`\n\tPassword *string `form:\"password\" json:\"password\" label:\"密码\" binding:\"omitempty,min=6,max=32\"`\n\tadminUserOptionalFields\n}\n\ntype adminUserOptionalFields struct {\n\tPhoneNumber *string `form:\"phone_number\" json:\"phone_number\" label:\"手机号\" binding:\"omitempty,phone_number\"`\n\tCountryCode *string `form:\"country_code\" json:\"country_code\" label:\"国家代码\" binding:\"omitempty\"`\n\tEmail       *string `form:\"email\" json:\"email\" label:\"邮箱\" binding:\"omitempty,email\"`\n\tStatus      *uint8  `form:\"status\" json:\"status\" label:\"状态\" binding:\"omitempty,oneof=0 1\"`\n\tAvatar      *string `form:\"avatar\" json:\"avatar\" label:\"头像\" binding:\"omitempty\"`\n\tDeptIds     *[]uint `form:\"dept_ids\" json:\"dept_ids\" label:\"部门ID\" binding:\"omitempty,dive,gt=0\"`\n}\n\ntype CreateAdminUser struct {\n\tUsername *string `form:\"username\" json:\"username\" label:\"用户名\" binding:\"required,min=3,max=20,regexp=^[a-zA-Z0-9_]+$\"`\n\tNickname *string `form:\"nickname\" json:\"nickname\" label:\"昵称\" binding:\"required\"`\n\tPassword *string `form:\"password\" json:\"password\" label:\"密码\" binding:\"required,min=6,max=32\"`\n\tadminUserOptionalFields\n}\n\nfunc NewCreateAdminUser() *CreateAdminUser {\n\treturn &CreateAdminUser{}\n}\n\ntype UpdateAdminUser struct {\n\tId uint `form:\"id\" json:\"id\" label:\"用户ID\" binding:\"required\"`\n\tadminUserEditableFields\n}\n\nfunc NewUpdateAdminUser() *UpdateAdminUser {\n\treturn &UpdateAdminUser{}\n}\n\ntype AdminUserList struct {\n\tPaginate\n\tEmail       string `form:\"email\" json:\"email\" binding:\"omitempty,email\"`\n\tUserName    string `form:\"username\" json:\"username\" binding:\"omitempty\"`\n\tStatus      *uint8 `form:\"status\" json:\"status\"  binding:\"omitempty,oneof=0 1\"`\n\tPhoneNumber string `form:\"phone_number\" json:\"phone_number\" binding:\"omitempty,phone_number\"`\n\tNickName    string `form:\"nickname\" json:\"nickname\" binding:\"omitempty\"`\n\tID          uint   `form:\"id\" json:\"id\" binding:\"omitempty\"`\n\tDeptId      uint   `form:\"dept_id\" json:\"dept_id\" binding:\"omitempty\"`\n}\n\nfunc NewAdminUserListQuery() *AdminUserList {\n\treturn &AdminUserList{}\n}\n\ntype UpdateProfile struct {\n\tNickname    *string `form:\"nickname\" json:\"nickname\" label:\"昵称\" binding:\"omitempty\"`\n\tPassword    *string `form:\"password\" json:\"password\" label:\"密码\" binding:\"omitempty,min=6,max=32\"`\n\tPhoneNumber *string `form:\"phone_number\" json:\"phone_number\" label:\"手机号\" binding:\"omitempty,phone_number\"`\n\tCountryCode *string `form:\"country_code\" json:\"country_code\" label:\"国家代码\" binding:\"omitempty\"`\n\tEmail       *string `form:\"email\" json:\"email\" label:\"邮箱\" binding:\"omitempty,email\"`\n\tAvatar      *string `form:\"avatar\" json:\"avatar\" label:\"头像\" binding:\"omitempty\"`\n}\n\nfunc NewUpdateProfile() *UpdateProfile {\n\treturn &UpdateProfile{}\n}\n\ntype BindRole struct {\n\tUserId  uint   `form:\"user_id\" json:\"user_id\" label:\"用户ID\" binding:\"required\"`             //  验证规则：必填\n\tRoleIds []uint `form:\"role_ids\" json:\"role_ids\" label:\"角色ID\" binding:\"required,dive,gt=0\"` //  验证规则：必填\n}\n\n// NewBindRole 绑定角色\nfunc NewBindRole() *BindRole {\n\treturn &BindRole{}\n}\n"
  },
  {
    "path": "internal/validator/form/admin_user_test.go",
    "content": "package form\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestUpdateAdminUserDeptIDsDistinguishEmptyArrayFromAbsentField(t *testing.T) {\n\tvar withEmpty UpdateAdminUser\n\tif err := json.Unmarshal([]byte(`{\"id\":1,\"dept_ids\":[]}`), &withEmpty); err != nil {\n\t\tt.Fatalf(\"unmarshal with empty dept_ids: %v\", err)\n\t}\n\tif withEmpty.DeptIds == nil {\n\t\tt.Fatal(\"expected dept_ids empty array to keep non-nil pointer\")\n\t}\n\tif len(*withEmpty.DeptIds) != 0 {\n\t\tt.Fatalf(\"expected empty dept_ids, got %#v\", *withEmpty.DeptIds)\n\t}\n\n\tvar withoutField UpdateAdminUser\n\tif err := json.Unmarshal([]byte(`{\"id\":1}`), &withoutField); err != nil {\n\t\tt.Fatalf(\"unmarshal without dept_ids: %v\", err)\n\t}\n\tif withoutField.DeptIds != nil {\n\t\tt.Fatalf(\"expected absent dept_ids to stay nil, got %#v\", *withoutField.DeptIds)\n\t}\n}\n\nfunc TestCreateAdminUserRequiresPassword(t *testing.T) {\n\terr := bindJSONBody(t, `{\"username\":\"admin_user\",\"nickname\":\"管理员\"}`, NewCreateAdminUser())\n\tif err == nil {\n\t\tt.Fatal(\"expected missing password to fail validation\")\n\t}\n}\n\nfunc TestCreateAdminUserAllowsRequiredFields(t *testing.T) {\n\terr := bindJSONBody(t, `{\"username\":\"admin_user\",\"nickname\":\"管理员\",\"password\":\"123456\"}`, NewCreateAdminUser())\n\tif err != nil {\n\t\tt.Fatalf(\"expected required create fields to pass validation, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/validator/form/auth.go",
    "content": "package form\n\ntype LoginAuth struct {\n\tUserName  string `form:\"username\" json:\"username\" label:\"用户名\" binding:\"required,min=3,max=16\"` //  验证规则：必填，最小长度为3\n\tPassWord  string `form:\"password\" json:\"password\" label:\"密码\" binding:\"required,min=6,max=18\"`  //  验证规则：必填，最小长度为6\n\tCaptcha   string `form:\"captcha\" json:\"captcha\" label:\"验证码\" binding:\"required\"`\n\tCaptchaID string `form:\"captcha_id\" json:\"captcha_id\" binding:\"required\"`\n}\n\n// NewLoginForm 创建登录表单。\nfunc NewLoginForm() *LoginAuth {\n\treturn &LoginAuth{}\n}\n"
  },
  {
    "path": "internal/validator/form/common.go",
    "content": "package form\n\ntype Paginate struct {\n\tPage    int `form:\"page\" json:\"page\" binding:\"omitempty,gt=0\"`         // 必填，页面值>=1\n\tPerPage int `form:\"per_page\" json:\"per_page\" binding:\"omitempty,gt=0\"` // 必填，每页条数值>=1\n}\n\n// NewPaginate 创建一个新的分页查询\nfunc NewPaginate() *Paginate {\n\treturn &Paginate{}\n}\n\ntype ID struct {\n\tID uint `form:\"id\" json:\"id\" binding:\"required\"`\n}\n\n// NewIdForm ID表单\nfunc NewIdForm() *ID {\n\treturn &ID{}\n}\n"
  },
  {
    "path": "internal/validator/form/dept.go",
    "content": "package form\n\ntype deptPayload struct {\n\tName        string `form:\"name\" json:\"name\" label:\"部门名称\" binding:\"required\"`\n\tPid         uint   `form:\"pid\" json:\"pid\" label:\"上级部门\" binding:\"omitempty\"`\n\tDescription string `form:\"description\" json:\"description\" label:\"描述\" binding:\"omitempty\"`\n\tSort        uint   `form:\"sort\" json:\"sort\" label:\"排序\" binding:\"omitempty\"`\n}\n\ntype CreateDept struct {\n\tdeptPayload\n}\n\nfunc NewCreateDeptForm() *CreateDept {\n\treturn &CreateDept{}\n}\n\ntype UpdateDept struct {\n\tId uint `form:\"id\" json:\"id\" binding:\"required\"`\n\tdeptPayload\n}\n\nfunc NewUpdateDeptForm() *UpdateDept {\n\treturn &UpdateDept{}\n}\n\nfunc (f *UpdateDept) GetIDPointer() *uint {\n\treturn &f.Id\n}\n\ntype ListDept struct {\n\tPaginate\n\tName string `form:\"name\" json:\"name\" label:\"部门名称\" binding:\"omitempty\"` // 关键字\n\tPid  *uint  `form:\"pid\" json:\"pid\" label:\"上级部门\" binding:\"omitempty\"`\n}\n\n// NewDeptListQuery 创建部门列表查询表单。\nfunc NewDeptListQuery() *ListDept {\n\treturn &ListDept{}\n}\n\n// DeptBindRole 部门绑定角色表单\ntype DeptBindRole struct {\n\tDeptId  uint   `form:\"dept_id\" json:\"dept_id\" label:\"部门 ID\" binding:\"required\"`             //  验证规则：必填\n\tRoleIds []uint `form:\"role_ids\" json:\"role_ids\" label:\"角色 ID\" binding:\"required,dive,gt=0\"` //  验证规则：必填\n}\n\n// NewDeptBindRole 部门绑定角色\nfunc NewDeptBindRole() *DeptBindRole {\n\treturn &DeptBindRole{}\n}\n"
  },
  {
    "path": "internal/validator/form/file_resource.go",
    "content": "package form\n\n// FileResourceList 文件资源列表查询参数。\ntype FileResourceList struct {\n\tPaginate\n\tOriginName       string `form:\"origin_name\" json:\"origin_name\" binding:\"omitempty\"`\n\tUUID             string `form:\"uuid\" json:\"uuid\" binding:\"omitempty\"`\n\tMimeType         string `form:\"mime_type\" json:\"mime_type\" binding:\"omitempty\"`\n\tFileType         string `form:\"file_type\" json:\"file_type\" binding:\"omitempty,oneof=image pdf word excel ppt archive text audio video\"`\n\tIsPublic         *uint8 `form:\"is_public\" json:\"is_public\" binding:\"omitempty,oneof=0 1\"`\n\tFolderID         *uint  `form:\"folder_id\" json:\"folder_id\" binding:\"omitempty\"`\n\tIncludeSubfolder uint8  `form:\"include_subfolder\" json:\"include_subfolder\" binding:\"omitempty,oneof=0 1\"`\n\tStorageDriver    string `form:\"storage_driver\" json:\"storage_driver\" binding:\"omitempty,oneof=local aliyun_oss s3\"`\n\tStorageStatus    string `form:\"storage_status\" json:\"storage_status\" binding:\"omitempty,oneof=stored delete_failed\"`\n\tIsReferenced     *uint8 `form:\"is_referenced\" json:\"is_referenced\" binding:\"omitempty,oneof=0 1\"`\n\tIsDeleted        *uint8 `form:\"is_deleted\" json:\"is_deleted\" binding:\"omitempty,oneof=0 1\"`\n\tUID              uint   `form:\"uid\" json:\"uid\" binding:\"omitempty,gt=0\"`\n\tStartTime        string `form:\"start_time\" json:\"start_time\" binding:\"omitempty\"`\n\tEndTime          string `form:\"end_time\" json:\"end_time\" binding:\"omitempty\"`\n}\n\nfunc NewFileResourceListQuery() *FileResourceList {\n\treturn &FileResourceList{}\n}\n\n// FileResourceID 文件资源 ID 参数。\ntype FileResourceID struct {\n\tID            uint   `form:\"id\" json:\"id\" binding:\"required,gt=0\"`\n\tDeletedReason string `form:\"deleted_reason\" json:\"deleted_reason\" binding:\"omitempty,max=255\"`\n}\n\nfunc NewFileResourceIDForm() *FileResourceID {\n\treturn &FileResourceID{}\n}\n\ntype FileFolderCreate struct {\n\tParentID uint   `form:\"parent_id\" json:\"parent_id\" binding:\"omitempty\"`\n\tName     string `form:\"name\" json:\"name\" binding:\"required,max=120\"`\n}\n\nfunc NewFileFolderCreateForm() *FileFolderCreate {\n\treturn &FileFolderCreate{}\n}\n\ntype FileFolderUpdate struct {\n\tID   uint   `form:\"id\" json:\"id\" binding:\"required,gt=0\"`\n\tName string `form:\"name\" json:\"name\" binding:\"required,max=120\"`\n}\n\nfunc NewFileFolderUpdateForm() *FileFolderUpdate {\n\treturn &FileFolderUpdate{}\n}\n\ntype FileFolderDelete struct {\n\tID uint `form:\"id\" json:\"id\" binding:\"required,gt=0\"`\n}\n\nfunc NewFileFolderDeleteForm() *FileFolderDelete {\n\treturn &FileFolderDelete{}\n}\n\ntype FileFolderMove struct {\n\tID             uint `form:\"id\" json:\"id\" binding:\"required,gt=0\"`\n\tParentID       uint `form:\"parent_id\" json:\"parent_id\" binding:\"omitempty\"`\n\tTargetParentID uint `form:\"target_parent_id\" json:\"target_parent_id\" binding:\"omitempty\"`\n}\n\nfunc NewFileFolderMoveForm() *FileFolderMove {\n\treturn &FileFolderMove{}\n}\n\ntype FileMove struct {\n\tIDs      []uint `form:\"ids\" json:\"ids\" binding:\"required,dive,gt=0\"`\n\tFolderID uint   `form:\"folder_id\" json:\"folder_id\" binding:\"omitempty\"`\n}\n\nfunc NewFileMoveForm() *FileMove {\n\treturn &FileMove{}\n}\n\ntype FileLocalUpload struct {\n\tFolderID    uint   `form:\"folder_id\" json:\"folder_id\" binding:\"omitempty\"`\n\tIsPublic    uint8  `form:\"is_public\" json:\"is_public\" binding:\"omitempty,oneof=0 1\"`\n\tUploadScene string `form:\"upload_scene\" json:\"upload_scene\" binding:\"omitempty,max=60\"`\n}\n\nfunc NewFileLocalUploadForm() *FileLocalUpload {\n\treturn &FileLocalUpload{IsPublic: 1}\n}\n\ntype FileUploadCredential struct {\n\tFolderID    uint   `form:\"folder_id\" json:\"folder_id\" binding:\"omitempty\"`\n\tFileName    string `form:\"file_name\" json:\"file_name\" binding:\"omitempty,max=255\"`\n\tOriginName  string `form:\"origin_name\" json:\"origin_name\" binding:\"omitempty,max=255\"`\n\tMimeType    string `form:\"mime_type\" json:\"mime_type\" binding:\"omitempty,max=100\"`\n\tSize        int64  `form:\"size\" json:\"size\" binding:\"omitempty,gte=0\"`\n\tHash        string `form:\"hash\" json:\"hash\" binding:\"omitempty,len=64\"`\n\tIsPublic    uint8  `form:\"is_public\" json:\"is_public\" binding:\"omitempty,oneof=0 1\"`\n\tUploadScene string `form:\"upload_scene\" json:\"upload_scene\" binding:\"omitempty,max=60\"`\n\tDriver      string `form:\"driver\" json:\"driver\" binding:\"omitempty,oneof=local aliyun_oss s3\"`\n}\n\nfunc NewFileUploadCredentialForm() *FileUploadCredential {\n\treturn &FileUploadCredential{IsPublic: 1}\n}\n\ntype FileUploadComplete struct {\n\tFolderID      uint   `form:\"folder_id\" json:\"folder_id\" binding:\"omitempty\"`\n\tReuse         bool   `form:\"reuse\" json:\"reuse\" binding:\"omitempty\"`\n\tFileObjectID  uint   `form:\"file_object_id\" json:\"file_object_id\" binding:\"omitempty,gt=0\"`\n\tOriginName    string `form:\"origin_name\" json:\"origin_name\" binding:\"required,max=255\"`\n\tDisplayName   string `form:\"display_name\" json:\"display_name\" binding:\"omitempty,max=255\"`\n\tName          string `form:\"name\" json:\"name\" binding:\"omitempty,max=255\"`\n\tSize          uint   `form:\"size\" json:\"size\" binding:\"omitempty\"`\n\tExt           string `form:\"ext\" json:\"ext\" binding:\"omitempty,max=20\"`\n\tHash          string `form:\"hash\" json:\"hash\" binding:\"omitempty,max=64\"`\n\tUUID          string `form:\"uuid\" json:\"uuid\" binding:\"omitempty,len=32\"`\n\tMimeType      string `form:\"mime_type\" json:\"mime_type\" binding:\"omitempty,max=100\"`\n\tFileType      string `form:\"file_type\" json:\"file_type\" binding:\"omitempty,oneof=image pdf word excel ppt archive text audio video other\"`\n\tIsPublic      uint8  `form:\"is_public\" json:\"is_public\" binding:\"omitempty,oneof=0 1\"`\n\tStorageDriver string `form:\"storage_driver\" json:\"storage_driver\" binding:\"omitempty,oneof=aliyun_oss s3\"`\n\tDriver        string `form:\"driver\" json:\"driver\" binding:\"omitempty,oneof=aliyun_oss s3\"`\n\tBucket        string `form:\"bucket\" json:\"bucket\" binding:\"omitempty,max=128\"`\n\tObjectKey     string `form:\"object_key\" json:\"object_key\" binding:\"omitempty,max=512\"`\n\tETag          string `form:\"etag\" json:\"etag\" binding:\"omitempty,max=128\"`\n\tUploadScene   string `form:\"upload_scene\" json:\"upload_scene\" binding:\"omitempty,max=60\"`\n}\n\nfunc NewFileUploadCompleteForm() *FileUploadComplete {\n\treturn &FileUploadComplete{IsPublic: 1}\n}\n\ntype FileReferenceList struct {\n\tPaginate\n\tID         uint   `form:\"id\" json:\"id\" binding:\"omitempty,gt=0\"`\n\tFileID     uint   `form:\"file_id\" json:\"file_id\" binding:\"omitempty,gt=0\"`\n\tUUID       string `form:\"uuid\" json:\"uuid\" binding:\"omitempty\"`\n\tOwnerType  string `form:\"owner_type\" json:\"owner_type\" binding:\"omitempty,max=60\"`\n\tOwnerID    uint   `form:\"owner_id\" json:\"owner_id\" binding:\"omitempty,gt=0\"`\n\tOwnerField string `form:\"owner_field\" json:\"owner_field\" binding:\"omitempty,max=60\"`\n}\n\nfunc NewFileReferenceListQuery() *FileReferenceList {\n\treturn &FileReferenceList{}\n}\n"
  },
  {
    "path": "internal/validator/form/file_resource_test.go",
    "content": "package form\n\nimport \"testing\"\n\nfunc TestFileResourceListRejectsInvalidIsPublic(t *testing.T) {\n\terr := bindJSONBody(t, `{\"is_public\":2}`, NewFileResourceListQuery())\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid is_public to fail validation\")\n\t}\n}\n\nfunc TestFileResourceListRejectsInvalidFileType(t *testing.T) {\n\terr := bindJSONBody(t, `{\"file_type\":\"exe\"}`, NewFileResourceListQuery())\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid file_type to fail validation\")\n\t}\n}\n\nfunc TestFileResourceIDRejectsZero(t *testing.T) {\n\terr := bindJSONBody(t, `{\"id\":0}`, NewFileResourceIDForm())\n\tif err == nil {\n\t\tt.Fatal(\"expected zero id to fail validation\")\n\t}\n}\n\nfunc TestFileResourceListRejectsInvalidStorageDriver(t *testing.T) {\n\terr := bindJSONBody(t, `{\"storage_driver\":\"ftp\"}`, NewFileResourceListQuery())\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid storage_driver to fail validation\")\n\t}\n}\n\nfunc TestFileResourceListAllowsStorageFilters(t *testing.T) {\n\terr := bindJSONBody(t, `{\"storage_driver\":\"s3\",\"storage_status\":\"stored\",\"is_referenced\":1,\"is_deleted\":0}`, NewFileResourceListQuery())\n\tif err != nil {\n\t\tt.Fatalf(\"expected storage filters to pass validation, got %v\", err)\n\t}\n}\n\nfunc TestFileReferenceListAllowsIDAlias(t *testing.T) {\n\terr := bindJSONBody(t, `{\"id\":1}`, NewFileReferenceListQuery())\n\tif err != nil {\n\t\tt.Fatalf(\"expected id alias to pass validation, got %v\", err)\n\t}\n}\n\nfunc TestFileMoveRejectsZeroID(t *testing.T) {\n\terr := bindJSONBody(t, `{\"ids\":[1,0],\"folder_id\":2}`, NewFileMoveForm())\n\tif err == nil {\n\t\tt.Fatal(\"expected zero file id to fail validation\")\n\t}\n}\n\nfunc TestFileUploadCompleteRejectsLocalDriver(t *testing.T) {\n\terr := bindJSONBody(t, `{\"origin_name\":\"a.txt\",\"storage_driver\":\"local\",\"object_key\":\"a.txt\"}`, NewFileUploadCompleteForm())\n\tif err == nil {\n\t\tt.Fatal(\"expected local direct complete to fail validation\")\n\t}\n}\n\nfunc TestFileResourceListAllowsFolderFilters(t *testing.T) {\n\terr := bindJSONBody(t, `{\"folder_id\":1,\"include_subfolder\":1}`, NewFileResourceListQuery())\n\tif err != nil {\n\t\tt.Fatalf(\"expected folder filters to pass validation, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/validator/form/id_array_validation_test.go",
    "content": "package form\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\n\tvalidatorx \"github.com/wannanbigpig/gin-layout/internal/validator\"\n)\n\nfunc bindJSONBody(t *testing.T, body string, payload any) error {\n\tt.Helper()\n\tif err := validatorx.InitValidatorTrans(\"zh\"); err != nil {\n\t\tt.Fatalf(\"init validator: %v\", err)\n\t}\n\tgin.SetMode(gin.TestMode)\n\n\treq := httptest.NewRequest(http.MethodPost, \"/\", strings.NewReader(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tctx.Request = req\n\treturn ctx.ShouldBind(payload)\n}\n\nfunc TestDeptBindRoleRejectsZeroRoleID(t *testing.T) {\n\tpayload := NewDeptBindRole()\n\terr := bindJSONBody(t, `{\"dept_id\":1,\"role_ids\":[0]}`, payload)\n\tif err == nil {\n\t\tt.Fatal(\"expected role_ids containing 0 to fail validation\")\n\t}\n}\n\nfunc TestAdminUserBindRoleRejectsZeroRoleID(t *testing.T) {\n\tpayload := NewBindRole()\n\terr := bindJSONBody(t, `{\"user_id\":1,\"role_ids\":[0]}`, payload)\n\tif err == nil {\n\t\tt.Fatal(\"expected role_ids containing 0 to fail validation\")\n\t}\n}\n\nfunc TestCreateRoleRejectsZeroMenuID(t *testing.T) {\n\tpayload := NewCreateRoleForm()\n\terr := bindJSONBody(t, `{\"name\":\"测试角色\",\"menu_list\":[0]}`, payload)\n\tif err == nil {\n\t\tt.Fatal(\"expected menu_list containing 0 to fail validation\")\n\t}\n}\n\nfunc TestCreateMenuRejectsZeroAPIID(t *testing.T) {\n\tpayload := NewCreateMenuForm()\n\terr := bindJSONBody(t, `{\"title_i18n\":{\"zh-CN\":\"测试菜单\"},\"sort\":1,\"type\":1,\"api_list\":[0]}`, payload)\n\tif err == nil {\n\t\tt.Fatal(\"expected api_list containing 0 to fail validation\")\n\t}\n}\n\nfunc TestIDArraysAllowPositiveValues(t *testing.T) {\n\tif err := bindJSONBody(t, `{\"dept_id\":1,\"role_ids\":[1]}`, NewDeptBindRole()); err != nil {\n\t\tt.Fatalf(\"expected positive dept role_ids to pass, got %v\", err)\n\t}\n\tif err := bindJSONBody(t, `{\"user_id\":1,\"role_ids\":[1]}`, NewBindRole()); err != nil {\n\t\tt.Fatalf(\"expected positive user role_ids to pass, got %v\", err)\n\t}\n\tif err := bindJSONBody(t, `{\"name\":\"测试角色\",\"menu_list\":[1]}`, NewCreateRoleForm()); err != nil {\n\t\tt.Fatalf(\"expected positive menu_list to pass, got %v\", err)\n\t}\n\tif err := bindJSONBody(t, `{\"title_i18n\":{\"zh-CN\":\"测试菜单\"},\"sort\":1,\"type\":1,\"api_list\":[1]}`, NewCreateMenuForm()); err != nil {\n\t\tt.Fatalf(\"expected positive api_list to pass, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/validator/form/login_log.go",
    "content": "package form\n\n// LoginLogList 登录日志列表查询表单\ntype AdminLoginLogList struct {\n\tPaginate\n\tUsername    string `form:\"username\" json:\"username\" binding:\"omitempty\"`                   // 登录账号\n\tLoginStatus *int8  `form:\"login_status\" json:\"login_status\" binding:\"omitempty,oneof=0 1\"` // 登录状态：1=成功, 0=失败\n\tIP          string `form:\"ip\" json:\"ip\" binding:\"omitempty\"`                               // 登录IP\n\tStartTime   string `form:\"start_time\" json:\"start_time\" binding:\"omitempty\"`               // 开始时间\n\tEndTime     string `form:\"end_time\" json:\"end_time\" binding:\"omitempty\"`                   // 结束时间\n}\n\n// NewAdminLoginLogListQuery 创建登录日志列表查询表单\nfunc NewAdminLoginLogListQuery() *AdminLoginLogList {\n\treturn &AdminLoginLogList{}\n}\n"
  },
  {
    "path": "internal/validator/form/menu.go",
    "content": "package form\n\ntype menuPayload struct {\n\tIcon            string            `form:\"icon\" json:\"icon\" label:\"图标\" binding:\"omitempty,max=255\"`\n\tTitleI18n       map[string]string `form:\"title_i18n\" json:\"title_i18n\" label:\"多语言标题\" binding:\"required\"`\n\tCode            string            `form:\"code\" json:\"code\" label:\"前端按钮权限标识\" binding:\"required_if=Type 3\"`\n\tPath            string            `form:\"path\" json:\"path\" label:\"路由地址\" binding:\"omitempty\"`\n\tName            string            `form:\"name\" json:\"name\" label:\"前端路由名称\" binding:\"required_if_exist=Type 2\"`\n\tAnimateEnter    string            `form:\"animate_enter\" json:\"animate_enter\" label:\"进入动画，动画类参考URL_ADDRESS\" binding:\"omitempty\"`\n\tAnimateLeave    string            `form:\"animate_leave\" json:\"animate_leave\" label:\"离开动画，动画类参考URL_ADDRESS\" binding:\"omitempty\"`\n\tAnimateDuration float32           `form:\"animate_duration\" json:\"animate_duration\" label:\"动画持续时间\" binding:\"omitempty\"`\n\tIsShow          uint8             `form:\"is_show\" json:\"is_show\" label:\"是否显示\" binding:\"omitempty,oneof=0 1\"`              // 0 否 1 是\n\tIsAuth          uint8             `form:\"is_auth\" json:\"is_auth\" label:\"是否需要授权\" binding:\"omitempty,oneof=0 1\"`            // 0 否 1 是\n\tIsNewWindow     uint8             `form:\"is_new_window\" json:\"is_new_window\" label:\"新窗口打开\" binding:\"omitempty,oneof=0 1\"` // 0 否 1 是\n\tSort            uint              `form:\"sort\" json:\"sort\" label:\"排序\" binding:\"required\"`\n\tType            uint8             `form:\"type\" json:\"type\" label:\"菜单类型\" binding:\"required,oneof=1 2 3\"` // 1 目录 2 菜单 3 按钮\n\tPid             uint              `form:\"pid\" json:\"pid\" label:\"上级菜单\" binding:\"omitempty\"`\n\tDescription     string            `form:\"description\" json:\"description\" label:\"描述\" binding:\"omitempty\"`\n\tApiList         []uint            `form:\"api_list\" json:\"api_list\" label:\"接口列表\" binding:\"omitempty,dive,gt=0\"`\n\tComponent       string            `form:\"component\" json:\"component\" label:\"前端组件路径\"`\n\tStatus          uint8             `form:\"status\" json:\"status\" label:\"状态\" binding:\"omitempty,oneof=0 1\"` // 0 禁用 1 启用\n\tRedirect        string            `form:\"redirect\" json:\"redirect\" label:\"重定向地址\" binding:\"omitempty\"`\n\tIsExternalLinks uint8             `form:\"is_external_links\" json:\"is_external_links\" label:\"是否外链\" binding:\"omitempty,oneof=0 1\"`\n}\n\ntype CreateMenu struct {\n\tmenuPayload\n}\n\nfunc NewCreateMenuForm() *CreateMenu {\n\treturn &CreateMenu{}\n}\n\ntype UpdateMenu struct {\n\tId uint `form:\"id\" json:\"id\" binding:\"required\"`\n\tmenuPayload\n}\n\nfunc NewUpdateMenuForm() *UpdateMenu {\n\treturn &UpdateMenu{}\n}\n\nfunc (f *UpdateMenu) GetIDPointer() *uint {\n\treturn &f.Id\n}\n\ntype ListMenu struct {\n\tPaginate\n\tKeyword string `form:\"keyword\" json:\"keyword\" binding:\"omitempty\"`           // 关键字\n\tIsAuth  *int8  `form:\"is_auth\" json:\"is_auth\" binding:\"omitempty,oneof=0 1\"` // 是否授权\n\tStatus  *int8  `form:\"status\" json:\"status\" binding:\"omitempty,oneof=0 1\"`   // 状态\n}\n\n// NewMenuListQuery 创建菜单列表查询表单。\nfunc NewMenuListQuery() *ListMenu {\n\treturn &ListMenu{}\n}\n"
  },
  {
    "path": "internal/validator/form/menu_test.go",
    "content": "package form\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\tvalidatorx \"github.com/wannanbigpig/gin-layout/internal/validator\"\n)\n\nfunc TestListMenuAllowsBinaryEnumFilters(t *testing.T) {\n\tif err := validatorx.InitValidatorTrans(\"zh\"); err != nil {\n\t\tt.Fatalf(\"init validator: %v\", err)\n\t}\n\tgin.SetMode(gin.TestMode)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/?is_auth=1&status=0\", nil)\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tctx.Request = req\n\n\tpayload := NewMenuListQuery()\n\tif err := ctx.ShouldBindQuery(payload); err != nil {\n\t\tt.Fatalf(\"expected is_auth/status in [0,1] to pass validation, got %v\", err)\n\t}\n}\n\nfunc TestListMenuRejectsInvalidIsAuth(t *testing.T) {\n\tif err := validatorx.InitValidatorTrans(\"zh\"); err != nil {\n\t\tt.Fatalf(\"init validator: %v\", err)\n\t}\n\tgin.SetMode(gin.TestMode)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/?is_auth=2\", nil)\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tctx.Request = req\n\n\tpayload := NewMenuListQuery()\n\tif err := ctx.ShouldBindQuery(payload); err == nil {\n\t\tt.Fatal(\"expected is_auth=2 to fail validation\")\n\t}\n}\n\nfunc TestListMenuRejectsInvalidStatus(t *testing.T) {\n\tif err := validatorx.InitValidatorTrans(\"zh\"); err != nil {\n\t\tt.Fatalf(\"init validator: %v\", err)\n\t}\n\tgin.SetMode(gin.TestMode)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/?status=2\", nil)\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tctx.Request = req\n\n\tpayload := NewMenuListQuery()\n\tif err := ctx.ShouldBindQuery(payload); err == nil {\n\t\tt.Fatal(\"expected status=2 to fail validation\")\n\t}\n}\n"
  },
  {
    "path": "internal/validator/form/permission.go",
    "content": "package form\n\ntype apiBasePayload struct {\n\tName        string `form:\"name\" json:\"name\" binding:\"required,max=60\"`              // 权限名称\n\tDescription string `form:\"description\" json:\"description\" binding:\"omitempty\"`      // 描述\n\tIsAuth      *int8  `form:\"is_auth\" json:\"is_auth\" binding:\"required,api_auth_mode\"` // 接口鉴权模式\n\tSort        int32  `form:\"sort\" json:\"sort\" binding:\"required\"`                     // 排序\n}\n\ntype CreatePermission struct {\n\tapiBasePayload\n\tMethod   string `form:\"method\" json:\"method\" binding:\"omitempty,oneof=GET POST PUT DELETE OPTIONS HEAD PATCH\" label:\"接口请求方法\"` // 接口请求方法\n\tRoute    string `form:\"route\" json:\"route\" binding:\"omitempty\"`                                                               // 接口路由\n\tFunc     string `form:\"func\" json:\"func\" binding:\"omitempty\"`                                                                 // 接口方法\n\tFuncPath string `form:\"func_path\" json:\"func_path\" binding:\"omitempty\"`                                                       // 接口方法\n}\n\nfunc NewCreateApiForm() *CreatePermission {\n\treturn &CreatePermission{}\n}\n\ntype UpdatePermission struct {\n\tId uint `form:\"id\" json:\"id\" binding:\"required\"` // id\n\tapiBasePayload\n}\n\nfunc NewUpdateApiForm() *UpdatePermission {\n\treturn &UpdatePermission{}\n}\n\nfunc (f *UpdatePermission) GetIDPointer() *uint {\n\treturn &f.Id\n}\n\ntype ListPermission struct {\n\tPaginate\n\tName        string `form:\"name\" json:\"name\" binding:\"omitempty,max=60\"`                                                          // 权限名称\n\tMethod      string `form:\"method\" json:\"method\" binding:\"omitempty,oneof=GET POST PUT DELETE OPTIONS HEAD PATCH\" label:\"接口请求方法\"` // 接口请求方法\n\tRoute       string `form:\"route\" json:\"route\" binding:\"omitempty\"`                                                               // 接口路由\n\tKeyword     string `form:\"keyword\" json:\"keyword\" binding:\"omitempty\"`                                                           // 关键字\n\tIsAuth      *int8  `form:\"is_auth\" json:\"is_auth\" binding:\"omitempty,api_auth_mode\"`                                             // 接口鉴权模式\n\tIsEffective *int8  `form:\"is_effective\" json:\"is_effective\" binding:\"omitempty,oneof=0 1\"`                                       // 是否授权\n}\n\n// NewListApiQuery 创建 API 列表查询表单。\nfunc NewListApiQuery() *ListPermission {\n\treturn &ListPermission{}\n}\n"
  },
  {
    "path": "internal/validator/form/permission_test.go",
    "content": "package form\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\n\tvalidatorx \"github.com/wannanbigpig/gin-layout/internal/validator\"\n)\n\nfunc TestUpdatePermissionAllowsThreeStateIsAuth(t *testing.T) {\n\tif err := validatorx.InitValidatorTrans(\"zh\"); err != nil {\n\t\tt.Fatalf(\"init validator: %v\", err)\n\t}\n\tgin.SetMode(gin.TestMode)\n\n\tbody := `{\"id\":1,\"name\":\"route-authz\",\"description\":\"test\",\"is_auth\":2,\"sort\":100}`\n\treq := httptest.NewRequest(http.MethodPost, \"/\", strings.NewReader(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tctx.Request = req\n\n\tpayload := NewUpdateApiForm()\n\tif err := ctx.ShouldBind(payload); err != nil {\n\t\tt.Fatalf(\"expected is_auth=2 to pass validation, got %v\", err)\n\t}\n}\n\nfunc TestListPermissionAllowsThreeStateIsAuth(t *testing.T) {\n\tif err := validatorx.InitValidatorTrans(\"zh\"); err != nil {\n\t\tt.Fatalf(\"init validator: %v\", err)\n\t}\n\tgin.SetMode(gin.TestMode)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/?is_auth=2\", nil)\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tctx.Request = req\n\n\tpayload := NewListApiQuery()\n\tif err := ctx.ShouldBindQuery(payload); err != nil {\n\t\tt.Fatalf(\"expected list is_auth=2 to pass validation, got %v\", err)\n\t}\n}\n\nfunc TestUpdatePermissionRejectsUnknownIsAuth(t *testing.T) {\n\tif err := validatorx.InitValidatorTrans(\"zh\"); err != nil {\n\t\tt.Fatalf(\"init validator: %v\", err)\n\t}\n\tgin.SetMode(gin.TestMode)\n\n\tbody := `{\"id\":1,\"name\":\"route-invalid\",\"is_auth\":3,\"sort\":100}`\n\treq := httptest.NewRequest(http.MethodPost, \"/\", strings.NewReader(body))\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tctx.Request = req\n\n\tpayload := NewUpdateApiForm()\n\tif err := ctx.ShouldBind(payload); err == nil {\n\t\tt.Fatal(\"expected is_auth=3 to fail validation\")\n\t}\n}\n"
  },
  {
    "path": "internal/validator/form/request_log.go",
    "content": "package form\n\n// RequestLogList 请求日志列表查询表单\ntype RequestLogList struct {\n\tPaginate\n\tOperatorID      uint   `form:\"operator_id\" json:\"operator_id\" binding:\"omitempty\"`                                    // 操作ID（用户ID）\n\tOperatorAccount string `form:\"operator_account\" json:\"operator_account\" binding:\"omitempty\"`                          // 操作账号\n\tOperationStatus *int   `form:\"operation_status\" json:\"operation_status\" binding:\"omitempty,oneof=0 1\"`                // 操作状态：0=成功，1=失败\n\tIsHighRisk      *uint8 `form:\"is_high_risk\" json:\"is_high_risk\" binding:\"omitempty,oneof=0 1\"`                        // 是否高危操作\n\tMethod          string `form:\"method\" json:\"method\" binding:\"omitempty,oneof=GET POST PUT DELETE OPTIONS HEAD PATCH\"` // HTTP请求方法\n\tBaseURL         string `form:\"base_url\" json:\"base_url\" binding:\"omitempty\"`                                          // 请求基础URL\n\tOperationName   string `form:\"operation_name\" json:\"operation_name\" binding:\"omitempty\"`                              // 操作接口\n\tIP              string `form:\"ip\" json:\"ip\" binding:\"omitempty\"`                                                      // 操作IP\n\tStartTime       string `form:\"start_time\" json:\"start_time\" binding:\"omitempty\"`                                      // 开始时间\n\tEndTime         string `form:\"end_time\" json:\"end_time\" binding:\"omitempty\"`                                          // 结束时间\n}\n\n// NewRequestLogListQuery 创建请求日志列表查询表单\nfunc NewRequestLogListQuery() *RequestLogList {\n\treturn &RequestLogList{}\n}\n\n// RequestLogExport 请求日志导出查询参数。\ntype RequestLogExport struct {\n\tOperatorID      uint   `form:\"operator_id\" json:\"operator_id\" binding:\"omitempty\"`\n\tOperatorAccount string `form:\"operator_account\" json:\"operator_account\" binding:\"omitempty\"`\n\tOperationStatus *int   `form:\"operation_status\" json:\"operation_status\" binding:\"omitempty,oneof=0 1\"`\n\tIsHighRisk      *uint8 `form:\"is_high_risk\" json:\"is_high_risk\" binding:\"omitempty,oneof=0 1\"`\n\tMethod          string `form:\"method\" json:\"method\" binding:\"omitempty,oneof=GET POST PUT DELETE OPTIONS HEAD PATCH\"`\n\tBaseURL         string `form:\"base_url\" json:\"base_url\" binding:\"omitempty\"`\n\tOperationName   string `form:\"operation_name\" json:\"operation_name\" binding:\"omitempty\"`\n\tIP              string `form:\"ip\" json:\"ip\" binding:\"omitempty\"`\n\tStartTime       string `form:\"start_time\" json:\"start_time\" binding:\"omitempty\"`\n\tEndTime         string `form:\"end_time\" json:\"end_time\" binding:\"omitempty\"`\n\tLimit           int    `form:\"limit\" json:\"limit\" binding:\"omitempty,min=1,max=5000\"`\n}\n\nfunc NewRequestLogExportQuery() *RequestLogExport {\n\treturn &RequestLogExport{Limit: 1000}\n}\n\n// RequestLogMaskConfigForm 请求日志脱敏配置。\ntype RequestLogMaskConfigForm struct {\n\tCommon         []string `form:\"common\" json:\"common\" binding:\"omitempty,dive,max=64\"`\n\tRequestHeader  []string `form:\"request_header\" json:\"request_header\" binding:\"omitempty,dive,max=64\"`\n\tRequestBody    []string `form:\"request_body\" json:\"request_body\" binding:\"omitempty,dive,max=64\"`\n\tResponseHeader []string `form:\"response_header\" json:\"response_header\" binding:\"omitempty,dive,max=64\"`\n\tResponseBody   []string `form:\"response_body\" json:\"response_body\" binding:\"omitempty,dive,max=64\"`\n}\n\nfunc NewRequestLogMaskConfigForm() *RequestLogMaskConfigForm {\n\treturn &RequestLogMaskConfigForm{}\n}\n"
  },
  {
    "path": "internal/validator/form/request_log_test.go",
    "content": "package form\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\n\tvalidatorx \"github.com/wannanbigpig/gin-layout/internal/validator\"\n)\n\nfunc TestRequestLogListAllowsKnownHTTPMethod(t *testing.T) {\n\tif err := validatorx.InitValidatorTrans(\"zh\"); err != nil {\n\t\tt.Fatalf(\"init validator: %v\", err)\n\t}\n\tgin.SetMode(gin.TestMode)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/?method=GET\", nil)\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tctx.Request = req\n\n\tpayload := NewRequestLogListQuery()\n\tif err := ctx.ShouldBindQuery(payload); err != nil {\n\t\tt.Fatalf(\"expected method=GET to pass validation, got %v\", err)\n\t}\n}\n\nfunc TestRequestLogListRejectsUnknownHTTPMethod(t *testing.T) {\n\tif err := validatorx.InitValidatorTrans(\"zh\"); err != nil {\n\t\tt.Fatalf(\"init validator: %v\", err)\n\t}\n\tgin.SetMode(gin.TestMode)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/?method=TRACE\", nil)\n\tctx, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tctx.Request = req\n\n\tpayload := NewRequestLogListQuery()\n\tif err := ctx.ShouldBindQuery(payload); err == nil {\n\t\tt.Fatal(\"expected method=TRACE to fail validation\")\n\t}\n}\n"
  },
  {
    "path": "internal/validator/form/role.go",
    "content": "package form\n\ntype RoleList struct {\n\tPaginate\n\tStatus *int8  `form:\"status\" json:\"status\"  binding:\"omitempty,oneof=0 1\"`\n\tName   string `form:\"name\" json:\"name\" binding:\"omitempty\"`\n\tPid    *uint  `form:\"pid\" json:\"pid\" binding:\"omitempty\"`\n}\n\n// NewRoleListQuery 初始化查询参数\nfunc NewRoleListQuery() *RoleList {\n\treturn &RoleList{}\n}\n\ntype rolePayload struct {\n\tCode        string `form:\"code\" json:\"code\" binding:\"omitempty,max=60\"`\n\tName        string `form:\"name\" json:\"name\" binding:\"required\"`\n\tDescription string `form:\"description\" json:\"description\" binding:\"omitempty\"`\n\tStatus      uint8  `form:\"status\" json:\"status\"  binding:\"omitempty,oneof=0 1\"`\n\tPid         uint   `form:\"pid\" json:\"pid\" binding:\"omitempty\"`\n\tSort        uint   `form:\"sort\" json:\"sort\" binding:\"omitempty\"`\n\tMenuList    []uint `form:\"menu_ids\" json:\"menu_list\" binding:\"omitempty,dive,gt=0\"`\n}\n\ntype CreateRole struct {\n\trolePayload\n}\n\nfunc NewCreateRoleForm() *CreateRole {\n\treturn &CreateRole{}\n}\n\ntype UpdateRole struct {\n\tId uint `form:\"id\" json:\"id\" binding:\"required\"`\n\trolePayload\n}\n\nfunc NewUpdateRoleForm() *UpdateRole {\n\treturn &UpdateRole{}\n}\n\nfunc (f *UpdateRole) GetIDPointer() *uint {\n\treturn &f.Id\n}\n"
  },
  {
    "path": "internal/validator/form/role_test.go",
    "content": "package form\n\nimport \"testing\"\n\nfunc TestRoleStatusUsesBinaryEnum(t *testing.T) {\n\tif err := bindJSONBody(t, `{\"name\":\"审计员\",\"status\":0}`, NewCreateRoleForm()); err != nil {\n\t\tt.Fatalf(\"expected status=0 to pass validation, got %v\", err)\n\t}\n\tif err := bindJSONBody(t, `{\"name\":\"审计员\",\"status\":1}`, NewCreateRoleForm()); err != nil {\n\t\tt.Fatalf(\"expected status=1 to pass validation, got %v\", err)\n\t}\n\tif err := bindJSONBody(t, `{\"name\":\"审计员\",\"status\":2}`, NewCreateRoleForm()); err == nil {\n\t\tt.Fatal(\"expected status=2 to fail validation\")\n\t}\n}\n"
  },
  {
    "path": "internal/validator/form/session.go",
    "content": "package form\n\n// SessionList 在线会话列表查询参数。\ntype SessionList struct {\n\tPaginate\n\tUID       uint   `form:\"uid\" json:\"uid\" binding:\"omitempty,gt=0\"`\n\tUsername  string `form:\"username\" json:\"username\" binding:\"omitempty\"`\n\tIP        string `form:\"ip\" json:\"ip\" binding:\"omitempty\"`\n\tIsRevoked *uint8 `form:\"is_revoked\" json:\"is_revoked\" binding:\"omitempty,oneof=0 1\"`\n\tStartTime string `form:\"start_time\" json:\"start_time\" binding:\"omitempty\"`\n\tEndTime   string `form:\"end_time\" json:\"end_time\" binding:\"omitempty\"`\n}\n\nfunc NewSessionListQuery() *SessionList {\n\treturn &SessionList{}\n}\n\n// SessionRevoke 撤销在线会话参数。\ntype SessionRevoke struct {\n\tID     uint   `form:\"id\" json:\"id\" binding:\"required,gt=0\"`\n\tReason string `form:\"reason\" json:\"reason\" binding:\"omitempty,max=255\"`\n}\n\nfunc NewSessionRevokeForm() *SessionRevoke {\n\treturn &SessionRevoke{}\n}\n"
  },
  {
    "path": "internal/validator/form/session_test.go",
    "content": "package form\n\nimport \"testing\"\n\nfunc TestSessionListRejectsInvalidIsRevoked(t *testing.T) {\n\terr := bindJSONBody(t, `{\"is_revoked\":2}`, NewSessionListQuery())\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid is_revoked to fail validation\")\n\t}\n}\n\nfunc TestSessionRevokeRejectsZeroID(t *testing.T) {\n\terr := bindJSONBody(t, `{\"id\":0}`, NewSessionRevokeForm())\n\tif err == nil {\n\t\tt.Fatal(\"expected zero id to fail validation\")\n\t}\n}\n"
  },
  {
    "path": "internal/validator/form/storage_config.go",
    "content": "package form\n\nimport \"github.com/wannanbigpig/gin-layout/internal/filestorage\"\n\ntype StorageConfigPayload struct {\n\tActiveDriver string             `form:\"active_driver\" json:\"active_driver\" label:\"存储驱动\" binding:\"required,oneof=local aliyun_oss s3\"`\n\tConfig       filestorage.Config `form:\"config\" json:\"config\" label:\"存储配置\" binding:\"required\"`\n}\n\nfunc NewStorageConfigPayload() *StorageConfigPayload {\n\treturn &StorageConfigPayload{}\n}\n"
  },
  {
    "path": "internal/validator/form/storage_config_test.go",
    "content": "package form\n\nimport \"testing\"\n\nfunc TestStorageConfigRejectsInvalidDriver(t *testing.T) {\n\terr := bindJSONBody(t, `{\"active_driver\":\"ftp\",\"config\":{}}`, NewStorageConfigPayload())\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid active_driver to fail validation\")\n\t}\n}\n\nfunc TestStorageConfigAllowsLocalDriver(t *testing.T) {\n\terr := bindJSONBody(t, `{\"active_driver\":\"local\",\"config\":{\"signed_url_ttl_seconds\":300,\"max_file_size_mb\":10}}`, NewStorageConfigPayload())\n\tif err != nil {\n\t\tt.Fatalf(\"expected local storage config to pass validation, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/validator/form/sys_config.go",
    "content": "package form\n\ntype SysConfigList struct {\n\tPaginate\n\tConfigKey     string `form:\"config_key\" json:\"config_key\" binding:\"omitempty,max=100\"`\n\tConfigName    string `form:\"config_name\" json:\"config_name\" binding:\"omitempty,max=100\"`\n\tGroupCode     string `form:\"group_code\" json:\"group_code\" binding:\"omitempty,max=60\"`\n\tValueType     string `form:\"value_type\" json:\"value_type\" binding:\"omitempty,oneof=string number bool json\"`\n\tStatus        *uint8 `form:\"status\" json:\"status\" binding:\"omitempty,oneof=0 1\"`\n\tIsVisible     *uint8 `form:\"is_visible\" json:\"is_visible\" binding:\"omitempty,oneof=0 1\"`\n\tManageTab     string `form:\"manage_tab\" json:\"manage_tab\" binding:\"omitempty,max=60\"`\n\tIncludeHidden *uint8 `form:\"include_hidden\" json:\"include_hidden\" binding:\"omitempty,oneof=0 1\"`\n}\n\ntype SysConfigPayload struct {\n\tConfigKey      string            `form:\"config_key\" json:\"config_key\" label:\"参数键名\" binding:\"required,max=100\"`\n\tConfigNameI18n map[string]string `form:\"config_name_i18n\" json:\"config_name_i18n\" label:\"参数名称多语言\" binding:\"required\"`\n\tConfigValue    string            `form:\"config_value\" json:\"config_value\" label:\"参数值\" binding:\"omitempty\"`\n\tValueType      string            `form:\"value_type\" json:\"value_type\" label:\"值类型\" binding:\"required,oneof=string number bool json\"`\n\tGroupCode      string            `form:\"group_code\" json:\"group_code\" label:\"参数分组\" binding:\"omitempty,max=60\"`\n\tIsSensitive    *uint8            `form:\"is_sensitive\" json:\"is_sensitive\" label:\"是否敏感\" binding:\"omitempty,oneof=0 1\"`\n\tIsVisible      *uint8            `form:\"is_visible\" json:\"is_visible\" label:\"是否展示\" binding:\"omitempty,oneof=0 1\"`\n\tManageTab      string            `form:\"manage_tab\" json:\"manage_tab\" label:\"专属配置Tab\" binding:\"omitempty,max=60\"`\n\tStatus         *uint8            `form:\"status\" json:\"status\" label:\"状态\" binding:\"omitempty,oneof=0 1\"`\n\tSort           uint              `form:\"sort\" json:\"sort\" label:\"排序\" binding:\"omitempty\"`\n\tRemark         string            `form:\"remark\" json:\"remark\" label:\"备注\" binding:\"omitempty,max=255\"`\n}\n\ntype CreateSysConfig struct {\n\tSysConfigPayload\n}\n\ntype UpdateSysConfig struct {\n\tId uint `form:\"id\" json:\"id\" label:\"参数ID\" binding:\"required\"`\n\tSysConfigPayload\n}\n\ntype SysConfigKeyQuery struct {\n\tConfigKey string `form:\"config_key\" json:\"config_key\" label:\"参数键名\" binding:\"required,max=100\"`\n}\n\nfunc NewSysConfigListQuery() *SysConfigList {\n\treturn &SysConfigList{}\n}\n\nfunc NewCreateSysConfigForm() *CreateSysConfig {\n\treturn &CreateSysConfig{}\n}\n\nfunc NewUpdateSysConfigForm() *UpdateSysConfig {\n\treturn &UpdateSysConfig{}\n}\n\nfunc NewSysConfigKeyQuery() *SysConfigKeyQuery {\n\treturn &SysConfigKeyQuery{}\n}\n"
  },
  {
    "path": "internal/validator/form/sys_config_test.go",
    "content": "package form\n\nimport \"testing\"\n\nfunc TestCreateSysConfigAllowsValidValueTypes(t *testing.T) {\n\tcases := []string{\n\t\t`{\"config_key\":\"feature.demo\",\"config_name_i18n\":{\"zh-CN\":\"演示开关\",\"en-US\":\"Feature Toggle\"},\"config_value\":\"true\",\"value_type\":\"bool\",\"status\":1}`,\n\t\t`{\"config_key\":\"number.demo\",\"config_name_i18n\":{\"zh-CN\":\"数字参数\"},\"config_value\":\"10.5\",\"value_type\":\"number\",\"status\":0}`,\n\t\t`{\"config_key\":\"json.demo\",\"config_name_i18n\":{\"zh-CN\":\"JSON参数\"},\"config_value\":\"{\\\"a\\\":1}\",\"value_type\":\"json\",\"status\":1}`,\n\t}\n\tfor _, body := range cases {\n\t\tif err := bindJSONBody(t, body, NewCreateSysConfigForm()); err != nil {\n\t\t\tt.Fatalf(\"expected sys_config payload to pass validation, got %v\", err)\n\t\t}\n\t}\n}\n\nfunc TestCreateSysConfigRejectsInvalidValueType(t *testing.T) {\n\terr := bindJSONBody(t, `{\"config_key\":\"feature.demo\",\"config_name_i18n\":{\"zh-CN\":\"演示开关\"},\"config_value\":\"1\",\"value_type\":\"yaml\"}`, NewCreateSysConfigForm())\n\tif err == nil {\n\t\tt.Fatal(\"expected unsupported value_type to fail validation\")\n\t}\n}\n\nfunc TestSysConfigListRejectsInvalidStatus(t *testing.T) {\n\terr := bindJSONBody(t, `{\"status\":2}`, NewSysConfigListQuery())\n\tif err == nil {\n\t\tt.Fatal(\"expected status=2 to fail validation\")\n\t}\n}\n"
  },
  {
    "path": "internal/validator/form/sys_dict.go",
    "content": "package form\n\ntype SysDictTypeList struct {\n\tPaginate\n\tTypeCode string `form:\"type_code\" json:\"type_code\" binding:\"omitempty,max=100\"`\n\tTypeName string `form:\"type_name\" json:\"type_name\" binding:\"omitempty,max=100\"`\n\tStatus   *uint8 `form:\"status\" json:\"status\" binding:\"omitempty,oneof=0 1\"`\n}\n\ntype SysDictTypePayload struct {\n\tTypeCode     string            `form:\"type_code\" json:\"type_code\" label:\"字典类型编码\" binding:\"required,max=100\"`\n\tTypeNameI18n map[string]string `form:\"type_name_i18n\" json:\"type_name_i18n\" label:\"字典类型名称多语言\" binding:\"required\"`\n\tStatus       *uint8            `form:\"status\" json:\"status\" label:\"状态\" binding:\"omitempty,oneof=0 1\"`\n\tSort         uint              `form:\"sort\" json:\"sort\" label:\"排序\" binding:\"omitempty\"`\n\tRemark       string            `form:\"remark\" json:\"remark\" label:\"备注\" binding:\"omitempty,max=255\"`\n}\n\ntype CreateSysDictType struct {\n\tSysDictTypePayload\n}\n\ntype UpdateSysDictType struct {\n\tId uint `form:\"id\" json:\"id\" label:\"字典类型ID\" binding:\"required\"`\n\tSysDictTypePayload\n}\n\ntype SysDictItemList struct {\n\tPaginate\n\tTypeCode string `form:\"type_code\" json:\"type_code\" label:\"字典类型编码\" binding:\"required,max=100\"`\n\tLabel    string `form:\"label\" json:\"label\" binding:\"omitempty,max=100\"`\n\tValue    string `form:\"value\" json:\"value\" binding:\"omitempty,max=100\"`\n\tStatus   *uint8 `form:\"status\" json:\"status\" binding:\"omitempty,oneof=0 1\"`\n}\n\ntype SysDictItemPayload struct {\n\tTypeCode  string            `form:\"type_code\" json:\"type_code\" label:\"字典类型编码\" binding:\"required,max=100\"`\n\tLabelI18n map[string]string `form:\"label_i18n\" json:\"label_i18n\" label:\"字典标签多语言\" binding:\"required\"`\n\tValue     string            `form:\"value\" json:\"value\" label:\"字典值\" binding:\"required,max=100\"`\n\tColor     string            `form:\"color\" json:\"color\" label:\"展示颜色\" binding:\"omitempty,max=30\"`\n\tTagType   string            `form:\"tag_type\" json:\"tag_type\" label:\"标签类型\" binding:\"omitempty,max=30\"`\n\tIsDefault *uint8            `form:\"is_default\" json:\"is_default\" label:\"是否默认\" binding:\"omitempty,oneof=0 1\"`\n\tStatus    *uint8            `form:\"status\" json:\"status\" label:\"状态\" binding:\"omitempty,oneof=0 1\"`\n\tSort      uint              `form:\"sort\" json:\"sort\" label:\"排序\" binding:\"omitempty\"`\n\tRemark    string            `form:\"remark\" json:\"remark\" label:\"备注\" binding:\"omitempty,max=255\"`\n}\n\ntype CreateSysDictItem struct {\n\tSysDictItemPayload\n}\n\ntype UpdateSysDictItem struct {\n\tId uint `form:\"id\" json:\"id\" label:\"字典项ID\" binding:\"required\"`\n\tSysDictItemPayload\n}\n\ntype SysDictOptionsQuery struct {\n\tTypeCode string `form:\"type_code\" json:\"type_code\" label:\"字典类型编码\" binding:\"required,max=100\"`\n}\n\nfunc NewSysDictTypeListQuery() *SysDictTypeList {\n\treturn &SysDictTypeList{}\n}\n\nfunc NewCreateSysDictTypeForm() *CreateSysDictType {\n\treturn &CreateSysDictType{}\n}\n\nfunc NewUpdateSysDictTypeForm() *UpdateSysDictType {\n\treturn &UpdateSysDictType{}\n}\n\nfunc NewSysDictItemListQuery() *SysDictItemList {\n\treturn &SysDictItemList{}\n}\n\nfunc NewCreateSysDictItemForm() *CreateSysDictItem {\n\treturn &CreateSysDictItem{}\n}\n\nfunc NewUpdateSysDictItemForm() *UpdateSysDictItem {\n\treturn &UpdateSysDictItem{}\n}\n\nfunc NewSysDictOptionsQuery() *SysDictOptionsQuery {\n\treturn &SysDictOptionsQuery{}\n}\n"
  },
  {
    "path": "internal/validator/form/sys_dict_test.go",
    "content": "package form\n\nimport \"testing\"\n\nfunc TestCreateSysDictTypeRejectsInvalidStatus(t *testing.T) {\n\terr := bindJSONBody(t, `{\"type_code\":\"test_type\",\"type_name_i18n\":{\"zh-CN\":\"测试字典\"},\"status\":2}`, NewCreateSysDictTypeForm())\n\tif err == nil {\n\t\tt.Fatal(\"expected status=2 to fail validation\")\n\t}\n}\n\nfunc TestCreateSysDictItemAllowsValidBinaryFlags(t *testing.T) {\n\tbody := `{\"type_code\":\"test_type\",\"label_i18n\":{\"zh-CN\":\"启用\",\"en-US\":\"Enabled\"},\"value\":\"1\",\"is_default\":1,\"status\":0}`\n\tif err := bindJSONBody(t, body, NewCreateSysDictItemForm()); err != nil {\n\t\tt.Fatalf(\"expected sys_dict_item payload to pass validation, got %v\", err)\n\t}\n}\n\nfunc TestCreateSysDictItemRejectsInvalidDefaultFlag(t *testing.T) {\n\tbody := `{\"type_code\":\"test_type\",\"label_i18n\":{\"zh-CN\":\"启用\"},\"value\":\"1\",\"is_default\":2}`\n\terr := bindJSONBody(t, body, NewCreateSysDictItemForm())\n\tif err == nil {\n\t\tt.Fatal(\"expected is_default=2 to fail validation\")\n\t}\n}\n"
  },
  {
    "path": "internal/validator/form/task_center.go",
    "content": "package form\n\n// TaskDefinitionList 任务定义列表查询参数。\ntype TaskDefinitionList struct {\n\tPaginate\n\tCode        string `form:\"code\" json:\"code\" binding:\"omitempty\"`\n\tName        string `form:\"name\" json:\"name\" binding:\"omitempty\"`\n\tKind        string `form:\"kind\" json:\"kind\" binding:\"omitempty,oneof=async cron\"`\n\tStatus      *uint8 `form:\"status\" json:\"status\" binding:\"omitempty,oneof=0 1\"`\n\tAllowManual *uint8 `form:\"allow_manual\" json:\"allow_manual\" binding:\"omitempty,oneof=0 1\"`\n\tAllowRetry  *uint8 `form:\"allow_retry\" json:\"allow_retry\" binding:\"omitempty,oneof=0 1\"`\n\tIsHighRisk  *uint8 `form:\"is_high_risk\" json:\"is_high_risk\" binding:\"omitempty,oneof=0 1\"`\n}\n\nfunc NewTaskDefinitionListQuery() *TaskDefinitionList {\n\treturn &TaskDefinitionList{}\n}\n\n// TaskRunList 任务执行记录列表查询参数。\ntype TaskRunList struct {\n\tPaginate\n\tTaskCode  string `form:\"task_code\" json:\"task_code\" binding:\"omitempty\"`\n\tKind      string `form:\"kind\" json:\"kind\" binding:\"omitempty,oneof=async cron\"`\n\tSource    string `form:\"source\" json:\"source\" binding:\"omitempty,oneof=queue cron manual\"`\n\tSourceID  string `form:\"source_id\" json:\"source_id\" binding:\"omitempty\"`\n\tStatus    string `form:\"status\" json:\"status\" binding:\"omitempty,oneof=pending running success failed canceled retrying\"`\n\tStartTime string `form:\"start_time\" json:\"start_time\" binding:\"omitempty\"`\n\tEndTime   string `form:\"end_time\" json:\"end_time\" binding:\"omitempty\"`\n}\n\nfunc NewTaskRunListQuery() *TaskRunList {\n\treturn &TaskRunList{}\n}\n\n// TaskRunEvents 任务执行事件查询参数。\ntype TaskRunEvents struct {\n\tRunID uint `form:\"run_id\" json:\"run_id\" binding:\"required,gt=0\"`\n}\n\nfunc NewTaskRunEventsQuery() *TaskRunEvents {\n\treturn &TaskRunEvents{}\n}\n\n// CronTaskStateList 定时任务状态列表查询参数。\ntype CronTaskStateList struct {\n\tPaginate\n\tTaskCode   string `form:\"task_code\" json:\"task_code\" binding:\"omitempty\"`\n\tLastStatus string `form:\"last_status\" json:\"last_status\" binding:\"omitempty,oneof=pending running success failed canceled retrying\"`\n}\n\nfunc NewCronTaskStateListQuery() *CronTaskStateList {\n\treturn &CronTaskStateList{}\n}\n\n// TaskTriggerForm 手动触发任务参数。\ntype TaskTriggerForm struct {\n\tTaskCode string         `form:\"task_code\" json:\"task_code\" binding:\"required\"`\n\tQueue    string         `form:\"queue\" json:\"queue\" binding:\"omitempty\"`\n\tTaskID   string         `form:\"task_id\" json:\"task_id\" binding:\"omitempty\"`\n\tPayload  map[string]any `form:\"payload\" json:\"payload\" binding:\"omitempty\"`\n\tConfirm  string         `form:\"confirm\" json:\"confirm\" binding:\"omitempty,max=120\"`\n\tReason   string         `form:\"reason\" json:\"reason\" binding:\"omitempty,max=255\"`\n}\n\nfunc NewTaskTriggerForm() *TaskTriggerForm {\n\treturn &TaskTriggerForm{}\n}\n\n// TaskRetryForm 重试任务参数。\ntype TaskRetryForm struct {\n\tRunID uint `form:\"run_id\" json:\"run_id\" binding:\"required,gt=0\"`\n}\n\nfunc NewTaskRetryForm() *TaskRetryForm {\n\treturn &TaskRetryForm{}\n}\n\n// TaskCancelForm 取消任务参数。\ntype TaskCancelForm struct {\n\tRunID  uint   `form:\"run_id\" json:\"run_id\" binding:\"required,gt=0\"`\n\tReason string `form:\"reason\" json:\"reason\" binding:\"omitempty,max=255\"`\n}\n\nfunc NewTaskCancelForm() *TaskCancelForm {\n\treturn &TaskCancelForm{}\n}\n"
  },
  {
    "path": "internal/validator/form/task_center_test.go",
    "content": "package form\n\nimport \"testing\"\n\nfunc TestTaskRunListRejectsInvalidStatus(t *testing.T) {\n\terr := bindJSONBody(t, `{\"status\":\"succeeded\"}`, NewTaskRunListQuery())\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid task run status to fail validation\")\n\t}\n}\n\nfunc TestTaskRunListAllowsKnownStatus(t *testing.T) {\n\terr := bindJSONBody(t, `{\"status\":\"success\"}`, NewTaskRunListQuery())\n\tif err != nil {\n\t\tt.Fatalf(\"expected known task run status to pass validation, got %v\", err)\n\t}\n}\n\nfunc TestCronTaskStateListRejectsInvalidLastStatus(t *testing.T) {\n\terr := bindJSONBody(t, `{\"last_status\":\"succeeded\"}`, NewCronTaskStateListQuery())\n\tif err == nil {\n\t\tt.Fatal(\"expected invalid cron task status to fail validation\")\n\t}\n}\n\nfunc TestCronTaskStateListAllowsKnownLastStatus(t *testing.T) {\n\terr := bindJSONBody(t, `{\"last_status\":\"retrying\"}`, NewCronTaskStateListQuery())\n\tif err != nil {\n\t\tt.Fatalf(\"expected known cron task status to pass validation, got %v\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/validator/rules.go",
    "content": "package validator\n\nimport (\n\t\"errors\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"sync\"\n\n\t\"github.com/go-playground/validator/v10\"\n)\n\nvar (\n\tphoneNumberRegex = regexp.MustCompile(`^1[3456789]\\d{9}$`)\n\tregexCache       sync.Map // map[string]*regexp.Regexp\n)\n\n// RegexpValidator 通用正则表达式验证器。\nfunc RegexpValidator(fl validator.FieldLevel) bool {\n\tparam := fl.Param()\n\tif param == \"\" {\n\t\treturn false\n\t}\n\n\tvalue := fl.Field().String()\n\tif cached, ok := regexCache.Load(param); ok {\n\t\treturn cached.(*regexp.Regexp).MatchString(value)\n\t}\n\n\treg, err := regexp.Compile(param)\n\tif err != nil {\n\t\treturn false\n\t}\n\tregexCache.Store(param, reg)\n\treturn reg.MatchString(value)\n}\n\nfunc initCustomRules(validate *validator.Validate) error {\n\terr := validate.RegisterValidation(\"phone_number\", func(fl validator.FieldLevel) bool {\n\t\treturn phoneNumberRegex.MatchString(fl.Field().String())\n\t})\n\tif err != nil {\n\t\treturn errors.New(\"注册 phone_number 校验规则失败\")\n\t}\n\n\terr = validate.RegisterValidation(\"required_if_exist\", requiredIf)\n\tif err != nil {\n\t\treturn errors.New(\"注册 required_if_exist 校验规则失败\")\n\t}\n\n\terr = validate.RegisterValidation(\"regexp\", RegexpValidator)\n\tif err != nil {\n\t\treturn errors.New(\"注册 regexp 校验规则失败\")\n\t}\n\n\terr = validate.RegisterValidation(\"api_auth_mode\", apiAuthModeValidator)\n\tif err != nil {\n\t\treturn errors.New(\"注册 api_auth_mode 校验规则失败\")\n\t}\n\treturn nil\n}\n\nfunc apiAuthModeValidator(fl validator.FieldLevel) bool {\n\tfield := fl.Field()\n\tfor field.Kind() == reflect.Ptr {\n\t\tif field.IsNil() {\n\t\t\treturn true\n\t\t}\n\t\tfield = field.Elem()\n\t}\n\n\tswitch field.Kind() {\n\tcase reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:\n\t\tvalue := field.Int()\n\t\treturn value >= 0 && value <= 2\n\tcase reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:\n\t\tvalue := field.Uint()\n\t\treturn value <= 2\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// requiredIf 字段B存在时，字段A必填。\nfunc requiredIf(fl validator.FieldLevel) bool {\n\tparam := fl.Param()\n\tif param == \"\" {\n\t\treturn false\n\t}\n\n\tparams := make([]string, 0, 4)\n\tstart := 0\n\tfor i := 0; i < len(param); i++ {\n\t\tif param[i] == ' ' || param[i] == '\\t' {\n\t\t\tif start < i {\n\t\t\t\tparams = append(params, param[start:i])\n\t\t\t}\n\t\t\tstart = i + 1\n\t\t}\n\t}\n\tif start < len(param) {\n\t\tparams = append(params, param[start:])\n\t}\n\n\tif len(params) < 2 {\n\t\treturn false\n\t}\n\n\ttargetField := params[0]\n\tvalidValues := params[1:]\n\tfieldValue := fl.Field().String()\n\n\ttargetFieldValue := fl.Parent().FieldByName(targetField)\n\tif !targetFieldValue.IsValid() {\n\t\treturn true\n\t}\n\n\tswitch targetFieldValue.Kind() {\n\tcase reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:\n\t\ttargetInt := targetFieldValue.Int()\n\t\tfor _, val := range validValues {\n\t\t\tif intVal, err := strconv.ParseInt(val, 10, 64); err == nil && targetInt == intVal {\n\t\t\t\treturn fieldValue != \"\"\n\t\t\t}\n\t\t}\n\tcase reflect.String:\n\t\ttargetStr := targetFieldValue.String()\n\t\tfor _, val := range validValues {\n\t\t\tif targetStr == val {\n\t\t\t\treturn fieldValue != \"\"\n\t\t\t}\n\t\t}\n\tcase reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:\n\t\ttargetUint := targetFieldValue.Uint()\n\t\tfor _, val := range validValues {\n\t\t\tif uintVal, err := strconv.ParseUint(val, 10, 64); err == nil && targetUint == uintVal {\n\t\t\t\treturn fieldValue != \"\"\n\t\t\t}\n\t\t}\n\tcase reflect.Float32, reflect.Float64:\n\t\ttargetFloat := targetFieldValue.Float()\n\t\tfor _, val := range validValues {\n\t\t\tif floatVal, err := strconv.ParseFloat(val, 64); err == nil && targetFloat == floatVal {\n\t\t\t\treturn fieldValue != \"\"\n\t\t\t}\n\t\t}\n\tdefault:\n\t\treturn false\n\t}\n\n\treturn true\n}\n"
  },
  {
    "path": "internal/validator/runtime.go",
    "content": "package validator\n\nimport (\n\t\"errors\"\n\t\"reflect\"\n\t\"sync\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gin-gonic/gin/binding\"\n\tut \"github.com/go-playground/universal-translator\"\n\t\"github.com/go-playground/validator/v10\"\n\t\"go.uber.org/zap\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/i18n\"\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n)\n\nvar validatorRuntime = newValidatorRuntime()\n\ntype validatorRuntimeState struct {\n\tonce             sync.Once\n\tvalidate         *validator.Validate\n\ttranslators      map[string]ut.Translator\n\ttranslatorLocale string\n\trulesReady       bool\n\ttagNameReady     bool\n\tinitErr          error\n}\n\nfunc newValidatorRuntime() *validatorRuntimeState {\n\treturn &validatorRuntimeState{}\n}\n\n// InitValidatorTrans 初始化验证器和翻译器。\nfunc InitValidatorTrans(locale string) error {\n\terr := validatorRuntime.initOnce(locale)\n\tif err != nil && log.Logger != nil {\n\t\tlog.Logger.Error(\"初始化 validator 失败\", zap.String(\"locale\", locale), zap.Error(err))\n\t}\n\treturn err\n}\n\nfunc (s *validatorRuntimeState) initOnce(locale string) error {\n\ts.once.Do(func() {\n\t\ts.initErr = s.init(locale)\n\t})\n\treturn s.initErr\n}\n\nfunc (s *validatorRuntimeState) init(locale string) error {\n\tengine, ok := getValidatorEngine()\n\tif !ok {\n\t\treturn errors.New(\"初始化 validator 失败\")\n\t}\n\ts.validate = engine\n\n\tif err := s.ensureRules(); err != nil {\n\t\treturn err\n\t}\n\ts.ensureTagNameFunc()\n\n\ttranslators, err := initTranslators(s.validate)\n\tif err != nil {\n\t\treturn err\n\t}\n\ts.translators = translators\n\ts.translatorLocale = normalizeValidatorLocale(locale)\n\treturn nil\n}\n\nfunc (s *validatorRuntimeState) ensureRules() error {\n\tif s.rulesReady {\n\t\treturn nil\n\t}\n\tif err := initCustomRules(s.validate); err != nil {\n\t\treturn err\n\t}\n\ts.rulesReady = true\n\treturn nil\n}\n\nfunc (s *validatorRuntimeState) ensureTagNameFunc() {\n\tif s.tagNameReady {\n\t\treturn\n\t}\n\tregisterTagNameFunc(s.validate)\n\ts.tagNameReady = true\n}\n\nfunc getValidatorEngine() (*validator.Validate, bool) {\n\tengine := binding.Validator.Engine()\n\tif engine == nil {\n\t\treturn nil, false\n\t}\n\tvalidate, ok := engine.(*validator.Validate)\n\treturn validate, ok\n}\n\nfunc registerTagNameFunc(validate *validator.Validate) {\n\tvalidate.RegisterTagNameFunc(func(field reflect.StructField) string {\n\t\tif label := field.Tag.Get(\"label\"); label != \"\" && label != \"-\" {\n\t\t\treturn label\n\t\t}\n\t\tif json := field.Tag.Get(\"json\"); json != \"\" && json != \"-\" {\n\t\t\treturn json\n\t\t}\n\t\tif form := field.Tag.Get(\"form\"); form != \"\" && form != \"-\" {\n\t\t\treturn form\n\t\t}\n\t\treturn field.Name\n\t})\n}\n\nfunc (s *validatorRuntimeState) translatorForRequest(c *gin.Context) ut.Translator {\n\tif s == nil {\n\t\treturn nil\n\t}\n\n\tlocale := s.translatorLocale\n\tif c != nil {\n\t\tif localeValue, exists := c.Get(global.ContextKeyLocale); exists {\n\t\t\tif localeText, ok := localeValue.(string); ok {\n\t\t\t\tlocale = normalizeValidatorLocale(i18n.ToErrorLanguage(localeText))\n\t\t\t}\n\t\t}\n\t}\n\n\tif trans := s.translators[locale]; trans != nil {\n\t\treturn trans\n\t}\n\tif trans := s.translators[s.translatorLocale]; trans != nil {\n\t\treturn trans\n\t}\n\tif trans := s.translators[\"zh\"]; trans != nil {\n\t\treturn trans\n\t}\n\treturn s.translators[\"en\"]\n}\n\nfunc (s *validatorRuntimeState) fallbackTranslator(primary ut.Translator) ut.Translator {\n\tif s == nil {\n\t\treturn nil\n\t}\n\tdefaultTrans := s.translators[s.translatorLocale]\n\tif primary != nil && defaultTrans != nil && primary != defaultTrans {\n\t\treturn defaultTrans\n\t}\n\tif primary != nil && s.translators[\"zh\"] != nil && primary != s.translators[\"zh\"] {\n\t\treturn s.translators[\"zh\"]\n\t}\n\tif primary != nil && s.translators[\"en\"] != nil && primary != s.translators[\"en\"] {\n\t\treturn s.translators[\"en\"]\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "internal/validator/translation.go",
    "content": "package validator\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/go-playground/locales/en\"\n\t\"github.com/go-playground/locales/zh\"\n\tut \"github.com/go-playground/universal-translator\"\n\t\"github.com/go-playground/validator/v10\"\n\tenTranslations \"github.com/go-playground/validator/v10/translations/en\"\n\tzhTranslations \"github.com/go-playground/validator/v10/translations/zh\"\n\t\"go.uber.org/zap\"\n\n\tlog \"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n)\n\nfunc initTranslators(validate *validator.Validate) (map[string]ut.Translator, error) {\n\tzhT := zh.New()\n\tenT := en.New()\n\tuni := ut.New(enT, zhT, enT)\n\n\tlocales := []string{\"zh\", \"en\"}\n\ttranslators := make(map[string]ut.Translator, len(locales))\n\tfor _, locale := range locales {\n\t\ttrans, ok := uni.GetTranslator(locale)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"validator translator locale not supported: %s\", locale)\n\t\t}\n\t\tif err := registerLocaleTranslations(validate, trans, locale); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ttranslators[locale] = trans\n\t}\n\treturn translators, nil\n}\n\nfunc registerLocaleTranslations(validate *validator.Validate, trans ut.Translator, locale string) error {\n\tvar err error\n\tswitch locale {\n\tcase \"en\":\n\t\terr = enTranslations.RegisterDefaultTranslations(validate, trans)\n\tcase \"zh\":\n\t\terr = zhTranslations.RegisterDefaultTranslations(validate, trans)\n\tdefault:\n\t\terr = enTranslations.RegisterDefaultTranslations(validate, trans)\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"注册默认翻译器失败: %w\", err)\n\t}\n\n\tif err := customRegisTranslation(validate, trans, locale); err != nil {\n\t\treturn fmt.Errorf(\"注册自定义翻译失败: %w\", err)\n\t}\n\treturn nil\n}\n\ntype translation struct {\n\ttag             string\n\ttranslation     string\n\toverride        bool\n\tcustomRegisFunc validator.RegisterTranslationsFunc\n\tcustomTransFunc validator.TranslationFunc\n}\n\nfunc customRegisTranslation(validate *validator.Validate, trans ut.Translator, locale string) error {\n\treturn registerTranslation(validate, trans, localeTranslations(locale))\n}\n\nfunc localeTranslations(locale string) []translation {\n\tswitch normalizeValidatorLocale(locale) {\n\tcase \"en\":\n\t\treturn []translation{\n\t\t\t{tag: \"phone_number\", translation: \"{0} format is invalid\", override: false},\n\t\t\t{tag: \"required_if_exist\", translation: \"{0} is required\", override: false},\n\t\t\t{tag: \"regexp\", translation: \"{0} format is invalid\", override: false},\n\t\t}\n\tdefault:\n\t\treturn []translation{\n\t\t\t{tag: \"phone_number\", translation: \"{0}格式不正确\", override: false},\n\t\t\t{tag: \"required_if_exist\", translation: \"{0}字段必填\", override: false},\n\t\t\t{tag: \"regexp\", translation: \"{0}字段规则不匹配\", override: false},\n\t\t}\n\t}\n}\n\nfunc registerTranslation(validate *validator.Validate, trans ut.Translator, translations []translation) error {\n\tfor _, t := range translations {\n\t\tregFunc := t.customRegisFunc\n\t\tif regFunc == nil {\n\t\t\tregFunc = registrationFunc(t.tag, t.translation, t.override)\n\t\t}\n\n\t\ttransFunc := t.customTransFunc\n\t\tif transFunc == nil {\n\t\t\ttransFunc = translateFunc\n\t\t}\n\n\t\tif err := validate.RegisterTranslation(t.tag, trans, regFunc, transFunc); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc registrationFunc(tag string, translation string, override bool) validator.RegisterTranslationsFunc {\n\treturn func(ut ut.Translator) (err error) {\n\t\tif err = ut.Add(tag, translation, override); err != nil {\n\t\t\treturn\n\t\t}\n\t\treturn\n\t}\n}\n\nfunc translateFunc(ut ut.Translator, fe validator.FieldError) string {\n\tt, err := ut.T(fe.Tag(), fe.Field())\n\tif err != nil {\n\t\tlog.Logger.Warn(\"警告: 翻译字段错误\", zap.Any(\"Error reason\", fe))\n\t\treturn fe.Error()\n\t}\n\treturn t\n}\n\nfunc normalizeValidatorLocale(locale string) string {\n\tnormalized := strings.ToLower(strings.TrimSpace(locale))\n\tswitch {\n\tcase strings.HasPrefix(normalized, \"en\"):\n\t\treturn \"en\"\n\tdefault:\n\t\treturn \"zh\"\n\t}\n}\n"
  },
  {
    "path": "internal/validator/validator_test.go",
    "content": "package validator\n\nimport (\n\t\"sync\"\n\t\"testing\"\n)\n\nfunc TestInitValidatorTransCanRetryAfterFailure(t *testing.T) {\n\tresetValidatorRuntimeForTest()\n\tt.Cleanup(resetValidatorRuntimeForTest)\n\n\tif err := InitValidatorTrans(\"invalid-locale\"); err != nil {\n\t\tt.Fatalf(\"expected invalid locale to fallback successfully, got %v\", err)\n\t}\n\tif validatorRuntime.translatorLocale != \"zh\" {\n\t\tt.Fatalf(\"expected invalid locale to fallback to zh, got %q\", validatorRuntime.translatorLocale)\n\t}\n\n\tresetValidatorRuntimeForTest()\n\n\tif err := InitValidatorTrans(\"en\"); err != nil {\n\t\tt.Fatalf(\"expected english locale initialization to succeed, got %v\", err)\n\t}\n\tif validatorRuntime.translatorLocale != \"en\" {\n\t\tt.Fatalf(\"expected default translator locale en, got %q\", validatorRuntime.translatorLocale)\n\t}\n}\n\nfunc resetValidatorRuntimeForTest() {\n\tvalidatorRuntime = newValidatorRuntime()\n\tregexCache = sync.Map{}\n}\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"github.com/wannanbigpig/gin-layout/cmd\"\n)\n\nfunc main() {\n\tcmd.Execute()\n}\n"
  },
  {
    "path": "pkg/convert/convert.go",
    "content": "package convert\n\nimport \"time\"\n\nfunc GetString(val interface{}) (s string) {\n\ts, _ = val.(string)\n\treturn\n}\n\n// GetBool returns the value associated with the key as a boolean.\nfunc GetBool(val interface{}) (b bool) {\n\tb, _ = val.(bool)\n\treturn\n}\n\n// GetInt returns the value associated with the key as an integer.\nfunc GetInt(val interface{}) (i int) {\n\ti, _ = val.(int)\n\treturn\n}\n\n// GetInt64 returns the value associated with the key as an integer.\nfunc GetInt64(val interface{}) (i64 int64) {\n\ti64, _ = val.(int64)\n\treturn\n}\n\n// GetUint returns the value associated with the key as an unsigned integer.\nfunc GetUint(val interface{}) (ui uint) {\n\tui, _ = val.(uint)\n\treturn\n}\n\n// GetUint8 returns the value associated with the key as an unsigned integer.\nfunc GetUint8(val interface{}) (ui uint8) {\n\tui, _ = val.(uint8)\n\treturn\n}\n\n// GetUint64 returns the value associated with the key as an unsigned integer.\nfunc GetUint64(val interface{}) (ui64 uint64) {\n\tui64, _ = val.(uint64)\n\treturn\n}\n\n// GetFloat64 returns the value associated with the key as a float64.\nfunc GetFloat64(val interface{}) (f64 float64) {\n\tf64, _ = val.(float64)\n\treturn\n}\n\n// GetTime returns the value associated with the key as time.\nfunc GetTime(val interface{}) (t time.Time) {\n\tt, _ = val.(time.Time)\n\treturn\n}\n\n// GetDuration returns the value associated with the key as a duration.\nfunc GetDuration(val interface{}) (d time.Duration) {\n\td, _ = val.(time.Duration)\n\treturn\n}\n"
  },
  {
    "path": "pkg/utils/captcha/captcha.go",
    "content": "package captcha\n\nimport (\n\t\"context\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/mojocn/base64Captcha\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n)\n\ntype Item struct {\n\tId     string `json:\"id\"`\n\tB64s   string `json:\"b64s\"`\n\tAnswer string `json:\"answer\"`\n}\n\n// 内存存储（当 Redis 不可用时使用）\ntype memoryStore struct {\n\tdata map[string]string\n\tmu   sync.RWMutex\n}\n\nvar memStore = &memoryStore{\n\tdata: make(map[string]string),\n}\n\n// captchaInstance 验证码实例\nvar captchaInstance *base64Captcha.Captcha\nvar captchaOnce sync.Once\nvar captchaBaseContext = context.Background\n\nfunc (m *memoryStore) Set(id, answer string, expiration time.Duration) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tm.data[id] = answer\n\t// 使用 time.AfterFunc 替代 goroutine + sleep，避免 goroutine 泄漏\n\ttime.AfterFunc(expiration, func() {\n\t\tm.mu.Lock()\n\t\tdelete(m.data, id)\n\t\tm.mu.Unlock()\n\t})\n}\n\nfunc (m *memoryStore) Get(id string) (string, bool) {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\tanswer, ok := m.data[id]\n\treturn answer, ok\n}\n\nfunc (m *memoryStore) Delete(id string) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tdelete(m.data, id)\n}\n\nconst (\n\t// captchaRedisKeyPrefix Redis key 前缀\n\tcaptchaRedisKeyPrefix = \"captcha:\"\n\t// captchaExpiration 验证码过期时间（5分钟）\n\tcaptchaExpiration = 5 * time.Minute\n\t// captchaLength 验证码长度\n\tcaptchaLength = 4\n\t// captchaRedisTimeout Redis 操作超时时间\n\tcaptchaRedisTimeout = 2 * time.Second\n\t// captchaCharset 验证码字符集：使用库提供的字符集，避免乱码\n\t// 组合字母和数字，排除容易混淆的字符（如 0/O, 1/l/I）\n\tcaptchaCharset = base64Captcha.TxtAlphabet + base64Captcha.TxtNumbers\n)\n\nfunc withRedisTimeout() (context.Context, context.CancelFunc) {\n\treturn context.WithTimeout(captchaBaseContext(), captchaRedisTimeout)\n}\n\n// initCaptcha 初始化验证码实例\nfunc initCaptcha() {\n\tcaptchaOnce.Do(func() {\n\t\t// 创建字母数字混合验证码驱动\n\t\t// 使用 NewDriverString 支持自定义字符集\n\t\t// 参数：高度80，宽度240，干扰线数量2，显示选项，长度4，字符集\n\t\tdriver := base64Captcha.NewDriverString(\n\t\t\t80,  // 高度\n\t\t\t240, // 宽度\n\t\t\t2,   // 干扰线数量\n\t\t\tbase64Captcha.OptionShowHollowLine|base64Captcha.OptionShowSlimeLine, // 显示选项\n\t\t\tcaptchaLength,                      // 长度\n\t\t\tcaptchaCharset,                     // 字符集（字母数字混合）\n\t\t\tnil,                                // 背景色（nil 使用默认）\n\t\t\tbase64Captcha.DefaultEmbeddedFonts, // 字体存储\n\t\t\tnil,                                // 字体列表（nil 使用默认字体）\n\t\t)\n\n\t\t// 创建内存存储\n\t\tstore := base64Captcha.NewMemoryStore(1000, captchaExpiration)\n\n\t\t// 创建验证码实例\n\t\tcaptchaInstance = base64Captcha.NewCaptcha(driver, store)\n\t})\n}\n\n// setCaptchaAnswer 存储验证码答案\nfunc setCaptchaAnswer(id, answer string) {\n\tcfg := config.GetConfig()\n\tredisClient := data.RedisClient()\n\tif cfg != nil && cfg.Redis.Enable && redisClient != nil {\n\t\t// 使用 Redis 存储\n\t\tctx, cancel := withRedisTimeout()\n\t\tdefer cancel()\n\t\tkey := captchaRedisKeyPrefix + id\n\t\tif err := redisClient.Set(ctx, key, answer, captchaExpiration).Err(); err != nil {\n\t\t\t// 记录日志但不返回错误，验证码仍可通过内存存储工作\n\t\t}\n\t} else {\n\t\t// 使用内存存储\n\t\tmemStore.Set(id, answer, captchaExpiration)\n\t}\n}\n\n// getCaptchaAnswer 获取验证码答案\nfunc getCaptchaAnswer(id string) (string, bool) {\n\tcfg := config.GetConfig()\n\tredisClient := data.RedisClient()\n\tif cfg != nil && cfg.Redis.Enable && redisClient != nil {\n\t\t// 从 Redis 获取\n\t\tctx, cancel := withRedisTimeout()\n\t\tdefer cancel()\n\t\tkey := captchaRedisKeyPrefix + id\n\t\tanswer, err := redisClient.Get(ctx, key).Result()\n\t\tif err != nil {\n\t\t\treturn \"\", false\n\t\t}\n\t\treturn answer, true\n\t}\n\t// 从内存获取\n\treturn memStore.Get(id)\n}\n\n// deleteCaptchaAnswer 删除验证码答案（验证后删除）\nfunc deleteCaptchaAnswer(id string) {\n\tcfg := config.GetConfig()\n\tredisClient := data.RedisClient()\n\tif cfg != nil && cfg.Redis.Enable && redisClient != nil {\n\t\t// 从 Redis 删除\n\t\tctx, cancel := withRedisTimeout()\n\t\tdefer cancel()\n\t\tkey := captchaRedisKeyPrefix + id\n\t\tif err := redisClient.Del(ctx, key).Err(); err != nil {\n\t\t\t// 记录日志但不返回错误，验证码仍可通过内存存储工作\n\t\t}\n\t} else {\n\t\t// 从内存删除\n\t\tmemStore.Delete(id)\n\t}\n}\n\n// Generate 创建验证码\n// 返回验证码 ID、base64 编码的图片和答案（本地环境返回答案，其他环境不返回）\n// 验证码为4位字母数字混合\nfunc Generate() (item *Item, err error) {\n\t// 初始化验证码实例\n\tinitCaptcha()\n\n\t// 生成唯一的验证码 ID（我们使用 UUID）\n\tcaptchaID := uuid.New().String()\n\n\t// 生成验证码（返回内部 ID、base64 编码的图片、答案和可能的错误）\n\tinternalID, b64s, answer, err := captchaInstance.Generate()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 存储验证码答案（使用我们的 UUID 作为 key，存储实际的验证码文本）\n\tsetCaptchaAnswer(captchaID, answer)\n\n\t// 同时存储内部 ID 到 UUID 的映射，以便后续验证时能找到\n\tsetCaptchaAnswer(\"internal:\"+captchaID, internalID)\n\n\t// 添加 data URI 前缀（base64Captcha 已经返回了 base64 字符串）\n\tif len(b64s) > 0 && b64s[:5] != \"data:\" {\n\t\tb64s = \"data:image/png;base64,\" + b64s\n\t}\n\n\t// 获取验证码答案（仅用于本地/测试环境）\n\tvar answerForClient string\n\tcfg := config.GetConfig()\n\tif cfg != nil && (cfg.AppEnv == \"local\" || cfg.AppEnv == \"test\") {\n\t\tanswerForClient = answer\n\t}\n\n\treturn &Item{\n\t\tId:     captchaID,\n\t\tB64s:   b64s,\n\t\tAnswer: answerForClient,\n\t}, nil\n}\n\n// Verify 校验验证码\nfunc Verify(id, value string) bool {\n\t// 初始化验证码实例\n\tinitCaptcha()\n\n\t// 获取存储的内部验证码 ID\n\tinternalID, ok := getCaptchaAnswer(\"internal:\" + id)\n\tif !ok {\n\t\t// 如果找不到内部 ID，尝试从存储中获取答案进行直接验证\n\t\tanswer, ok := getCaptchaAnswer(id)\n\t\tif !ok {\n\t\t\treturn false\n\t\t}\n\t\t// 比较验证码（不区分大小写）\n\t\tif !equalIgnoreCase(answer, value) {\n\t\t\treturn false\n\t\t}\n\t\t// 验证成功后删除\n\t\tdeleteCaptchaAnswer(id)\n\t\treturn true\n\t}\n\n\t// 使用 base64Captcha 的验证方法\n\t// 第三个参数 true 表示验证后删除\n\tif captchaInstance.Verify(internalID, value, true) {\n\t\t// 验证成功后删除我们的存储\n\t\tdeleteCaptchaAnswer(id)\n\t\tdeleteCaptchaAnswer(\"internal:\" + id)\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// equalIgnoreCase 不区分大小写比较字符串\nfunc equalIgnoreCase(s1, s2 string) bool {\n\tif len(s1) != len(s2) {\n\t\treturn false\n\t}\n\tfor i := 0; i < len(s1); i++ {\n\t\tc1 := s1[i]\n\t\tc2 := s2[i]\n\t\tif c1 >= 'A' && c1 <= 'Z' {\n\t\t\tc1 += 32 // 转小写\n\t\t}\n\t\tif c2 >= 'A' && c2 <= 'Z' {\n\t\t\tc2 += 32 // 转小写\n\t\t}\n\t\tif c1 != c2 {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "pkg/utils/crypto/README.md",
    "content": "# 加密工具使用说明\n\n本包提供 AES-256-GCM 加密算法，用于字符串的加密和解密。支持通过参数选择加密算法。\n\n## 快速开始\n\n```go\nimport \"github.com/wannanbigpig/gin-layout/pkg/utils/crypto\"\n\n// 使用默认算法加密（推荐，不传算法参数）\nencrypted, err := crypto.Encrypt(\"your-secret-key\", \"plaintext\")\nif err != nil {\n    // 处理错误\n}\n\n// 使用默认算法解密\ndecrypted, err := crypto.Decrypt(\"your-secret-key\", encrypted)\nif err != nil {\n    // 处理错误\n}\n\n// 使用指定算法加密（可选）\nencrypted, err := crypto.Encrypt(\"your-secret-key\", \"plaintext\", crypto.AlgorithmAES256GCM)\n\n// 使用指定算法解密（可选）\ndecrypted, err := crypto.Decrypt(\"your-secret-key\", encrypted, crypto.AlgorithmAES256GCM)\n```\n\n## 算法说明\n\n### AES-256-GCM\n\n**特点：**\n- 使用 AES-256（高级加密标准，256 位密钥）\n- GCM 模式（Galois/Counter Mode），提供认证加密（AEAD）\n- 国际标准，广泛使用\n- 性能优秀，硬件加速支持好\n- 兼容性好，所有平台支持\n\n**密钥处理：**\n- 输入密钥为字符串，通过 SHA256 哈希派生为 32 字节密钥（AES-256 需要 32 字节）\n- 每次加密使用随机 nonce（12 字节），确保相同明文产生不同密文\n\n**密文格式：**\n- 密文格式：`nonce + encrypted_data`\n- 最终以 base64 编码返回\n\n## 支持的加密算法\n\n### AlgorithmAES256GCM\n\nAES-256-GCM 加密算法（默认算法）\n\n```go\ncrypto.AlgorithmAES256GCM\n```\n\n## API 文档\n\n### Encrypt\n\n```go\nfunc Encrypt(key, plaintext string, algorithm ...Algorithm) (string, error)\n```\n\n**参数：**\n- `key`: 加密密钥（字符串）\n- `plaintext`: 待加密的明文\n- `algorithm`: 加密算法（可选参数，可变参数，不传则使用默认算法 `AlgorithmAES256GCM`）\n\n**返回：**\n- `string`: base64 编码的密文\n- `error`: 错误信息（如果算法不支持、密钥为空或加密失败）\n\n**示例：**\n```go\n// 使用默认算法（推荐）\nencrypted, err := crypto.Encrypt(\"key\", \"plaintext\")\n\n// 使用指定算法\nencrypted, err := crypto.Encrypt(\"key\", \"plaintext\", crypto.AlgorithmAES256GCM)\n```\n\n### Decrypt\n\n```go\nfunc Decrypt(key, ciphertext string, algorithm ...Algorithm) (string, error)\n```\n\n**参数：**\n- `key`: 解密密钥（字符串，必须与加密时使用的密钥相同）\n- `ciphertext`: base64 编码的密文\n- `algorithm`: 解密算法（可选参数，可变参数，不传则使用默认算法 `AlgorithmAES256GCM`）\n\n**返回：**\n- `string`: 解密后的明文\n- `error`: 错误信息（如果算法不支持、密钥为空、密文格式错误或解密失败）\n\n**示例：**\n```go\n// 使用默认算法（推荐）\ndecrypted, err := crypto.Decrypt(\"key\", encrypted)\n\n// 使用指定算法\ndecrypted, err := crypto.Decrypt(\"key\", encrypted, crypto.AlgorithmAES256GCM)\n```\n\n## 使用示例\n\n### 示例 1：使用默认算法（推荐）\n\n```go\npackage main\n\nimport (\n    \"fmt\"\n    \"github.com/wannanbigpig/gin-layout/pkg/utils/crypto\"\n)\n\nfunc main() {\n    key := \"my-secret-key-12345\"\n    plaintext := \"Hello, World!\"\n\n    // 使用默认算法加密（不传算法参数）\n    encrypted, err := crypto.Encrypt(key, plaintext)\n    if err != nil {\n        fmt.Printf(\"加密失败: %v\\n\", err)\n        return\n    }\n    fmt.Printf(\"密文: %s\\n\", encrypted)\n\n    // 使用默认算法解密（不传算法参数）\n    decrypted, err := crypto.Decrypt(key, encrypted)\n    if err != nil {\n        fmt.Printf(\"解密失败: %v\\n\", err)\n        return\n    }\n    fmt.Printf(\"明文: %s\\n\", decrypted)\n}\n```\n\n### 示例 2：使用指定算法\n\n```go\npackage main\n\nimport (\n    \"fmt\"\n    \"github.com/wannanbigpig/gin-layout/pkg/utils/crypto\"\n)\n\nfunc main() {\n    key := \"my-secret-key-12345\"\n    plaintext := \"Hello, World!\"\n\n    // 使用指定算法加密\n    encrypted, err := crypto.Encrypt(key, plaintext, crypto.AlgorithmAES256GCM)\n    if err != nil {\n        fmt.Printf(\"加密失败: %v\\n\", err)\n        return\n    }\n    fmt.Printf(\"密文: %s\\n\", encrypted)\n\n    // 使用指定算法解密\n    decrypted, err := crypto.Decrypt(key, encrypted, crypto.AlgorithmAES256GCM)\n    if err != nil {\n        fmt.Printf(\"解密失败: %v\\n\", err)\n        return\n    }\n    fmt.Printf(\"明文: %s\\n\", decrypted)\n}\n```\n\n## 注意事项\n\n1. **密钥管理**：请妥善保管加密密钥，建议使用环境变量或密钥管理服务\n2. **密钥长度**：密钥通过 SHA256 派生为 32 字节，建议使用足够长的密钥字符串\n3. **安全性**：每次加密使用随机 nonce，相同明文会产生不同密文，提高安全性\n4. **错误处理**：请务必检查返回的错误，确保加密/解密操作成功\n5. **空值处理**：空字符串会直接返回空字符串，不会进行加密操作\n\n## 性能\n\n- **加密速度**：快（有硬件加速支持）\n- **解密速度**：快（有硬件加速支持）\n- **CPU 占用**：低\n- **内存占用**：低\n\n## 适用场景\n\n- 敏感数据加密存储（如 token、密码等）\n- 配置文件加密\n- 数据库字段加密\n- 日志敏感信息加密\n"
  },
  {
    "path": "pkg/utils/crypto/crypto.go",
    "content": "package crypto\n\nimport \"errors\"\n\n// Encrypt 使用指定算法加密字符串（默认使用 AES-256-GCM）\n// key: 加密密钥（字符串，会通过 SHA256 派生为 32 字节密钥）\n// plaintext: 待加密的明文\n// algorithm: 加密算法（可选参数，不传则使用默认算法 AlgorithmAES256GCM）\n// 返回: base64 编码的密文\nfunc Encrypt(key, plaintext string, algorithm ...Algorithm) (string, error) {\n\t// 确定使用的算法\n\tvar algo Algorithm\n\tif len(algorithm) > 0 && algorithm[0] != \"\" {\n\t\talgo = algorithm[0]\n\t} else {\n\t\talgo = AlgorithmAES256GCM\n\t}\n\n\t// 验证算法有效性\n\tif !algo.IsValid() {\n\t\treturn \"\", errors.New(\"不支持的加密算法: \" + algo.String())\n\t}\n\n\t// 根据算法选择加密方法\n\tswitch algo {\n\tcase AlgorithmAES256GCM:\n\t\treturn AESEncrypt(key, plaintext)\n\tdefault:\n\t\treturn \"\", errors.New(\"不支持的加密算法: \" + algo.String())\n\t}\n}\n\n// Decrypt 使用指定算法解密字符串（默认使用 AES-256-GCM）\n// key: 解密密钥（字符串，会通过 SHA256 派生为 32 字节密钥）\n// ciphertext: base64 编码的密文\n// algorithm: 解密算法（可选参数，不传则使用默认算法 AlgorithmAES256GCM）\n// 返回: 解密后的明文\nfunc Decrypt(key, ciphertext string, algorithm ...Algorithm) (string, error) {\n\t// 确定使用的算法\n\tvar algo Algorithm\n\tif len(algorithm) > 0 && algorithm[0] != \"\" {\n\t\talgo = algorithm[0]\n\t} else {\n\t\talgo = AlgorithmAES256GCM\n\t}\n\n\t// 验证算法有效性\n\tif !algo.IsValid() {\n\t\treturn \"\", errors.New(\"不支持的解密算法: \" + algo.String())\n\t}\n\n\t// 根据算法选择解密方法\n\tswitch algo {\n\tcase AlgorithmAES256GCM:\n\t\treturn AESDecrypt(key, ciphertext)\n\tdefault:\n\t\treturn \"\", errors.New(\"不支持的解密算法: \" + algo.String())\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/crypto/crypto_aes.go",
    "content": "package crypto\n\nimport (\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"io\"\n)\n\n// AESEncrypt 使用 AES-256-GCM 加密字符串\n// key: 加密密钥（字符串，会通过 SHA256 派生为 32 字节密钥）\n// plaintext: 待加密的明文\n// 返回: base64 编码的密文\nfunc AESEncrypt(key, plaintext string) (string, error) {\n\tif plaintext == \"\" {\n\t\treturn \"\", nil\n\t}\n\n\tif key == \"\" {\n\t\treturn \"\", errors.New(\"加密密钥不能为空\")\n\t}\n\n\t// 从字符串密钥派生 32 字节密钥（AES-256 需要 32 字节）\n\tderivedKey := deriveKey256(key)\n\n\t// 创建 AES cipher\n\tblock, err := aes.NewCipher(derivedKey)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 创建 GCM\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 生成随机 nonce（12 字节，GCM 推荐大小）\n\tnonce := make([]byte, gcm.NonceSize())\n\tif _, err := io.ReadFull(rand.Reader, nonce); err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 加密\n\tciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)\n\n\t// 返回 base64 编码的密文\n\treturn base64.StdEncoding.EncodeToString(ciphertext), nil\n}\n\n// AESDecrypt 使用 AES-256-GCM 解密字符串\n// key: 解密密钥（字符串，会通过 SHA256 派生为 32 字节密钥）\n// ciphertext: base64 编码的密文\n// 返回: 解密后的明文\nfunc AESDecrypt(key, ciphertext string) (string, error) {\n\tif ciphertext == \"\" {\n\t\treturn \"\", nil\n\t}\n\n\tif key == \"\" {\n\t\treturn \"\", errors.New(\"解密密钥不能为空\")\n\t}\n\n\t// 从字符串密钥派生 32 字节密钥\n\tderivedKey := deriveKey256(key)\n\n\t// 解码 base64\n\tciphertextBytes, err := base64.StdEncoding.DecodeString(ciphertext)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 创建 AES cipher\n\tblock, err := aes.NewCipher(derivedKey)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 创建 GCM\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// 检查密文长度\n\tnonceSize := gcm.NonceSize()\n\tif len(ciphertextBytes) < nonceSize {\n\t\treturn \"\", errors.New(\"密文长度不足\")\n\t}\n\n\t// 提取 nonce 和密文\n\tnonce, ciphertextBytes := ciphertextBytes[:nonceSize], ciphertextBytes[nonceSize:]\n\n\t// 解密\n\tplaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn string(plaintext), nil\n}\n\n// deriveKey256 从字符串密钥派生 32 字节密钥（用于 AES-256）\nfunc deriveKey256(key string) []byte {\n\thash := sha256.Sum256([]byte(key))\n\treturn hash[:]\n}\n"
  },
  {
    "path": "pkg/utils/crypto/types.go",
    "content": "package crypto\n\n// Algorithm 加密算法类型\ntype Algorithm string\n\nconst (\n\t// AlgorithmAES256GCM AES-256-GCM 加密算法（默认）\n\tAlgorithmAES256GCM Algorithm = \"aes-256-gcm\"\n)\n\n// String 返回算法名称\nfunc (a Algorithm) String() string {\n\treturn string(a)\n}\n\n// IsValid 检查算法是否有效\nfunc (a Algorithm) IsValid() bool {\n\tswitch a {\n\tcase AlgorithmAES256GCM:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n"
  },
  {
    "path": "pkg/utils/helpers.go",
    "content": "package utils\n\nimport (\n\t\"strings\"\n\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\nconst passwordHashCost = 12\n\n// MaskSensitiveInfo 对于字符串脱敏\n// s 需要脱敏的字符串\n// start 从第几位开始脱敏\n// maskNumber 需要脱敏长度\n// maskChars 掩饰字符串，替代需要脱敏处理的字符串\nfunc MaskSensitiveInfo(s string, start int, maskNumber int, maskChars ...string) string {\n\t// 将字符串s的[start, end)区间用maskChar替换，并返回替换后的结果。\n\tmaskChar := \"*\"\n\tif len(maskChars) > 0 && maskChars[0] != \"\" {\n\t\tmaskChar = maskChars[0]\n\t}\n\tif maskNumber <= 0 || len(s) == 0 {\n\t\treturn s\n\t}\n\t// 处理起始位置超出边界的情况\n\tif start < 0 {\n\t\tstart = 0\n\t}\n\tif start > len(s) {\n\t\tstart = len(s)\n\t}\n\t// 处理结束位置超出边界的情况\n\tend := start + maskNumber\n\tif end > len(s) {\n\t\tend = len(s)\n\t}\n\treturn s[:start] + strings.Repeat(maskChar, end-start) + s[end:]\n}\n\n// PasswordHash 密码hash并自动加盐\nfunc PasswordHash(pwd string) (string, error) {\n\thash, err := bcrypt.GenerateFromPassword([]byte(pwd), passwordHashCost)\n\treturn string(hash), err\n}\n\n// ComparePasswords 比对用户密码是否正确\nfunc ComparePasswords(oldPassword, password string) bool {\n\tif err := bcrypt.CompareHashAndPassword([]byte(oldPassword), []byte(password)); err != nil {\n\t\treturn false\n\t}\n\treturn true\n}\n\n// SliceToAny 将切片转换为any类型切片\nfunc SliceToAny[T any](data []T) []any {\n\tanyData := make([]any, len(data))\n\tfor i, v := range data {\n\t\tanyData[i] = v\n\t}\n\treturn anyData\n}\n"
  },
  {
    "path": "pkg/utils/helpers_test.go",
    "content": "package utils\n\nimport (\n\t\"testing\"\n\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\nfunc TestMaskSensitiveInfo(t *testing.T) {\n\tmobile := \"13200000000\"\n\tm := MaskSensitiveInfo(mobile, 3, 4)\n\tif m != \"132****0000\" {\n\t\tt.Error(\"手机号脱敏失败\")\n\t}\n\tm1 := MaskSensitiveInfo(mobile, -1, 15)\n\tif m1 != \"***********\" {\n\t\tt.Error(\"手机号脱敏失败\")\n\t}\n\tidNumber := \"110101199001010010\"\n\tid := MaskSensitiveInfo(idNumber, 6, 8)\n\tif id != \"110101********0010\" {\n\t\tt.Error(\"身份证脱敏失败\")\n\t}\n\n\t// 空可选参数切片不应触发 panic\n\temptyMaskChars := []string{}\n\tnoPanicMasked := MaskSensitiveInfo(mobile, 3, 4, emptyMaskChars...)\n\tif noPanicMasked != \"132****0000\" {\n\t\tt.Error(\"空掩码参数脱敏失败\")\n\t}\n\n\t// start 超出长度时应直接返回原值\n\toutOfRange := MaskSensitiveInfo(mobile, len(mobile)+3, 2)\n\tif outOfRange != mobile {\n\t\tt.Error(\"start 越界处理失败\")\n\t}\n\n\t// maskNumber 非正时应直接返回原值\n\tunchanged := MaskSensitiveInfo(mobile, 3, 0)\n\tif unchanged != mobile {\n\t\tt.Error(\"maskNumber 非正处理失败\")\n\t}\n}\n\nfunc TestPasswordHashUsesStrongerCost(t *testing.T) {\n\thashed, err := PasswordHash(\"hello-password\")\n\tif err != nil {\n\t\tt.Fatalf(\"password hash failed: %v\", err)\n\t}\n\n\tcost, err := bcrypt.Cost([]byte(hashed))\n\tif err != nil {\n\t\tt.Fatalf(\"read bcrypt cost failed: %v\", err)\n\t}\n\tif cost < 12 {\n\t\tt.Fatalf(\"expected bcrypt cost >= 12, got %d\", cost)\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/http.go",
    "content": "package utils\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n)\n\n// HttpRequest 封装带状态的 HTTP 请求客户端。\ntype HttpRequest struct {\n\thttp.Client\n\tResponse *http.Response\n\tError    error\n}\n\n// JsonRequest 发送默认 Content-Type 为 application/json 的请求。\nfunc (hr *HttpRequest) JsonRequest(method string, url string, body io.Reader, args ...any) *HttpRequest {\n\tvar options map[string]string\n\tif args != nil {\n\t\tvar ok bool\n\t\tif options, ok = args[0].(map[string]string); ok {\n\t\t\toptions[\"Content-Type\"] = \"application/json\"\n\t\t}\n\t} else {\n\t\toptions = map[string]string{\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t}\n\t}\n\treturn hr.Request(method, url, body, options)\n}\n\n// GetRequest 发送 GET 请求并拼接查询参数。\nfunc (hr *HttpRequest) GetRequest(url string, params *url.Values, args ...any) *HttpRequest {\n\tr := url\n\tif params != nil {\n\t\tr = url + \"?\" + params.Encode()\n\t}\n\n\treturn hr.Request(\"GET\", r, nil, args...)\n}\n\n// Request 构造并发送 HTTP 请求。\nfunc (hr *HttpRequest) Request(method string, url string, body io.Reader, args ...any) *HttpRequest {\n\treq, err := http.NewRequest(method, url, body)\n\tif err != nil {\n\t\thr.Error = err\n\t}\n\n\tif args != nil {\n\t\tif options, ok := args[0].(map[string]string); ok {\n\t\t\tfor k, v := range options {\n\t\t\t\treq.Header.Set(k, v)\n\t\t\t}\n\t\t}\n\t}\n\n\thr.Response, hr.Error = hr.Do(req)\n\n\treturn hr\n}\n\n// ParseJson 将响应体解析为目标 JSON 结构。\nfunc (hr *HttpRequest) ParseJson(payload any) error {\n\tbytes, err := hr.ParseBytes()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(bytes, &payload)\n}\n\n// ParseBytes 读取并返回原始响应体字节。\nfunc (hr *HttpRequest) ParseBytes() (body []byte, err error) {\n\tif hr.Error != nil {\n\t\treturn nil, hr.Error\n\t}\n\tif hr.Response == nil || hr.Response.Body == nil {\n\t\treturn nil, errors.New(\"http response body is nil\")\n\t}\n\n\tdefer func() {\n\t\tif closeErr := hr.Response.Body.Close(); closeErr != nil && err == nil {\n\t\t\terr = closeErr\n\t\t}\n\t}()\n\n\tbody, err = io.ReadAll(hr.Response.Body)\n\treturn body, err\n}\n\n// Raw 以字符串形式返回原始响应体。\nfunc (hr *HttpRequest) Raw() (string, error) {\n\tstr, err := hr.ParseBytes()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(str), nil\n}\n"
  },
  {
    "path": "pkg/utils/http_test.go",
    "content": "package utils\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"testing\"\n)\n\ntype roundTripFunc func(*http.Request) (*http.Response, error)\n\nfunc (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {\n\treturn f(req)\n}\n\nfunc TestGetRequest(t *testing.T) {\n\tclient := HttpRequest{}\n\tclient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {\n\t\tif req.URL.Query().Get(\"name\") != \"world\" {\n\t\t\tt.Fatalf(\"unexpected query: %s\", req.URL.RawQuery)\n\t\t}\n\t\treturn &http.Response{\n\t\t\tStatusCode: http.StatusOK,\n\t\t\tBody:       io.NopCloser(strings.NewReader(`{\"hello\":\"world\"}`)),\n\t\t\tHeader:     make(http.Header),\n\t\t}, nil\n\t})\n\n\tparams := &url.Values{}\n\tparams.Set(\"name\", \"world\")\n\tresp := client.GetRequest(\"http://example.com\", params)\n\tif resp.Error != nil {\n\t\tt.Fatalf(\"request failed: %v\", resp.Error)\n\t}\n\n\tvar payload map[string]string\n\tif err := resp.ParseJson(&payload); err != nil {\n\t\tt.Fatalf(\"parse failed: %v\", err)\n\t}\n\tif payload[\"hello\"] != \"world\" {\n\t\tt.Fatalf(\"unexpected payload: %#v\", payload)\n\t}\n}\n\nfunc TestJsonRequestSetsContentType(t *testing.T) {\n\tclient := HttpRequest{}\n\tclient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {\n\t\tif got := req.Header.Get(\"Content-Type\"); got != \"application/json\" {\n\t\t\tt.Fatalf(\"unexpected content-type: %s\", got)\n\t\t}\n\t\treturn &http.Response{\n\t\t\tStatusCode: http.StatusOK,\n\t\t\tBody:       io.NopCloser(strings.NewReader(\"ok\")),\n\t\t\tHeader:     make(http.Header),\n\t\t}, nil\n\t})\n\n\toptions := map[string]string{}\n\tresp := client.JsonRequest(http.MethodPost, \"http://example.com\", strings.NewReader(`{\"x\":1}`), options)\n\tif resp.Error != nil {\n\t\tt.Fatalf(\"request failed: %v\", resp.Error)\n\t}\n\n\traw, err := resp.Raw()\n\tif err != nil {\n\t\tt.Fatalf(\"raw failed: %v\", err)\n\t}\n\tif raw != \"ok\" {\n\t\tt.Fatalf(\"unexpected raw: %s\", raw)\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/upload.go",
    "content": "package utils\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/h2non/filetype\"\n\t\"github.com/h2non/filetype/types\"\n)\n\n// FileInfo 描述上传文件的存储结果和对外展示字段。\ntype FileInfo struct {\n\tFileID        uint   `json:\"-\"`              // 文件ID（数据库ID，不返回给前端）\n\tSha256        string `json:\"sha256\"`         // 文件SHA256哈希值（用于去重）\n\tUUID          string `json:\"uuid\"`           // 文件UUID（用于URL访问，32位十六进制字符串）\n\tName          string `json:\"name\"`           // 存储的文件名（UUID+扩展名）\n\tOriginName    string `json:\"origin_name\"`    // 原始文件名\n\tSize          int64  `json:\"size\"`           // 文件大小（字节）\n\tPath          string `json:\"path\"`           // 文件路径\n\tExt           string `json:\"ext\"`            // 文件扩展名\n\tMimeType      string `json:\"mime_type\"`      // MIME类型\n\tURL           string `json:\"url\"`            // 文件访问完整URL\n\tFailureReason string `json:\"failure_reason\"` // 失败原因\n\tStatus        string `json:\"status\"`         // 上传状态：SUCCESS、ERROR\n}\n\nconst fileHeaderSampleSize = 261\nconst uploadBufferSize = 32 * 1024\n\n// ErrInvalidImageType 表示上传文件不是允许的图片类型。\nvar ErrInvalidImageType = errors.New(\"uploaded file is not an allowed image\")\n\n// UploadFile 接收上传文件并保存到目标目录。\nfunc UploadFile(fileHeader *multipart.FileHeader, path ...string) (fileInfo *FileInfo, err error) {\n\tuploadSubDir := \"default\"\n\tif len(path) > 0 && path[0] != \"\" {\n\t\tuploadSubDir = path[0]\n\t}\n\n\tabsolutePath, err := resolveUploadDir(uploadSubDir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn SaveUploadedFileWithUUID(fileHeader, absolutePath)\n}\n\n// SaveUploadedFileWithUUID 保存文件、计算摘要并生成 UUID 文件名。\nfunc SaveUploadedFileWithUUID(fileHeader *multipart.FileHeader, uploadDir string) (*FileInfo, error) {\n\treturn saveUploadedFile(fileHeader, uploadDir, false)\n}\n\n// SaveUploadedImageWithUUID 保存图片文件，拒绝非允许图片类型。\nfunc SaveUploadedImageWithUUID(fileHeader *multipart.FileHeader, uploadDir string) (*FileInfo, error) {\n\treturn saveUploadedFile(fileHeader, uploadDir, true)\n}\n\n// EnsureAbsPath 将相对路径转换为绝对路径。\nfunc EnsureAbsPath(path string, baseDir ...string) (string, error) {\n\tif filepath.IsAbs(path) {\n\t\treturn path, nil\n\t}\n\n\tvar base string\n\tif len(baseDir) > 0 && baseDir[0] != \"\" {\n\t\tbase = baseDir[0]\n\t} else {\n\t\t// 默认使用二进制文件所在目录\n\t\tvar err error\n\t\tbase, err = GetCurrentAbPathByExecutable()\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"获取执行文件目录失败: %w\", err)\n\t\t}\n\t}\n\treturn filepath.Join(base, path), nil\n}\n\n// GetFileSha256AndSizeFromHeader 计算文件的 SHA-256 和大小。\nfunc GetFileSha256AndSizeFromHeader(file io.ReadSeeker) (string, int64, error) {\n\tif _, err := file.Seek(0, io.SeekStart); err != nil {\n\t\treturn \"\", 0, fmt.Errorf(\"指针重置失败: %w\", err)\n\t}\n\n\thash := sha256.New()\n\tsize, err := io.Copy(hash, file)\n\tif err != nil {\n\t\treturn \"\", 0, fmt.Errorf(\"计算SHA-256失败: %w\", err)\n\t}\n\n\tif _, err := file.Seek(0, io.SeekStart); err != nil {\n\t\treturn \"\", 0, fmt.Errorf(\"指针重置失败: %w\", err)\n\t}\n\n\treturn hex.EncodeToString(hash.Sum(nil)), size, nil\n}\n\n// IsAllowedImage 判断文件头是否匹配允许的图片类型。\nfunc IsAllowedImage(file io.ReadSeeker) (string, bool, error) {\n\thead := make([]byte, fileHeaderSampleSize)\n\tn, err := file.Read(head)\n\tif err != nil && !errors.Is(err, io.EOF) {\n\t\treturn \"\", false, fmt.Errorf(\"读取文件头失败: %w\", err)\n\t}\n\tif _, err := file.Seek(0, io.SeekStart); err != nil {\n\t\treturn \"\", false, fmt.Errorf(\"重置文件指针失败: %w\", err)\n\t}\n\tif n == 0 {\n\t\treturn \"\", false, nil\n\t}\n\n\text, allowed, _, err := detectAllowedImage(head[:n])\n\tif err != nil {\n\t\treturn \"\", false, fmt.Errorf(\"检测文件类型失败: %w\", err)\n\t}\n\treturn ext, allowed, nil\n}\n\n// getMimeTypeByExt 根据扩展名获取MIME类型\nfunc getMimeTypeByExt(ext string) string {\n\textMap := map[string]string{\n\t\t\".jpg\":  \"image/jpeg\",\n\t\t\".jpeg\": \"image/jpeg\",\n\t\t\".png\":  \"image/png\",\n\t\t\".gif\":  \"image/gif\",\n\t\t\".webp\": \"image/webp\",\n\t\t\".pdf\":  \"application/pdf\",\n\t\t\".doc\":  \"application/msword\",\n\t\t\".docx\": \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n\t\t\".xls\":  \"application/vnd.ms-excel\",\n\t\t\".xlsx\": \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n\t\t\".zip\":  \"application/zip\",\n\t\t\".txt\":  \"text/plain\",\n\t}\n\tif mime, ok := extMap[strings.ToLower(ext)]; ok {\n\t\treturn mime\n\t}\n\treturn \"application/octet-stream\"\n}\n\nfunc resolveUploadDir(uploadSubDir string) (string, error) {\n\tif filepath.IsAbs(uploadSubDir) {\n\t\tif err := ensureUploadDir(uploadSubDir); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\treturn uploadSubDir, nil\n\t}\n\n\tbaseDir, err := GetCurrentAbPathByExecutable()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"获取执行文件目录失败: %w\", err)\n\t}\n\tabsolutePath := filepath.Join(baseDir, \"storage\", uploadSubDir)\n\tif err := ensureUploadDir(absolutePath); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn absolutePath, nil\n}\n\nfunc ensureUploadDir(uploadDir string) error {\n\tif err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {\n\t\treturn fmt.Errorf(\"创建上传目录失败: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc saveUploadedFile(fileHeader *multipart.FileHeader, uploadDir string, imagesOnly bool) (*FileInfo, error) {\n\tif err := ensureUploadDir(uploadDir); err != nil {\n\t\treturn nil, err\n\t}\n\n\tsrc, err := fileHeader.Open()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"打开上传文件失败: %w\", err)\n\t}\n\tdefer src.Close()\n\n\ttempFile, err := os.CreateTemp(uploadDir, \"upload-*\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建临时文件失败: %w\", err)\n\t}\n\ttempPath := tempFile.Name()\n\tkeepTemp := false\n\tdefer func() {\n\t\t_ = tempFile.Close()\n\t\tif !keepTemp {\n\t\t\t_ = os.Remove(tempPath)\n\t\t}\n\t}()\n\n\theaderBytes, size, sum, err := copyUploadedContent(tempFile, src)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdetectedExt, mimeType, err := detectMimeType(headerBytes)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"检测文件类型失败: %w\", err)\n\t}\n\tif imagesOnly {\n\t\tif _, allowed, detectedMime, detectErr := detectAllowedImage(headerBytes); detectErr != nil {\n\t\t\treturn nil, fmt.Errorf(\"检测文件类型失败: %w\", detectErr)\n\t\t} else if !allowed {\n\t\t\treturn nil, ErrInvalidImageType\n\t\t} else if detectedMime != \"\" {\n\t\t\tmimeType = detectedMime\n\t\t}\n\t}\n\n\text := filepath.Ext(fileHeader.Filename)\n\tif detectedExt != \"\" {\n\t\text = \".\" + detectedExt\n\t}\n\tif mimeType == \"\" {\n\t\tmimeType = getMimeTypeByExt(ext)\n\t}\n\n\tfileUUID := uuid.New()\n\tfileUUIDStr := strings.ReplaceAll(fileUUID.String(), \"-\", \"\")\n\tnewFilename := fileUUID.String() + ext\n\tsavePath := filepath.Join(uploadDir, newFilename)\n\n\tif err := tempFile.Chmod(0644); err != nil {\n\t\treturn nil, fmt.Errorf(\"设置文件权限失败: %w\", err)\n\t}\n\tif err := tempFile.Close(); err != nil {\n\t\treturn nil, fmt.Errorf(\"关闭临时文件失败: %w\", err)\n\t}\n\tif err := os.Rename(tempPath, savePath); err != nil {\n\t\treturn nil, fmt.Errorf(\"重命名文件失败: %w\", err)\n\t}\n\tkeepTemp = true\n\n\treturn &FileInfo{\n\t\tOriginName: fileHeader.Filename,\n\t\tName:       newFilename,\n\t\tPath:       savePath,\n\t\tSize:       size,\n\t\tSha256:     sum,\n\t\tUUID:       fileUUIDStr,\n\t\tExt:        ext,\n\t\tMimeType:   mimeType,\n\t\tStatus:     \"SUCCESS\",\n\t}, nil\n}\n\nfunc copyUploadedContent(dst io.Writer, src io.Reader) ([]byte, int64, string, error) {\n\thash := sha256.New()\n\twriter := io.MultiWriter(dst, hash)\n\theader := make([]byte, 0, fileHeaderSampleSize)\n\tbuffer := make([]byte, uploadBufferSize)\n\tvar total int64\n\n\tfor {\n\t\tn, readErr := src.Read(buffer)\n\t\tif n > 0 {\n\t\t\tchunk := buffer[:n]\n\t\t\theader = appendHeaderSample(header, chunk)\n\t\t\twritten, err := writeUploadChunk(writer, chunk)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, 0, \"\", err\n\t\t\t}\n\t\t\ttotal += int64(written)\n\t\t}\n\n\t\tdone, err := shouldStopUploadRead(readErr)\n\t\tif !done {\n\t\t\tcontinue\n\t\t}\n\t\tif err == nil {\n\t\t\treturn header, total, hex.EncodeToString(hash.Sum(nil)), nil\n\t\t}\n\t\treturn nil, 0, \"\", fmt.Errorf(\"读取文件失败: %w\", err)\n\t}\n}\n\nfunc appendHeaderSample(header []byte, chunk []byte) []byte {\n\tif len(header) >= fileHeaderSampleSize {\n\t\treturn header\n\t}\n\tremaining := fileHeaderSampleSize - len(header)\n\tif remaining > len(chunk) {\n\t\tremaining = len(chunk)\n\t}\n\treturn append(header, chunk[:remaining]...)\n}\n\nfunc writeUploadChunk(dst io.Writer, chunk []byte) (int, error) {\n\twritten, err := dst.Write(chunk)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"写入临时文件失败: %w\", err)\n\t}\n\treturn written, nil\n}\n\nfunc shouldStopUploadRead(err error) (bool, error) {\n\tif err == nil {\n\t\treturn false, nil\n\t}\n\tif errors.Is(err, io.EOF) {\n\t\treturn true, nil\n\t}\n\treturn true, err\n}\n\nfunc detectMimeType(header []byte) (string, string, error) {\n\tif len(header) == 0 {\n\t\treturn \"\", \"\", nil\n\t}\n\tkind, err := filetype.Match(header)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\tif kind == filetype.Unknown {\n\t\treturn \"\", \"\", nil\n\t}\n\treturn kind.Extension, kind.MIME.Value, nil\n}\n\nfunc detectAllowedImage(header []byte) (string, bool, string, error) {\n\tif len(header) == 0 {\n\t\treturn \"\", false, \"\", nil\n\t}\n\n\tkind, err := filetype.Match(header)\n\tif err != nil {\n\t\treturn \"\", false, \"\", err\n\t}\n\tif kind == filetype.Unknown {\n\t\treturn \"\", false, \"\", nil\n\t}\n\n\tallowed := map[string]types.Type{\n\t\t\"jpg\":  filetype.GetType(\"jpg\"),\n\t\t\"jpeg\": filetype.GetType(\"jpeg\"),\n\t\t\"png\":  filetype.GetType(\"png\"),\n\t\t\"gif\":  filetype.GetType(\"gif\"),\n\t}\n\n\tfor _, imageType := range allowed {\n\t\tif kind.MIME.Value == imageType.MIME.Value {\n\t\t\treturn kind.Extension, true, kind.MIME.Value, nil\n\t\t}\n\t}\n\treturn kind.Extension, false, kind.MIME.Value, nil\n}\n"
  },
  {
    "path": "pkg/utils/upload_test.go",
    "content": "package utils\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"io\"\n\t\"testing\"\n)\n\nfunc TestIsAllowedImageHandlesShortNonImageFile(t *testing.T) {\n\tfile := bytes.NewReader([]byte(\"x\"))\n\n\text, allowed, err := IsAllowedImage(file)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif allowed {\n\t\tt.Fatalf(\"expected non-image short file to be rejected\")\n\t}\n\tif ext != \"\" {\n\t\tt.Fatalf(\"expected empty extension, got %q\", ext)\n\t}\n}\n\nfunc TestAppendHeaderSampleRespectsLimit(t *testing.T) {\n\theader := make([]byte, 0, fileHeaderSampleSize)\n\tchunk := bytes.Repeat([]byte(\"a\"), fileHeaderSampleSize+10)\n\n\theader = appendHeaderSample(header, chunk)\n\tif len(header) != fileHeaderSampleSize {\n\t\tt.Fatalf(\"expected header size %d, got %d\", fileHeaderSampleSize, len(header))\n\t}\n\n\theader = appendHeaderSample(header, []byte(\"b\"))\n\tif len(header) != fileHeaderSampleSize {\n\t\tt.Fatalf(\"expected header size to stay %d, got %d\", fileHeaderSampleSize, len(header))\n\t}\n}\n\nfunc TestShouldStopUploadRead(t *testing.T) {\n\tdone, err := shouldStopUploadRead(nil)\n\tif done || err != nil {\n\t\tt.Fatalf(\"expected continue reading, got done=%v err=%v\", done, err)\n\t}\n\n\tdone, err = shouldStopUploadRead(io.EOF)\n\tif !done || err != nil {\n\t\tt.Fatalf(\"expected eof to finish cleanly, got done=%v err=%v\", done, err)\n\t}\n\n\twantErr := errors.New(\"read failed\")\n\tdone, err = shouldStopUploadRead(wantErr)\n\tif !done || !errors.Is(err, wantErr) {\n\t\tt.Fatalf(\"expected done with original err, got done=%v err=%v\", done, err)\n\t}\n}\n"
  },
  {
    "path": "pkg/utils/utils.go",
    "content": "package utils\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n)\n\n// If 模拟简单的三元操作\nfunc If[T any](condition bool, trueVal, falseVal T) T {\n\tif condition {\n\t\treturn trueVal\n\t}\n\treturn falseVal\n}\n\n// WouldCauseCycle 检查新的父节点是否是当前节点的子节点，防止循环引用\nfunc WouldCauseCycle(id, parentPid uint, parentPids string) bool {\n\tif id == 0 {\n\t\treturn false\n\t}\n\tif parentPid == id {\n\t\treturn true\n\t}\n\t// 检测循环引用\n\tidStr := fmt.Sprintf(\"%d\", id)\n\tpidsSlice := strings.Split(parentPids, \",\")\n\tfor _, pid := range pidsSlice {\n\t\tif pid == idStr {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// GetRunPath 获取执行目录作为默认目录\nfunc GetRunPath() string {\n\tcurrentPath, err := os.Getwd()\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn currentPath\n}\n\n// GetFileDirectoryToCaller 根据运行堆栈信息获取文件目录，skip 默认1\nfunc GetFileDirectoryToCaller(opts ...int) (directory string, ok bool) {\n\tvar filename string\n\tdirectory = \"\"\n\tskip := 1\n\tif opts != nil {\n\t\tskip = opts[0]\n\t}\n\tif _, filename, _, ok = runtime.Caller(skip); ok {\n\t\tdirectory = filepath.Dir(filename)\n\t}\n\treturn\n}\n\n// GetCurrentAbPathByExecutable 获取当前执行文件所在目录的绝对路径\n// 这是最可靠的获取二进制文件所在目录的方法，适用于所有环境\nfunc GetCurrentAbPathByExecutable() (string, error) {\n\texePath, err := os.Executable()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"获取执行文件路径失败: %w\", err)\n\t}\n\n\t// 解析符号链接，获取真实路径\n\trealPath, err := filepath.EvalSymlinks(exePath)\n\tif err != nil {\n\t\t// 如果解析符号链接失败，使用原始路径\n\t\trealPath = exePath\n\t}\n\n\t// 获取目录路径并转换为绝对路径\n\tdir := filepath.Dir(realPath)\n\tabsDir, err := filepath.Abs(dir)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"获取绝对路径失败: %w\", err)\n\t}\n\n\treturn absDir, nil\n}\n\n// GetCurrentPath 获取当前执行文件路径（始终使用二进制文件所在目录）\n// 这是统一的路径获取方法，确保所有环境行为一致\nfunc GetCurrentPath() (dir string, err error) {\n\treturn GetCurrentAbPathByExecutable()\n}\n\n// GetDefaultPath 获取当前执行文件路径，如果是临时目录则获取运行命令的工作目录\nfunc GetDefaultPath() (dir string, err error) {\n\tif os.Getenv(\"GO_ENV\") != \"development\" {\n\t\tdir, err = GetCurrentAbPathByExecutable()\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t} else {\n\t\tdir = GetRunPath()\n\t}\n\n\treturn dir, nil\n}\n\n// MD5 计算字符串的 MD5 值\nfunc MD5(str string) string {\n\t// 计算 MD5 哈希\n\thash := md5.Sum([]byte(str))\n\n\t// 将哈希值转换为十六进制字符串\n\treturn hex.EncodeToString(hash[:])\n}\n"
  },
  {
    "path": "pkg/utils/utils_test.go",
    "content": "package utils\n\nimport (\n\t\"testing\"\n)\n\nfunc TestGetRunPath(t *testing.T) {\n\tpath := GetRunPath()\n\tif path == \"\" {\n\t\tt.Error(\"获取运行路径失败\")\n\t}\n}\n\nfunc TestGetCurrentPath(t *testing.T) {\n\t_, err := GetCurrentPath()\n\tif err != nil {\n\t\tt.Error(\"获取运行路径失败\")\n\t}\n}\n\nfunc TestGetCurrentAbPathByExecutable(t *testing.T) {\n\t_, err := GetCurrentAbPathByExecutable()\n\tif err != nil {\n\t\tt.Error(\"获取路径失败\")\n\t}\n}\n\nfunc TestGetCurrentFileDirectory(t *testing.T) {\n\tpath, ok := GetFileDirectoryToCaller()\n\tif !ok {\n\t\tt.Error(\"获取路径失败\", path)\n\t}\n\n\tpath, ok = GetFileDirectoryToCaller(1)\n\tif !ok {\n\t\tt.Error(\"获取路径失败\", path)\n\t}\n}\n\nfunc TestIf(t *testing.T) {\n\tif 3 != If(false, 1, 3) {\n\t\tt.Error(\"模拟三元操作失败\")\n\t}\n\n\tif 1 != If(true, 1, 3) {\n\t\tt.Error(\"模拟三元操作失败\")\n\t}\n}\n"
  },
  {
    "path": "policy.csv",
    "content": "# 用户-角色绑定\r\ng, user:1, dept:2\r\n\r\n# 角色继承\r\ng, role:1, role:2\r\ng, role:2, role:3\r\n\r\n# 部门-》角色绑定\r\ng, dept:1, role:1\r\ng, dept:2, role:2\r\ng, dept:2, role:4\r\n\r\n# 角色-》菜单绑定\r\ng, role:1, menu:1\r\ng, role:3, menu:3\r\ng, role:2, menu:2\r\n\r\n# 菜单-》权限绑定\r\np, menu:1, /menu/*, GET\r\np, menu:1, /api/hr/*, POST\r\np, menu:2, /api/v1/menu/update, POST\r\np, menu:2, /api/data, GET\r\np, menu:3, /api/v1/menu/list, GET\r\np, menu:3, /api/v1/menu/add, POST\r\n\r\n# 测试用例\r\n# user:1, /api/data, GET\r\n# user:1, /api/v1/menu/update, POST\r\n# user:1, /api/v1/menu/add1, POST\r\n"
  },
  {
    "path": "rbac_model.conf",
    "content": "[request_definition]\nr = sub, obj, act\n\n[policy_definition]\np = sub, obj, act\n\n[role_definition]\ng = _, _\n\n[policy_effect]\ne = some(where (p.eft == allow))\n\n[matchers]\nm = (g(r.sub, p.sub) && keyMatch3(r.obj, p.obj) && regexMatch(r.act, p.act))"
  },
  {
    "path": "tests/README.md",
    "content": "# tests 目录说明\n\n当前项目对外路由共 `42` 个：\n\n- 根路由 `GET /ping` 1 个\n- 管理端 `/admin/v1` 路由 41 个\n\n目前这 `42` 个接口都能在 `tests` 目录下找到对应测试入口。\n\n## 目录职责\n\n- `test.go`\n  - 测试公共初始化入口\n  - 负责装载配置、初始化数据库、验证器、Gin Router\n- `ping_test.go`\n  - 根路由 `GET /ping`\n- `admin_test/`\n  - 管理端接口测试目录\n  - 按资源域拆分，同一资源的读写测试放在同一个文件里\n\n## admin_test 文件分工\n\n- `public_routes_test.go`\n  - `/admin/v1/demo`\n  - `/admin/v1/file/:uuid`\n- `auth_routes_test.go`\n  - `/admin/v1/auth/captcha`\n  - `/admin/v1/auth/login`\n  - `/admin/v1/auth/check-token`\n  - `/admin/v1/auth/logout`\n- `common_routes_test.go`\n  - `/admin/v1/common/upload`\n- `admin_user_test.go`\n  - `GET /admin/v1/admin-user/*`\n  - `POST /admin/v1/admin-user/update-profile`\n  - `POST /admin/v1/admin-user/update-password`\n  - `POST /admin/v1/admin-user/create`\n  - `POST /admin/v1/admin-user/update`\n  - `POST /admin/v1/admin-user/delete`\n  - `POST /admin/v1/admin-user/bind-role`\n- `permission_routes_test.go`\n  - `POST /admin/v1/permission/update`\n  - `GET /admin/v1/permission/list`\n- `menu_test.go`\n  - `GET /admin/v1/menu/*`\n  - `POST /admin/v1/menu/create`\n  - `POST /admin/v1/menu/update`\n  - `POST /admin/v1/menu/delete`\n- `role_test.go`\n  - `GET /admin/v1/role/*`\n  - `POST /admin/v1/role/create`\n  - `POST /admin/v1/role/update`\n  - `POST /admin/v1/role/delete`\n  - `POST /admin/v1/role/menu-access`\n- `department_test.go`\n  - `GET /admin/v1/department/*`\n  - `POST /admin/v1/department/create`\n  - `POST /admin/v1/department/update`\n  - `POST /admin/v1/department/delete`\n  - `POST /admin/v1/department/user-access`\n- `log_routes_test.go`\n  - `GET /admin/v1/log/request/list`\n  - `GET /admin/v1/log/request/detail`\n  - `GET /admin/v1/log/login/list`\n  - `GET /admin/v1/log/login/detail`\n\n## 辅助文件\n\n- `admin_test/admin_test.go`\n  - `TestMain`\n  - 请求发送、鉴权头注入、统一响应解析\n- `admin_test/test_helpers_test.go`\n  - 环境判断、测试资源命名、查找、清理、兜底数据创建\n\n## 维护规则\n\n- 新增接口时，必须在 `tests` 目录补对应测试。\n- 管理端新接口默认放到 `tests/admin_test`，并按资源域归类。\n- 同一资源的读写测试优先合并在同一个 `*_test.go` 文件内。\n"
  },
  {
    "path": "tests/admin_test/README.md",
    "content": "# tests/admin_test 索引\n\n这份索引用于解决两个问题：\n\n1. 当前后台接口哪些已经在 `tests/admin_test` 里被覆盖到了\n2. 某个接口该去哪个测试文件里找\n\n## 当前结论\n\n- `internal/routers/admin_router.go` 中共定义 **68 个接口**\n- 所有 68 个接口都至少有一处测试引用\n- 但覆盖深度并不完全一致\n  - 64 个接口有成功路径测试（含完整 CRUD 流程）\n  - 4 个接口仅有未登录拦截 / 参数校验测试\n\n结论：\n\n- **接口覆盖有了**\n- **覆盖深度不均匀**\n\n## 目录规则\n\n当前目录按\"领域\"拆分，同一资源的读写测试放在同一个文件里：\n\n- `public_routes_test.go` — 公开接口\n- `auth_routes_test.go` — 登录、验证码、token 校验、登出\n- `common_routes_test.go` — 通用接口（上传等）\n- `admin_user_test.go` — 管理员相关接口（含读测试、鉴权测试、写流程测试）\n- `permission_routes_test.go` — API 权限接口\n- `menu_test.go` — 菜单相关接口（含读测试、鉴权测试、写流程测试）\n- `role_test.go` — 角色相关接口（含读测试、鉴权测试、写流程测试）\n- `department_test.go` — 部门相关接口（含读测试、鉴权测试、写流程测试）\n- `system_routes_test.go` — 系统参数 / 字典接口\n- `log_routes_test.go` — 请求日志 / 登录日志接口\n- `task_routes_test.go` — 任务中心组接口\n- `test_helpers_test.go` — 测试辅助函数（环境判断、测试资源命名/查找/清理/兜底数据创建）\n\n## 接口到文件映射\n\n### 公开接口\n\n| 接口 | 测试文件 | 当前覆盖 |\n| --- | --- | --- |\n| `GET /admin/v1/demo` | `public_routes_test.go` | 成功路径 |\n| `GET /admin/v1/file/:uuid` | `public_routes_test.go` | Not-found |\n| `POST /admin/v1/login` | `auth_routes_test.go` | 参数校验（验证码错误） |\n| `GET /admin/v1/login-captcha` | `auth_routes_test.go` | 成功路径 |\n\n### 通用 / 认证接口\n\n| 接口 | 测试文件 | 当前覆盖 |\n| --- | --- | --- |\n| `POST /admin/v1/common/upload` | `common_routes_test.go` | 未登录拦截 |\n| `POST /admin/v1/auth/logout` | `auth_routes_test.go` | 成功路径（DB token 撤销）+ 未登录拦截 |\n| `GET /admin/v1/auth/check-token` | `auth_routes_test.go` | 成功路径 |\n\n### 管理员接口\n\n| 接口 | 测试文件 | 当前覆盖 |\n| --- | --- | --- |\n| `GET /admin/v1/admin-user/get` | `admin_user_test.go` | 成功路径 + 未登录拦截 |\n| `GET /admin/v1/admin-user/user-menu-info` | `admin_user_test.go` | 成功路径 |\n| `POST /admin/v1/admin-user/update-profile` | `admin_user_test.go` | 参数校验 |\n| `GET /admin/v1/admin-user/list` | `admin_user_test.go` | 成功路径 + 未登录拦截 |\n| `GET /admin/v1/admin-user/detail` | `admin_user_test.go` | 成功路径 + 未登录拦截 |\n| `GET /admin/v1/admin-user/get-full-phone` | `admin_user_test.go` | 未登录拦截 |\n| `GET /admin/v1/admin-user/get-full-email` | `admin_user_test.go` | 未登录拦截 |\n| `POST /admin/v1/admin-user/create` | `admin_user_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/admin-user/update` | `admin_user_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/admin-user/delete` | `admin_user_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/admin-user/bind-role` | `admin_user_test.go` | 未登录拦截 |\n\n### 权限接口\n\n| 接口 | 测试文件 | 当前覆盖 |\n| --- | --- | --- |\n| `GET /admin/v1/permission/list` | `permission_routes_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/permission/update` | `permission_routes_test.go` | 未登录拦截 + 参数校验 |\n\n### 菜单接口\n\n| 接口 | 测试文件 | 当前覆盖 |\n| --- | --- | --- |\n| `GET /admin/v1/menu/list` | `menu_test.go` | 成功路径 + 未登录拦截 |\n| `GET /admin/v1/menu/detail` | `menu_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/menu/create` | `menu_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/menu/update` | `menu_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/menu/delete` | `menu_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/menu/update-all-menu-permissions` | `menu_test.go` | 成功路径 + 未登录拦截 |\n\n### 角色接口\n\n| 接口 | 测试文件 | 当前覆盖 |\n| --- | --- | --- |\n| `GET /admin/v1/role/list` | `role_test.go` | 成功路径 + 未登录拦截 |\n| `GET /admin/v1/role/detail` | `role_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/role/create` | `role_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/role/update` | `role_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/role/delete` | `role_test.go` | 成功路径 + 未登录拦截 |\n\n### 部门接口\n\n| 接口 | 测试文件 | 当前覆盖 |\n| --- | --- | --- |\n| `GET /admin/v1/department/list` | `department_test.go` | 成功路径 + 未登录拦截 |\n| `GET /admin/v1/department/detail` | `department_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/department/create` | `department_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/department/update` | `department_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/department/delete` | `department_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/department/bind-role` | `department_test.go` | 成功路径 + 未登录拦截 |\n\n### 系统参数\n\n| 接口 | 测试文件 | 当前覆盖 |\n| --- | --- | --- |\n| `GET /admin/v1/system/config/list` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `GET /admin/v1/system/config/detail` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `GET /admin/v1/system/config/value` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `POST /admin/v1/system/config/create` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `POST /admin/v1/system/config/update` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `POST /admin/v1/system/config/delete` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `POST /admin/v1/system/config/refresh` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n\n### 系统字典\n\n| 接口 | 测试文件 | 当前覆盖 |\n| --- | --- | --- |\n| `GET /admin/v1/system/dict/type/list` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `GET /admin/v1/system/dict/type/detail` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `POST /admin/v1/system/dict/type/create` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `POST /admin/v1/system/dict/type/update` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `POST /admin/v1/system/dict/type/delete` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `GET /admin/v1/system/dict/item/list` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `POST /admin/v1/system/dict/item/create` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `POST /admin/v1/system/dict/item/update` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `POST /admin/v1/system/dict/item/delete` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n| `GET /admin/v1/system/dict/options` | `system_routes_test.go` | 成功路径 + 未登录拦截（成功路径依赖 `sys_*` 表已迁移） |\n\n### 日志接口\n\n| 接口 | 测试文件 | 当前覆盖 |\n| --- | --- | --- |\n| `GET /admin/v1/log/request/list` | `log_routes_test.go` | 成功路径 + 未登录拦截 |\n| `GET /admin/v1/log/request/detail` | `log_routes_test.go` | 未登录拦截 |\n| `GET /admin/v1/log/request/export` | `log_routes_test.go` | 成功路径 + 未登录拦截 |\n| `GET /admin/v1/log/request/mask-config` | `log_routes_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/log/request/mask-config` | `log_routes_test.go` | 成功路径 + 未登录拦截 |\n| `GET /admin/v1/log/login/list` | `log_routes_test.go` | 成功路径 + 未登录拦截 |\n| `GET /admin/v1/log/login/detail` | `log_routes_test.go` | 未登录拦截 |\n\n### 任务中心\n\n| 接口 | 测试文件 | 当前覆盖 |\n| --- | --- | --- |\n| `GET /admin/v1/task/list` | `task_routes_test.go` | 成功路径 + 未登录拦截 |\n| `POST /admin/v1/task/trigger` | `task_routes_test.go` | 未登录拦截 + 参数校验 |\n| `GET /admin/v1/task/run/list` | `task_routes_test.go` | 成功路径 + 未登录拦截 |\n| `GET /admin/v1/task/run/detail` | `task_routes_test.go` | Not-found + 未登录拦截 |\n| `POST /admin/v1/task/run/retry` | `task_routes_test.go` | 未登录拦截 + 参数校验 |\n| `POST /admin/v1/task/run/cancel` | `task_routes_test.go` | 未登录拦截 + 参数校验 |\n| `GET /admin/v1/task/cron/state` | `task_routes_test.go` | 成功路径 + 未登录拦截 |\n\n## 覆盖统计\n\n| 覆盖等级 | 接口数 | 说明 |\n| --- | --- | --- |\n| 成功路径 + CRUD 流程 | 50 | admin-user / menu / role / department / system 等有完整 write flow |\n| 成功路径（仅读） | 14 | 列表、详情、导出等只读操作 |\n| 未登录拦截（仅有） | 4 | 仅覆盖了鉴权，无成功路径 |\n\n## 当前仍然偏弱的接口\n\n这些接口仅有未登录拦截或参数校验测试，后续增强应优先补成功路径：\n\n**其他模块**\n- `POST /admin/v1/common/upload` — 仅有未登录拦截\n- `POST /admin/v1/admin-user/bind-role` — 仅有未登录拦截\n- `GET /admin/v1/admin-user/get-full-phone` — 仅有未登录拦截\n- `GET /admin/v1/admin-user/get-full-email` — 仅有未登录拦截\n\n**覆盖较浅但暂可接受的接口**\n- `POST /admin/v1/admin-user/update-profile` — 仅有参数校验（无成功路径）\n- `POST /admin/v1/permission/update` — 仅有未登录拦截 + 参数校验\n- `GET /admin/v1/log/request/detail` — 仅有未登录拦截\n- `GET /admin/v1/log/login/detail` — 仅有未登录拦截\n"
  },
  {
    "path": "tests/admin_test/admin_test.go",
    "content": "package admin_test\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/golang-jwt/jwt/v5\"\n\t\"github.com/google/uuid\"\n\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/internal/global\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/response\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token\"\n\t\"github.com/wannanbigpig/gin-layout/tests\"\n)\n\nvar (\n\trouter        *gin.Engine\n\tauthorization string\n\tmysqlEnabled  bool\n)\n\nfunc TestMain(m *testing.M) {\n\tvar err error\n\trouter, err = tests.SetupRouter()\n\tif err != nil {\n\t\t_, _ = os.Stderr.WriteString(\"初始化测试路由失败: \" + err.Error() + \"\\n\")\n\t\tos.Exit(1)\n\t}\n\tnow := time.Now().UTC()\n\texpiresAt := now.Add(time.Second * c.Config.Jwt.TTL)\n\tclaims := token.AdminCustomClaims{\n\t\tAdminUserInfo: token.AdminUserInfo{\n\t\t\tUserID:       1,\n\t\t\tNickname:     \"super_admin\",\n\t\t\tIsSuperAdmin: 1,\n\t\t},\n\t\tRegisteredClaims: jwt.RegisteredClaims{\n\t\t\tExpiresAt: jwt.NewNumericDate(expiresAt),\n\t\t\tIssuer:    global.Issuer,\n\t\t\tSubject:   global.PcAdminSubject,\n\t\t\tIssuedAt:  jwt.NewNumericDate(now),\n\t\t\tNotBefore: jwt.NewNumericDate(now),\n\t\t\tID:        uuid.NewString(),\n\t\t},\n\t}\n\taccessToken, err := token.Generate(claims)\n\tif err != nil {\n\t\t_, _ = os.Stderr.WriteString(\"创建管理员Token失败: \" + err.Error() + \"\\n\")\n\t\tos.Exit(1)\n\t}\n\tauthorization = \"Bearer \" + accessToken\n\tmysqlEnabled = c.Config.Mysql.Enable\n\tos.Exit(m.Run())\n}\n\nfunc postRequest(route string, body *string) *httptest.ResponseRecorder {\n\treturn performRequest(http.MethodPost, route, body, authorization)\n}\n\nfunc anonymousPostRequest(route string, body *string) *httptest.ResponseRecorder {\n\treturn performRequest(http.MethodPost, route, body, \"\")\n}\n\nfunc getRequest(route string, queryParams *url.Values) *httptest.ResponseRecorder {\n\tpath := route\n\tif queryParams != nil {\n\t\tpath += \"?\" + queryParams.Encode()\n\t}\n\treturn performRequest(http.MethodGet, path, nil, authorization)\n}\n\nfunc anonymousGetRequest(route string, queryParams *url.Values) *httptest.ResponseRecorder {\n\tpath := route\n\tif queryParams != nil {\n\t\tpath += \"?\" + queryParams.Encode()\n\t}\n\treturn performRequest(http.MethodGet, path, nil, \"\")\n}\n\nfunc performRequest(method, route string, body *string, authHeader string) *httptest.ResponseRecorder {\n\tvar reader io.Reader\n\tif body != nil {\n\t\treader = bytes.NewBufferString(*body)\n\t}\n\n\treq := httptest.NewRequest(method, route, reader)\n\tif body != nil {\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t}\n\tif authHeader != \"\" {\n\t\treq.Header.Set(\"Authorization\", authHeader)\n\t}\n\n\trecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(recorder, req)\n\treturn recorder\n}\n\n// decodeResult 解析统一响应结构。\nfunc decodeResult(t *testing.T, recorder *httptest.ResponseRecorder) *response.Result {\n\tt.Helper()\n\n\tresult := new(response.Result)\n\tif err := json.Unmarshal(recorder.Body.Bytes(), result); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v, body=%s\", err, recorder.Body.String())\n\t}\n\treturn result\n}\n\n// requireMySQL 在需要真实数据库链路时跳过测试。\nfunc requireMySQL(t *testing.T) {\n\tt.Helper()\n\tif !mysqlEnabled {\n\t\tt.Skip(\"当前测试配置未启用 MySQL，跳过需要真实数据库链路的接口流程测试\")\n\t}\n}\n"
  },
  {
    "path": "tests/admin_test/admin_user_test.go",
    "content": "package admin_test\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\nfunc TestGetAdminUserRequiresLogin(t *testing.T) {\n\tqueryParams := &url.Values{}\n\tqueryParams.Set(\"id\", \"1\")\n\tresp := anonymousGetRequest(\"/admin/v1/admin-user/get\", queryParams)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n\nfunc TestGetCurrentAdminUserWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\tresp := getRequest(\"/admin/v1/admin-user/get\", nil)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.SUCCESS, result.Code)\n\n\tdata, ok := result.Data.(map[string]any)\n\tassert.True(t, ok)\n\tassert.Equal(t, float64(1), data[\"id\"])\n}\n\nfunc TestGetUserMenuInfoWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\tresp := getRequest(\"/admin/v1/admin-user/user-menu-info\", nil)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.SUCCESS, result.Code)\n}\n\nfunc TestUpdateProfileInvalidEmail(t *testing.T) {\n\trequireMySQL(t)\n\n\tbody := `{\"email\":\"invalid-email\"}`\n\tresp := postRequest(\"/admin/v1/admin-user/update-profile\", &body)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.InvalidParameter, result.Code)\n}\n\nfunc TestAdminUserListWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\tresp := getRequest(\"/admin/v1/admin-user/list\", &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}})\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.SUCCESS, result.Code)\n}\n\nfunc TestAdminUserListRequiresLogin(t *testing.T) {\n\tresp := anonymousGetRequest(\"/admin/v1/admin-user/list\", &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}})\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n\nfunc TestAdminUserProtectedGetRoutesRequireLogin(t *testing.T) {\n\ttestCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tquery *url.Values\n\t}{\n\t\t{name: \"管理员详情需要登录\", route: \"/admin/v1/admin-user/detail\", query: &url.Values{\"id\": {\"1\"}}},\n\t\t{name: \"管理员完整手机号需要登录\", route: \"/admin/v1/admin-user/get-full-phone\", query: &url.Values{\"id\": {\"1\"}}},\n\t\t{name: \"管理员完整邮箱需要登录\", route: \"/admin/v1/admin-user/get-full-email\", query: &url.Values{\"id\": {\"1\"}}},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresp := anonymousGetRequest(tc.route, tc.query)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n}\n\nfunc TestAdminUserProtectedPostRoutesRequireLogin(t *testing.T) {\n\ttestCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tbody  string\n\t}{\n\t\t{name: \"管理员创建需要登录\", route: \"/admin/v1/admin-user/create\", body: `{}`},\n\t\t{name: \"管理员更新需要登录\", route: \"/admin/v1/admin-user/update\", body: `{}`},\n\t\t{name: \"管理员删除需要登录\", route: \"/admin/v1/admin-user/delete\", body: `{}`},\n\t\t{name: \"管理员绑定角色需要登录\", route: \"/admin/v1/admin-user/bind-role\", body: `{}`},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tbody := tc.body\n\t\t\tresp := anonymousPostRequest(tc.route, &body)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n}\n\nfunc TestAdminUserWriteFlow(t *testing.T) {\n\trequireWritableDB(t)\n\n\tusername := fmt.Sprintf(\"ta%d\", time.Now().UnixNano()%1e10)\n\tcleanupAdminUsers(t, \"ta\")\n\n\tcreateBody := map[string]any{\n\t\t\"username\": username,\n\t\t\"nickname\": \"测试管理员\",\n\t\t\"password\": \"12345678\",\n\t\t\"email\":    username + \"@example.com\",\n\t\t\"dept_ids\": []uint{1},\n\t\t\"status\":   1,\n\t}\n\tbodyBytes, _ := json.Marshal(createBody)\n\tbody := string(bodyBytes)\n\n\tresp := postRequest(\"/admin/v1/admin-user/create\", &body)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tassert.Equal(t, e.SUCCESS, result.Code)\n\n\tuser := findAdminUserByUsername(t, username)\n\n\tdetailResp := getRequest(\"/admin/v1/admin-user/detail\", &url.Values{\"id\": {strconv.FormatUint(uint64(user.ID), 10)}})\n\tdetailResult := decodeResult(t, detailResp)\n\tassert.Equal(t, e.SUCCESS, detailResult.Code)\n\n\tupdateBody := map[string]any{\n\t\t\"id\":       user.ID,\n\t\t\"nickname\": \"测试管理员-更新\",\n\t\t\"email\":    username + \"-updated@example.com\",\n\t\t\"dept_ids\": []uint{1},\n\t}\n\tupdateBytes, _ := json.Marshal(updateBody)\n\tupdatePayload := string(updateBytes)\n\tupdateResp := postRequest(\"/admin/v1/admin-user/update\", &updatePayload)\n\tupdateResult := decodeResult(t, updateResp)\n\tassert.Equal(t, e.SUCCESS, updateResult.Code)\n\n\tdeleteBytes, _ := json.Marshal(map[string]any{\"id\": user.ID})\n\tdeletePayload := string(deleteBytes)\n\tdeleteResp := postRequest(\"/admin/v1/admin-user/delete\", &deletePayload)\n\tdeleteResult := decodeResult(t, deleteResp)\n\tassert.Equal(t, e.SUCCESS, deleteResult.Code)\n}\n"
  },
  {
    "path": "tests/admin_test/auth_routes_test.go",
    "content": "package admin_test\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/golang-jwt/jwt/v5\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\tc \"github.com/wannanbigpig/gin-layout/config\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/utils/token\"\n)\n\n// parseTestToken 解析测试用 token 并返回 claims。\nfunc parseTestToken(accessToken string) (*token.AdminCustomClaims, error) {\n\tclaims := new(token.AdminCustomClaims)\n\tsecret := []byte(c.GetConfig().Jwt.SecretKey)\n\tparsedToken, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (interface{}, error) {\n\t\treturn secret, nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif !parsedToken.Valid {\n\t\treturn nil, fmt.Errorf(\"invalid token\")\n\t}\n\treturn claims, nil\n}\n\nfunc TestLoginCaptcha(t *testing.T) {\n\tcaptchaResp := anonymousGetRequest(\"/admin/v1/login-captcha\", nil)\n\tassert.Equal(t, http.StatusOK, captchaResp.Code)\n\tcaptchaResult := decodeResult(t, captchaResp)\n\tassert.Equal(t, e.SUCCESS, captchaResult.Code)\n}\n\nfunc TestLoginInvalidCaptcha(t *testing.T) {\n\tloginData := map[string]any{\n\t\t\"username\":   \"super_admin\",\n\t\t\"password\":   \"123456\",\n\t\t\"captcha\":    \"wrong\",\n\t\t\"captcha_id\": \"invalid\",\n\t}\n\tbody, err := json.Marshal(loginData)\n\tassert.Nil(t, err)\n\tbodyStr := string(body)\n\tresp := anonymousPostRequest(\"/admin/v1/login\", &bodyStr)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.CaptchaErr, result.Code)\n}\n\nfunc TestCheckTokenWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\tresp := getRequest(\"/admin/v1/auth/check-token\", nil)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.SUCCESS, result.Code)\n\tdata, ok := result.Data.(map[string]any)\n\tassert.True(t, ok)\n\tassert.Equal(t, true, data[\"result\"])\n}\n\nfunc TestProtectedAuthRoutesRequireLogin(t *testing.T) {\n\ttestCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tbody  string\n\t}{\n\t\t{name: \"退出登录需要登录\", route: \"/admin/v1/auth/logout\", body: `{}`},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tbody := tc.body\n\t\t\tresp := anonymousPostRequest(tc.route, &body)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n}\n\n// TestLogoutUpdatesDatabase 测试退出登录接口能够正确更新数据库中的 token 撤销状态。\nfunc TestLogoutUpdatesDatabase(t *testing.T) {\n\trequireMySQL(t)\n\n\t// 先获取验证码\n\tcaptchaResp := anonymousGetRequest(\"/admin/v1/login-captcha\", nil)\n\tassert.Equal(t, http.StatusOK, captchaResp.Code)\n\tcaptchaResult := decodeResult(t, captchaResp)\n\tif captchaResult.Code != e.SUCCESS {\n\t\tt.Skipf(\"获取验证码失败，跳过测试：%s\", captchaResult.Msg)\n\t}\n\tcaptchaData, ok := captchaResult.Data.(map[string]any)\n\tassert.True(t, ok)\n\tcaptchaID, _ := captchaData[\"id\"].(string)\n\t// 验证码答案在测试环境下会返回\n\tcaptchaAnswer, _ := captchaData[\"answer\"].(string)\n\n\t// 使用验证码登录\n\tloginData := map[string]any{\n\t\t\"username\":   \"super_admin\",\n\t\t\"password\":   \"123456\",\n\t\t\"captcha\":    captchaAnswer,\n\t\t\"captcha_id\": captchaID,\n\t}\n\tbody, err := json.Marshal(loginData)\n\tassert.Nil(t, err)\n\tbodyStr := string(body)\n\tloginResp := anonymousPostRequest(\"/admin/v1/login\", &bodyStr)\n\tassert.Equal(t, http.StatusOK, loginResp.Code)\n\tloginResult := decodeResult(t, loginResp)\n\tif loginResult.Code != e.SUCCESS {\n\t\tt.Skipf(\"登录失败，跳过测试：%s\", loginResult.Msg)\n\t}\n\n\t// 提取 token 中的 jwt_id 用于后续验证\n\tdata, ok := loginResult.Data.(map[string]any)\n\tassert.True(t, ok)\n\taccessToken, ok := data[\"access_token\"].(string)\n\tassert.True(t, ok)\n\n\t// 解析 token 获取 jwt_id\n\tclaims, err := parseTestToken(accessToken)\n\tassert.Nil(t, err)\n\tjwtID := claims.ID\n\n\t// 调用退出登录接口（使用登录后返回的 token）\n\tlogoutHeader := \"Bearer \" + accessToken\n\tlogoutResp := performRequest(http.MethodPost, \"/admin/v1/auth/logout\", &bodyStr, logoutHeader)\n\tassert.Equal(t, http.StatusOK, logoutResp.Code)\n\tlogoutResult := decodeResult(t, logoutResp)\n\tassert.Equal(t, e.SUCCESS, logoutResult.Code)\n\n\t// 验证数据库中该 jwt_id 的记录被标记为已撤销\n\tloginLog := model.NewAdminLoginLogs()\n\tdb, err := loginLog.GetDB()\n\tassert.Nil(t, err)\n\terr = db.Where(\"jwt_id = ? AND deleted_at = 0\", jwtID).First(loginLog).Error\n\tassert.Nil(t, err)\n\tassert.Equal(t, uint8(1), loginLog.IsRevoked, \"退出登录后 token 应被标记为已撤销\")\n\tassert.Equal(t, uint8(1), loginLog.RevokedCode, \"撤销原因码应为用户主动登出\")\n}\n"
  },
  {
    "path": "tests/admin_test/common_routes_test.go",
    "content": "package admin_test\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\nfunc TestCommonUploadRequiresLogin(t *testing.T) {\n\tbody := `{}`\n\tresp := anonymousPostRequest(\"/admin/v1/common/upload\", &body)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n"
  },
  {
    "path": "tests/admin_test/department_test.go",
    "content": "package admin_test\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\nfunc TestDepartmentListWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\tresp := getRequest(\"/admin/v1/department/list\", &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}})\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.SUCCESS, result.Code)\n}\n\nfunc TestDepartmentListRequiresLogin(t *testing.T) {\n\tresp := anonymousGetRequest(\"/admin/v1/department/list\", &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}})\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n\nfunc TestDepartmentProtectedRoutesRequireLogin(t *testing.T) {\n\tpostCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tbody  string\n\t}{\n\t\t{name: \"部门创建需要登录\", route: \"/admin/v1/department/create\", body: `{}`},\n\t\t{name: \"部门更新需要登录\", route: \"/admin/v1/department/update\", body: `{}`},\n\t\t{name: \"部门删除需要登录\", route: \"/admin/v1/department/delete\", body: `{}`},\n\t\t{name: \"部门绑定角色需要登录\", route: \"/admin/v1/department/bind-role\", body: `{}`},\n\t}\n\n\tfor _, tc := range postCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tbody := tc.body\n\t\t\tresp := anonymousPostRequest(tc.route, &body)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n\n\tresp := anonymousGetRequest(\"/admin/v1/department/detail\", &url.Values{\"id\": {\"1\"}})\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n\nfunc TestDepartmentWriteFlow(t *testing.T) {\n\trequireWritableDB(t)\n\n\tname := uniqueTestName(\"dept\")\n\tcleanupDepartments(t, testResourcePrefix+\"dept\")\n\n\tcreateBody := map[string]any{\n\t\t\"name\":        name,\n\t\t\"description\": \"测试部门\",\n\t\t\"sort\":        10,\n\t}\n\tcreateBytes, _ := json.Marshal(createBody)\n\tcreatePayload := string(createBytes)\n\tcreateResp := postRequest(\"/admin/v1/department/create\", &createPayload)\n\tcreateResult := decodeResult(t, createResp)\n\tassert.Equal(t, e.SUCCESS, createResult.Code)\n\n\tdept := findDepartmentByName(t, name)\n\n\tdetailResp := getRequest(\"/admin/v1/department/detail\", &url.Values{\"id\": {strconv.FormatUint(uint64(dept.ID), 10)}})\n\tdetailResult := decodeResult(t, detailResp)\n\tassert.Equal(t, e.SUCCESS, detailResult.Code)\n\n\tupdateBody := map[string]any{\n\t\t\"id\":          dept.ID,\n\t\t\"name\":        name,\n\t\t\"description\": \"测试部门-更新\",\n\t\t\"sort\":        20,\n\t}\n\tupdateBytes, _ := json.Marshal(updateBody)\n\tupdatePayload := string(updateBytes)\n\tupdateResp := postRequest(\"/admin/v1/department/update\", &updatePayload)\n\tupdateResult := decodeResult(t, updateResp)\n\tassert.Equal(t, e.SUCCESS, updateResult.Code)\n\n\tbindBytes, _ := json.Marshal(map[string]any{\"dept_id\": dept.ID, \"role_ids\": []uint{firstActiveRoleID(t)}})\n\tbindPayload := string(bindBytes)\n\tbindResp := postRequest(\"/admin/v1/department/bind-role\", &bindPayload)\n\tbindResult := decodeResult(t, bindResp)\n\tassert.Equal(t, e.SUCCESS, bindResult.Code)\n\n\tdeleteBytes, _ := json.Marshal(map[string]any{\"id\": dept.ID})\n\tdeletePayload := string(deleteBytes)\n\tdeleteResp := postRequest(\"/admin/v1/department/delete\", &deletePayload)\n\tdeleteResult := decodeResult(t, deleteResp)\n\tassert.Equal(t, e.SUCCESS, deleteResult.Code)\n}\n"
  },
  {
    "path": "tests/admin_test/log_routes_test.go",
    "content": "package admin_test\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\nfunc TestLogListRoutesWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\ttestCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tquery *url.Values\n\t}{\n\t\t{\n\t\t\tname:  \"请求日志列表\",\n\t\t\troute: \"/admin/v1/log/request/list\",\n\t\t\tquery: &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}},\n\t\t},\n\t\t{\n\t\t\tname:  \"登录日志列表\",\n\t\t\troute: \"/admin/v1/log/login/list\",\n\t\t\tquery: &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresp := getRequest(tc.route, tc.query)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.SUCCESS, result.Code)\n\t\t})\n\t}\n}\n\nfunc TestRequestLogExportRouteWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\tresp := getRequest(\"/admin/v1/log/request/export\", &url.Values{\"limit\": {\"10\"}})\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tassert.Contains(t, resp.Header().Get(\"Content-Type\"), \"text/csv\")\n\tassert.Contains(t, resp.Header().Get(\"Content-Disposition\"), \"request_logs_\")\n}\n\nfunc TestRequestLogMaskConfigRoutesWithAuthorization(t *testing.T) {\n\tresp := getRequest(\"/admin/v1/log/request/mask-config\", nil)\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.SUCCESS, result.Code)\n\n\tbody := `{\"common\":[\"password\",\"token\"],\"request_body\":[\"phone\"]}`\n\tupdateResp := postRequest(\"/admin/v1/log/request/mask-config\", &body)\n\tassert.Equal(t, http.StatusOK, updateResp.Code)\n\tupdateResult := decodeResult(t, updateResp)\n\tassert.Equal(t, e.SUCCESS, updateResult.Code)\n}\n\nfunc TestLogRoutesRequireLogin(t *testing.T) {\n\tgetCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tquery *url.Values\n\t}{\n\t\t{\n\t\t\tname:  \"请求日志列表需要登录\",\n\t\t\troute: \"/admin/v1/log/request/list\",\n\t\t\tquery: &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}},\n\t\t},\n\t\t{\n\t\t\tname:  \"请求日志详情需要登录\",\n\t\t\troute: \"/admin/v1/log/request/detail\",\n\t\t\tquery: &url.Values{\"id\": {\"1\"}},\n\t\t},\n\t\t{\n\t\t\tname:  \"请求日志导出需要登录\",\n\t\t\troute: \"/admin/v1/log/request/export\",\n\t\t\tquery: &url.Values{\"limit\": {\"10\"}},\n\t\t},\n\t\t{\n\t\t\tname:  \"请求日志脱敏配置需要登录\",\n\t\t\troute: \"/admin/v1/log/request/mask-config\",\n\t\t\tquery: nil,\n\t\t},\n\t\t{\n\t\t\tname:  \"登录日志列表需要登录\",\n\t\t\troute: \"/admin/v1/log/login/list\",\n\t\t\tquery: &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}},\n\t\t},\n\t\t{\n\t\t\tname:  \"登录日志详情需要登录\",\n\t\t\troute: \"/admin/v1/log/login/detail\",\n\t\t\tquery: &url.Values{\"id\": {\"1\"}},\n\t\t},\n\t}\n\n\tfor _, tc := range getCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresp := anonymousGetRequest(tc.route, tc.query)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n\n\tbody := `{\"common\":[\"password\"]}`\n\tresp := anonymousPostRequest(\"/admin/v1/log/request/mask-config\", &body)\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n"
  },
  {
    "path": "tests/admin_test/menu_test.go",
    "content": "package admin_test\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\nfunc TestMenuListWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\tresp := getRequest(\"/admin/v1/menu/list\", &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}})\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.SUCCESS, result.Code)\n\tfirstMenu := firstMenuNode(result.Data)\n\tif firstMenu != nil {\n\t\t_, hasTitle := firstMenu[\"title\"]\n\t\tassert.True(t, hasTitle)\n\t\t_, hasTitleI18n := firstMenu[\"title_i18n\"]\n\t\tassert.False(t, hasTitleI18n)\n\t}\n}\n\nfunc TestMenuListRequiresLogin(t *testing.T) {\n\tresp := anonymousGetRequest(\"/admin/v1/menu/list\", &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}})\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n\nfunc TestMenuProtectedRoutesRequireLogin(t *testing.T) {\n\tpostCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tbody  string\n\t}{\n\t\t{name: \"菜单创建需要登录\", route: \"/admin/v1/menu/create\", body: `{}`},\n\t\t{name: \"菜单更新需要登录\", route: \"/admin/v1/menu/update\", body: `{}`},\n\t\t{name: \"菜单删除需要登录\", route: \"/admin/v1/menu/delete\", body: `{}`},\n\t\t{name: \"刷新菜单权限需要登录\", route: \"/admin/v1/menu/update-all-menu-permissions\", body: `{}`},\n\t}\n\n\tfor _, tc := range postCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tbody := tc.body\n\t\t\tresp := anonymousPostRequest(tc.route, &body)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n\n\tresp := anonymousGetRequest(\"/admin/v1/menu/detail\", &url.Values{\"id\": {\"1\"}})\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n\nfunc TestMenuWriteFlow(t *testing.T) {\n\trequireWritableDB(t)\n\n\ttitle := uniqueTestName(\"menu\")\n\tcleanupMenus(t, testResourcePrefix+\"menu\")\n\n\tcreateBody := map[string]any{\n\t\t\"title_i18n\": map[string]string{\n\t\t\t\"zh-CN\": title,\n\t\t},\n\t\t\"name\":      title,\n\t\t\"path\":      \"/\" + title,\n\t\t\"component\": \"test/component\",\n\t\t\"sort\":      10,\n\t\t\"type\":      2,\n\t\t\"status\":    1,\n\t\t\"is_show\":   1,\n\t\t\"is_auth\":   1,\n\t}\n\tcreateBytes, _ := json.Marshal(createBody)\n\tcreatePayload := string(createBytes)\n\tcreateResp := postRequest(\"/admin/v1/menu/create\", &createPayload)\n\tcreateResult := decodeResult(t, createResp)\n\tassert.Equal(t, e.SUCCESS, createResult.Code)\n\n\tmenu := findMenuByTitle(t, title)\n\n\tdetailResp := getRequest(\"/admin/v1/menu/detail\", &url.Values{\"id\": {strconv.FormatUint(uint64(menu.ID), 10)}})\n\tdetailResult := decodeResult(t, detailResp)\n\tassert.Equal(t, e.SUCCESS, detailResult.Code)\n\tdetailData, ok := detailResult.Data.(map[string]any)\n\tassert.True(t, ok)\n\t_, hasTitle := detailData[\"title\"]\n\tassert.False(t, hasTitle)\n\ttitleI18n, ok := detailData[\"title_i18n\"].(map[string]any)\n\tassert.True(t, ok)\n\tassert.Equal(t, title, titleI18n[\"zh-CN\"])\n\n\tupdateBody := map[string]any{\n\t\t\"id\": menu.ID,\n\t\t\"title_i18n\": map[string]string{\n\t\t\t\"en-US\": title + \"-u-en\",\n\t\t},\n\t\t\"name\":      title + \"-u-name\",\n\t\t\"path\":      \"/\" + title + \"-u-path\",\n\t\t\"component\": \"test/component\",\n\t\t\"sort\":      20,\n\t\t\"type\":      2,\n\t\t\"status\":    1,\n\t\t\"is_show\":   1,\n\t\t\"is_auth\":   1,\n\t}\n\tupdateBytes, _ := json.Marshal(updateBody)\n\tupdatePayload := string(updateBytes)\n\tupdateResp := postRequest(\"/admin/v1/menu/update\", &updatePayload)\n\tupdateResult := decodeResult(t, updateResp)\n\tassert.Equal(t, e.SUCCESS, updateResult.Code, updateResult.Msg)\n\n\tupdatedDetailResp := getRequest(\"/admin/v1/menu/detail\", &url.Values{\"id\": {strconv.FormatUint(uint64(menu.ID), 10)}})\n\tupdatedDetailResult := decodeResult(t, updatedDetailResp)\n\tassert.Equal(t, e.SUCCESS, updatedDetailResult.Code)\n\tupdatedDetailData, ok := updatedDetailResult.Data.(map[string]any)\n\tassert.True(t, ok)\n\t_, hasUpdatedTitle := updatedDetailData[\"title\"]\n\tassert.False(t, hasUpdatedTitle)\n\tupdatedTitleI18n, ok := updatedDetailData[\"title_i18n\"].(map[string]any)\n\tassert.True(t, ok)\n\tassert.Equal(t, title, updatedTitleI18n[\"zh-CN\"])\n\tassert.Equal(t, title+\"-u-en\", updatedTitleI18n[\"en-US\"])\n\n\trefreshBody := `{}`\n\trefreshResp := postRequest(\"/admin/v1/menu/update-all-menu-permissions\", &refreshBody)\n\trefreshResult := decodeResult(t, refreshResp)\n\tassert.Equal(t, e.SUCCESS, refreshResult.Code, refreshResult.Msg)\n\n\tdeleteBytes, _ := json.Marshal(map[string]any{\"id\": menu.ID})\n\tdeletePayload := string(deleteBytes)\n\tdeleteResp := postRequest(\"/admin/v1/menu/delete\", &deletePayload)\n\tdeleteResult := decodeResult(t, deleteResp)\n\tassert.Equal(t, e.SUCCESS, deleteResult.Code, deleteResult.Msg)\n}\n\nfunc firstMenuNode(data any) map[string]any {\n\tswitch typed := data.(type) {\n\tcase []any:\n\t\tif len(typed) == 0 {\n\t\t\treturn nil\n\t\t}\n\t\tnode, _ := typed[0].(map[string]any)\n\t\treturn node\n\tcase map[string]any:\n\t\trows, ok := typed[\"data\"].([]any)\n\t\tif !ok || len(rows) == 0 {\n\t\t\treturn nil\n\t\t}\n\t\tnode, _ := rows[0].(map[string]any)\n\t\treturn node\n\tdefault:\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "tests/admin_test/permission_routes_test.go",
    "content": "package admin_test\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\nfunc TestPermissionEditRequiresLogin(t *testing.T) {\n\tbody := `{\"id\":10,\"name\":\"ping\",\"description\":\"服务心跳检测接口\",\"method\":\"GET\",\"route\":\"/ping\",\"is_auth\":0,\"sort\":100}`\n\tresp := anonymousPostRequest(\"/admin/v1/permission/update\", &body)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n\nfunc TestPermissionListRequiresLogin(t *testing.T) {\n\tqueryParams := &url.Values{}\n\tqueryParams.Set(\"page\", \"1\")\n\tqueryParams.Set(\"per_page\", \"1\")\n\tqueryParams.Set(\"name\", \"ping\")\n\tqueryParams.Set(\"method\", \"GET\")\n\tqueryParams.Set(\"route\", \"/ping\")\n\tqueryParams.Set(\"is_auth\", \"1\")\n\tresp := anonymousGetRequest(\"/admin/v1/permission/list\", queryParams)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n\nfunc TestPermissionListWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\tqueryParams := &url.Values{}\n\tqueryParams.Set(\"page\", \"1\")\n\tqueryParams.Set(\"per_page\", \"5\")\n\tqueryParams.Set(\"method\", \"GET\")\n\n\tresp := getRequest(\"/admin/v1/permission/list\", queryParams)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.SUCCESS, result.Code)\n}\n\nfunc TestPermissionUpdateValidationWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\tbody := `{}`\n\tresp := postRequest(\"/admin/v1/permission/update\", &body)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.InvalidParameter, result.Code)\n}\n"
  },
  {
    "path": "tests/admin_test/public_routes_test.go",
    "content": "package admin_test\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\nfunc TestPublicDemoRoute(t *testing.T) {\n\tresp := anonymousGetRequest(\"/admin/v1/demo\", nil)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.SUCCESS, result.Code)\n}\n\nfunc TestPublicFileRouteWithoutAuthorization(t *testing.T) {\n\tresp := anonymousGetRequest(\"/admin/v1/file/not-found-uuid\", nil)\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.NotEqual(t, e.NotLogin, result.Code)\n}\n"
  },
  {
    "path": "tests/admin_test/role_test.go",
    "content": "package admin_test\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\nfunc TestRoleListWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\tresp := getRequest(\"/admin/v1/role/list\", &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}})\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.SUCCESS, result.Code)\n}\n\nfunc TestRoleListRequiresLogin(t *testing.T) {\n\tresp := anonymousGetRequest(\"/admin/v1/role/list\", &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}})\n\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n\nfunc TestRoleProtectedRoutesRequireLogin(t *testing.T) {\n\tpostCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tbody  string\n\t}{\n\t\t{name: \"角色创建需要登录\", route: \"/admin/v1/role/create\", body: `{}`},\n\t\t{name: \"角色更新需要登录\", route: \"/admin/v1/role/update\", body: `{}`},\n\t\t{name: \"角色删除需要登录\", route: \"/admin/v1/role/delete\", body: `{}`},\n\t}\n\n\tfor _, tc := range postCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tbody := tc.body\n\t\t\tresp := anonymousPostRequest(tc.route, &body)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n\n\tresp := anonymousGetRequest(\"/admin/v1/role/detail\", &url.Values{\"id\": {\"1\"}})\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotLogin, result.Code)\n}\n\nfunc TestRoleWriteFlow(t *testing.T) {\n\trequireWritableDB(t)\n\n\tname := uniqueTestName(\"role\")\n\tcleanupRoles(t, testResourcePrefix+\"role\")\n\n\tcreateBody := map[string]any{\n\t\t\"name\":      name,\n\t\t\"status\":    1,\n\t\t\"sort\":      10,\n\t\t\"menu_list\": []uint{firstActiveMenuID(t)},\n\t}\n\tcreateBytes, _ := json.Marshal(createBody)\n\tcreatePayload := string(createBytes)\n\tcreateResp := postRequest(\"/admin/v1/role/create\", &createPayload)\n\tcreateResult := decodeResult(t, createResp)\n\tassert.Equal(t, e.SUCCESS, createResult.Code)\n\n\trole := findRoleByName(t, name)\n\n\tdetailResp := getRequest(\"/admin/v1/role/detail\", &url.Values{\"id\": {strconv.FormatUint(uint64(role.ID), 10)}})\n\tdetailResult := decodeResult(t, detailResp)\n\tassert.Equal(t, e.SUCCESS, detailResult.Code)\n\n\tupdateBody := map[string]any{\n\t\t\"id\":          role.ID,\n\t\t\"name\":        name,\n\t\t\"description\": \"测试角色-更新\",\n\t\t\"status\":      1,\n\t\t\"sort\":        20,\n\t\t\"menu_list\":   []uint{firstActiveMenuID(t)},\n\t}\n\tupdateBytes, _ := json.Marshal(updateBody)\n\tupdatePayload := string(updateBytes)\n\tupdateResp := postRequest(\"/admin/v1/role/update\", &updatePayload)\n\tupdateResult := decodeResult(t, updateResp)\n\tassert.Equal(t, e.SUCCESS, updateResult.Code)\n\n\tdeleteBytes, _ := json.Marshal(map[string]any{\"id\": role.ID})\n\tdeletePayload := string(deleteBytes)\n\tdeleteResp := postRequest(\"/admin/v1/role/delete\", &deletePayload)\n\tdeleteResult := decodeResult(t, deleteResp)\n\tassert.Equal(t, e.SUCCESS, deleteResult.Code)\n}\n"
  },
  {
    "path": "tests/admin_test/system_routes_test.go",
    "content": "package admin_test\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\nfunc TestSystemConfigWriteFlow(t *testing.T) {\n\trequireWritableDB(t)\n\trequireSystemModuleTables(t)\n\n\tconfigKeyPrefix := uniqueCompactTestName(\"cfg\")\n\tconfigKey := configKeyPrefix + \".key\"\n\tconfigNameZh := configKeyPrefix + \"-zh\"\n\tconfigNameEn := configKeyPrefix + \"-en\"\n\tupdatedConfigValue := configKeyPrefix + \"-updated\"\n\n\tcleanupSysConfigsByKeyPrefix(t, configKeyPrefix)\n\tt.Cleanup(func() {\n\t\tcleanupSysConfigsByKeyPrefix(t, configKeyPrefix)\n\t})\n\n\tcreateBody := map[string]any{\n\t\t\"config_key\": configKey,\n\t\t\"config_name_i18n\": map[string]string{\n\t\t\t\"zh-CN\": configNameZh,\n\t\t\t\"en-US\": configNameEn,\n\t\t},\n\t\t\"config_value\": \"init-value\",\n\t\t\"value_type\":   \"string\",\n\t\t\"group_code\":   \"test\",\n\t\t\"status\":       1,\n\t\t\"sort\":         10,\n\t}\n\tcreateBytes, _ := json.Marshal(createBody)\n\tcreatePayload := string(createBytes)\n\tcreateResp := postRequest(\"/admin/v1/system/config/create\", &createPayload)\n\tcreateResult := decodeResult(t, createResp)\n\tassert.Equal(t, e.SUCCESS, createResult.Code, createResult.Msg)\n\n\tconfig := findSysConfigByKey(t, configKey)\n\n\tlistResp := getRequest(\"/admin/v1/system/config/list\", &url.Values{\n\t\t\"page\":       {\"1\"},\n\t\t\"per_page\":   {\"20\"},\n\t\t\"config_key\": {configKey},\n\t})\n\tlistResult := decodeResult(t, listResp)\n\tassert.Equal(t, e.SUCCESS, listResult.Code, listResult.Msg)\n\tassert.True(t, collectionContainsFieldValue(listResult.Data, \"config_key\", configKey))\n\n\tdetailResp := getRequest(\"/admin/v1/system/config/detail\", &url.Values{\n\t\t\"id\": {strconv.FormatUint(uint64(config.ID), 10)},\n\t})\n\tdetailResult := decodeResult(t, detailResp)\n\tassert.Equal(t, e.SUCCESS, detailResult.Code, detailResult.Msg)\n\tdetailData, ok := detailResult.Data.(map[string]any)\n\tassert.True(t, ok)\n\tassert.Equal(t, configKey, detailData[\"config_key\"])\n\tassert.NotEmpty(t, detailData[\"config_name\"])\n\tconfigNameI18n, ok := detailData[\"config_name_i18n\"].(map[string]any)\n\tassert.True(t, ok)\n\tassert.Equal(t, configNameZh, configNameI18n[\"zh-CN\"])\n\tassert.Equal(t, configNameEn, configNameI18n[\"en-US\"])\n\n\tvalueResp := getRequest(\"/admin/v1/system/config/value\", &url.Values{\n\t\t\"config_key\": {configKey},\n\t})\n\tvalueResult := decodeResult(t, valueResp)\n\tassert.Equal(t, e.SUCCESS, valueResult.Code, valueResult.Msg)\n\tvalueData, ok := valueResult.Data.(map[string]any)\n\tassert.True(t, ok)\n\tassert.Equal(t, configKey, valueData[\"config_key\"])\n\tassert.Equal(t, \"init-value\", valueData[\"config_value\"])\n\n\tupdateBody := map[string]any{\n\t\t\"id\":         config.ID,\n\t\t\"config_key\": configKey,\n\t\t\"config_name_i18n\": map[string]string{\n\t\t\t\"en-US\": configNameEn + \"-u\",\n\t\t},\n\t\t\"config_value\": updatedConfigValue,\n\t\t\"value_type\":   \"string\",\n\t\t\"group_code\":   \"test\",\n\t\t\"status\":       1,\n\t\t\"sort\":         20,\n\t}\n\tupdateBytes, _ := json.Marshal(updateBody)\n\tupdatePayload := string(updateBytes)\n\tupdateResp := postRequest(\"/admin/v1/system/config/update\", &updatePayload)\n\tupdateResult := decodeResult(t, updateResp)\n\tassert.Equal(t, e.SUCCESS, updateResult.Code, updateResult.Msg)\n\n\trefeshBody := `{}`\n\trefreshResp := postRequest(\"/admin/v1/system/config/refresh\", &refeshBody)\n\trefreshResult := decodeResult(t, refreshResp)\n\tassert.Equal(t, e.SUCCESS, refreshResult.Code, refreshResult.Msg)\n\n\tupdatedDetailResp := getRequest(\"/admin/v1/system/config/detail\", &url.Values{\n\t\t\"id\": {strconv.FormatUint(uint64(config.ID), 10)},\n\t})\n\tupdatedDetailResult := decodeResult(t, updatedDetailResp)\n\tassert.Equal(t, e.SUCCESS, updatedDetailResult.Code, updatedDetailResult.Msg)\n\tupdatedDetailData, ok := updatedDetailResult.Data.(map[string]any)\n\tassert.True(t, ok)\n\tassert.Equal(t, updatedConfigValue, updatedDetailData[\"config_value\"])\n\tupdatedNameI18n, ok := updatedDetailData[\"config_name_i18n\"].(map[string]any)\n\tassert.True(t, ok)\n\tassert.Equal(t, configNameZh, updatedNameI18n[\"zh-CN\"])\n\tassert.Equal(t, configNameEn+\"-u\", updatedNameI18n[\"en-US\"])\n\n\tdeleteBytes, _ := json.Marshal(map[string]any{\"id\": config.ID})\n\tdeletePayload := string(deleteBytes)\n\tdeleteResp := postRequest(\"/admin/v1/system/config/delete\", &deletePayload)\n\tdeleteResult := decodeResult(t, deleteResp)\n\tassert.Equal(t, e.SUCCESS, deleteResult.Code, deleteResult.Msg)\n\n\tdeletedDetailResp := getRequest(\"/admin/v1/system/config/detail\", &url.Values{\n\t\t\"id\": {strconv.FormatUint(uint64(config.ID), 10)},\n\t})\n\tdeletedDetailResult := decodeResult(t, deletedDetailResp)\n\tassert.Equal(t, e.NotFound, deletedDetailResult.Code)\n}\n\nfunc TestSystemConfigVisibilityTabsAndHiddenSaveFlags(t *testing.T) {\n\trequireWritableDB(t)\n\trequireSystemModuleTables(t)\n\n\thiddenKeys := []string{\n\t\t\"storage.active_driver\",\n\t\t\"storage.config\",\n\t\t\"audit.sensitive_fields\",\n\t}\n\n\tlistResp := getRequest(\"/admin/v1/system/config/list\", &url.Values{\n\t\t\"page\":     {\"1\"},\n\t\t\"per_page\": {\"50\"},\n\t})\n\tlistResult := decodeResult(t, listResp)\n\tassert.Equal(t, e.SUCCESS, listResult.Code, listResult.Msg)\n\tfor _, key := range hiddenKeys {\n\t\tassert.False(t, collectionContainsFieldValue(listResult.Data, \"config_key\", key))\n\t}\n\n\tincludeHiddenResp := getRequest(\"/admin/v1/system/config/list\", &url.Values{\n\t\t\"page\":           {\"1\"},\n\t\t\"per_page\":       {\"50\"},\n\t\t\"include_hidden\": {\"1\"},\n\t})\n\tincludeHiddenResult := decodeResult(t, includeHiddenResp)\n\tassert.Equal(t, e.SUCCESS, includeHiddenResult.Code, includeHiddenResult.Msg)\n\tfor _, key := range hiddenKeys {\n\t\tassert.True(t, collectionContainsFieldValue(includeHiddenResult.Data, \"config_key\", key))\n\t}\n\n\tstorageBody := map[string]any{\n\t\t\"active_driver\": \"local\",\n\t\t\"config\": map[string]any{\n\t\t\t\"local\": map[string]any{},\n\t\t\t\"aliyun_oss\": map[string]any{\n\t\t\t\t\"bucket\":            \"\",\n\t\t\t\t\"endpoint\":          \"\",\n\t\t\t\t\"access_key_id\":     \"\",\n\t\t\t\t\"access_key_secret\": \"\",\n\t\t\t\t\"public_url\":        \"\",\n\t\t\t\t\"private_url\":       \"\",\n\t\t\t},\n\t\t\t\"s3\": map[string]any{\n\t\t\t\t\"bucket\":            \"\",\n\t\t\t\t\"region\":            \"\",\n\t\t\t\t\"endpoint\":          \"\",\n\t\t\t\t\"access_key_id\":     \"\",\n\t\t\t\t\"secret_access_key\": \"\",\n\t\t\t\t\"session_token\":     \"\",\n\t\t\t\t\"public_url\":        \"\",\n\t\t\t\t\"private_url\":       \"\",\n\t\t\t},\n\t\t\t\"signed_url_ttl_seconds\": 300,\n\t\t\t\"max_file_size_mb\":       10,\n\t\t\t\"allowed_mime_types\":     []string{},\n\t\t},\n\t}\n\tstorageBytes, _ := json.Marshal(storageBody)\n\tstoragePayload := string(storageBytes)\n\tstorageResp := postRequest(\"/admin/v1/system/storage/config\", &storagePayload)\n\tstorageResult := decodeResult(t, storageResp)\n\tassert.Equal(t, e.SUCCESS, storageResult.Code, storageResult.Msg)\n\n\tauditBody := map[string]any{\n\t\t\"common\":          []string{\"password\"},\n\t\t\"request_header\":  []string{\"authorization\"},\n\t\t\"request_body\":    []string{\"secret\"},\n\t\t\"response_header\": []string{},\n\t\t\"response_body\":   []string{},\n\t}\n\tauditBytes, _ := json.Marshal(auditBody)\n\tauditPayload := string(auditBytes)\n\tauditResp := postRequest(\"/admin/v1/log/request/mask-config\", &auditPayload)\n\tauditResult := decodeResult(t, auditResp)\n\tassert.Equal(t, e.SUCCESS, auditResult.Code, auditResult.Msg)\n\n\tstorageConfig := findSysConfigByKey(t, \"storage.active_driver\")\n\tassert.Equal(t, uint8(0), storageConfig.IsVisible)\n\tassert.Equal(t, \"storage\", storageConfig.ManageTab)\n\n\tstorageJSONConfig := findSysConfigByKey(t, \"storage.config\")\n\tassert.Equal(t, uint8(0), storageJSONConfig.IsVisible)\n\tassert.Equal(t, \"storage\", storageJSONConfig.ManageTab)\n\n\tauditConfig := findSysConfigByKey(t, \"audit.sensitive_fields\")\n\tassert.Equal(t, uint8(0), auditConfig.IsVisible)\n\tassert.Equal(t, \"audit_mask\", auditConfig.ManageTab)\n}\n\nfunc TestSystemDictWriteFlow(t *testing.T) {\n\trequireWritableDB(t)\n\trequireSystemModuleTables(t)\n\n\ttypeCodePrefix := uniqueCompactTestName(\"dict\")\n\ttypeCode := typeCodePrefix + \"_type\"\n\ttypeNameZh := typeCodePrefix + \"-zh\"\n\ttypeNameEn := typeCodePrefix + \"-en\"\n\titemValue := \"v1\"\n\titemLabelZh := typeCodePrefix + \"-label-zh\"\n\titemLabelEn := typeCodePrefix + \"-label-en\"\n\n\tcleanupSysDictByTypeCodePrefix(t, typeCodePrefix)\n\tt.Cleanup(func() {\n\t\tcleanupSysDictByTypeCodePrefix(t, typeCodePrefix)\n\t})\n\n\tcreateTypeBody := map[string]any{\n\t\t\"type_code\": typeCode,\n\t\t\"type_name_i18n\": map[string]string{\n\t\t\t\"zh-CN\": typeNameZh,\n\t\t\t\"en-US\": typeNameEn,\n\t\t},\n\t\t\"status\": 1,\n\t\t\"sort\":   10,\n\t}\n\tcreateTypeBytes, _ := json.Marshal(createTypeBody)\n\tcreateTypePayload := string(createTypeBytes)\n\tcreateTypeResp := postRequest(\"/admin/v1/system/dict/type/create\", &createTypePayload)\n\tcreateTypeResult := decodeResult(t, createTypeResp)\n\tassert.Equal(t, e.SUCCESS, createTypeResult.Code, createTypeResult.Msg)\n\n\tdictType := findSysDictTypeByCode(t, typeCode)\n\n\ttypeListResp := getRequest(\"/admin/v1/system/dict/type/list\", &url.Values{\n\t\t\"page\":      {\"1\"},\n\t\t\"per_page\":  {\"20\"},\n\t\t\"type_code\": {typeCode},\n\t})\n\ttypeListResult := decodeResult(t, typeListResp)\n\tassert.Equal(t, e.SUCCESS, typeListResult.Code, typeListResult.Msg)\n\tassert.True(t, collectionContainsFieldValue(typeListResult.Data, \"type_code\", typeCode))\n\n\ttypeDetailResp := getRequest(\"/admin/v1/system/dict/type/detail\", &url.Values{\n\t\t\"id\": {strconv.FormatUint(uint64(dictType.ID), 10)},\n\t})\n\ttypeDetailResult := decodeResult(t, typeDetailResp)\n\tassert.Equal(t, e.SUCCESS, typeDetailResult.Code, typeDetailResult.Msg)\n\ttypeDetailData, ok := typeDetailResult.Data.(map[string]any)\n\tassert.True(t, ok)\n\ttypeNameI18n, ok := typeDetailData[\"type_name_i18n\"].(map[string]any)\n\tassert.True(t, ok)\n\tassert.Equal(t, typeNameZh, typeNameI18n[\"zh-CN\"])\n\tassert.Equal(t, typeNameEn, typeNameI18n[\"en-US\"])\n\n\tupdateTypeBody := map[string]any{\n\t\t\"id\":        dictType.ID,\n\t\t\"type_code\": typeCode,\n\t\t\"type_name_i18n\": map[string]string{\n\t\t\t\"en-US\": typeNameEn + \"-u\",\n\t\t},\n\t\t\"status\": 1,\n\t\t\"sort\":   20,\n\t}\n\tupdateTypeBytes, _ := json.Marshal(updateTypeBody)\n\tupdateTypePayload := string(updateTypeBytes)\n\tupdateTypeResp := postRequest(\"/admin/v1/system/dict/type/update\", &updateTypePayload)\n\tupdateTypeResult := decodeResult(t, updateTypeResp)\n\tassert.Equal(t, e.SUCCESS, updateTypeResult.Code, updateTypeResult.Msg)\n\n\tcreateItemBody := map[string]any{\n\t\t\"type_code\": typeCode,\n\t\t\"label_i18n\": map[string]string{\n\t\t\t\"zh-CN\": itemLabelZh,\n\t\t\t\"en-US\": itemLabelEn,\n\t\t},\n\t\t\"value\":      itemValue,\n\t\t\"color\":      \"success\",\n\t\t\"tag_type\":   \"success\",\n\t\t\"is_default\": 1,\n\t\t\"status\":     1,\n\t\t\"sort\":       10,\n\t}\n\tcreateItemBytes, _ := json.Marshal(createItemBody)\n\tcreateItemPayload := string(createItemBytes)\n\tcreateItemResp := postRequest(\"/admin/v1/system/dict/item/create\", &createItemPayload)\n\tcreateItemResult := decodeResult(t, createItemResp)\n\tassert.Equal(t, e.SUCCESS, createItemResult.Code, createItemResult.Msg)\n\n\titem := findSysDictItemByTypeAndValue(t, typeCode, itemValue)\n\n\titemListResp := getRequest(\"/admin/v1/system/dict/item/list\", &url.Values{\n\t\t\"page\":      {\"1\"},\n\t\t\"per_page\":  {\"20\"},\n\t\t\"type_code\": {typeCode},\n\t})\n\titemListResult := decodeResult(t, itemListResp)\n\tassert.Equal(t, e.SUCCESS, itemListResult.Code, itemListResult.Msg)\n\tassert.True(t, collectionContainsFieldValue(itemListResult.Data, \"value\", itemValue))\n\n\toptionsResp := getRequest(\"/admin/v1/system/dict/options\", &url.Values{\n\t\t\"type_code\": {typeCode},\n\t})\n\toptionsResult := decodeResult(t, optionsResp)\n\tassert.Equal(t, e.SUCCESS, optionsResult.Code, optionsResult.Msg)\n\tassert.True(t, plainListContainsFieldValue(optionsResult.Data, \"value\", itemValue))\n\n\tupdateItemBody := map[string]any{\n\t\t\"id\":        item.ID,\n\t\t\"type_code\": typeCode,\n\t\t\"label_i18n\": map[string]string{\n\t\t\t\"en-US\": itemLabelEn + \"-u\",\n\t\t},\n\t\t\"value\":      itemValue,\n\t\t\"color\":      \"info\",\n\t\t\"tag_type\":   \"warning\",\n\t\t\"is_default\": 1,\n\t\t\"status\":     1,\n\t\t\"sort\":       15,\n\t}\n\tupdateItemBytes, _ := json.Marshal(updateItemBody)\n\tupdateItemPayload := string(updateItemBytes)\n\tupdateItemResp := postRequest(\"/admin/v1/system/dict/item/update\", &updateItemPayload)\n\tupdateItemResult := decodeResult(t, updateItemResp)\n\tassert.Equal(t, e.SUCCESS, updateItemResult.Code, updateItemResult.Msg)\n\n\tdeleteItemBytes, _ := json.Marshal(map[string]any{\"id\": item.ID})\n\tdeleteItemPayload := string(deleteItemBytes)\n\tdeleteItemResp := postRequest(\"/admin/v1/system/dict/item/delete\", &deleteItemPayload)\n\tdeleteItemResult := decodeResult(t, deleteItemResp)\n\tassert.Equal(t, e.SUCCESS, deleteItemResult.Code, deleteItemResult.Msg)\n\n\tdeleteTypeBytes, _ := json.Marshal(map[string]any{\"id\": dictType.ID})\n\tdeleteTypePayload := string(deleteTypeBytes)\n\tdeleteTypeResp := postRequest(\"/admin/v1/system/dict/type/delete\", &deleteTypePayload)\n\tdeleteTypeResult := decodeResult(t, deleteTypeResp)\n\tassert.Equal(t, e.SUCCESS, deleteTypeResult.Code, deleteTypeResult.Msg)\n\n\tdeletedTypeDetailResp := getRequest(\"/admin/v1/system/dict/type/detail\", &url.Values{\n\t\t\"id\": {strconv.FormatUint(uint64(dictType.ID), 10)},\n\t})\n\tdeletedTypeDetailResult := decodeResult(t, deletedTypeDetailResp)\n\tassert.Equal(t, e.NotFound, deletedTypeDetailResult.Code)\n}\n\nfunc TestSystemConfigProtectedRoutesRequireLogin(t *testing.T) {\n\tpostCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tbody  string\n\t}{\n\t\t{name: \"系统参数创建需要登录\", route: \"/admin/v1/system/config/create\", body: `{}`},\n\t\t{name: \"系统参数更新需要登录\", route: \"/admin/v1/system/config/update\", body: `{}`},\n\t\t{name: \"系统参数删除需要登录\", route: \"/admin/v1/system/config/delete\", body: `{}`},\n\t\t{name: \"系统参数刷新缓存需要登录\", route: \"/admin/v1/system/config/refresh\", body: `{}`},\n\t}\n\tfor _, tc := range postCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tbody := tc.body\n\t\t\tresp := anonymousPostRequest(tc.route, &body)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n\n\tgetCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tquery *url.Values\n\t}{\n\t\t{name: \"系统参数列表需要登录\", route: \"/admin/v1/system/config/list\", query: &url.Values{\"page\": {\"1\"}}},\n\t\t{name: \"系统参数详情需要登录\", route: \"/admin/v1/system/config/detail\", query: &url.Values{\"id\": {\"1\"}}},\n\t\t{name: \"系统参数值需要登录\", route: \"/admin/v1/system/config/value\", query: &url.Values{\"config_key\": {\"auth.login_lock_enabled\"}}},\n\t}\n\tfor _, tc := range getCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresp := anonymousGetRequest(tc.route, tc.query)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n}\n\nfunc TestSystemDictProtectedRoutesRequireLogin(t *testing.T) {\n\tpostCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tbody  string\n\t}{\n\t\t{name: \"字典类型创建需要登录\", route: \"/admin/v1/system/dict/type/create\", body: `{}`},\n\t\t{name: \"字典类型更新需要登录\", route: \"/admin/v1/system/dict/type/update\", body: `{}`},\n\t\t{name: \"字典类型删除需要登录\", route: \"/admin/v1/system/dict/type/delete\", body: `{}`},\n\t\t{name: \"字典项创建需要登录\", route: \"/admin/v1/system/dict/item/create\", body: `{}`},\n\t\t{name: \"字典项更新需要登录\", route: \"/admin/v1/system/dict/item/update\", body: `{}`},\n\t\t{name: \"字典项删除需要登录\", route: \"/admin/v1/system/dict/item/delete\", body: `{}`},\n\t}\n\tfor _, tc := range postCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tbody := tc.body\n\t\t\tresp := anonymousPostRequest(tc.route, &body)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n\n\tgetCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tquery *url.Values\n\t}{\n\t\t{name: \"字典类型列表需要登录\", route: \"/admin/v1/system/dict/type/list\", query: &url.Values{\"page\": {\"1\"}}},\n\t\t{name: \"字典类型详情需要登录\", route: \"/admin/v1/system/dict/type/detail\", query: &url.Values{\"id\": {\"1\"}}},\n\t\t{name: \"字典项列表需要登录\", route: \"/admin/v1/system/dict/item/list\", query: &url.Values{\"type_code\": {\"common_status\"}}},\n\t\t{name: \"字典选项需要登录\", route: \"/admin/v1/system/dict/options\", query: &url.Values{\"type_code\": {\"common_status\"}}},\n\t}\n\tfor _, tc := range getCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresp := anonymousGetRequest(tc.route, tc.query)\n\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n}\n\nfunc collectionContainsFieldValue(collectionData any, field string, target any) bool {\n\tcollectionMap, ok := collectionData.(map[string]any)\n\tif !ok {\n\t\treturn false\n\t}\n\trows, ok := collectionMap[\"data\"].([]any)\n\tif !ok {\n\t\treturn false\n\t}\n\tfor _, row := range rows {\n\t\trowMap, ok := row.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif rowMap[field] == target {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc plainListContainsFieldValue(data any, field string, target any) bool {\n\trows, ok := data.([]any)\n\tif !ok {\n\t\t// 全局 response.WithData 对非 object 数据会包装为 data.result。\n\t\tif wrapper, ok := data.(map[string]any); ok {\n\t\t\tif result, exists := wrapper[\"result\"]; exists {\n\t\t\t\trows, ok = result.([]any)\n\t\t\t\tif !ok {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treturn false\n\t\t\t}\n\t\t} else {\n\t\t\treturn false\n\t\t}\n\t}\n\tfor _, row := range rows {\n\t\trowMap, ok := row.(map[string]any)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tif rowMap[field] == target {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// requireSystemModuleTables 确认系统参数/字典相关表已迁移。\nfunc requireSystemModuleTables(t *testing.T) {\n\tt.Helper()\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\tt.Skipf(\"数据库不可用，跳过 system 成功路径测试: %v\", err)\n\t}\n\trequiredTables := []string{\n\t\t\"sys_config\",\n\t\t\"sys_config_i18n\",\n\t\t\"sys_dict_type\",\n\t\t\"sys_dict_type_i18n\",\n\t\t\"sys_dict_item\",\n\t\t\"sys_dict_item_i18n\",\n\t}\n\tfor _, table := range requiredTables {\n\t\tif !db.Migrator().HasTable(table) {\n\t\t\tt.Skipf(\"测试库缺少表 %s，跳过 system 成功路径测试\", table)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "tests/admin_test/task_routes_test.go",
    "content": "package admin_test\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\te \"github.com/wannanbigpig/gin-layout/internal/pkg/errors\"\n)\n\nfunc TestTaskCenterListRoutesWithAuthorization(t *testing.T) {\n\trequireMySQL(t)\n\n\ttestCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tquery *url.Values\n\t}{\n\t\t{\n\t\t\tname:  \"任务定义列表\",\n\t\t\troute: \"/admin/v1/task/list\",\n\t\t\tquery: &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}},\n\t\t},\n\t\t{\n\t\t\tname:  \"任务执行记录列表\",\n\t\t\troute: \"/admin/v1/task/run/list\",\n\t\t\tquery: &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}},\n\t\t},\n\t\t{\n\t\t\tname:  \"定时任务最近状态列表\",\n\t\t\troute: \"/admin/v1/task/cron/state\",\n\t\t\tquery: &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresp := getRequest(tc.route, tc.query)\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.SUCCESS, result.Code)\n\t\t})\n\t}\n}\n\nfunc TestTaskCenterRunDetailNotFound(t *testing.T) {\n\trequireMySQL(t)\n\n\tresp := getRequest(\"/admin/v1/task/run/detail\", &url.Values{\"id\": {\"99999999\"}})\n\tassert.Equal(t, http.StatusOK, resp.Code)\n\tresult := decodeResult(t, resp)\n\tassert.Equal(t, e.NotFound, result.Code)\n}\n\nfunc TestTaskCenterRoutesRequireLogin(t *testing.T) {\n\tgetCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tquery *url.Values\n\t}{\n\t\t{\n\t\t\tname:  \"任务定义列表需要登录\",\n\t\t\troute: \"/admin/v1/task/list\",\n\t\t\tquery: &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}},\n\t\t},\n\t\t{\n\t\t\tname:  \"任务执行记录列表需要登录\",\n\t\t\troute: \"/admin/v1/task/run/list\",\n\t\t\tquery: &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}},\n\t\t},\n\t\t{name: \"任务执行记录详情需要登录\", route: \"/admin/v1/task/run/detail\", query: &url.Values{\"id\": {\"1\"}}},\n\t\t{\n\t\t\tname:  \"定时任务最近状态需要登录\",\n\t\t\troute: \"/admin/v1/task/cron/state\",\n\t\t\tquery: &url.Values{\"page\": {\"1\"}, \"per_page\": {\"5\"}},\n\t\t},\n\t}\n\n\tfor _, tc := range getCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresp := anonymousGetRequest(tc.route, tc.query)\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n\n\tpostCases := []struct {\n\t\tname  string\n\t\troute string\n\t\tbody  string\n\t}{\n\t\t{name: \"手动触发任务需要登录\", route: \"/admin/v1/task/trigger\", body: `{\"task_code\":\"demo:send\"}`},\n\t\t{name: \"重试任务需要登录\", route: \"/admin/v1/task/run/retry\", body: `{\"run_id\":1}`},\n\t\t{name: \"取消任务需要登录\", route: \"/admin/v1/task/run/cancel\", body: `{\"run_id\":1}`},\n\t}\n\tfor _, tc := range postCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tbody := tc.body\n\t\t\tresp := anonymousPostRequest(tc.route, &body)\n\t\t\tassert.Equal(t, http.StatusOK, resp.Code)\n\t\t\tresult := decodeResult(t, resp)\n\t\t\tassert.Equal(t, e.NotLogin, result.Code)\n\t\t})\n\t}\n}\n\nfunc TestTaskCenterOperateRouteValidation(t *testing.T) {\n\trequireMySQL(t)\n\n\ttriggerBody := `{}`\n\ttriggerResp := postRequest(\"/admin/v1/task/trigger\", &triggerBody)\n\tassert.Equal(t, http.StatusOK, triggerResp.Code)\n\ttriggerResult := decodeResult(t, triggerResp)\n\tassert.Equal(t, e.InvalidParameter, triggerResult.Code)\n\n\tretryBody := `{}`\n\tretryResp := postRequest(\"/admin/v1/task/run/retry\", &retryBody)\n\tassert.Equal(t, http.StatusOK, retryResp.Code)\n\tretryResult := decodeResult(t, retryResp)\n\tassert.Equal(t, e.InvalidParameter, retryResult.Code)\n\n\tcancelBody := `{}`\n\tcancelResp := postRequest(\"/admin/v1/task/run/cancel\", &cancelBody)\n\tassert.Equal(t, http.StatusOK, cancelResp.Code)\n\tcancelResult := decodeResult(t, cancelResp)\n\tassert.Equal(t, e.InvalidParameter, cancelResult.Code)\n}\n"
  },
  {
    "path": "tests/admin_test/test_helpers_test.go",
    "content": "package admin_test\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"gorm.io/gorm\"\n\n\t\"github.com/wannanbigpig/gin-layout/internal/model\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/i18n\"\n)\n\nconst testResourcePrefix = \"test-auto-\"\n\n// requireWritableDB 在需要真实数据库写入时跳过测试。\nfunc requireWritableDB(t *testing.T) {\n\tt.Helper()\n\trequireMySQL(t)\n\tif _, err := model.GetDB(); err != nil {\n\t\tt.Skip(\"数据库连接不可用，跳过真实写入测试\")\n\t}\n}\n\n// uniqueTestName 生成用于测试资源的唯一名称。\nfunc uniqueTestName(kind string) string {\n\treturn fmt.Sprintf(\"%s%s-%d\", testResourcePrefix, kind, time.Now().UnixNano())\n}\n\n// containsPrefix 判断字符串是否包含测试前缀。\nfunc containsPrefix(s string) bool {\n\treturn strings.HasPrefix(s, testResourcePrefix)\n}\n\n// uniqueCompactTestName 生成适合表单校验长度限制的测试名称。\nfunc uniqueCompactTestName(kind string) string {\n\treturn fmt.Sprintf(\"ta%s%d\", strings.ReplaceAll(kind, \"-\", \"\"), time.Now().UnixNano()%1e8)\n}\n\n// findAdminUserByUsername 根据用户名查找管理员。\nfunc findAdminUserByUsername(t *testing.T, username string) *model.AdminUser {\n\tt.Helper()\n\tuser := model.NewAdminUsers()\n\tdb, err := user.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"查询管理员失败: %v\", err)\n\t}\n\tif err := db.Where(\"username = ?\", username).First(user).Error; err != nil {\n\t\tt.Fatalf(\"查询管理员失败: %v\", err)\n\t}\n\treturn user\n}\n\n// findRoleByName 根据角色名称查找角色。\nfunc findRoleByName(t *testing.T, name string) *model.Role {\n\tt.Helper()\n\trole := model.NewRole()\n\tdb, err := role.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"查询角色失败: %v\", err)\n\t}\n\tif err := db.Where(\"name = ?\", name).First(role).Error; err != nil {\n\t\tt.Fatalf(\"查询角色失败: %v\", err)\n\t}\n\treturn role\n}\n\n// findDepartmentByName 根据部门名称查找部门。\nfunc findDepartmentByName(t *testing.T, name string) *model.Department {\n\tt.Helper()\n\tdept := model.NewDepartment()\n\tdb, err := dept.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"查询部门失败: %v\", err)\n\t}\n\tif err := db.Where(\"name = ?\", name).First(dept).Error; err != nil {\n\t\tt.Fatalf(\"查询部门失败: %v\", err)\n\t}\n\treturn dept\n}\n\n// findMenuByTitle 根据菜单标题查找菜单。\nfunc findMenuByTitle(t *testing.T, title string) *model.Menu {\n\tt.Helper()\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"查询菜单失败: %v\", err)\n\t}\n\ttranslation := model.NewMenuI18n()\n\tif err := db.Where(\"locale = ? AND title = ?\", i18n.LocaleZhCN, title).First(translation).Error; err != nil {\n\t\tt.Fatalf(\"查询菜单翻译失败: %v\", err)\n\t}\n\tmenu := model.NewMenu()\n\tif err := db.Where(\"id = ?\", translation.MenuID).First(menu).Error; err != nil {\n\t\tt.Fatalf(\"查询菜单失败: %v\", err)\n\t}\n\treturn menu\n}\n\n// cleanupAdminUsers 清理指定前缀创建的管理员测试数据。\nfunc cleanupAdminUsers(t *testing.T, usernamePrefix string) {\n\tt.Helper()\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn\n\t}\n\t_ = db.Where(\"username LIKE ?\", usernamePrefix+\"%\").Delete(&model.AdminUser{}).Error\n}\n\n// cleanupRoles 清理指定前缀创建的角色测试数据。\nfunc cleanupRoles(t *testing.T, namePrefix string) {\n\tt.Helper()\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn\n\t}\n\n\tvar roles []model.Role\n\tif err := db.Where(\"name LIKE ?\", namePrefix+\"%\").Find(&roles).Error; err != nil {\n\t\treturn\n\t}\n\tif len(roles) == 0 {\n\t\treturn\n\t}\n\n\tids := make([]uint, 0, len(roles))\n\tfor _, role := range roles {\n\t\tids = append(ids, role.ID)\n\t}\n\t_ = db.Where(\"role_id IN ?\", ids).Delete(&model.RoleMenuMap{}).Error\n\t_ = db.Where(\"role_id IN ?\", ids).Delete(&model.AdminUserRoleMap{}).Error\n\t_ = db.Where(\"role_id IN ?\", ids).Delete(&model.DeptRoleMap{}).Error\n\t_ = db.Where(\"id IN ?\", ids).Delete(&model.Role{}).Error\n}\n\n// cleanupDepartments 清理指定前缀创建的部门测试数据。\nfunc cleanupDepartments(t *testing.T, namePrefix string) {\n\tt.Helper()\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn\n\t}\n\n\tvar depts []model.Department\n\tif err := db.Where(\"name LIKE ?\", namePrefix+\"%\").Find(&depts).Error; err != nil {\n\t\treturn\n\t}\n\tif len(depts) == 0 {\n\t\treturn\n\t}\n\n\tids := make([]uint, 0, len(depts))\n\tfor _, dept := range depts {\n\t\tids = append(ids, dept.ID)\n\t}\n\t_ = db.Where(\"dept_id IN ?\", ids).Delete(&model.AdminUserDeptMap{}).Error\n\t_ = db.Where(\"dept_id IN ?\", ids).Delete(&model.DeptRoleMap{}).Error\n\t_ = db.Where(\"id IN ?\", ids).Delete(&model.Department{}).Error\n}\n\n// cleanupMenus 清理指定前缀创建的菜单测试数据。\nfunc cleanupMenus(t *testing.T, titlePrefix string) {\n\tt.Helper()\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn\n\t}\n\n\tvar ids []uint\n\tif err := db.Model(&model.MenuI18n{}).\n\t\tWhere(\"locale = ? AND title LIKE ?\", i18n.LocaleZhCN, titlePrefix+\"%\").\n\t\tDistinct().\n\t\tPluck(\"menu_id\", &ids).Error; err != nil {\n\t\treturn\n\t}\n\tif len(ids) == 0 {\n\t\treturn\n\t}\n\t_ = db.Where(\"menu_id IN ?\", ids).Delete(&model.MenuApiMap{}).Error\n\t_ = db.Where(\"menu_id IN ?\", ids).Delete(&model.RoleMenuMap{}).Error\n\t_ = db.Where(\"menu_id IN ?\", ids).Delete(&model.MenuI18n{}).Error\n\t_ = db.Where(\"id IN ?\", ids).Delete(&model.Menu{}).Error\n}\n\n// firstActiveRoleID 返回一个可用于绑定的启用角色 ID。\nfunc firstActiveRoleID(t *testing.T) uint {\n\tt.Helper()\n\trole := model.NewRole()\n\tdb, err := role.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"查询启用角色失败: %v\", err)\n\t}\n\tif err := db.Where(\"status = 1\").First(role).Error; err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn createFallbackRole(t)\n\t\t}\n\t\tt.Fatalf(\"查询启用角色失败: %v\", err)\n\t}\n\treturn role.ID\n}\n\n// firstActiveMenuID 返回一个可用于角色绑定的启用菜单 ID。\nfunc firstActiveMenuID(t *testing.T) uint {\n\tt.Helper()\n\tmenu := model.NewMenu()\n\tdb, err := menu.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"查询启用菜单失败: %v\", err)\n\t}\n\tif err := db.Where(\"status = 1\").First(menu).Error; err != nil {\n\t\tif err == gorm.ErrRecordNotFound {\n\t\t\treturn createFallbackMenu(t)\n\t\t}\n\t\tt.Fatalf(\"查询启用菜单失败: %v\", err)\n\t}\n\treturn menu.ID\n}\n\n// createFallbackRole 创建测试兜底角色。\nfunc createFallbackRole(t *testing.T) uint {\n\tt.Helper()\n\tname := uniqueCompactTestName(\"role-seed\")\n\trole := &model.Role{\n\t\tName:   name,\n\t\tStatus: 1,\n\t\tLevel:  1,\n\t\tPids:   \"0\",\n\t\tSort:   1,\n\t}\n\tdb, err := role.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"创建兜底角色失败: %v\", err)\n\t}\n\tif err := db.Create(role).Error; err != nil {\n\t\tt.Fatalf(\"创建兜底角色失败: %v\", err)\n\t}\n\treturn role.ID\n}\n\n// createFallbackMenu 创建测试兜底菜单。\nfunc createFallbackMenu(t *testing.T) uint {\n\tt.Helper()\n\tname := uniqueCompactTestName(\"menu\")\n\tmenu := &model.Menu{\n\t\tName:      name,\n\t\tPath:      \"/\" + name,\n\t\tFullPath:  \"/\" + name,\n\t\tComponent: \"test/component\",\n\t\tIsShow:    1,\n\t\tSort:      1,\n\t\tType:      model.MENU,\n\t\tLevel:     1,\n\t\tPids:      \"0\",\n\t\tIsAuth:    1,\n\t\tStatus:    1,\n\t}\n\tdb, err := menu.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"创建兜底菜单失败: %v\", err)\n\t}\n\tif err := db.Create(menu).Error; err != nil {\n\t\tt.Fatalf(\"创建兜底菜单失败: %v\", err)\n\t}\n\tif err := model.NewMenuI18n().UpsertMenuTitles(menu.ID, map[string]string{\n\t\ti18n.LocaleZhCN: name,\n\t\ti18n.LocaleEnUS: name,\n\t}, db); err != nil {\n\t\tt.Fatalf(\"创建兜底菜单翻译失败: %v\", err)\n\t}\n\treturn menu.ID\n}\n\n// findSysConfigByKey 根据参数键名查找系统参数。\nfunc findSysConfigByKey(t *testing.T, key string) *model.SysConfig {\n\tt.Helper()\n\tconfig := model.NewSysConfig()\n\tdb, err := config.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"查询系统参数失败: %v\", err)\n\t}\n\tif err := db.Where(\"config_key = ? AND deleted_at = 0\", key).First(config).Error; err != nil {\n\t\tt.Fatalf(\"查询系统参数失败: %v\", err)\n\t}\n\treturn config\n}\n\n// findSysDictTypeByCode 根据字典类型编码查找字典类型。\nfunc findSysDictTypeByCode(t *testing.T, typeCode string) *model.SysDictType {\n\tt.Helper()\n\tdictType := model.NewSysDictType()\n\tdb, err := dictType.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"查询字典类型失败: %v\", err)\n\t}\n\tif err := db.Where(\"type_code = ? AND deleted_at = 0\", typeCode).First(dictType).Error; err != nil {\n\t\tt.Fatalf(\"查询字典类型失败: %v\", err)\n\t}\n\treturn dictType\n}\n\n// findSysDictItemByTypeAndValue 根据类型编码与字典值查找字典项。\nfunc findSysDictItemByTypeAndValue(t *testing.T, typeCode, value string) *model.SysDictItem {\n\tt.Helper()\n\titem := model.NewSysDictItem()\n\tdb, err := item.GetDB()\n\tif err != nil {\n\t\tt.Fatalf(\"查询字典项失败: %v\", err)\n\t}\n\tif err := db.Where(\"type_code = ? AND value = ? AND deleted_at = 0\", typeCode, value).First(item).Error; err != nil {\n\t\tt.Fatalf(\"查询字典项失败: %v\", err)\n\t}\n\treturn item\n}\n\n// cleanupSysConfigsByKeyPrefix 清理指定参数键前缀的系统参数测试数据。\nfunc cleanupSysConfigsByKeyPrefix(t *testing.T, keyPrefix string) {\n\tt.Helper()\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn\n\t}\n\tvar configs []model.SysConfig\n\tif err := db.Unscoped().Where(\"config_key LIKE ?\", keyPrefix+\"%\").Find(&configs).Error; err != nil {\n\t\treturn\n\t}\n\tif len(configs) == 0 {\n\t\treturn\n\t}\n\tids := make([]uint, 0, len(configs))\n\tfor _, config := range configs {\n\t\tids = append(ids, config.ID)\n\t}\n\t_ = db.Unscoped().Where(\"config_id IN ?\", ids).Delete(&model.SysConfigI18n{}).Error\n\t_ = db.Unscoped().Where(\"id IN ?\", ids).Delete(&model.SysConfig{}).Error\n}\n\n// cleanupSysDictByTypeCodePrefix 清理指定字典类型编码前缀的测试数据。\nfunc cleanupSysDictByTypeCodePrefix(t *testing.T, typeCodePrefix string) {\n\tt.Helper()\n\tdb, err := model.GetDB()\n\tif err != nil {\n\t\treturn\n\t}\n\n\tvar dictTypes []model.SysDictType\n\tif err := db.Unscoped().Where(\"type_code LIKE ?\", typeCodePrefix+\"%\").Find(&dictTypes).Error; err != nil {\n\t\treturn\n\t}\n\tif len(dictTypes) == 0 {\n\t\treturn\n\t}\n\n\ttypeIDs := make([]uint, 0, len(dictTypes))\n\ttypeCodes := make([]string, 0, len(dictTypes))\n\tfor _, dictType := range dictTypes {\n\t\ttypeIDs = append(typeIDs, dictType.ID)\n\t\ttypeCodes = append(typeCodes, dictType.TypeCode)\n\t}\n\n\tvar itemIDs []uint\n\t_ = db.Unscoped().\n\t\tModel(&model.SysDictItem{}).\n\t\tWhere(\"type_code IN ?\", typeCodes).\n\t\tPluck(\"id\", &itemIDs).Error\n\n\tif len(itemIDs) > 0 {\n\t\t_ = db.Unscoped().Where(\"dict_item_id IN ?\", itemIDs).Delete(&model.SysDictItemI18n{}).Error\n\t}\n\t_ = db.Unscoped().Where(\"type_code IN ?\", typeCodes).Delete(&model.SysDictItem{}).Error\n\t_ = db.Unscoped().Where(\"dict_type_id IN ?\", typeIDs).Delete(&model.SysDictTypeI18n{}).Error\n\t_ = db.Unscoped().Where(\"id IN ?\", typeIDs).Delete(&model.SysDictType{}).Error\n}\n"
  },
  {
    "path": "tests/ping_test.go",
    "content": "package tests\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n)\n\nfunc TestPingRoute(t *testing.T) {\n\trouter, err := SetupRouter()\n\tif err != nil {\n\t\tt.Fatalf(\"setup router failed: %v\", err)\n\t}\n\treq := httptest.NewRequest(http.MethodGet, \"/ping\", nil)\n\trecorder := httptest.NewRecorder()\n\n\trouter.ServeHTTP(recorder, req)\n\n\tif recorder.Code != http.StatusOK {\n\t\tt.Fatalf(\"unexpected status code: %d\", recorder.Code)\n\t}\n\tif body := recorder.Body.String(); body != \"pong\" {\n\t\tt.Fatalf(\"unexpected response body: %s\", body)\n\t}\n}\n"
  },
  {
    "path": "tests/test.go",
    "content": "package tests\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/spf13/viper\"\n\n\t\"github.com/wannanbigpig/gin-layout/config\"\n\t\"github.com/wannanbigpig/gin-layout/data\"\n\t\"github.com/wannanbigpig/gin-layout/internal/pkg/logger\"\n\t\"github.com/wannanbigpig/gin-layout/internal/routers\"\n\t\"github.com/wannanbigpig/gin-layout/internal/validator\"\n)\n\n// SetupRouter 初始化测试用路由。\nfunc SetupRouter() (*gin.Engine, error) {\n\trootPath, err := projectRootPath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tconfigPath, err := testConfigPath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 1、初始化配置\n\tif err := config.InitConfig(configPath); err != nil {\n\t\treturn nil, err\n\t}\n\tcfg := config.GetConfig()\n\tif cfg != nil {\n\t\tcfg.BasePath = rootPath\n\t\tcfg.Mysql.PrintSql = false\n\t}\n\t// 2、初始化zap日志\n\tif err := logger.InitLogger(); err != nil {\n\t\treturn nil, err\n\t}\n\t// 初始化数据库\n\tif err := data.InitData(); err != nil {\n\t\treturn nil, err\n\t}\n\t// 初始化验证器\n\tif err := validator.InitValidatorTrans(\"zh\"); err != nil {\n\t\treturn nil, err\n\t}\n\n\tengine, err := routers.SetRouters()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn engine, nil\n}\n\n// testConfigPath 返回测试运行使用的临时配置文件路径。\nfunc testConfigPath() (string, error) {\n\tprojectRoot, err := projectRootPath()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tprojectConfigPath := filepath.Join(projectRoot, \"config.yaml\")\n\tif fileInfo, err := os.Stat(projectConfigPath); err == nil {\n\t\tif fileInfo.IsDir() {\n\t\t\treturn \"\", fmt.Errorf(\"项目根目录 config.yaml 是目录，无法作为测试配置文件\")\n\t\t}\n\t\tif isProjectConfigUsable(projectConfigPath) {\n\t\t\treturn projectConfigPath, nil\n\t\t}\n\t}\n\n\texamplePath := filepath.Join(projectRoot, \"config\", \"config.yaml.example\")\n\tcontent, err := os.ReadFile(examplePath)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\ttempPath := filepath.Join(os.TempDir(), \"go-layout-test-config.yaml\")\n\tif err := os.WriteFile(tempPath, content, 0o600); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn tempPath, nil\n}\n\n// projectRootPath 返回项目根目录路径。\nfunc projectRootPath() (string, error) {\n\t_, file, _, ok := runtime.Caller(0)\n\tif !ok {\n\t\treturn \"\", fmt.Errorf(\"failed to resolve project root path\")\n\t}\n\treturn filepath.Dir(filepath.Dir(file)), nil\n}\n\n// isProjectConfigUsable 判断根目录配置是否适合当前测试环境直接使用。\nfunc isProjectConfigUsable(configPath string) bool {\n\tcontent, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\tv := viper.New()\n\tv.SetConfigType(\"yaml\")\n\tif err := v.ReadConfig(bytes.NewReader(content)); err != nil {\n\t\treturn false\n\t}\n\n\tif v.GetBool(\"mysql.enable\") && !canDial(v.GetString(\"mysql.host\"), v.GetInt(\"mysql.port\")) {\n\t\treturn false\n\t}\n\tif v.GetBool(\"redis.enable\") && !canDial(v.GetString(\"redis.host\"), v.GetInt(\"redis.port\")) {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\n// canDial 检查测试环境是否能连接到指定地址。\nfunc canDial(host string, port int) bool {\n\tif host == \"\" || port == 0 {\n\t\treturn false\n\t}\n\n\tconn, err := net.DialTimeout(\"tcp\", fmt.Sprintf(\"%s:%d\", host, port), time.Second)\n\tif err != nil {\n\t\treturn false\n\t}\n\t_ = conn.Close()\n\treturn true\n}\n"
  }
]